mock_upcloud/clock.rs
1//! **Virtual time, and the measured durations it plays back.**
2//!
3//! Every duration in [`Timings`] was measured against the live UpCloud account
4//! or against a KVM guest of the same image. At `speed = 1.0` the mock waits
5//! exactly as long as the provider does, which is the mode to use when the thing
6//! under test is a TIMEOUT. At `speed = 0.0` nothing waits and the clock jumps,
7//! which is the mode the 100 000-run storm uses — the ORDER of the states and
8//! their RELATIVE lengths are unchanged, so a client that skips its poll loop
9//! still fails, it just fails in microseconds.
10//!
11//! The clock is virtual rather than "sleep less" because a shortened sleep still
12//! sleeps: 100 000 server creates at one millisecond each is a hundred seconds
13//! of doing nothing, per resource, and there are five per run. A monotonically
14//! advanced counter costs nothing and is exactly as ordered.
15
16use std::sync::atomic::{AtomicU64, Ordering};
17use std::time::{Duration, Instant};
18
19/// The measured durations. Milliseconds, because the guest install was measured
20/// at 10 011 ms and rounding it to 10 s throws away the only digit that says it
21/// was measured.
22#[derive(Clone, Copy, Debug)]
23pub struct Timings {
24 /// **98–105 s.** MEASURED across the estate re-lays; the mock draws
25 /// uniformly in the range from the run's seed, because a client that
26 /// happened to be tuned to 100 s exactly is a client that will break.
27 pub server_create_lo_ms: u64,
28 pub server_create_hi_ms: u64,
29 /// **≈60 s** for a front or a twin: a server with its own boot disk and
30 /// nothing else.
31 pub server_delete_ms: u64,
32 /// **The extra per attached member volume.** MEASURED: an appliance
33 /// carrying four member volumes stayed in `maintenance` for more than five
34 /// minutes. 60 s + 4 × 65 s = 320 s, which is the >5 min that was seen.
35 pub server_delete_per_volume_ms: u64,
36 /// A storage create: it is `maintenance` for this long before `online`.
37 pub storage_create_ms: u64,
38 /// A storage delete.
39 pub storage_delete_ms: u64,
40 /// A soft stop that the guest answers.
41 pub stop_soft_ms: u64,
42 /// A hard stop: the plug, so it is quick.
43 pub stop_hard_ms: u64,
44 /// **Start → `started` for a disk boot: ≈10 s.** MEASURED 2026-09-19
45 /// (private-gunnar-ops RESUME-2026-09-19, "`started` in 10 s"). It was
46 /// 20 000 here, unmeasured — behaviour 45.
47 pub start_ms: u64,
48 /// A plan change (the resize proper), server already stopped.
49 pub resize_ms: u64,
50 /// **10 011 ms**, MEASURED under KVM on this estate's own boxes:
51 /// `INSTALL-OK` from the installer's first instruction. Kept because it is
52 /// a real measurement of a real thing — but it is NOT what the appliance
53 /// does, see below.
54 pub guest_install_ms: u64,
55 /// **2 001 / 2 751 / 4 752 ms**, MEASURED on the appliance's UART across
56 /// three re-images. This is the guest's own clock, observed from inside.
57 pub guest_install_uart_lo_ms: u64,
58 pub guest_install_uart_hi_ms: u64,
59 /// **110 000–253 000 ms**, MEASURED by the ladder as `install-time`, wall,
60 /// from outside, across the same three re-images.
61 ///
62 /// **Two observers, two orders of magnitude, one word.** The installer is
63 /// not slow — the PROVIDER is: create, media sync, firmware, boot order,
64 /// DHCP. A bar set at 60 s against the ladder's number reads RED forever
65 /// and tempts somebody to move the bar, when the number to move is the
66 /// provider's and the thing to report is which observer said what. See
67 /// [`Timings::reimage_observers`].
68 pub provider_wall_lo_ms: u64,
69 pub provider_wall_hi_ms: u64,
70 /// The Ubuntu template's first boot, during which `dpkg lock-frontend` is
71 /// held by `unattended-upgrade-shutdown --wait-for-signal` — for the LIFE
72 /// of the boot, so `apt-get update` blocks rather than failing (behaviour
73 /// 21). This is how long the mock's guest stays in that state; the real one
74 /// never leaves it.
75 pub template_first_boot_ms: u64,
76
77 // ── the direct-upload import, and the wait that follows it ──────────────
78 /// **The upload itself.** MEASURED: 43 485 184 bytes (41.47 MiB) `created
79 /// 09:36:16Z`, `completed 09:36:21Z` — five seconds, so about 120 ms per
80 /// MiB. `read_bytes == written_bytes == 43485184`, both checksums present,
81 /// the sha256 matching the local file exactly.
82 pub import_upload_ms_per_mib: u64,
83 /// **The wait NOBODY is doing any work during.** After every byte has
84 /// arrived and been checksummed, the storage sits in `syncing` for this
85 /// long. MEASURED 100–130 s: the poller read `syncing` at 65 s and at 97 s
86 /// while the import object already said `completed`.
87 ///
88 /// And it is paid TWICE per appliance re-image, by design: the ladder
89 /// uploads the same 43 MB medium once as a CD-ROM and again as a virtio
90 /// DISK, because `korp-installer` probes virtio and nothing else. Two
91 /// `POST /storage`, two imports, two syncs — about four minutes of pure
92 /// waiting per re-image.
93 pub storage_sync_lo_ms: u64,
94 pub storage_sync_hi_ms: u64,
95 /// **A clone, MEASURED 2026-09-20** (gunnar `deploy/upcloud/tests/clone_probe.rs`):
96 /// the call 728 ms, then `maintenance` → `online` in **47 s, with NO
97 /// `syncing` at all**. This crate said "NOT MEASURED" and made a clone sync
98 /// like an import for years of storm; the measurement existed in the next
99 /// repository over (ledger X3, behaviour 53).
100 pub clone_online_ms: u64,
101 /// **The old pessimistic guess, kept by name only**
102 /// ([`crate::Fault::CloneSyncsLikeImport`]): the clone waits in `syncing`
103 /// for this long, like an import. Contradicted by the measurement above.
104 pub clone_sync_lo_ms: u64,
105 pub clone_sync_hi_ms: u64,
106 /// What a clone costs before the sync even starts (the volume is
107 /// `maintenance` while the copy is set up). Used only under the guess.
108 pub clone_prepare_ms: u64,
109
110 // ── added by lane T13 (BEHAVIOURS-LEDGER.md) ─────────────────────────────
111 /// **A start whose guest powers itself off inside the start operation.**
112 /// MEASURED 2026-09-19: `POST /start` → `maintenance` t+10…t+130 s →
113 /// `stopped` at t+140 s, and 2026-09-15: `stopped` 146 s after. The API
114 /// NEVER reads `started` (behaviour 46). Counted from the `start` call.
115 /// This is the FLOOR the API reports even when a real guest behind the mock
116 /// finished its install in 2 s: the API reports UpCloud time.
117 pub install_pass_lo_ms: u64,
118 pub install_pass_hi_ms: u64,
119 /// **An installer start DOES read `started` — briefly.** MEASURED
120 /// 2026-09-21 08:42 (private-gunnar-ops `.reimage/reinstall-receipt.json`,
121 /// the live re-image, `done: true`): step `start` at 377 421 ms, a read of
122 /// `started` at 381 091 ms ("the hypervisor reports the installer
123 /// running"), `maintenance` at 381 174 ms, `stopped` at 530 945 ms. The
124 /// 2026-09-19 "never `started`" was polled every 10 s from t+10 s
125 /// (RESUME-2026-09-19:17-23), which cannot see a window this short. So:
126 /// `started` from `installer_started_ms` for `installer_started_window_ms`
127 /// — the window is only BOUNDED by the reads (≤ 83 ms after one that saw
128 /// it), not measured — then `maintenance` until the pass ends.
129 pub installer_started_ms: u64,
130 pub installer_started_window_ms: u64,
131 /// **The REBOOTING medium's pass (behaviour 69): 900–1100 s, read `started`
132 /// throughout.** gunnar deploy/upcloud plan.rs: every measured pass
133 /// ≥ 900 s; 1000–1100 s live on 2026-09-08. At its end the guest reboots.
134 pub reboot_pass_lo_ms: u64,
135 pub reboot_pass_hi_ms: u64,
136 /// **The lag between the guest powering off and the API saying `stopped`,**
137 /// used only when a REAL guest outlives the floor above: the API may not
138 /// say `stopped` while the machine still runs, and says it this long after
139 /// the machine is gone. Bounded by the 10 s polls that measured it.
140 pub poweroff_notice_ms: u64,
141 /// **A size grow: `maintenance` for 37 s, then `online`.** MEASURED
142 /// 2026-09-14 on a detached 1→2 GiB volume (DATA-SET-GROWTH-DESIGN §2).
143 /// It was instant here (behaviour 55).
144 pub storage_grow_ms: u64,
145 /// **The VNC toggle's settle.** The PUT returns before the console has
146 /// moved; read inside this window and the OLD port comes back. MEASURED as
147 /// the pause the working tools need (gunnar `console.rs`, 2 s).
148 pub vnc_settle_ms: u64,
149 /// **The last leg of an import: `syncing` → `maintenance` → `online`.**
150 /// MEASURED as a sequence (clone_probe: "maintenance, syncing, maintenance,
151 /// online"); the split of the 100–130 s between the two legs is not, so
152 /// this is carved out of the sync range rather than added to it.
153 pub sync_tail_maintenance_ms: u64,
154}
155
156impl Timings {
157 /// **The same re-image, as the two observers see it.**
158 ///
159 /// Returns `(guest_uart_ms, ladder_wall_ms)`. They differ by two orders of
160 /// magnitude and they are both right: the first is the guest's own install,
161 /// the second is everything the provider does around it. Reporting one
162 /// number for "install" is what makes a 60-second bar unpassable and makes
163 /// moving the bar look reasonable.
164 pub fn reimage_observers(&self, seed: u64, tag: &str) -> (u64, u64) {
165 let mut r = crate::rng::SplitMix64::derive(seed, &format!("reimage/{tag}"));
166 (
167 r.range(self.guest_install_uart_lo_ms, self.guest_install_uart_hi_ms),
168 r.range(self.provider_wall_lo_ms, self.provider_wall_hi_ms),
169 )
170 }
171}
172
173impl Default for Timings {
174 fn default() -> Self {
175 Timings {
176 server_create_lo_ms: 98_000,
177 server_create_hi_ms: 105_000,
178 server_delete_ms: 60_000,
179 server_delete_per_volume_ms: 65_000,
180 storage_create_ms: 3_000,
181 storage_delete_ms: 4_000,
182 stop_soft_ms: 12_000,
183 stop_hard_ms: 2_000,
184 start_ms: 10_000,
185 resize_ms: 25_000,
186 guest_install_ms: 10_011,
187 guest_install_uart_lo_ms: 2_001,
188 guest_install_uart_hi_ms: 4_752,
189 provider_wall_lo_ms: 110_000,
190 provider_wall_hi_ms: 253_000,
191 template_first_boot_ms: 45_000,
192 import_upload_ms_per_mib: 120,
193 storage_sync_lo_ms: 100_000,
194 storage_sync_hi_ms: 130_000,
195 clone_online_ms: 47_000,
196 // The old guess: identical to the storage sync. By name only.
197 clone_sync_lo_ms: 100_000,
198 clone_sync_hi_ms: 130_000,
199 clone_prepare_ms: 3_000,
200 install_pass_lo_ms: 130_000,
201 install_pass_hi_ms: 146_000,
202 installer_started_ms: 3_600,
203 installer_started_window_ms: 100,
204 reboot_pass_lo_ms: 900_000,
205 reboot_pass_hi_ms: 1_100_000,
206 poweroff_notice_ms: 10_000,
207 storage_grow_ms: 37_000,
208 vnc_settle_ms: 2_000,
209 sync_tail_maintenance_ms: 4_000,
210 }
211 }
212}
213
214/// Virtual milliseconds since the mock started.
215///
216/// `speed` is how many virtual milliseconds pass per real millisecond, so `1.0`
217/// is real time. At `0.0` real time contributes nothing and the clock only
218/// moves when something [`Clock::advance`]s it — which the request handlers do,
219/// by the smallest amount that lets the next scheduled transition happen. That
220/// is what makes a storm run at CPU speed without reordering anything.
221pub struct Clock {
222 started: Instant,
223 speed_milli: u64,
224 /// Virtual ms added on top of the elapsed-real contribution.
225 pushed: AtomicU64,
226}
227
228impl Clock {
229 /// `speed` in thousandths, so the knob is an integer and two mocks with the
230 /// same knob are the same mock. `1000` = real time, `0` = virtual only.
231 pub fn new(speed_milli: u64) -> Clock {
232 Clock {
233 started: Instant::now(),
234 speed_milli,
235 pushed: AtomicU64::new(0),
236 }
237 }
238
239 pub fn faithful() -> Clock {
240 Clock::new(1000)
241 }
242
243 pub fn virtual_only() -> Clock {
244 Clock::new(0)
245 }
246
247 pub fn speed_milli(&self) -> u64 {
248 self.speed_milli
249 }
250
251 /// Now, in virtual milliseconds.
252 pub fn now_ms(&self) -> u64 {
253 let real = self.started.elapsed().as_millis() as u64;
254 real.saturating_mul(self.speed_milli) / 1000 + self.pushed.load(Ordering::SeqCst)
255 }
256
257 /// Push the virtual clock forward. Used by the handlers when `speed_milli`
258 /// is 0.
259 ///
260 /// **This sentence used to say the wrong thing, and it cost an afternoon.**
261 /// It claimed a read of an object mid-transition "advances time to the
262 /// moment the transition completes, so the SECOND poll sees it done".
263 /// `Estate::tick` does no such thing and says so in its own comment: it
264 /// advances HALFWAY to the next deadline, on purpose, so that a client must
265 /// poll several times as it does against the provider. Halving a 12 000 ms
266 /// stop takes about FOURTEEN polls to close.
267 ///
268 /// That is fine for a client with its own loop and fatal for one with a
269 /// BUDGET: MEASURED 2026-09-21, `UpCloudLtd/upcloud` 5.44.1 waiting for a
270 /// server to reach `stopped` before a filesystem resize gave up while the
271 /// mock still said `maintenance`, and the resize was then refused
272 /// `SERVER_STATE_ILLEGAL` — a defect that belongs to neither the estate nor
273 /// the provider. **Drive terraform against a mock with a non-zero
274 /// `--speed`** (20 000 is 20× faster than the account and still real
275 /// waiting); `--speed 0` is for in-process callers that poll without a
276 /// wall-clock budget.
277 pub fn advance_ms(&self, ms: u64) {
278 self.pushed.fetch_add(ms, Ordering::SeqCst);
279 }
280
281 /// The gap the mock should let a caller experience for a scheduled `at_ms`.
282 /// At real speed this is a real sleep; at virtual speed it is nothing, and
283 /// the caller is expected to [`Clock::advance_ms`] instead.
284 pub fn real_wait_for(&self, at_ms: u64) -> Option<Duration> {
285 if self.speed_milli == 0 {
286 return None;
287 }
288 let now = self.now_ms();
289 if at_ms <= now {
290 return None;
291 }
292 let virt = at_ms - now;
293 Some(Duration::from_millis(virt.saturating_mul(1000) / self.speed_milli))
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[test]
302 fn virtual_clock_only_moves_when_pushed() {
303 let c = Clock::virtual_only();
304 assert_eq!(c.now_ms(), 0);
305 std::thread::sleep(Duration::from_millis(30));
306 assert_eq!(c.now_ms(), 0, "real time must not leak in at speed 0");
307 c.advance_ms(98_000);
308 assert_eq!(c.now_ms(), 98_000);
309 }
310
311 #[test]
312 fn faithful_clock_tracks_real_time() {
313 let c = Clock::faithful();
314 std::thread::sleep(Duration::from_millis(40));
315 let n = c.now_ms();
316 assert!((30..400).contains(&n), "{n}");
317 }
318
319 #[test]
320 fn a_speed_knob_scales_the_wait_not_the_order() {
321 let c = Clock::new(100_000); // 100x
322 let w = c.real_wait_for(100_000).unwrap();
323 assert!(w <= Duration::from_millis(1100), "{w:?}");
324 assert_eq!(Clock::virtual_only().real_wait_for(100_000), None);
325 }
326
327 /// **Two observers, two orders of magnitude.** The guest's own install is
328 /// seconds; the ladder's wall measurement of the same re-image is minutes.
329 /// A bar set against one and named after the other is unpassable forever.
330 #[test]
331 fn the_installer_is_not_slow_the_provider_is() {
332 let t = Timings::default();
333 for tag in ["a", "b", "c", "d", "e"] {
334 let (uart, wall) = t.reimage_observers(42, tag);
335 assert!((2_001..=4_752).contains(&uart), "uart {uart}");
336 assert!((110_000..=253_000).contains(&wall), "wall {wall}");
337 assert!(
338 wall > uart * 20,
339 "the gap is two orders of magnitude, and it is the PROVIDER's: {wall} vs {uart}"
340 );
341 }
342 // And it replays: the same seed and tag give the same pair, so a row
343 // that reported a number can be re-derived rather than re-measured.
344 assert_eq!(t.reimage_observers(42, "a"), t.reimage_observers(42, "a"));
345 assert_ne!(t.reimage_observers(42, "a"), t.reimage_observers(43, "a"));
346 }
347
348 /// The measured upload: 43 485 184 bytes in five seconds. If this table
349 /// ever stops reproducing that, the numbers behind the re-image's cost
350 /// have stopped being the measured ones.
351 #[test]
352 fn the_measured_iso_uploads_in_five_seconds() {
353 let t = Timings::default();
354 let mib = 43_485_184u64 / (1024 * 1024);
355 let ms = mib * t.import_upload_ms_per_mib;
356 assert!((4_500..=5_500).contains(&ms), "{ms} ms for {mib} MiB");
357 }
358
359 /// And then the storage waits twenty times longer than the upload took,
360 /// doing nothing, twice per re-image.
361 #[test]
362 fn the_sync_dwarfs_the_upload_and_is_paid_twice() {
363 let t = Timings::default();
364 let upload = (43_485_184u64 / (1024 * 1024)) * t.import_upload_ms_per_mib;
365 assert!(t.storage_sync_lo_ms > upload * 15, "the wait is the cost, not the transfer");
366 let one_medium = upload + t.storage_sync_lo_ms;
367 let a_reimage = 2 * one_medium;
368 assert!(a_reimage > 200_000, "two media is over three minutes: {a_reimage} ms");
369 }
370
371 /// The delete of an appliance with four member volumes is the >5 min that
372 /// was measured; the front's is the ~60 s. One table, both numbers.
373 #[test]
374 fn delete_of_a_four_volume_appliance_exceeds_five_minutes() {
375 let t = Timings::default();
376 let front = t.server_delete_ms;
377 let appliance = t.server_delete_ms + 4 * t.server_delete_per_volume_ms;
378 assert!((55_000..=65_000).contains(&front), "{front}");
379 assert!(appliance > 300_000, "{appliance}");
380 }
381}