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