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}