spate_coordination/clock.rs
1//! The monotonic time source behind the coordination control loop.
2//!
3//! Production uses [`SystemClock`] — real wall time. Tests inject
4//! `TestClock`, which only moves when the test moves it, so every
5//! time-driven transition is deterministic regardless of CI scheduler
6//! jitter: the coordinator forces a multi-thread runtime, so
7//! `tokio::time::pause()` is unavailable and the timing would otherwise
8//! track real time.
9//!
10//! The clock owns both halves of "when": reading the current instant
11//! ([`Clock::now`]) *and* waiting for one ([`Clock::sleep_until`]). Every
12//! deadline that drives a *protocol state transition* is drawn from it —
13//! lease expiry and the starvation self-fence, the heartbeat/reconcile/
14//! replan cadence, the `rebalance_delay` grace window, the drain deadline,
15//! and the renewal cadence gate — so a frozen clock cannot freeze half the
16//! protocol and let the other half race.
17//!
18//! Deliberately *not* on the clock, because each bounds real I/O rather
19//! than protocol time: `op_timeout` (`store::metered`), the command reply
20//! budget (`coordinator`), the startup and re-watch retry backoffs
21//! (`task`), and `MemoryStore`'s sweeper cadence — the sweeper's *cadence*
22//! is real, but what it expires is judged against the clock. So a frozen
23//! clock stops the protocol; it does not stop the crate from using real
24//! time where real time is the thing being bounded. Commands are served on
25//! a clock-independent select arm, which is what lets a test drive a
26//! coordinator whose clock is not moving.
27//!
28//! # Advancing a frozen clock
29//!
30//! Time only moves on `TestClock::advance`, and what is safe to advance
31//! by depends on whether the worker under test can still renew:
32//!
33//! - **Advance to expire.** Jumping past a whole lease is deterministic
34//! *only* when the target cannot renew anyway — a crashed runtime, a
35//! kill-switch store, an injected fault. Nothing is racing the jump.
36//! - **Advance to settle.** For "hold steady and assert nothing changes"
37//! windows the worker is alive and must win its renewals, so step in
38//! increments no larger than `renew_interval` and let the task run
39//! between them. `TestClock::advance_stepped` does exactly that; a
40//! single large jump here would make a lease expiry and the renewal that
41//! prevents it come due at the same instant, which is a real race.
42
43use std::future::Future;
44use std::pin::Pin;
45// Only `TestClock` needs `Duration`; the trait and `SystemClock` do not.
46#[cfg(any(test, feature = "testing"))]
47use std::time::Duration;
48use tokio::time::Instant;
49
50/// A future that resolves when a [`Clock`] reaches some instant.
51///
52/// Boxed and `'static`: implementations must not borrow the clock, so a
53/// caller can hold the future across a `select!` arm whose body needs
54/// `&mut self`.
55///
56/// One is drawn per timer arm per `select!` entry, so the rate is the
57/// control loop's wakeup rate — not a fixed cadence. A commit wakes the
58/// loop (`Command::Commit` is sent unconditionally), and the pipeline
59/// controller tightens to `FAST_COMMIT_POLL` while chasing a fast-commit
60/// partition, so the loop can iterate in the low milliseconds. Still off
61/// the per-record path, and every such iteration already `Box::pin`s its
62/// handler — the same trade, at the same rate.
63pub type Sleep = Pin<Box<dyn Future<Output = ()> + Send + 'static>>;
64
65/// A monotonic clock. Returns [`tokio::time::Instant`] so the value slots
66/// straight into the existing lease/deadline fields without a type change.
67///
68/// `Debug` is required because [`MemoryStore`](crate::store::memory::MemoryStore)
69/// derives it and holds a clock.
70pub trait Clock: Send + Sync + std::fmt::Debug {
71 /// The current instant on this clock's timeline.
72 fn now(&self) -> Instant;
73
74 /// Resolve once this clock reaches `deadline`.
75 ///
76 /// Returns immediately if `deadline` is already past. On a frozen
77 /// clock this parks until the test advances past `deadline` — it must
78 /// never fall back to real time, or the freeze would leak.
79 fn sleep_until(&self, deadline: Instant) -> Sleep;
80}
81
82/// Real wall time — the production clock.
83#[derive(Debug, Default, Clone, Copy)]
84pub struct SystemClock;
85
86impl Clock for SystemClock {
87 fn now(&self) -> Instant {
88 Instant::now()
89 }
90
91 fn sleep_until(&self, deadline: Instant) -> Sleep {
92 Box::pin(tokio::time::sleep_until(deadline))
93 }
94}
95
96/// A clock that only moves when a test moves it. See the [module
97/// docs](self) for the two advancing patterns.
98///
99/// Behind the `testing` feature and `#[doc(hidden)]`: it has to be `pub`
100/// for this crate's `tests/` binaries, which are external crates and
101/// cannot see `#[cfg(test)]` items, but it must not reach a consumer — a
102/// `TestClock` wired into production would stop the control loop dead.
103/// Off by default and never enabled by the `spate` facade, the same shape as
104/// `spate-s3`'s `testing` feature.
105#[cfg(any(test, feature = "testing"))]
106#[doc(hidden)]
107#[derive(Debug)]
108pub struct TestClock {
109 /// The instant this clock was created at. Fixed for its lifetime.
110 base: Instant,
111 /// Nanoseconds elapsed on this clock's timeline. The watch channel is
112 /// the single source of truth for "now" *and* the wakeup for parked
113 /// [`Clock::sleep_until`] futures, so the two can never disagree.
114 offset_nanos: tokio::sync::watch::Sender<u64>,
115}
116
117#[cfg(any(test, feature = "testing"))]
118impl TestClock {
119 /// A clock frozen at construction. Build it after the runtime exists
120 /// so `tokio::time::Instant::now()` reads the runtime's clock.
121 #[must_use]
122 pub fn frozen() -> std::sync::Arc<TestClock> {
123 std::sync::Arc::new(TestClock {
124 base: Instant::now(),
125 offset_nanos: tokio::sync::watch::Sender::new(0),
126 })
127 }
128
129 /// Move the clock forward, waking everything parked on a deadline this
130 /// jump passes.
131 ///
132 /// Safe as one jump only when nothing is racing it — see the [module
133 /// docs](self). Prefer [`advance_stepped`](TestClock::advance_stepped)
134 /// for a live worker.
135 pub fn advance(&self, by: Duration) {
136 let nanos = u64::try_from(by.as_nanos()).expect("test clock advance fits in u64 nanos");
137 self.offset_nanos.send_modify(|n| *n += nanos);
138 }
139
140 /// Advance by `total` in `step`-sized increments, running `between`
141 /// after each one.
142 ///
143 /// This is the shape a live worker needs: it gets a chance to renew
144 /// inside every step, so its lease never expires merely because the
145 /// test moved time faster than the protocol could react. `between` is
146 /// where the test drives its coordinators (or just yields).
147 pub fn advance_stepped(&self, total: Duration, step: Duration, mut between: impl FnMut()) {
148 assert!(!step.is_zero(), "advance_stepped needs a non-zero step");
149 let mut moved = Duration::ZERO;
150 while moved < total {
151 let chunk = step.min(total - moved);
152 self.advance(chunk);
153 moved += chunk;
154 between();
155 }
156 }
157}
158
159#[cfg(any(test, feature = "testing"))]
160impl Clock for TestClock {
161 fn now(&self) -> Instant {
162 self.base + Duration::from_nanos(*self.offset_nanos.borrow())
163 }
164
165 fn sleep_until(&self, deadline: Instant) -> Sleep {
166 let base = self.base;
167 let mut rx = self.offset_nanos.subscribe();
168 Box::pin(async move {
169 loop {
170 if base + Duration::from_nanos(*rx.borrow_and_update()) >= deadline {
171 return;
172 }
173 if rx.changed().await.is_err() {
174 // The clock is gone, so it can never reach `deadline`.
175 // Park rather than return: waking would fire a timer
176 // whose time never came.
177 std::future::pending::<()>().await;
178 }
179 }
180 })
181 }
182}