Skip to main content

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}