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` — their p95 latency is unaffected.
37//!
38//! Holding `wal_mu` from before [append group frames] through [fsync OR
39//! truncation resolution] closes the truncation race: no concurrent direct
40//! write can insert WAL frames between the group's append and its fsync
41//! outcome, so `truncate_wal_at(pre_len)` is always a safe tail-trim.
42//!
43//! # Fsync-failure contract
44//!
45//! If the group fsync fails the drain thread immediately:
46//! 1. **Truncates** the WAL file back to the pre-group offset — this prevents
47//!    a later successful fsync from silently making the failed group durable
48//!    by flushing the full inode page cache.  The truncation is safe because
49//!    `wal_mu` prevents any concurrent write from appending after `pre_len`.
50//! 2. **Marks** the database degraded via [`GraphDb::set_degraded`] — all
51//!    subsequent [`submit_batch`] and `db.write()` mutation attempts return
52//!    an IO error until the database is reopened.
53//! 3. **Discards** buffered event notifications (no subscriber sees un-durable
54//!    data).
55//! 4. **Signals** all submitters in the failed group with an IO error.
56//! 5. **Exits** the drain loop.
57//!
58//! Readers may have already observed the failed group's data (between the
59//! write-lock release and the truncation); that window is equivalent to the
60//! `Relaxed` durability contract.
61//!
62//! # Event ordering (Strict policy, R2)
63//!
64//! Under `FsyncPolicy::Strict` or `Batched`, subscription events are deferred
65//! until after the group fsync.  The drain thread then reacquires the write lock
66//! (while still holding `wal_mu`) to call [`GraphDb::flush_deferred_events`],
67//! releases the write lock, releases `wal_mu`, and finally signals submitters.
68//! Flushing events while `wal_mu` is held prevents a concurrent direct writer
69//! from slipping in between the group fsync and the event flush and delivering
70//! its event before the group's events — preserving a global monotone event
71//! order across both the drain path and the direct write path.
72//! Under `Relaxed`, events fire immediately (no fsync to wait for).
73//!
74//! # Event delivery and crashes (R2)
75//!
76//! Subscription events are best-effort post-durability notifications.  A crash
77//! between a successful group fsync and the `flush_deferred_events` call drops
78//! those events — a strictly narrower loss window than pre-4b (where events
79//! could fire before any fsync).
80//!
81//! # Shutdown
82//!
83//! `SharedDb` clones the drain handle via an `Arc`; the last clone to drop
84//! triggers `DrainHandle::drop`, which signals shutdown + joins the thread.
85//! The drain thread can never exit while any `SharedDb` clone exists (the
86//! `Arc<Inner>` is held by every clone); no submission enqueued before the
87//! last clone is dropped can be silently lost.
88
89use crate::db::{BatchOp, FsyncPolicy, Precondition, WriteAuthz};
90use crate::reader::ReaderSnapshot;
91use crate::GraphDb;
92use core_storage::sync_wal_at;
93use core_storage::truncate_wal_at;
94use core_storage::GraphError;
95use core_storage::RealFs;
96use core_storage::Result;
97use std::ops::{Deref, DerefMut};
98use std::path::Path;
99use std::sync::atomic::{AtomicBool, Ordering};
100use std::sync::{Arc, Condvar, Mutex, RwLock};
101use std::thread;
102
103// ── Group-commit constants ────────────────────────────────────────────────────
104
105/// Maximum submissions coalesced into one group.  Caps write-lock hold time
106/// under extreme write bursts.
107const MAX_GROUP_SIZE: usize = 256;
108
109// ── WAL sync function type ────────────────────────────────────────────────────
110
111/// A callable that syncs the WAL at a given directory path.
112///
113/// In production this is always `sync_wal_at`.  Tests may inject a failing
114/// implementation via [`SharedDb::open_with_test_sync`] to exercise the
115/// fsync-failure contract through the live drain thread.
116type SyncWalFn = Arc<dyn Fn(&Path) -> std::io::Result<()> + Send + Sync>;
117
118// ── Submission type ───────────────────────────────────────────────────────────
119
120struct Submission {
121    ops: Vec<BatchOp>,
122    /// Compare-and-set preconditions.  Empty for plain `submit_batch` calls;
123    /// non-empty for `submit_batch_cas` calls.  The drain thread checks these
124    /// under the same write guard as the batch apply (no TOCTOU).
125    preconds: Vec<Precondition>,
126    /// Role name for role-scoped write authz.  `Some` only for
127    /// `submit_batch_authz` calls; `None` for full-authority submissions.
128    /// The drain thread resolves mask + scope under `inner.write()` (§5 lock
129    /// discipline: authz check and mutation share one guard lifetime).
130    authz_role: Option<String>,
131    done: std::sync::mpsc::SyncSender<Result<(usize, usize)>>,
132}
133
134// ── WriteQueue ────────────────────────────────────────────────────────────────
135
136struct WriteQueue {
137    pending: Mutex<Vec<Submission>>,
138    notify: Condvar,
139    shutdown: AtomicBool,
140    /// Set by the drain thread on a group fsync failure.  Non-None means the
141    /// drain thread has exited; future `submit_batch` calls return Err immediately
142    /// rather than blocking forever on a dead drain thread.
143    degraded_msg: Mutex<Option<String>>,
144}
145
146impl WriteQueue {
147    fn new() -> Arc<Self> {
148        Arc::new(Self {
149            pending: Mutex::new(Vec::new()),
150            notify: Condvar::new(),
151            shutdown: AtomicBool::new(false),
152            degraded_msg: Mutex::new(None),
153        })
154    }
155
156    fn enqueue(&self, sub: Submission) {
157        self.pending
158            .lock()
159            .unwrap_or_else(|e| e.into_inner())
160            .push(sub);
161        self.notify.notify_one();
162    }
163
164    fn signal_shutdown(&self) {
165        self.shutdown.store(true, Ordering::Release);
166        self.notify.notify_all();
167    }
168
169    fn set_degraded(&self, msg: String) {
170        *self.degraded_msg.lock().unwrap_or_else(|e| e.into_inner()) = Some(msg);
171    }
172
173    fn degraded_message(&self) -> Option<String> {
174        self.degraded_msg
175            .lock()
176            .unwrap_or_else(|e| e.into_inner())
177            .clone()
178    }
179
180    /// Block until work is available or shutdown; return at most `MAX_GROUP_SIZE`
181    /// submissions.  Returns an empty `Vec` only when shutdown is set AND the
182    /// queue is empty.
183    fn wait_and_drain(&self) -> Vec<Submission> {
184        let mut lock = self.pending.lock().unwrap_or_else(|e| e.into_inner());
185        loop {
186            if !lock.is_empty() {
187                let n = lock.len().min(MAX_GROUP_SIZE);
188                return lock.drain(..n).collect();
189            }
190            if self.shutdown.load(Ordering::Acquire) {
191                return vec![];
192            }
193            lock = self.notify.wait(lock).unwrap_or_else(|e| e.into_inner());
194        }
195    }
196}
197
198// ── DrainHandle ───────────────────────────────────────────────────────────────
199
200/// Signals the drain thread and joins it when dropped.  Owned inside an
201/// `Arc` so the last `SharedDb` clone triggers the join.
202struct DrainHandle {
203    queue: Arc<WriteQueue>,
204    handle: Option<thread::JoinHandle<()>>,
205}
206
207impl Drop for DrainHandle {
208    fn drop(&mut self) {
209        self.queue.signal_shutdown();
210        if let Some(h) = self.handle.take() {
211            let _ = h.join();
212        }
213    }
214}
215
216// ── WriteGuard ────────────────────────────────────────────────────────────────
217
218/// Compound write guard returned by [`SharedDb::write`].
219///
220/// Holds both the WAL mutex and the RwLock write guard.  Fields are declared
221/// in drop order — `inner` (RwLock) releases first, then `_wal` (WAL mutex)
222/// — preserving the lock-release ordering required by the WAL I/O discipline.
223///
224/// # Lock order
225///
226/// Acquisition: `wal_mu` → `inner` (RwLock write).
227/// Release (RAII, struct field declaration order): `inner` → `wal_mu`.
228pub struct WriteGuard<'a> {
229    /// RwLock write guard — dropped first (field declared first).
230    inner: std::sync::RwLockWriteGuard<'a, GraphDb<RealFs>>,
231    /// WAL mutex guard — dropped second (field declared second).
232    _wal: std::sync::MutexGuard<'a, ()>,
233}
234
235impl<'a> Deref for WriteGuard<'a> {
236    type Target = GraphDb<RealFs>;
237    fn deref(&self) -> &GraphDb<RealFs> {
238        &self.inner
239    }
240}
241
242impl<'a> DerefMut for WriteGuard<'a> {
243    fn deref_mut(&mut self) -> &mut GraphDb<RealFs> {
244        &mut self.inner
245    }
246}
247
248// ── SharedDb ──────────────────────────────────────────────────────────────────
249
250/// Shared handle to an on-disk [`GraphDb`]. [`Clone`] is cheap and shares state.
251///
252/// # Group-commit write path
253///
254/// [`SharedDb::submit_batch`] routes mutations through a group-commit queue.
255/// A background drain thread batches concurrent submissions under a single WAL
256/// fsync, yielding throughput that scales with concurrency.
257///
258/// # Direct write path
259///
260/// [`SharedDb::write`] gives exclusive `&mut GraphDb` access for callers that
261/// need complex multi-step mutations (e.g. Cypher write queries).  It acquires
262/// the WAL mutex first, then the RwLock write guard, satisfying the lock-order
263/// discipline described in the module doc.
264///
265/// # Event-sink deadlock
266///
267/// [`GraphDb::set_event_sink`] runs inside `log_then_apply` while the write
268/// guard is held.  A sink must never call [`SharedDb::read`] or
269/// [`SharedDb::write`] on the same handle.
270#[derive(Clone)]
271pub struct SharedDb {
272    inner: Arc<RwLock<GraphDb<RealFs>>>,
273    queue: Arc<WriteQueue>,
274    /// Keeps the drain thread alive; signals + joins on last drop.
275    _drain: Arc<DrainHandle>,
276    /// WAL I/O mutex.  Serialises all WAL appends, fsyncs, and truncations
277    /// across the drain thread and direct writers.  See module-level doc for
278    /// the required acquisition order.
279    wal_mu: Arc<Mutex<()>>,
280}
281
282const _: () = {
283    fn assert_send_sync<T: Send + Sync>() {}
284    let _ = assert_send_sync::<SharedDb>;
285};
286
287impl SharedDb {
288    pub fn open(dir: &Path) -> Result<Self> {
289        let db = GraphDb::open(dir)?;
290        Ok(Self::from_db_and_dir_with_sync(
291            db,
292            dir.to_path_buf(),
293            Arc::new(sync_wal_at),
294        ))
295    }
296
297    /// Open with an injectable WAL sync function.
298    ///
299    /// Allows tests to inject fsync failures through the live drain thread
300    /// without requiring real filesystem manipulation.  Not intended for
301    /// production use; the `test_sync` name signals its purpose.
302    pub fn open_with_test_sync(
303        dir: &Path,
304        sync: impl Fn(&Path) -> std::io::Result<()> + Send + Sync + 'static,
305    ) -> Result<Self> {
306        let db = GraphDb::open(dir)?;
307        Ok(Self::from_db_and_dir_with_sync(
308            db,
309            dir.to_path_buf(),
310            Arc::new(sync),
311        ))
312    }
313
314    fn from_db_and_dir_with_sync(
315        db: GraphDb<RealFs>,
316        dir: std::path::PathBuf,
317        sync_fn: SyncWalFn,
318    ) -> Self {
319        let inner = Arc::new(RwLock::new(db));
320        let queue = WriteQueue::new();
321        let wal_mu = Arc::new(Mutex::new(()));
322        let dir_arc = Arc::new(dir);
323
324        let drain_inner = Arc::clone(&inner);
325        let drain_queue = Arc::clone(&queue);
326        let drain_dir = Arc::clone(&dir_arc);
327        let drain_wal_mu = Arc::clone(&wal_mu);
328        let drain_sync_fn = Arc::clone(&sync_fn);
329
330        let handle = thread::Builder::new()
331            .name("groupcommit-drain".into())
332            .spawn(move || {
333                drain_loop(
334                    drain_inner,
335                    drain_queue,
336                    drain_dir,
337                    drain_wal_mu,
338                    drain_sync_fn,
339                )
340            })
341            .expect("failed to spawn group-commit drain thread");
342
343        SharedDb {
344            inner,
345            queue: Arc::clone(&queue),
346            _drain: Arc::new(DrainHandle {
347                queue,
348                handle: Some(handle),
349            }),
350            wal_mu,
351        }
352    }
353
354    /// Shared read access. Many readers may hold this concurrently.
355    ///
356    /// Readers never acquire the WAL mutex — their p95 latency is unaffected
357    /// by concurrent write or fsync activity.
358    ///
359    /// # Deadlock warning
360    ///
361    /// Do not hold a returned guard while calling any method on the same
362    /// [`SharedDb`]; the [`RwLock`] is not re-entrant; doing so deadlocks.
363    pub fn read(&self) -> impl Deref<Target = GraphDb<RealFs>> + '_ {
364        self.inner.read().unwrap_or_else(|e| e.into_inner())
365    }
366
367    /// Exclusive write access.
368    ///
369    /// Acquires the WAL mutex first, then the RwLock write guard, satisfying
370    /// the lock order required by the fsync-failure contract (see module doc).
371    /// The returned [`WriteGuard`] releases the RwLock before the WAL mutex
372    /// on drop.
373    ///
374    /// # Deadlock warning
375    ///
376    /// Do not hold a returned guard while calling any method on the same
377    /// [`SharedDb`]; the [`RwLock`] is not re-entrant; doing so deadlocks.
378    pub fn write(&self) -> WriteGuard<'_> {
379        // Acquire wal_mu BEFORE the RwLock write guard.  This matches the
380        // drain thread's acquisition order and prevents the truncation race:
381        // no direct write can interleave WAL I/O with an in-progress group.
382        let _wal = self.wal_mu.lock().unwrap_or_else(|e| e.into_inner());
383        let inner = self.inner.write().unwrap_or_else(|e| e.into_inner());
384        WriteGuard { inner, _wal }
385    }
386
387    /// Capture a lock-free [`ReaderSnapshot`] of the current db state.
388    ///
389    /// Acquires the read lock only long enough to clone a handful of `Arc`
390    /// handles.  Subsequent reads on the returned snapshot are lock-free.
391    pub fn reader(&self) -> ReaderSnapshot {
392        self.read().reader()
393    }
394
395    /// Enqueue a mutation batch for the group-committing writer.
396    ///
397    /// Blocks until the **containing group** is durably committed (one WAL
398    /// fsync per group under `Strict` policy).  Submissions from concurrent
399    /// callers are coalesced into groups of up to 256 items.
400    ///
401    /// # Durability semantics
402    ///
403    /// Under `Strict` policy (the default):
404    /// - Each submission becomes a separate WAL `Batch` frame.
405    /// - All frames in a group share one fsync — the caller unblocks only
406    ///   after that fsync.
407    /// - **Fsync failure**: the drain thread truncates the WAL back to the
408    ///   pre-group offset and marks the database degraded.  All submitters in
409    ///   the failed group and all subsequent callers receive `Err`.  Data that
410    ///   was already in readers' snapshots (observed between write-lock release
411    ///   and truncation) is not rolled back — equivalent to the `Relaxed`
412    ///   window for in-flight readers.  Reopen the database to recover.
413    /// - A crash between group fsyncs loses the entire unfsynced group, but
414    ///   never tears an individual submission (CRC-protected frame boundaries).
415    ///
416    /// Under `Relaxed` policy (set via `db.write().set_fsync_policy`):
417    /// - WAL frames are appended but NOT synced; caller unblocks after apply.
418    ///
419    /// # Event ordering
420    ///
421    /// Under `Strict` / `Batched` policy, subscription events fire AFTER the
422    /// group fsync (durability before notification).  Under `Relaxed`, events
423    /// fire immediately after apply.
424    ///
425    /// # FIFO ordering
426    ///
427    /// Submissions from the same caller arrive FIFO at the queue.  Across
428    /// concurrent callers, drain order within a group is arbitrary, but each
429    /// submission's commit sequence is monotonically increasing.
430    ///
431    /// # Returns
432    ///
433    /// `(nodes_inserted, edges_inserted)` on success.  An all-noop batch
434    /// returns `(0, 0)`.
435    pub fn submit_batch(&self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
436        // Fast-path rejection: if the drain thread already exited due to a
437        // fsync failure, return Err immediately rather than blocking forever.
438        if let Some(msg) = self.queue.degraded_message() {
439            return Err(GraphError::Io(std::io::Error::other(msg)));
440        }
441        let (tx, rx) = std::sync::mpsc::sync_channel(1);
442        self.queue.enqueue(Submission {
443            ops,
444            preconds: Vec::new(),
445            authz_role: None,
446            done: tx,
447        });
448        rx.recv().unwrap_or_else(|_| {
449            Err(GraphError::Io(std::io::Error::other(
450                "group-commit drain thread terminated unexpectedly",
451            )))
452        })
453    }
454
455    /// Like [`submit_batch`] but with compare-and-set preconditions.
456    ///
457    /// The preconditions are evaluated by the drain thread under the **same**
458    /// write guard as the batch apply — there is no TOCTOU window.  If any
459    /// precondition fails, the entire batch is rejected with
460    /// [`core_storage::GraphError::CasConflict`] and no WAL frame is written.
461    ///
462    /// See [`crate::Precondition`] for the full semantics.
463    pub fn submit_batch_cas(
464        &self,
465        preconds: Vec<Precondition>,
466        ops: Vec<BatchOp>,
467    ) -> Result<(usize, usize)> {
468        if let Some(msg) = self.queue.degraded_message() {
469            return Err(GraphError::Io(std::io::Error::other(msg)));
470        }
471        let (tx, rx) = std::sync::mpsc::sync_channel(1);
472        self.queue.enqueue(Submission {
473            ops,
474            preconds,
475            authz_role: None,
476            done: tx,
477        });
478        rx.recv().unwrap_or_else(|_| {
479            Err(GraphError::Io(std::io::Error::other(
480                "group-commit drain thread terminated unexpectedly",
481            )))
482        })
483    }
484
485    /// Like [`submit_batch`] but with role-scoped write authorization.
486    ///
487    /// The drain thread resolves `mask_for_role` + scope under the same write
488    /// guard as the mutation (§5 lock discipline: authz BEFORE any CAS
489    /// preconditions, BEFORE the WAL write).
490    ///
491    /// - Role with `write: None` → `GraphError::RoleWriteDenied` (endpoint not
492    ///   permitted) — maps to HTTP 403.
493    /// - Scope / visibility violations inside the batch → `GraphError::RoleWriteDenied`
494    ///   with the appropriate §4.3 reason string.
495    ///
496    /// All-or-nothing semantics: a single denied op rejects the entire batch
497    /// with no WAL frame written.
498    pub fn submit_batch_authz(&self, role: String, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
499        if let Some(msg) = self.queue.degraded_message() {
500            return Err(GraphError::Io(std::io::Error::other(msg)));
501        }
502        let (tx, rx) = std::sync::mpsc::sync_channel(1);
503        self.queue.enqueue(Submission {
504            ops,
505            preconds: Vec::new(),
506            authz_role: Some(role),
507            done: tx,
508        });
509        rx.recv().unwrap_or_else(|_| {
510            Err(GraphError::Io(std::io::Error::other(
511                "group-commit drain thread terminated unexpectedly",
512            )))
513        })
514    }
515}
516
517// ── Drain thread ──────────────────────────────────────────────────────────────
518
519fn drain_loop(
520    inner: Arc<RwLock<GraphDb<RealFs>>>,
521    queue: Arc<WriteQueue>,
522    dir: Arc<std::path::PathBuf>,
523    wal_mu: Arc<Mutex<()>>,
524    sync_fn: SyncWalFn,
525) {
526    loop {
527        // Wait for work (or shutdown with empty queue).
528        let mut group = queue.wait_and_drain();
529        if group.is_empty() {
530            return; // shutdown + nothing pending
531        }
532
533        // Extract ops, preconditions, and authz_role together; keep alignment
534        // with group index.
535        let submissions: Vec<(Vec<Precondition>, Vec<BatchOp>, Option<String>)> = group
536            .iter_mut()
537            .map(|s| {
538                (
539                    std::mem::take(&mut s.preconds),
540                    std::mem::take(&mut s.ops),
541                    s.authz_role.take(),
542                )
543            })
544            .collect();
545
546        // ── Step 1: Acquire WAL mutex BEFORE the write lock ─────────────────
547        //
548        // Lock order: wal_mu → RwLock write guard.
549        //
550        // Holding wal_mu from here through [fsync OR truncation resolution]
551        // closes the truncation race: no concurrent db.write() caller can
552        // append WAL frames between the group's own appends and its fsync
553        // outcome.  truncate_wal_at(pre_len) is therefore always a safe
554        // tail-trim with no risk of wiping acknowledged direct writes.
555        let wal_guard = wal_mu.lock().unwrap_or_else(|e| e.into_inner());
556
557        // Snapshot WAL size while holding wal_mu — no concurrent WAL append
558        // is possible, so this offset is a stable pre-group boundary.
559        let pre_group_wal_len = std::fs::metadata(dir.join("wal.bin"))
560            .map(|m| m.len())
561            .unwrap_or(0);
562
563        // ── Step 2: Apply all submissions under the write lock, NO fsync ────
564        //
565        // For Strict / Batched policy we enable deferred event mode so that
566        // subscription notifications only fire after the group fsync (R2:
567        // durability before notification).  For Relaxed policy events fire
568        // immediately (no fsync to wait for).
569        let (results, should_sync): (Vec<Result<(usize, usize)>>, bool) = {
570            let mut db = inner.write().unwrap_or_else(|e| e.into_inner());
571            let sync_needed = db.fsync_policy() != FsyncPolicy::Relaxed;
572            if sync_needed {
573                db.set_deferred_events_mode(true);
574            }
575            // Apply submissions in FIFO order.
576            //
577            // Non-CAS submissions are coalesced into a single commit_group_nosync
578            // call (preserving the T4b group-write batching intent — one write
579            // boundary per drain group, not per submission).  CAS submissions
580            // break the coalescing because their preconditions must be evaluated
581            // AFTER all prior submissions in the group have been applied (no
582            // TOCTOU); they are processed individually between coalesced non-CAS
583            // runs.
584            let mut r: Vec<Result<(usize, usize)>> = Vec::with_capacity(submissions.len());
585            let mut pending_non_cas: Vec<Vec<BatchOp>> = Vec::new();
586
587            for (preconds, ops, authz_role) in submissions {
588                if let Some(role) = authz_role {
589                    // Authz submission: process individually (breaks coalescing).
590                    // Flush accumulated non-CAS batch first so authz evaluation
591                    // sees their writes already applied.
592                    if !pending_non_cas.is_empty() {
593                        let batch = std::mem::take(&mut pending_non_cas);
594                        r.extend(db.commit_group_nosync(batch));
595                    }
596                    // Resolve WriteAuthz under the write guard (§5 lock discipline:
597                    // scope + mask resolved in the same guard as the mutation;
598                    // authz check fires BEFORE any WAL write).
599                    let result = (|| -> Result<(usize, usize)> {
600                        let scope = {
601                            // Temporary scope so the borrow on db.roles ends
602                            // before write_batch_authz_nosync borrows db mutably.
603                            let roles_vec = db.roles();
604                            let def = roles_vec
605                                .iter()
606                                .find(|r| r.name == role)
607                                .ok_or_else(|| GraphError::KeyNotFound {
608                                    key: format!("role:{role}"),
609                                })?
610                                .clone();
611                            // write:None → byte-identical v1 blanket-403 body.
612                            def.write.ok_or_else(|| GraphError::RoleWriteDenied {
613                                reason: "role-bound token: writes are not permitted".into(),
614                            })?
615                        };
616                        let mask = db.mask_for_role(&role)?;
617                        let authz = WriteAuthz { role, scope, mask };
618                        db.write_batch_authz_nosync(Some(&authz), ops)
619                    })();
620                    r.push(result);
621                } else if preconds.is_empty() {
622                    // Non-CAS: accumulate for a batched commit_group_nosync call.
623                    pending_non_cas.push(ops);
624                } else {
625                    // CAS: flush accumulated non-CAS batch first so that precond
626                    // evaluation sees their writes already applied.
627                    if !pending_non_cas.is_empty() {
628                        let batch = std::mem::take(&mut pending_non_cas);
629                        r.extend(db.commit_group_nosync(batch));
630                    }
631                    let result = match db.check_preconditions(&preconds) {
632                        Ok(()) => db
633                            .commit_group_nosync(vec![ops])
634                            .into_iter()
635                            .next()
636                            .unwrap_or(Ok((0, 0))),
637                        Err(e) => Err(e),
638                    };
639                    r.push(result);
640                }
641            }
642            // Flush any remaining non-CAS submissions.
643            if !pending_non_cas.is_empty() {
644                r.extend(db.commit_group_nosync(pending_non_cas));
645            }
646            (r, sync_needed)
647            // ← RwLock write guard released here; wal_mu still held
648        };
649
650        // ── Step 3: ONE fsync for the group, OUTSIDE the write lock ─────────
651        //
652        // Readers may see committed-but-unfsynced data between here and the
653        // fsync below (same contract as Relaxed).  Submitters unblock only
654        // after the fsync, guaranteeing durability from their perspective.
655        // wal_mu remains held so no concurrent writer can extend the WAL tail.
656        let sync_result: Result<()> = if should_sync && results.iter().any(|r| r.is_ok()) {
657            sync_fn(&dir).map_err(GraphError::Io)
658        } else {
659            Ok(()) // Relaxed policy or all submissions failed validation
660        };
661
662        // ── Step 4: Handle fsync failure ─────────────────────────────────────
663        if let Err(ref io_err) = sync_result {
664            if results.iter().any(|r| r.is_ok()) {
665                // Truncate WAL to the pre-group boundary.  Safe because wal_mu
666                // is held — no other writer can have appended since pre_len was
667                // measured, so this always removes exactly the failed group's
668                // frames and nothing else.
669                let _ = truncate_wal_at(&dir, pre_group_wal_len);
670            }
671            // Acquire write lock to update in-memory degraded state.
672            // Lock order maintained: wal_mu (held) → RwLock write.
673            {
674                let mut db = inner.write().unwrap_or_else(|e| e.into_inner());
675                db.discard_deferred_events();
676                db.set_deferred_events_mode(false);
677                // Mark degraded so db.write().insert_node(...) etc. also fail.
678                db.set_degraded();
679            }
680            // Propagate failure to queue BEFORE releasing wal_mu so any
681            // direct writer waiting for wal_mu sees the degraded flag when
682            // it wakes (and log_then_apply_with will return Err(degraded)).
683            queue.set_degraded(io_err.to_string());
684            // Release WAL mutex — no more WAL I/O will happen.
685            drop(wal_guard);
686            // Signal each submitter with an IO error.
687            for sub in group {
688                let _ = sub.done.send(Err(GraphError::Io(std::io::Error::other(
689                    "group-commit fsync failed; database is degraded, reopen required",
690                ))));
691            }
692            return; // drain loop exits; no further groups accepted
693        }
694
695        // ── Step 5: Flush deferred events AFTER successful fsync (R2) ────────
696        //
697        // Flush events while wal_mu is still held so no direct writer can slip
698        // between this group's fsync and its event delivery.  Lock order is
699        // wal_mu (held) → inner.write() — the same order enforced everywhere
700        // else; no deadlock: the drain thread never holds inner while waiting
701        // for wal_mu.  Event delivery is pure in-memory (subscriber callbacks
702        // only); no WAL I/O occurs in flush_deferred_events.
703        if should_sync {
704            let mut db = inner.write().unwrap_or_else(|e| e.into_inner());
705            db.flush_deferred_events();
706            db.set_deferred_events_mode(false);
707            // inner.write() released here (RAII) before wal_mu below.
708        }
709
710        // Release WAL mutex after events are flushed.  Unblocks any direct
711        // writer that was waiting for wal_mu; they will observe the correct
712        // event order when they subsequently deliver their own events.
713        drop(wal_guard);
714
715        // ── Step 6: Signal each submitter ────────────────────────────────────
716        for (sub, result) in group.into_iter().zip(results) {
717            let _ = sub.done.send(result);
718        }
719    }
720}