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}