Skip to main content

core_api/
shared.rs

1//! Concurrent access to a [`GraphDb`] via a process-wide reader-writer lock,
2//! plus a group-commit write queue that batches concurrent submissions behind
3//! a single WAL fsync per group.
4//!
5//! # Group-commit design
6//!
7//! [`SharedDb::submit_batch`] enqueues a `Vec<BatchOp>` and blocks until the
8//! containing **group** is durably committed.  A background drain thread owned
9//! by `SharedDb` wakes on new submissions, drains up to `MAX_GROUP_SIZE`
10//! pending submissions, acquires the write lock, applies each submission as a
11//! separate WAL `Batch` frame (no per-submission fsync), **releases the write
12//! lock**, then performs **one** fsync on the WAL file outside the lock.  Only
13//! after the fsync completes does the drain thread signal each submitter.
14//!
15//! # Fsync-outside-guard
16//!
17//! Moving the WAL fsync outside the exclusive write-lock window means
18//! concurrent readers can acquire the read lock while the fsync is in flight,
19//! reducing p95 read latency under write bursts.  Readers may transiently
20//! observe committed-but-not-yet-synced data during that window — the same
21//! contract as `FsyncPolicy::Relaxed` — but submitters only receive `Ok`
22//! after the fsync, so durability is fully guaranteed from their perspective.
23//!
24//! # WAL I/O lock order (load-bearing)
25//!
26//! A WAL mutex (`SharedDb::wal_mu`) serialises all WAL I/O — appends, fsyncs,
27//! and truncations — across the drain thread and direct writers.
28//!
29//! **Required acquisition order (must be consistent in all code paths):**
30//!
31//! 1. `wal_mu`  — acquired first
32//! 2. `inner` (RwLock write guard) — acquired second, while holding `wal_mu`
33//!
34//! [`SharedDb::write`] enforces this by acquiring `wal_mu` before the RwLock.
35//! The drain thread acquires `wal_mu` before `inner.write()`.
36//! Readers NEVER acquire `wal_mu`, nor the cross-process lock — neither a
37//! concurrent fsync nor a peer process holding the store lock lengthens a read.
38//! What a reader can wait on is another thread's RwLock write guard: a direct
39//! writer's mutation, a group apply, or its own periodic refresh.
40//!
41//! Holding `wal_mu` from before [append group frames] through [fsync OR
42//! truncation resolution] closes the truncation race: no concurrent direct
43//! write can insert WAL frames between the group's append and its fsync
44//! outcome, so `truncate_wal_at(pre_len)` is always a safe tail-trim.
45//!
46//! # Cross-process write lock
47//!
48//! The two locks above serialise writers inside this process. Across processes
49//! the store carries an advisory exclusive lock on its `LOCK` file, and it is
50//! taken **between** them, not last. The full order is:
51//!
52//! 1. `wal_mu`
53//! 2. cross-process `LOCK` — polled here, with no RwLock guard held
54//! 3. `inner` (RwLock write guard)
55//!
56//! Waiting for the cross-process lock is the slow step: a busy peer can hold it
57//! for the whole [`WRITE_LOCK_WAIT`](crate::WRITE_LOCK_WAIT) budget. Polling it
58//! before the RwLock is what keeps that wait off the read path — each attempt
59//! takes the RwLock in *read* mode for one `try_lock` syscall and releases it,
60//! so readers are never blocked by another process. Taking `inner.write()`
61//! first would stall every reader in this process behind a peer in another one.
62//!
63//! Both WAL-appending paths take it: [`SharedDb::write`] for the whole guard
64//! scope, and the drain thread for the whole of each group, from before the
65//! first append until after the group's fsync resolves. Releasing it only after
66//! the fsync is what stops another process's snapshot from truncating bytes
67//! this one has written but not yet made durable. `snapshot_with` refuses to
68//! run without it, for the same reason.
69//!
70//! Entering the write scope also refreshes, so a write always applies on top of
71//! every other process's commits. A writer that cannot get the lock within its
72//! wait budget fails with [`GraphError::Busy`], having written nothing.
73//!
74//! Readers take neither `wal_mu` nor the cross-process lock. They check for
75//! other processes' commits on every read — a metadata-only staleness check —
76//! and apply the WAL tail under the in-process write lock, which is the same
77//! lock the drain thread holds while it appends and applies — so a refresh
78//! never observes a half-committed group.
79//!
80//! # Fsync-failure contract
81//!
82//! If the group fsync fails the drain thread immediately:
83//! 1. **Truncates** the WAL file back to the pre-group offset — this prevents
84//!    a later successful fsync from silently making the failed group durable
85//!    by flushing the full inode page cache.  The truncation is safe because
86//!    `wal_mu` prevents any concurrent write from appending after `pre_len`.
87//! 2. **Marks** the database degraded via [`GraphDb::set_degraded`] — all
88//!    subsequent [`submit_batch`] and `db.write()` mutation attempts return
89//!    an IO error until the database is reopened.
90//! 3. **Discards** buffered event notifications (no subscriber sees un-durable
91//!    data).
92//! 4. **Signals** all submitters in the failed group with an IO error.
93//! 5. **Exits** the drain loop.
94//!
95//! Readers may have already observed the failed group's data (between the
96//! write-lock release and the truncation); that window is equivalent to the
97//! `Relaxed` durability contract.
98//!
99//! # Event ordering (Strict policy, R2)
100//!
101//! Under `FsyncPolicy::Strict` or `Batched`, subscription events are deferred
102//! until after the group fsync.  The drain thread then reacquires the write lock
103//! (while still holding `wal_mu`) to call [`GraphDb::flush_deferred_events`],
104//! releases the write lock, releases `wal_mu`, and finally signals submitters.
105//! Flushing events while `wal_mu` is held prevents a concurrent direct writer
106//! from slipping in between the group fsync and the event flush and delivering
107//! its event before the group's events — preserving a global monotone event
108//! order across both the drain path and the direct write path.
109//! Under `Relaxed`, events fire immediately (no fsync to wait for).
110//!
111//! # Event delivery and crashes (R2)
112//!
113//! Subscription events are best-effort post-durability notifications.  A crash
114//! between a successful group fsync and the `flush_deferred_events` call drops
115//! those events — a strictly narrower loss window than pre-4b (where events
116//! could fire before any fsync).
117//!
118//! # Shutdown
119//!
120//! `SharedDb` clones the drain handle via an `Arc`; the last clone to drop
121//! triggers `DrainHandle::drop`, which signals shutdown + joins the thread.
122//! The drain thread can never exit while any `SharedDb` clone exists (the
123//! `Arc<Inner>` is held by every clone); no submission enqueued before the
124//! last clone is dropped can be silently lost.
125
126use crate::db::{
127    BatchOp, FsyncPolicy, Precondition, WriteAuthz, LOCK_POLL_INTERVAL, WRITE_LOCK_WAIT,
128};
129use crate::reader::ReaderSnapshot;
130use crate::GraphDb;
131use core_storage::sync_wal_at;
132use core_storage::truncate_wal_at;
133use core_storage::GraphError;
134use core_storage::RealFs;
135use core_storage::Result;
136use std::ops::{Deref, DerefMut};
137use std::path::Path;
138use std::sync::atomic::{AtomicBool, Ordering};
139use std::sync::{Arc, Condvar, Mutex, RwLock};
140use std::thread;
141use std::time::{Duration, Instant};
142
143// ── Group-commit constants ────────────────────────────────────────────────────
144
145/// Maximum submissions coalesced into one group.  Caps write-lock hold time
146/// under extreme write bursts.
147const MAX_GROUP_SIZE: usize = 256;
148
149// ── WAL sync function type ────────────────────────────────────────────────────
150
151/// A callable that syncs the WAL at a given directory path.
152///
153/// In production this is always `sync_wal_at`.  Tests may inject a failing
154/// implementation via [`SharedDb::open_with_test_sync`] to exercise the
155/// fsync-failure contract through the live drain thread.
156type SyncWalFn = Arc<dyn Fn(&Path) -> std::io::Result<()> + Send + Sync>;
157
158// ── Submission type ───────────────────────────────────────────────────────────
159
160struct Submission {
161    ops: Vec<BatchOp>,
162    /// Compare-and-set preconditions.  Empty for plain `submit_batch` calls;
163    /// non-empty for `submit_batch_cas` calls.  The drain thread checks these
164    /// under the same write guard as the batch apply (no TOCTOU).
165    preconds: Vec<Precondition>,
166    /// Role name for role-scoped write authz.  `Some` only for
167    /// `submit_batch_authz` calls; `None` for full-authority submissions.
168    /// The drain thread resolves mask + scope under `inner.write()` (§5 lock
169    /// discipline: authz check and mutation share one guard lifetime).
170    authz_role: Option<String>,
171    done: std::sync::mpsc::SyncSender<Result<(usize, usize)>>,
172}
173
174// ── WriteQueue ────────────────────────────────────────────────────────────────
175
176struct WriteQueue {
177    pending: Mutex<Vec<Submission>>,
178    notify: Condvar,
179    shutdown: AtomicBool,
180    /// Set by the drain thread on a group fsync failure.  Non-None means the
181    /// drain thread has exited; future `submit_batch` calls return Err immediately
182    /// rather than blocking forever on a dead drain thread.
183    degraded_msg: Mutex<Option<String>>,
184}
185
186impl WriteQueue {
187    fn new() -> Arc<Self> {
188        Arc::new(Self {
189            pending: Mutex::new(Vec::new()),
190            notify: Condvar::new(),
191            shutdown: AtomicBool::new(false),
192            degraded_msg: Mutex::new(None),
193        })
194    }
195
196    fn enqueue(&self, sub: Submission) {
197        self.pending
198            .lock()
199            .unwrap_or_else(|e| e.into_inner())
200            .push(sub);
201        self.notify.notify_one();
202    }
203
204    fn signal_shutdown(&self) {
205        // Hold the queue mutex while flagging + notifying: the drain thread
206        // checks `shutdown` only under this mutex, so the notify can no longer
207        // fire between its predicate check and Condvar::wait (missed wakeup).
208        let _lock = self.pending.lock().unwrap_or_else(|e| e.into_inner());
209        self.shutdown.store(true, Ordering::Release);
210        self.notify.notify_all();
211    }
212
213    fn set_degraded(&self, msg: String) {
214        *self.degraded_msg.lock().unwrap_or_else(|e| e.into_inner()) = Some(msg);
215    }
216
217    fn degraded_message(&self) -> Option<String> {
218        self.degraded_msg
219            .lock()
220            .unwrap_or_else(|e| e.into_inner())
221            .clone()
222    }
223
224    /// Block until work is available or shutdown; return at most `MAX_GROUP_SIZE`
225    /// submissions.  Returns an empty `Vec` only when shutdown is set AND the
226    /// queue is empty.
227    fn wait_and_drain(&self) -> Vec<Submission> {
228        let mut lock = self.pending.lock().unwrap_or_else(|e| e.into_inner());
229        loop {
230            if !lock.is_empty() {
231                let n = lock.len().min(MAX_GROUP_SIZE);
232                return lock.drain(..n).collect();
233            }
234            if self.shutdown.load(Ordering::Acquire) {
235                return vec![];
236            }
237            lock = self.notify.wait(lock).unwrap_or_else(|e| e.into_inner());
238        }
239    }
240}
241
242// ── DrainHandle ───────────────────────────────────────────────────────────────
243
244/// Signals the drain thread and joins it when dropped.  Owned inside an
245/// `Arc` so the last `SharedDb` clone triggers the join.
246struct DrainHandle {
247    queue: Arc<WriteQueue>,
248    handle: Option<thread::JoinHandle<()>>,
249}
250
251impl Drop for DrainHandle {
252    fn drop(&mut self) {
253        self.queue.signal_shutdown();
254        if let Some(h) = self.handle.take() {
255            let _ = h.join();
256        }
257    }
258}
259
260// ── WriteGuard ────────────────────────────────────────────────────────────────
261
262/// Compound write guard returned by [`SharedDb::write`].
263///
264/// Holds both the WAL mutex and the RwLock write guard.  Fields are declared
265/// in drop order — `inner` (RwLock) releases first, then `_wal` (WAL mutex)
266/// — preserving the lock-release ordering required by the WAL I/O discipline.
267///
268/// # Lock order
269///
270/// Acquisition: `wal_mu` → cross-process `LOCK` → `inner` (RwLock write). The
271/// cross-process lock is polled with no RwLock write guard held, so a peer
272/// process holding it never stalls this process's readers.
273/// Release: cross-process `LOCK` (in `Drop`), then `inner`, then `wal_mu`
274/// (RAII, struct field declaration order).
275///
276/// # Cross-process lock
277///
278/// Constructing the guard also takes the store's advisory cross-process write
279/// lock and refreshes, so mutations made through it land on top of every other
280/// process's commits. If the lock could not be taken within the caller's wait
281/// budget, the guard is still returned but every mutation on it fails with
282/// [`GraphError::Busy`] — use [`SharedDb::write_with_wait`] to see the failure
283/// up front instead.
284pub struct WriteGuard<'a> {
285    /// RwLock write guard — dropped first (field declared first).
286    inner: std::sync::RwLockWriteGuard<'a, GraphDb<RealFs>>,
287    /// WAL mutex guard — dropped second (field declared second).
288    _wal: std::sync::MutexGuard<'a, ()>,
289}
290
291impl<'a> Drop for WriteGuard<'a> {
292    fn drop(&mut self) {
293        // Release the cross-process lock before either in-process lock: another
294        // process may be polling for it, and there is nothing left to serialise.
295        self.inner.end_write_lock();
296    }
297}
298
299impl<'a> Deref for WriteGuard<'a> {
300    type Target = GraphDb<RealFs>;
301    fn deref(&self) -> &GraphDb<RealFs> {
302        &self.inner
303    }
304}
305
306impl<'a> DerefMut for WriteGuard<'a> {
307    fn deref_mut(&mut self) -> &mut GraphDb<RealFs> {
308        &mut self.inner
309    }
310}
311
312// ── SharedDb ──────────────────────────────────────────────────────────────────
313
314/// Shared handle to an on-disk [`GraphDb`]. [`Clone`] is cheap and shares state.
315///
316/// # Group-commit write path
317///
318/// [`SharedDb::submit_batch`] routes mutations through a group-commit queue.
319/// A background drain thread batches concurrent submissions under a single WAL
320/// fsync, yielding throughput that scales with concurrency.
321///
322/// # Direct write path
323///
324/// [`SharedDb::write`] gives exclusive `&mut GraphDb` access for callers that
325/// need complex multi-step mutations (e.g. Cypher write queries).  It acquires
326/// the WAL mutex first, then the RwLock write guard, satisfying the lock-order
327/// discipline described in the module doc.
328///
329/// # Event-sink deadlock
330///
331/// [`GraphDb::set_event_sink`] runs inside `log_then_apply` while the write
332/// guard is held.  A sink must never call [`SharedDb::read`] or
333/// [`SharedDb::write`] on the same handle.
334#[derive(Clone)]
335pub struct SharedDb {
336    inner: Arc<RwLock<GraphDb<RealFs>>>,
337    queue: Arc<WriteQueue>,
338    /// Keeps the drain thread alive; signals + joins on last drop.
339    _drain: Arc<DrainHandle>,
340    /// WAL I/O mutex.  Serialises all WAL appends, fsyncs, and truncations
341    /// across the drain thread and direct writers.  See module-level doc for
342    /// the required acquisition order.
343    wal_mu: Arc<Mutex<()>>,
344}
345
346const _: () = {
347    fn assert_send_sync<T: Send + Sync>() {}
348    let _ = assert_send_sync::<SharedDb>;
349};
350
351impl SharedDb {
352    /// Open the store at `dir` as a shared, multi-reader handle.
353    ///
354    /// Unlike a plain [`GraphDb`], this does not hold the store's cross-process
355    /// write lock for the handle's lifetime — a server would otherwise lock out
356    /// every other process for as long as it runs. The lock is taken per write
357    /// instead, and reads follow other processes' commits automatically.
358    pub fn open(dir: &Path) -> Result<Self> {
359        let db = GraphDb::open_unlocked(dir)?;
360        Ok(Self::from_db_and_dir_with_sync(
361            db,
362            dir.to_path_buf(),
363            Arc::new(sync_wal_at),
364        ))
365    }
366
367    /// Open with an injectable WAL sync function.
368    ///
369    /// Allows tests to inject fsync failures through the live drain thread
370    /// without requiring real filesystem manipulation.  Not intended for
371    /// production use; the `test_sync` name signals its purpose.
372    pub fn open_with_test_sync(
373        dir: &Path,
374        sync: impl Fn(&Path) -> std::io::Result<()> + Send + Sync + 'static,
375    ) -> Result<Self> {
376        let db = GraphDb::open_unlocked(dir)?;
377        Ok(Self::from_db_and_dir_with_sync(
378            db,
379            dir.to_path_buf(),
380            Arc::new(sync),
381        ))
382    }
383
384    fn from_db_and_dir_with_sync(
385        db: GraphDb<RealFs>,
386        dir: std::path::PathBuf,
387        sync_fn: SyncWalFn,
388    ) -> Self {
389        let inner = Arc::new(RwLock::new(db));
390        let queue = WriteQueue::new();
391        let wal_mu = Arc::new(Mutex::new(()));
392        let dir_arc = Arc::new(dir);
393
394        let drain_inner = Arc::clone(&inner);
395        let drain_queue = Arc::clone(&queue);
396        let drain_dir = Arc::clone(&dir_arc);
397        let drain_wal_mu = Arc::clone(&wal_mu);
398        let drain_sync_fn = Arc::clone(&sync_fn);
399
400        let handle = thread::Builder::new()
401            .name("groupcommit-drain".into())
402            .spawn(move || {
403                drain_loop(
404                    drain_inner,
405                    drain_queue,
406                    drain_dir,
407                    drain_wal_mu,
408                    drain_sync_fn,
409                )
410            })
411            .expect("failed to spawn group-commit drain thread");
412
413        SharedDb {
414            inner,
415            queue: Arc::clone(&queue),
416            _drain: Arc::new(DrainHandle {
417                queue,
418                handle: Some(handle),
419            }),
420            wal_mu,
421        }
422    }
423
424    /// Check whether another process has committed, and if so absorb its
425    /// commits.
426    ///
427    /// Runs on every read. The check is metadata-only — one `stat` of the WAL,
428    /// and a second of the snapshot when the WAL length is unchanged — which
429    /// is the price of a handle that follows its peers.
430    ///
431    /// It is deliberately not rate-limited by a timer. A wall-clock window
432    /// makes visibility depend on how fast the peer happened to be: a read
433    /// landing inside the window returns a view that predates a commit which
434    /// had already completed, while the same read on a slower machine would
435    /// have seen it. The child process in `multiprocess.rs` test 1 writes its
436    /// 100 nodes in ~11 ms on Linux — well inside the 50 ms window this check
437    /// used to keep, and outside it on macOS, where spawning the child costs
438    /// more than the window itself.
439    ///
440    /// Readers never touch the cross-process lock and never block on it: the
441    /// worst a reader does is take the in-process write lock briefly to apply
442    /// the WAL tail. Applying the tail under that lock is what makes the check
443    /// safe against a concurrent group commit — the drain thread appends,
444    /// applies, and advances the cursor under the same lock, so a refresh never
445    /// sees a half-committed group and never replays one twice.
446    ///
447    /// A staleness check that fails (an unreadable store directory, say) is
448    /// treated as "not stale": a read must not fail because the store might
449    /// have moved on.
450    fn refresh_if_stale(&self) {
451        let stale = self
452            .inner
453            .read()
454            .unwrap_or_else(|e| e.into_inner())
455            .is_stale()
456            .unwrap_or(false);
457        if stale {
458            let mut db = self.inner.write().unwrap_or_else(|e| e.into_inner());
459            if let Err(e) = db.refresh() {
460                // `refresh` degrades the handle on failure, so mutations
461                // already refuse. Tell the write queue too, so a submitter gets
462                // the error immediately instead of blocking on a drain thread
463                // whose next group will fail anyway.
464                self.queue.set_degraded(format!(
465                    "refresh failed while following another process's commits: {e}; \
466                     reopen required"
467                ));
468            }
469        }
470    }
471
472    /// Shared read access. Many readers may hold this concurrently.
473    ///
474    /// Readers never acquire the WAL mutex and never wait on the cross-process
475    /// write lock, so neither a concurrent fsync nor a peer process writing the
476    /// store lengthens a read. A reader can still wait on another thread's
477    /// RwLock write guard, including the refresh below.
478    ///
479    /// # Following other processes
480    ///
481    /// Every read checks whether another process has committed and, if so,
482    /// applies its commits before handing out the guard. A handle therefore
483    /// stays current without reopening, and the guarantee is not a timing
484    /// accident: a read started after a peer's commit completed sees that
485    /// commit. The check is metadata-only — one or two `stat` calls, no file
486    /// contents — so a read loop pays a syscall, not a reload.
487    ///
488    /// # Deadlock warning
489    ///
490    /// Do not hold a returned guard while calling any method on the same
491    /// [`SharedDb`]; the [`RwLock`] is not re-entrant; doing so deadlocks.
492    pub fn read(&self) -> impl Deref<Target = GraphDb<RealFs>> + '_ {
493        self.refresh_if_stale();
494        self.inner.read().unwrap_or_else(|e| e.into_inner())
495    }
496
497    /// Exclusive write access, waiting up to [`WRITE_LOCK_WAIT`] for the
498    /// store's cross-process write lock.
499    ///
500    /// Acquires the WAL mutex first, then the cross-process lock, then the
501    /// RwLock write guard — the order required by the fsync-failure contract
502    /// (see module doc). Waiting for the cross-process lock happens with no
503    /// RwLock write guard held, which is what keeps a busy peer process off
504    /// this process's read path. The returned [`WriteGuard`] releases the
505    /// cross-process lock, then the RwLock, then the WAL mutex on drop.
506    ///
507    /// # When another process holds the lock
508    ///
509    /// The guard is still returned, but every mutation through it fails with
510    /// [`GraphError::Busy`] and writes nothing. Call
511    /// [`write_with_wait`](SharedDb::write_with_wait) when you would rather see
512    /// that up front, or choose your own wait budget.
513    ///
514    /// # Deadlock warning
515    ///
516    /// Do not hold a returned guard while calling any method on the same
517    /// [`SharedDb`]; the [`RwLock`] is not re-entrant; doing so deadlocks.
518    pub fn write(&self) -> WriteGuard<'_> {
519        let _wal = self.wal_mu.lock().unwrap_or_else(|e| e.into_inner());
520        let acquired = self
521            .poll_cross_process_lock(WRITE_LOCK_WAIT)
522            .unwrap_or(false);
523        // The outcome is latched on the db itself, so it surfaces on the first
524        // mutation: a denied lock as `Busy`, and a refresh failure under a
525        // taken lock as the degraded error (`refresh` marks the handle).
526        self.enter_write_scope(_wal, acquired).0
527    }
528
529    /// Like [`write`](SharedDb::write) but with an explicit wait budget, and
530    /// [`GraphError::Busy`] returned up front when the cross-process write lock
531    /// is not free within it.
532    ///
533    /// A zero wait makes exactly one attempt. Nothing is written on failure, so
534    /// retrying later is always safe.
535    pub fn write_with_wait(&self, wait: Duration) -> Result<WriteGuard<'_>> {
536        let _wal = self.wal_mu.lock().unwrap_or_else(|e| e.into_inner());
537        if !self.poll_cross_process_lock(wait)? {
538            return Err(GraphError::Busy { holder: None });
539        }
540        let (guard, entered) = self.enter_write_scope(_wal, true);
541        entered?;
542        Ok(guard)
543    }
544
545    /// Poll for the store's cross-process write lock, holding **no** in-process
546    /// guard while waiting.
547    ///
548    /// Lock order is `wal_mu` → cross-process `LOCK` → `inner` (RwLock write).
549    /// The caller already holds `wal_mu`, which is what keeps two writers in
550    /// this process from polling at once. Each attempt takes the RwLock in
551    /// *read* mode for the length of one `try_lock` syscall and releases it, so
552    /// concurrent readers are never blocked by a peer process: waiting for a
553    /// busy peer costs readers nothing.
554    fn poll_cross_process_lock(&self, wait: Duration) -> Result<bool> {
555        let deadline = Instant::now() + wait;
556        loop {
557            {
558                let db = self.inner.read().unwrap_or_else(|e| e.into_inner());
559                if db.try_cross_process_lock()? {
560                    return Ok(true);
561                }
562            }
563            let now = Instant::now();
564            if now >= deadline {
565                return Ok(false);
566            }
567            std::thread::sleep(LOCK_POLL_INTERVAL.min(deadline.saturating_duration_since(now)));
568        }
569    }
570
571    /// Take the RwLock write guard, wrap both in-process guards in a
572    /// [`WriteGuard`], and open the write scope with the lock outcome the
573    /// caller already obtained.
574    ///
575    /// The guard is returned even when the scope failed to open, so its `Drop`
576    /// always releases the cross-process lock; the failure rides on the second
577    /// tuple element and is latched on the handle for `write()`'s benefit.
578    fn enter_write_scope<'a>(
579        &'a self,
580        wal: std::sync::MutexGuard<'a, ()>,
581        acquired: bool,
582    ) -> (WriteGuard<'a>, Result<()>) {
583        let mut inner = self.inner.write().unwrap_or_else(|e| e.into_inner());
584        let entered = inner.enter_write_scope(acquired);
585        (WriteGuard { inner, _wal: wal }, entered)
586    }
587
588    /// Capture a lock-free [`ReaderSnapshot`] of the current db state.
589    ///
590    /// Acquires the read lock only long enough to clone a handful of `Arc`
591    /// handles.  Subsequent reads on the returned snapshot are lock-free.
592    pub fn reader(&self) -> ReaderSnapshot {
593        self.read().reader()
594    }
595
596    /// Enqueue a mutation batch for the group-committing writer.
597    ///
598    /// Blocks until the **containing group** is durably committed (one WAL
599    /// fsync per group under `Strict` policy).  Submissions from concurrent
600    /// callers are coalesced into groups of up to 256 items.
601    ///
602    /// # Durability semantics
603    ///
604    /// Under `Strict` policy (the default):
605    /// - Each submission becomes a separate WAL `Batch` frame.
606    /// - All frames in a group share one fsync — the caller unblocks only
607    ///   after that fsync.
608    /// - **Fsync failure**: the drain thread truncates the WAL back to the
609    ///   pre-group offset and marks the database degraded.  All submitters in
610    ///   the failed group and all subsequent callers receive `Err`.  Data that
611    ///   was already in readers' snapshots (observed between write-lock release
612    ///   and truncation) is not rolled back — equivalent to the `Relaxed`
613    ///   window for in-flight readers.  Reopen the database to recover.
614    /// - A crash between group fsyncs loses the entire unfsynced group, but
615    ///   never tears an individual submission (CRC-protected frame boundaries).
616    ///
617    /// Under `Relaxed` policy (set via `db.write().set_fsync_policy`):
618    /// - WAL frames are appended but NOT synced; caller unblocks after apply.
619    ///
620    /// # Event ordering
621    ///
622    /// Under `Strict` / `Batched` policy, subscription events fire AFTER the
623    /// group fsync (durability before notification).  Under `Relaxed`, events
624    /// fire immediately after apply.
625    ///
626    /// # FIFO ordering
627    ///
628    /// Submissions from the same caller arrive FIFO at the queue.  Across
629    /// concurrent callers, drain order within a group is arbitrary, but each
630    /// submission's commit sequence is monotonically increasing.
631    ///
632    /// # Returns
633    ///
634    /// `(nodes_inserted, edges_inserted)` on success.  An all-noop batch
635    /// returns `(0, 0)`.
636    pub fn submit_batch(&self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
637        // Fast-path rejection: if the drain thread already exited due to a
638        // fsync failure, return Err immediately rather than blocking forever.
639        if let Some(msg) = self.queue.degraded_message() {
640            return Err(GraphError::Io(std::io::Error::other(msg)));
641        }
642        let (tx, rx) = std::sync::mpsc::sync_channel(1);
643        self.queue.enqueue(Submission {
644            ops,
645            preconds: Vec::new(),
646            authz_role: None,
647            done: tx,
648        });
649        rx.recv().unwrap_or_else(|_| {
650            Err(GraphError::Io(std::io::Error::other(
651                "group-commit drain thread terminated unexpectedly",
652            )))
653        })
654    }
655
656    /// Like [`submit_batch`] but with compare-and-set preconditions.
657    ///
658    /// The preconditions are evaluated by the drain thread under the **same**
659    /// write guard as the batch apply — there is no TOCTOU window.  If any
660    /// precondition fails, the entire batch is rejected with
661    /// [`core_storage::GraphError::CasConflict`] and no WAL frame is written.
662    ///
663    /// See [`crate::Precondition`] for the full semantics.
664    pub fn submit_batch_cas(
665        &self,
666        preconds: Vec<Precondition>,
667        ops: Vec<BatchOp>,
668    ) -> Result<(usize, usize)> {
669        if let Some(msg) = self.queue.degraded_message() {
670            return Err(GraphError::Io(std::io::Error::other(msg)));
671        }
672        let (tx, rx) = std::sync::mpsc::sync_channel(1);
673        self.queue.enqueue(Submission {
674            ops,
675            preconds,
676            authz_role: None,
677            done: tx,
678        });
679        rx.recv().unwrap_or_else(|_| {
680            Err(GraphError::Io(std::io::Error::other(
681                "group-commit drain thread terminated unexpectedly",
682            )))
683        })
684    }
685
686    /// Like [`submit_batch`] but with role-scoped write authorization.
687    ///
688    /// The drain thread resolves `mask_for_role` + scope under the same write
689    /// guard as the mutation (§5 lock discipline: authz BEFORE any CAS
690    /// preconditions, BEFORE the WAL write).
691    ///
692    /// - Role with `write: None` → `GraphError::RoleWriteDenied` (endpoint not
693    ///   permitted) — maps to HTTP 403.
694    /// - Scope / visibility violations inside the batch → `GraphError::RoleWriteDenied`
695    ///   with the appropriate §4.3 reason string.
696    ///
697    /// All-or-nothing semantics: a single denied op rejects the entire batch
698    /// with no WAL frame written.
699    pub fn submit_batch_authz(&self, role: String, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
700        if let Some(msg) = self.queue.degraded_message() {
701            return Err(GraphError::Io(std::io::Error::other(msg)));
702        }
703        let (tx, rx) = std::sync::mpsc::sync_channel(1);
704        self.queue.enqueue(Submission {
705            ops,
706            preconds: Vec::new(),
707            authz_role: Some(role),
708            done: tx,
709        });
710        rx.recv().unwrap_or_else(|_| {
711            Err(GraphError::Io(std::io::Error::other(
712                "group-commit drain thread terminated unexpectedly",
713            )))
714        })
715    }
716}
717
718// ── Drain thread ──────────────────────────────────────────────────────────────
719
720/// Poll for the store's cross-process write lock without holding the RwLock
721/// write guard, so a busy peer never stalls this process's readers.
722///
723/// The free-function twin of [`SharedDb::poll_cross_process_lock`]; the drain
724/// thread has the `Arc<RwLock<..>>` but no `SharedDb`. The caller must already
725/// hold `wal_mu`.
726fn poll_cross_process_lock(inner: &Arc<RwLock<GraphDb<RealFs>>>, wait: Duration) -> Result<bool> {
727    let deadline = Instant::now() + wait;
728    loop {
729        {
730            let db = inner.read().unwrap_or_else(|e| e.into_inner());
731            if db.try_cross_process_lock()? {
732                return Ok(true);
733            }
734        }
735        let now = Instant::now();
736        if now >= deadline {
737            return Ok(false);
738        }
739        std::thread::sleep(LOCK_POLL_INTERVAL.min(deadline.saturating_duration_since(now)));
740    }
741}
742
743fn drain_loop(
744    inner: Arc<RwLock<GraphDb<RealFs>>>,
745    queue: Arc<WriteQueue>,
746    dir: Arc<std::path::PathBuf>,
747    wal_mu: Arc<Mutex<()>>,
748    sync_fn: SyncWalFn,
749) {
750    loop {
751        // Wait for work (or shutdown with empty queue).
752        let mut group = queue.wait_and_drain();
753        if group.is_empty() {
754            return; // shutdown + nothing pending
755        }
756
757        // Extract ops, preconditions, and authz_role together; keep alignment
758        // with group index.
759        let submissions: Vec<(Vec<Precondition>, Vec<BatchOp>, Option<String>)> = group
760            .iter_mut()
761            .map(|s| {
762                (
763                    std::mem::take(&mut s.preconds),
764                    std::mem::take(&mut s.ops),
765                    s.authz_role.take(),
766                )
767            })
768            .collect();
769
770        // ── Step 1: Acquire WAL mutex BEFORE the write lock ─────────────────
771        //
772        // Lock order: wal_mu → RwLock write guard.
773        //
774        // Holding wal_mu from here through [fsync OR truncation resolution]
775        // closes the truncation race: no concurrent db.write() caller can
776        // append WAL frames between the group's own appends and its fsync
777        // outcome.  truncate_wal_at(pre_len) is therefore always a safe
778        // tail-trim with no risk of wiping acknowledged direct writes.
779        let wal_guard = wal_mu.lock().unwrap_or_else(|e| e.into_inner());
780
781        // ── Step 1b: Take the cross-process write lock for this group ────────
782        //
783        // The drain thread appends to the WAL without going through
784        // `SharedDb::write`, so it must serialise against other processes
785        // itself.  Polling happens with NO RwLock guard held (lock order:
786        // wal_mu → cross-process LOCK → inner.write), so a busy peer cannot
787        // stall this process's readers for the duration of the wait.
788        // `enter_write_scope` then refreshes under the lock, so the group
789        // applies on top of every commit another process has made.  The lock is
790        // released only after this group's fsync resolves: until then the tail
791        // contains bytes we have not made durable, and another process's
792        // snapshot must not truncate them away.
793        let lock_outcome = poll_cross_process_lock(&inner, WRITE_LOCK_WAIT).and_then(|acquired| {
794            let mut db = inner.write().unwrap_or_else(|e| e.into_inner());
795            db.enter_write_scope(acquired).map(|()| acquired)
796        });
797        match lock_outcome {
798            Ok(true) => {}
799            Ok(false) => {
800                // Another process is writing.  Every submitter in this group is
801                // told so; the drain loop stays alive and the next group tries
802                // again — a busy peer is a transient condition, not a failure
803                // of this database handle.
804                {
805                    let mut db = inner.write().unwrap_or_else(|e| e.into_inner());
806                    db.end_write_lock();
807                }
808                drop(wal_guard);
809                for sub in group {
810                    let _ = sub.done.send(Err(GraphError::Busy { holder: None }));
811                }
812                continue;
813            }
814            Err(e) => {
815                // The lock was taken but the refresh under it failed, which
816                // leaves the handle degraded (see `GraphDb::refresh`).  This is
817                // the same class of failure as a group fsync failure: the
818                // database must be reopened, so the drain loop exits rather
819                // than failing every future group identically.
820                let reason = format!("refresh under the write lock failed: {e}; reopen required");
821                {
822                    let mut db = inner.write().unwrap_or_else(|e| e.into_inner());
823                    db.discard_deferred_events();
824                    db.set_deferred_events_mode(false);
825                    db.set_degraded();
826                    db.end_write_lock();
827                }
828                queue.set_degraded(reason.clone());
829                drop(wal_guard);
830                for sub in group {
831                    let _ = sub
832                        .done
833                        .send(Err(GraphError::Io(std::io::Error::other(reason.clone()))));
834                }
835                return;
836            }
837        }
838
839        // Snapshot WAL size while holding wal_mu — no concurrent WAL append
840        // is possible, so this offset is a stable pre-group boundary.
841        let pre_group_wal_len = std::fs::metadata(dir.join("wal.bin"))
842            .map(|m| m.len())
843            .unwrap_or(0);
844
845        // ── Step 2: Apply all submissions under the write lock, NO fsync ────
846        //
847        // For Strict / Batched policy we enable deferred event mode so that
848        // subscription notifications only fire after the group fsync (R2:
849        // durability before notification).  For Relaxed policy events fire
850        // immediately (no fsync to wait for).
851        let (results, should_sync): (Vec<Result<(usize, usize)>>, bool) = {
852            let mut db = inner.write().unwrap_or_else(|e| e.into_inner());
853            let sync_needed = db.fsync_policy() != FsyncPolicy::Relaxed;
854            if sync_needed {
855                db.set_deferred_events_mode(true);
856            }
857            // Apply submissions in FIFO order.
858            //
859            // Non-CAS submissions are coalesced into a single commit_group_nosync
860            // call (preserving the T4b group-write batching intent — one write
861            // boundary per drain group, not per submission).  CAS submissions
862            // break the coalescing because their preconditions must be evaluated
863            // AFTER all prior submissions in the group have been applied (no
864            // TOCTOU); they are processed individually between coalesced non-CAS
865            // runs.
866            let mut r: Vec<Result<(usize, usize)>> = Vec::with_capacity(submissions.len());
867            let mut pending_non_cas: Vec<Vec<BatchOp>> = Vec::new();
868
869            for (preconds, ops, authz_role) in submissions {
870                if let Some(role) = authz_role {
871                    // Authz submission: process individually (breaks coalescing).
872                    // Flush accumulated non-CAS batch first so authz evaluation
873                    // sees their writes already applied.
874                    if !pending_non_cas.is_empty() {
875                        let batch = std::mem::take(&mut pending_non_cas);
876                        r.extend(db.commit_group_nosync(batch));
877                    }
878                    // Resolve WriteAuthz under the write guard (§5 lock discipline:
879                    // scope + mask resolved in the same guard as the mutation;
880                    // authz check fires BEFORE any WAL write).
881                    let result = (|| -> Result<(usize, usize)> {
882                        let scope = {
883                            // Temporary scope so the borrow on db.roles ends
884                            // before write_batch_authz_nosync borrows db mutably.
885                            let roles_vec = db.roles();
886                            let def = roles_vec
887                                .iter()
888                                .find(|r| r.name == role)
889                                .ok_or_else(|| GraphError::KeyNotFound {
890                                    key: format!("role:{role}"),
891                                })?
892                                .clone();
893                            // write:None → byte-identical v1 blanket-403 body.
894                            def.write.ok_or_else(|| GraphError::RoleWriteDenied {
895                                reason: "role-bound token: writes are not permitted".into(),
896                            })?
897                        };
898                        let mask = db.mask_for_role(&role)?;
899                        let authz = WriteAuthz { role, scope, mask };
900                        db.write_batch_authz_nosync(Some(&authz), ops)
901                    })();
902                    r.push(result);
903                } else if preconds.is_empty() {
904                    // Non-CAS: accumulate for a batched commit_group_nosync call.
905                    pending_non_cas.push(ops);
906                } else {
907                    // CAS: flush accumulated non-CAS batch first so that precond
908                    // evaluation sees their writes already applied.
909                    if !pending_non_cas.is_empty() {
910                        let batch = std::mem::take(&mut pending_non_cas);
911                        r.extend(db.commit_group_nosync(batch));
912                    }
913                    let result = match db.check_preconditions(&preconds) {
914                        Ok(()) => db
915                            .commit_group_nosync(vec![ops])
916                            .into_iter()
917                            .next()
918                            .unwrap_or(Ok((0, 0))),
919                        Err(e) => Err(e),
920                    };
921                    r.push(result);
922                }
923            }
924            // Flush any remaining non-CAS submissions.
925            if !pending_non_cas.is_empty() {
926                r.extend(db.commit_group_nosync(pending_non_cas));
927            }
928            (r, sync_needed)
929            // ← RwLock write guard released here; wal_mu still held
930        };
931
932        // ── Step 3: ONE fsync for the group, OUTSIDE the write lock ─────────
933        //
934        // Readers may see committed-but-unfsynced data between here and the
935        // fsync below (same contract as Relaxed).  Submitters unblock only
936        // after the fsync, guaranteeing durability from their perspective.
937        // wal_mu remains held so no concurrent writer can extend the WAL tail.
938        let sync_result: Result<()> = if should_sync && results.iter().any(|r| r.is_ok()) {
939            sync_fn(&dir).map_err(GraphError::Io)
940        } else {
941            Ok(()) // Relaxed policy or all submissions failed validation
942        };
943
944        // ── Step 4: Handle fsync failure ─────────────────────────────────────
945        if let Err(ref io_err) = sync_result {
946            if results.iter().any(|r| r.is_ok()) {
947                // Truncate WAL to the pre-group boundary.  Safe because wal_mu
948                // is held — no other writer can have appended since pre_len was
949                // measured, so this always removes exactly the failed group's
950                // frames and nothing else.
951                let _ = truncate_wal_at(&dir, pre_group_wal_len);
952            }
953            // Acquire write lock to update in-memory degraded state.
954            // Lock order maintained: wal_mu (held) → RwLock write.
955            {
956                let mut db = inner.write().unwrap_or_else(|e| e.into_inner());
957                db.discard_deferred_events();
958                db.set_deferred_events_mode(false);
959                // Mark degraded so db.write().insert_node(...) etc. also fail.
960                db.set_degraded();
961                // The truncation shortened the WAL: bring the frame cursor back
962                // to the file's real length so a later staleness check does not
963                // read our own in-memory state as a peer's rollback.
964                db.set_wal_consumed(pre_group_wal_len);
965                db.end_write_lock();
966            }
967            // Propagate failure to queue BEFORE releasing wal_mu so any
968            // direct writer waiting for wal_mu sees the degraded flag when
969            // it wakes (and log_then_apply_with will return Err(degraded)).
970            queue.set_degraded(io_err.to_string());
971            // Release WAL mutex — no more WAL I/O will happen.
972            drop(wal_guard);
973            // Signal each submitter with an IO error.
974            for sub in group {
975                let _ = sub.done.send(Err(GraphError::Io(std::io::Error::other(
976                    "group-commit fsync failed; database is degraded, reopen required",
977                ))));
978            }
979            return; // drain loop exits; no further groups accepted
980        }
981
982        // ── Step 5: Flush deferred events AFTER successful fsync (R2) ────────
983        //
984        // Flush events while wal_mu is still held so no direct writer can slip
985        // between this group's fsync and its event delivery.  Lock order is
986        // wal_mu (held) → inner.write() — the same order enforced everywhere
987        // else; no deadlock: the drain thread never holds inner while waiting
988        // for wal_mu.  Event delivery is pure in-memory (subscriber callbacks
989        // only); no WAL I/O occurs in flush_deferred_events.
990        if should_sync {
991            let mut db = inner.write().unwrap_or_else(|e| e.into_inner());
992            db.flush_deferred_events();
993            db.set_deferred_events_mode(false);
994            // inner.write() released here (RAII) before wal_mu below.
995        }
996
997        // ── Step 5b: Release the cross-process lock ──────────────────────────
998        //
999        // The group's bytes are now fsynced, so another process may safely
1000        // snapshot or truncate around them.
1001        {
1002            let mut db = inner.write().unwrap_or_else(|e| e.into_inner());
1003            db.end_write_lock();
1004        }
1005
1006        // Release WAL mutex after events are flushed.  Unblocks any direct
1007        // writer that was waiting for wal_mu; they will observe the correct
1008        // event order when they subsequently deliver their own events.
1009        drop(wal_guard);
1010
1011        // ── Step 6: Signal each submitter ────────────────────────────────────
1012        for (sub, result) in group.into_iter().zip(results) {
1013            let _ = sub.done.send(result);
1014        }
1015    }
1016}