Skip to main content

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}