Skip to main content

nodedb_lite/
runtime.rs

1//! Platform-specific async runtime abstractions.
2//!
3//! NodeDB-Lite compiles for native (Tokio) and WASM (`wasm-bindgen-futures`).
4//! This module provides a thin abstraction over the differences so engine
5//! code doesn't need `#[cfg]` everywhere.
6//!
7//! **Native (iOS/Android/Desktop):** Tokio — `spawn`, `spawn_blocking`, `sleep`, `interval`.
8//! **WASM (Browser):** `wasm-bindgen-futures` + `gloo-timers` — `spawn_local`, no blocking
9//! threads, timer-backed sleep and interval.
10
11use std::future::Future;
12use std::time::Duration;
13
14/// Spawn a future on the runtime, returning a handle to the running task.
15///
16/// - Native: `tokio::spawn` (runs on Tokio thread pool, requires `Send`).
17/// - WASM: `wasm_bindgen_futures::spawn_local` (runs on the microtask queue,
18///   no `Send` requirement).
19///
20/// Ignoring the handle detaches the task: nothing can then stop it or wait for
21/// it. Register it with [`crate::tasks::TaskRegistry`] instead, so database
22/// shutdown can stop it before the runtime goes away.
23#[cfg(not(target_arch = "wasm32"))]
24pub fn spawn<F>(future: F) -> TaskHandle
25where
26    F: Future<Output = ()> + Send + 'static,
27{
28    TaskHandle {
29        inner: tokio::spawn(future),
30    }
31}
32
33#[cfg(target_arch = "wasm32")]
34pub fn spawn<F>(future: F) -> TaskHandle
35where
36    F: Future<Output = ()> + 'static,
37{
38    wasm_bindgen_futures::spawn_local(future);
39    TaskHandle
40}
41
42/// Handle to a task started by [`spawn`].
43///
44/// Native wraps a Tokio `JoinHandle`, so the task can be joined and, as a last
45/// resort, aborted. WASM has neither primitive — `spawn_local` returns nothing
46/// — so the handle is inert there and shutdown rests entirely on the
47/// cooperative stop signal.
48#[cfg(not(target_arch = "wasm32"))]
49pub struct TaskHandle {
50    inner: tokio::task::JoinHandle<()>,
51}
52
53#[cfg(target_arch = "wasm32")]
54pub struct TaskHandle;
55
56impl TaskHandle {
57    /// Cancel the task at its next await point.
58    ///
59    /// The backstop for a task that does not observe its stop signal. Prefer
60    /// the signal: abort drops the future wherever it happens to be suspended,
61    /// which can be mid-write.
62    pub fn abort(&self) {
63        #[cfg(not(target_arch = "wasm32"))]
64        {
65            self.inner.abort();
66        }
67    }
68
69    /// Wait for the task to finish, up to `timeout`.
70    ///
71    /// Returns `true` when the task finished, `false` on timeout — leaving the
72    /// caller free to [`abort`](Self::abort) it. Always `true` on WASM, which
73    /// has nothing to join.
74    pub async fn join_within(&mut self, timeout: Duration) -> bool {
75        #[cfg(not(target_arch = "wasm32"))]
76        {
77            tokio::select! {
78                _ = &mut self.inner => true,
79                _ = tokio::time::sleep(timeout) => false,
80            }
81        }
82        #[cfg(target_arch = "wasm32")]
83        {
84            let _ = timeout;
85            true
86        }
87    }
88}
89
90/// Run a blocking closure off the async runtime.
91///
92/// - Native: `tokio::task::spawn_blocking` — moves closure to the blocking pool.
93/// - WASM: Runs synchronously (WASM has no blocking pool; callers must
94///   ensure the closure is fast or use the async StorageEngine path).
95#[cfg(not(target_arch = "wasm32"))]
96pub async fn spawn_blocking<F, T>(f: F) -> Result<T, crate::error::LiteError>
97where
98    F: FnOnce() -> T + Send + 'static,
99    T: Send + 'static,
100{
101    tokio::task::spawn_blocking(f)
102        .await
103        .map_err(|e| crate::error::LiteError::JoinError {
104            detail: e.to_string(),
105        })
106}
107
108#[cfg(target_arch = "wasm32")]
109pub async fn spawn_blocking<F, T>(f: F) -> Result<T, crate::error::LiteError>
110where
111    F: FnOnce() -> T,
112{
113    // No blocking pool on WASM — run synchronously.
114    // This is acceptable because:
115    // 1. SQLite WASM operations are fast (in-memory or OPFS sync access)
116    // 2. HNSW/CSR operations are CPU-bound but sub-millisecond for edge datasets
117    Ok(f())
118}
119
120/// Sleep for a duration.
121///
122/// - Native: `tokio::time::sleep`.
123/// - WASM: `gloo_timers::future::sleep` (backed by JS `setTimeout`).
124#[cfg(not(target_arch = "wasm32"))]
125pub async fn sleep(duration: Duration) {
126    tokio::time::sleep(duration).await;
127}
128
129#[cfg(target_arch = "wasm32")]
130pub async fn sleep(duration: Duration) {
131    gloo_timers::future::sleep(duration).await;
132}
133
134/// A recurring interval timer.
135///
136/// Obtain one via [`interval`]. Call `.tick().await` to wait for each period.
137///
138/// On native the first `tick()` returns immediately (matches Tokio semantics).
139/// On WASM the first `tick()` waits one full period. The primary consumer
140/// (sync keepalive) tolerates either behaviour.
141pub struct Interval {
142    #[cfg(not(target_arch = "wasm32"))]
143    inner: tokio::time::Interval,
144    #[cfg(target_arch = "wasm32")]
145    period: Duration,
146}
147
148impl Interval {
149    /// Measure the next period from when this tick is consumed rather than
150    /// from the schedule, so a slow consumer does not come back to a burst of
151    /// catch-up ticks.
152    ///
153    /// Tokio's default replays every missed tick immediately. For a periodic
154    /// task whose work can outlast its own period — a flush, say — that turns
155    /// one slow pass into back-to-back passes with no gap between them, and the
156    /// task never yields long enough for anything else to make progress. On
157    /// WASM each tick already sleeps a full period after the previous one, so
158    /// this is the behaviour there either way.
159    pub fn delay_missed_ticks(&mut self) {
160        #[cfg(not(target_arch = "wasm32"))]
161        {
162            self.inner
163                .set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
164        }
165    }
166
167    /// Wait until the next tick.
168    pub async fn tick(&mut self) {
169        #[cfg(not(target_arch = "wasm32"))]
170        {
171            self.inner.tick().await;
172        }
173        #[cfg(target_arch = "wasm32")]
174        {
175            gloo_timers::future::sleep(self.period).await;
176        }
177    }
178}
179
180/// Create a recurring interval timer that ticks every `period`.
181///
182/// - Native: wraps `tokio::time::interval`; first tick is immediate.
183/// - WASM: backed by `gloo_timers`; first tick waits one period.
184pub fn interval(period: Duration) -> Interval {
185    #[cfg(not(target_arch = "wasm32"))]
186    {
187        Interval {
188            inner: tokio::time::interval(period),
189        }
190    }
191    #[cfg(target_arch = "wasm32")]
192    {
193        Interval { period }
194    }
195}
196
197/// Get the current timestamp in milliseconds since Unix epoch.
198///
199/// Platform-independent — works on native and WASM.
200pub fn now_millis() -> u64 {
201    #[cfg(not(target_arch = "wasm32"))]
202    {
203        std::time::SystemTime::now()
204            .duration_since(std::time::UNIX_EPOCH)
205            .unwrap_or_default()
206            .as_millis() as u64
207    }
208    #[cfg(target_arch = "wasm32")]
209    {
210        // js_sys::Date::now() returns milliseconds since epoch as f64.
211        js_sys::Date::now() as u64
212    }
213}
214
215/// Get the current timestamp in milliseconds since Unix epoch, as `i64`.
216///
217/// Same clock as [`now_millis`] but signed, for the system/valid-time fields
218/// used by the bitemporal engines. Platform-independent — works on native and
219/// WASM.
220pub fn now_millis_i64() -> i64 {
221    now_millis() as i64
222}
223
224/// Last system-time millisecond handed out by [`monotonic_millis_i64`].
225static LAST_SYS_MS: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0);
226
227/// Get a strictly-monotonic system timestamp in milliseconds since Unix epoch.
228///
229/// Like [`now_millis_i64`] but guarantees the returned value is strictly greater
230/// than any value previously returned **within this process**. When the
231/// wall-clock has not advanced since the last call (or moves backwards), the
232/// previous value `+ 1` is returned instead.
233///
234/// The bitemporal document history keys each version by `system_from_ms`
235/// (`{collection}:{doc_id}\0{system_from_ms:020}`). Two writes landing in the
236/// same wall-clock millisecond — e.g. a `put` immediately followed by a
237/// valid-time close — would otherwise derive the identical version key and the
238/// second write would silently overwrite the first (the storage layer is plain
239/// last-write-wins KV). This clock gives every version a distinct, ordered key.
240/// The sub-millisecond forward skew under burst is bounded by the write rate and
241/// self-corrects once wall-clock time catches up.
242pub fn monotonic_millis_i64() -> i64 {
243    use std::sync::atomic::Ordering;
244    let now = now_millis_i64();
245    loop {
246        let last = LAST_SYS_MS.load(Ordering::Relaxed);
247        let next = if now > last { now } else { last + 1 };
248        match LAST_SYS_MS.compare_exchange_weak(last, next, Ordering::Relaxed, Ordering::Relaxed) {
249            Ok(_) => return next,
250            Err(_) => continue,
251        }
252    }
253}
254
255/// Get the current timestamp in seconds since Unix epoch.
256pub fn now_secs() -> u64 {
257    now_millis() / 1000
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[tokio::test]
265    async fn spawn_blocking_works() {
266        let result = spawn_blocking(|| 42).await.unwrap();
267        assert_eq!(result, 42);
268    }
269
270    #[tokio::test]
271    async fn spawn_blocking_string() {
272        let result = spawn_blocking(|| "hello".to_string()).await.unwrap();
273        assert_eq!(result, "hello");
274    }
275
276    #[tokio::test]
277    async fn sleep_returns() {
278        // Just verify it doesn't hang.
279        sleep(Duration::from_millis(1)).await;
280    }
281
282    #[test]
283    fn now_millis_nonzero() {
284        let ts = now_millis();
285        assert!(ts > 0, "timestamp should be nonzero on native");
286    }
287
288    #[test]
289    fn now_secs_reasonable() {
290        let ts = now_secs();
291        // Should be after 2024-01-01 (1704067200).
292        assert!(ts > 1_704_067_200, "timestamp {ts} seems too old");
293    }
294
295    #[tokio::test]
296    async fn interval_ticks_twice() {
297        let mut iv = interval(Duration::from_millis(1));
298        // First tick is immediate on native (Tokio semantics).
299        iv.tick().await;
300        // Second tick waits one period — should still resolve promptly.
301        iv.tick().await;
302    }
303
304    #[tokio::test]
305    async fn spawn_fires() {
306        let (tx, rx) = tokio::sync::oneshot::channel();
307        spawn(async move {
308            let _ = tx.send(42);
309        });
310        let val = rx.await.unwrap();
311        assert_eq!(val, 42);
312    }
313}