/build/source/nativelink-sim/src/clock.rs
Line | Count | Source |
1 | | // Copyright 2026 Trace Machina, Inc. All rights reserved. |
2 | | // |
3 | | // Licensed under the Functional Source License, Version 1.1, Apache 2.0 Future License (the "License"); |
4 | | // you may not use this file except in compliance with the License. |
5 | | // You may obtain a copy of the License at |
6 | | // |
7 | | // See LICENSE file for details |
8 | | // |
9 | | // Unless required by applicable law or agreed to in writing, software |
10 | | // distributed under the License is distributed on an "AS IS" BASIS, |
11 | | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
12 | | // See the License for the specific language governing permissions and |
13 | | // limitations under the License. |
14 | | |
15 | | //! One virtual clock for every component: tokio's, paused. The scheduler, |
16 | | //! its db and the servers read it through `SimInstant` and `SimClock::now`; |
17 | | //! the workers and the simulated actions sleep on it directly. A jump moves |
18 | | //! what `now` reads, the way an NTP step does, and no timer sees it. |
19 | | //! |
20 | | //! On the memory backend the clock moves only when every task is waiting on |
21 | | //! a timer, so work costs no virtual time and a timeout fires exactly when |
22 | | //! its duration has passed. The fake Redis is reached over a socket, and a |
23 | | //! paused runtime that is idle while a reply is on its way jumps straight |
24 | | //! to the next timer, a connect timeout included; there the runner keeps |
25 | | //! the runtime busy and steps time itself, holding each step until the |
26 | | //! store stops answering. |
27 | | |
28 | | use core::cell::Cell; |
29 | | use core::future::Future; |
30 | | use core::time::Duration; |
31 | | use std::sync::Arc; |
32 | | use std::time::{SystemTime, UNIX_EPOCH}; |
33 | | |
34 | | use nativelink_util::instant_wrapper::InstantWrapper; |
35 | | use tokio::time::Instant; |
36 | | |
37 | | /// Where virtual time starts. Not zero: a zero timestamp reads as unset in |
38 | | /// places. |
39 | | pub const EPOCH_OFFSET: Duration = Duration::from_secs(1_000_000); |
40 | | |
41 | | thread_local! { |
42 | | static START: Cell<Option<Instant>> = const { Cell::new(None) }; |
43 | | static JUMPED: Cell<Duration> = const { Cell::new(Duration::ZERO) }; |
44 | | } |
45 | | |
46 | | /// How far one manual step moves the clock. |
47 | | pub const STEP: Duration = Duration::from_millis(10); |
48 | | |
49 | | /// The most rounds of yields a step waits for a busy store to go quiet. |
50 | | const MAX_QUIET_ROUNDS: usize = 1_000; |
51 | | |
52 | | /// A counter of work outside the runtime's view, such as the commands a |
53 | | /// store has answered; a manual step waits while it moves. |
54 | | pub type ActivityProbe = Arc<dyn Fn() -> u64 + Send + Sync>; |
55 | | |
56 | | #[derive(Clone, Default)] |
57 | | pub struct SimClock { |
58 | | /// Set when time is stepped by hand. |
59 | | activity: Option<ActivityProbe>, |
60 | | } |
61 | | |
62 | | impl core::fmt::Debug for SimClock { |
63 | 0 | fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { |
64 | 0 | f.debug_struct("SimClock") |
65 | 0 | .field("stepped", &self.activity.is_some()) |
66 | 0 | .finish() |
67 | 0 | } |
68 | | } |
69 | | |
70 | | impl SimClock { |
71 | | /// Starts virtual time on this thread. Call once per run, inside the |
72 | | /// paused runtime, before anything reads the time. |
73 | 185 | pub fn start() -> Self { |
74 | 185 | START.set(Some(Instant::now())); |
75 | 185 | JUMPED.set(Duration::ZERO); |
76 | 185 | Self { activity: None } |
77 | 185 | } |
78 | | |
79 | | /// Steps time by hand, waiting at each step until `activity` stops |
80 | | /// moving. The caller keeps the runtime from going idle. |
81 | | #[must_use] |
82 | 68 | pub fn stepped(mut self, activity: ActivityProbe) -> Self { |
83 | 68 | self.activity = Some(activity); |
84 | 68 | self |
85 | 68 | } |
86 | | |
87 | 18.7M | fn elapsed() -> Duration { |
88 | 18.7M | START.get().map_or(Duration::ZERO, |start| start.elapsed()) + JUMPED.get() |
89 | 18.7M | } |
90 | | |
91 | | /// Milliseconds since the run started, jumps included. |
92 | 160k | pub fn now_ms() -> u64 { |
93 | 160k | u64::try_from(Self::elapsed().as_millis()).unwrap_or(u64::MAX) |
94 | 160k | } |
95 | | |
96 | | /// What the scheduler and the servers read as now. |
97 | 18.6M | pub fn now() -> SystemTime { |
98 | 18.6M | UNIX_EPOCH + EPOCH_OFFSET + Self::elapsed() |
99 | 18.6M | } |
100 | | |
101 | | /// Lets every runnable task run without moving time. |
102 | 1.52M | pub async fn yield_all() { |
103 | 1.52M | for _ in 0..8 { |
104 | 12.1M | tokio::task::yield_now().await; |
105 | | } |
106 | 1.52M | } |
107 | | |
108 | | /// Lets `by` of virtual time pass: timer by timer, each as soon as |
109 | | /// nothing is left to run, or in manual steps. |
110 | 43.6k | pub async fn advance(&self, by: Duration) { |
111 | 43.6k | let Some(activity22.4k ) = &self.activity else { |
112 | 21.1k | tokio::time::sleep(by).await; |
113 | 21.1k | return; |
114 | | }; |
115 | 22.4k | let mut left = by; |
116 | 522k | while !left.is_zero() { |
117 | 500k | let step = left.min(STEP); |
118 | 500k | tokio::time::advance(step).await; |
119 | 500k | Self::yield_all().await; |
120 | 500k | for _ in 0..MAX_QUIET_ROUNDS { |
121 | 1.00M | let before = activity(); |
122 | 1.00M | Self::yield_all().await; |
123 | 1.00M | if activity() == before { |
124 | 500k | break; |
125 | 502k | } |
126 | | } |
127 | 500k | left -= step; |
128 | | } |
129 | 43.6k | } |
130 | | |
131 | | /// Moves what `now` reads forward by `by`; timers do not see it. Forward |
132 | | /// only, as a clock that steps back breaks `Instant` arithmetic. |
133 | 4 | pub fn jump(by: Duration) { |
134 | 4 | JUMPED.set(JUMPED.get() + by); |
135 | 4 | } |
136 | | } |
137 | | |
138 | | /// `InstantWrapper` over the virtual clock, for the scheduler and its db: |
139 | | /// production's `SystemTime` wrapper with `SimClock::now` in place of the |
140 | | /// wall clock, method for method. `from_secs(0)` is the epoch, so a fresh |
141 | | /// subscriber's keepalive is long overdue, as in production, and `elapsed` |
142 | | /// sees a jump, as `SystemTime` sees an NTP step. |
143 | | #[derive(Debug, Clone, Copy)] |
144 | | pub struct SimInstant(SystemTime); |
145 | | |
146 | | impl SimInstant { |
147 | 8.49M | pub fn now() -> Self { |
148 | 8.49M | Self(SimClock::now()) |
149 | 8.49M | } |
150 | | } |
151 | | |
152 | | impl InstantWrapper for SimInstant { |
153 | 3.08M | fn from_secs(secs: u64) -> Self { |
154 | 3.08M | Self(UNIX_EPOCH + Duration::from_secs(secs)) |
155 | 3.08M | } |
156 | | |
157 | 0 | fn unix_timestamp(&self) -> u64 { |
158 | 0 | self.0.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs()) |
159 | 0 | } |
160 | | |
161 | 6.84M | fn now(&self) -> SystemTime { |
162 | 6.84M | SimClock::now() |
163 | 6.84M | } |
164 | | |
165 | 3.15M | fn elapsed(&self) -> Duration { |
166 | 3.15M | SimClock::now().duration_since(self.0).unwrap_or_default() |
167 | 3.15M | } |
168 | | |
169 | 1.63M | fn sleep(self, duration: Duration) -> impl Future<Output = ()> + Send + Sync + 'static { |
170 | 1.63M | tokio::time::sleep(duration) |
171 | 1.63M | } |
172 | | } |