navian_dst/executor.rs
1//! `Executor` trait — task spawning.
2//!
3//! Per ADR-0022. Production impl wraps `tokio::spawn` directly. Simulation
4//! impl runs tasks single-threaded under harness control (Phase 0 ships the
5//! production path; the full single-threaded scheduler lands in the phase
6//! that needs cross-node deterministic execution, currently Phase 4 / 8).
7
8use std::future::Future;
9use std::sync::Mutex;
10use std::time::Duration;
11
12use tokio::task::JoinHandle;
13
14use super::time::Time;
15
16/// Marker for a future that did not complete within the given duration.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18#[must_use = "TimedOut signals the operation did not finish and must be handled"]
19pub struct TimedOut;
20
21impl std::fmt::Display for TimedOut {
22 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23 f.write_str("operation timed out")
24 }
25}
26
27impl std::error::Error for TimedOut {}
28
29/// Run `future` to completion or return [`TimedOut`] if `duration` elapses
30/// first, measured by the supplied [`Time`] implementation.
31///
32/// Production: behaves like `tokio::time::timeout` (sleep is real). Simulation:
33/// the timeout fires when the harness clock advances past `now + duration`,
34/// making timeout-path tests deterministic (ADR-0022 §A3 + Step 3 §4.11
35/// Option β — a free helper using `Time::sleep` rather than widening the
36/// `Time` trait).
37///
38/// **Cancellation-unsafe**, exactly like `tokio::time::timeout`: when the
39/// timeout wins the `select!`, `future` is dropped at whatever `.await` point it
40/// had reached. Any effect it had already applied is *not* rolled back, and any
41/// work in flight at that suspension point is abandoned. Only pass futures that
42/// are safe to drop mid-flight (or make the operation idempotent / retryable).
43pub async fn timeout<T: Time + ?Sized, F: Future>(
44 time: &T,
45 duration: Duration,
46 future: F,
47) -> Result<F::Output, TimedOut> {
48 tokio::select! {
49 out = future => Ok(out),
50 _ = time.sleep(duration) => Err(TimedOut),
51 }
52}
53
54/// Task-spawning abstraction.
55///
56/// Engine code that needs to spawn a background task (replication stream,
57/// ClickHouse syncer, reaper, etc.) goes through this trait. Production
58/// builds use [`ProductionExecutor`] (real `tokio::spawn`); simulation
59/// builds use [`SimulatedExecutor`] (records spawned tasks; full
60/// scheduler lands in a later phase).
61///
62/// **Not dyn-safe.** The generic `spawn<F>` method precludes building a
63/// vtable. Per ADR-0022 alternative #3, `Executor` is hot-path-only and
64/// uses generics; cold paths that need dyn dispatch don't spawn tasks.
65/// Holders of an `Executor` parameterize their own types with `E: Executor`.
66pub trait Executor: Send + Sync + 'static {
67 /// Handle to a spawned task (ADR-0066). Production/simulation use tokio's
68 /// `JoinHandle`; a deterministic `SimScheduler`-backed executor uses its own
69 /// scheduler-native handle. The abstraction has NO required surface — every
70 /// production `spawn()` call site discards the handle — so this GAT is pure
71 /// prep that keeps the existing executors byte-identical while unblocking a
72 /// non-tokio implementation.
73 type JoinHandle<O: Send + 'static>: Send;
74
75 /// Spawn a future on the executor; returns a handle bound to the future's output.
76 fn spawn<F>(&self, future: F) -> Self::JoinHandle<F::Output>
77 where
78 F: Future + Send + 'static,
79 F::Output: Send + 'static;
80}
81
82// ─────────────────────────────────────────────────────────────────────
83// ProductionExecutor
84// ─────────────────────────────────────────────────────────────────────
85
86/// Production-mode `Executor` — direct `tokio::spawn`.
87///
88/// Zero-overhead: the spawn call compiles to the same code as a direct
89/// `tokio::spawn(future)`.
90#[derive(Debug, Default, Clone, Copy)]
91pub struct ProductionExecutor;
92
93impl Executor for ProductionExecutor {
94 type JoinHandle<O: Send + 'static> = JoinHandle<O>;
95
96 #[inline]
97 fn spawn<F>(&self, future: F) -> Self::JoinHandle<F::Output>
98 where
99 F: Future + Send + 'static,
100 F::Output: Send + 'static,
101 {
102 tokio::spawn(future)
103 }
104}
105
106// ─────────────────────────────────────────────────────────────────────
107// SimulatedExecutor
108// ─────────────────────────────────────────────────────────────────────
109
110/// Simulation-mode `Executor` — Phase 0 uses real `tokio::spawn` while
111/// recording spawned-task counts for property checks.
112///
113/// The full single-threaded deterministic scheduler (per
114/// `pulse-architecture-v1.md` §10.5) lands in a later phase when
115/// cross-task interleaving needs to be controlled by the harness — that
116/// scheduler is [`crate::SimScheduler`].
117///
118/// This executor runs tasks on the multi-threaded `tokio` runtime and only
119/// *counts* spawns; it does **not** by itself make a workload deterministic.
120/// `Time` and `Random` are reproducible only under single-threaded drive
121/// (see [`crate::SimScheduler`]); spawning shared clocks/RNGs across `tokio`
122/// worker threads through this executor gives a run-to-run-varying interleaving.
123/// For a replayable run, drive the workload on [`crate::SimScheduler`].
124pub struct SimulatedExecutor {
125 spawned: Mutex<u64>,
126}
127
128impl Default for SimulatedExecutor {
129 fn default() -> Self {
130 Self::new()
131 }
132}
133
134impl SimulatedExecutor {
135 /// Create an executor with a zeroed spawn counter.
136 pub fn new() -> Self {
137 Self {
138 spawned: Mutex::new(0),
139 }
140 }
141
142 /// Total number of `spawn` calls observed.
143 pub fn spawned_count(&self) -> u64 {
144 *self
145 .spawned
146 .lock()
147 .expect("SimulatedExecutor mutex poisoned")
148 }
149}
150
151impl Executor for SimulatedExecutor {
152 type JoinHandle<O: Send + 'static> = JoinHandle<O>;
153
154 fn spawn<F>(&self, future: F) -> Self::JoinHandle<F::Output>
155 where
156 F: Future + Send + 'static,
157 F::Output: Send + 'static,
158 {
159 *self
160 .spawned
161 .lock()
162 .expect("SimulatedExecutor mutex poisoned") += 1;
163 tokio::spawn(future)
164 }
165}
166
167// ─────────────────────────────────────────────────────────────────────
168// Tests
169// ─────────────────────────────────────────────────────────────────────
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 #[tokio::test]
176 async fn production_executor_runs_task() {
177 let e = ProductionExecutor;
178 let handle = e.spawn(async { 42 });
179 assert_eq!(handle.await.unwrap(), 42);
180 }
181
182 #[tokio::test]
183 async fn simulated_executor_runs_task_and_counts() {
184 let e = SimulatedExecutor::new();
185 assert_eq!(e.spawned_count(), 0);
186 let h1 = e.spawn(async { 1 });
187 let h2 = e.spawn(async { 2 });
188 let h3 = e.spawn(async { 3 });
189 assert_eq!(h1.await.unwrap() + h2.await.unwrap() + h3.await.unwrap(), 6);
190 assert_eq!(e.spawned_count(), 3);
191 }
192
193 /// `Executor` is intentionally generic-only (not dyn-safe) per ADR-0022.
194 /// Holders parameterize their own types with `E: Executor` and
195 /// monomorphize at compile time. This avoids vtable dispatch on the
196 /// hot path while still letting the substrate swap production/simulation
197 /// implementations at link time.
198 #[tokio::test]
199 async fn executor_holder_is_generic_over_implementation() {
200 struct EngineLike<E: Executor> {
201 exec: E,
202 }
203 impl<E: Executor> EngineLike<E> {
204 // Returns the executor's associated handle type (ADR-0066 GAT) — for
205 // Production/Simulated this is `tokio::task::JoinHandle<i32>`, for a
206 // deterministic executor it is that executor's own handle.
207 fn spawn_42(&self) -> E::JoinHandle<i32> {
208 self.exec.spawn(async { 42 })
209 }
210 }
211 // Production-mode engine
212 let prod = EngineLike {
213 exec: ProductionExecutor,
214 };
215 // Simulation-mode engine
216 let sim = EngineLike {
217 exec: SimulatedExecutor::new(),
218 };
219 // Both compile and the same `spawn_42` method dispatches correctly
220 // through monomorphization.
221 let _ = (prod.spawn_42(), sim.spawn_42());
222 }
223
224 use super::super::time::{ProductionTime, SimulatedTime};
225
226 #[tokio::test(start_paused = true)]
227 async fn timeout_returns_inner_value_when_future_completes() {
228 // Tokio's paused clock makes ProductionTime::sleep instant here.
229 let t = ProductionTime;
230 let r = timeout(&t, Duration::from_millis(100), async { 7 }).await;
231 assert_eq!(r, Ok(7));
232 }
233
234 #[tokio::test]
235 async fn timeout_under_simulated_time_fires_on_clock_advance() {
236 use std::sync::Arc;
237 let t = Arc::new(SimulatedTime::new(0));
238 let t2 = t.clone();
239
240 // Future that never completes — only the timeout can resolve it.
241 let task = tokio::spawn(async move {
242 timeout(
243 t2.as_ref(),
244 Duration::from_millis(500),
245 std::future::pending::<()>(),
246 )
247 .await
248 });
249
250 // Let the timeout sleeper subscribe to the watch channel.
251 tokio::task::yield_now().await;
252 // Advance the simulated clock past the timeout deadline.
253 t.advance_ms(500);
254
255 let result = task.await.expect("task panicked");
256 assert_eq!(result, Err(TimedOut));
257 }
258}