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