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}