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