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`, nor the cross-process lock — neither a
37//! concurrent fsync nor a peer process holding the store lock lengthens a read.
38//! What a reader can wait on is another thread's RwLock write guard: a direct
39//! writer's mutation, a group apply, or its own periodic refresh.
40//!
41//! Holding `wal_mu` from before [append group frames] through [fsync OR
42//! truncation resolution] closes the truncation race: no concurrent direct
43//! write can insert WAL frames between the group's append and its fsync
44//! outcome, so `truncate_wal_at(pre_len)` is always a safe tail-trim.
45//!
46//! # Cross-process write lock
47//!
48//! The two locks above serialise writers inside this process. Across processes
49//! the store carries an advisory exclusive lock on its `LOCK` file, and it is
50//! taken **between** them, not last. The full order is:
51//!
52//! 1. `wal_mu`
53//! 2. cross-process `LOCK` — polled here, with no RwLock guard held
54//! 3. `inner` (RwLock write guard)
55//!
56//! Waiting for the cross-process lock is the slow step: a busy peer can hold it
57//! for the whole [`WRITE_LOCK_WAIT`](crate::WRITE_LOCK_WAIT) budget. Polling it
58//! before the RwLock is what keeps that wait off the read path — each attempt
59//! takes the RwLock in *read* mode for one `try_lock` syscall and releases it,
60//! so readers are never blocked by another process. Taking `inner.write()`
61//! first would stall every reader in this process behind a peer in another one.
62//!
63//! Both WAL-appending paths take it: [`SharedDb::write`] for the whole guard
64//! scope, and the drain thread for the whole of each group, from before the
65//! first append until after the group's fsync resolves. Releasing it only after
66//! the fsync is what stops another process's snapshot from truncating bytes
67//! this one has written but not yet made durable. `snapshot_with` refuses to
68//! run without it, for the same reason.
69//!
70//! Entering the write scope also refreshes, so a write always applies on top of
71//! every other process's commits. A writer that cannot get the lock within its
72//! wait budget fails with [`GraphError::Busy`], having written nothing.
73//!
74//! Readers take neither `wal_mu` nor the cross-process lock. They check for
75//! other processes' commits on every read — a metadata-only staleness check —
76//! and apply the WAL tail under the in-process write lock, which is the same
77//! lock the drain thread holds while it appends and applies — so a refresh
78//! never observes a half-committed group.
79//!
80//! # Fsync-failure contract
81//!
82//! If the group fsync fails the drain thread immediately:
83//! 1. **Truncates** the WAL file back to the pre-group offset — this prevents
84//! a later successful fsync from silently making the failed group durable
85//! by flushing the full inode page cache. The truncation is safe because
86//! `wal_mu` prevents any concurrent write from appending after `pre_len`.
87//! 2. **Marks** the database degraded via [`GraphDb::set_degraded`] — all
88//! subsequent [`submit_batch`] and `db.write()` mutation attempts return
89//! an IO error until the database is reopened.
90//! 3. **Discards** buffered event notifications (no subscriber sees un-durable
91//! data).
92//! 4. **Signals** all submitters in the failed group with an IO error.
93//! 5. **Exits** the drain loop.
94//!
95//! Readers may have already observed the failed group's data (between the
96//! write-lock release and the truncation); that window is equivalent to the
97//! `Relaxed` durability contract.
98//!
99//! # Event ordering (Strict policy, R2)
100//!
101//! Under `FsyncPolicy::Strict` or `Batched`, subscription events are deferred
102//! until after the group fsync. The drain thread then reacquires the write lock
103//! (while still holding `wal_mu`) to call [`GraphDb::flush_deferred_events`],
104//! releases the write lock, releases `wal_mu`, and finally signals submitters.
105//! Flushing events while `wal_mu` is held prevents a concurrent direct writer
106//! from slipping in between the group fsync and the event flush and delivering
107//! its event before the group's events — preserving a global monotone event
108//! order across both the drain path and the direct write path.
109//! Under `Relaxed`, events fire immediately (no fsync to wait for).
110//!
111//! # Event delivery and crashes (R2)
112//!
113//! Subscription events are best-effort post-durability notifications. A crash
114//! between a successful group fsync and the `flush_deferred_events` call drops
115//! those events — a strictly narrower loss window than pre-4b (where events
116//! could fire before any fsync).
117//!
118//! # Shutdown
119//!
120//! `SharedDb` clones the drain handle via an `Arc`; the last clone to drop
121//! triggers `DrainHandle::drop`, which signals shutdown + joins the thread.
122//! The drain thread can never exit while any `SharedDb` clone exists (the
123//! `Arc<Inner>` is held by every clone); no submission enqueued before the
124//! last clone is dropped can be silently lost.
125
126use crate::db::{
127 BatchOp, FsyncPolicy, Precondition, WriteAuthz, LOCK_POLL_INTERVAL, WRITE_LOCK_WAIT,
128};
129use crate::reader::ReaderSnapshot;
130use crate::GraphDb;
131use core_storage::sync_wal_at;
132use core_storage::truncate_wal_at;
133use core_storage::GraphError;
134use core_storage::RealFs;
135use core_storage::Result;
136use std::ops::{Deref, DerefMut};
137use std::path::Path;
138use std::sync::atomic::{AtomicBool, Ordering};
139use std::sync::{Arc, Condvar, Mutex, RwLock};
140use std::thread;
141use std::time::{Duration, Instant};
142
143// ── Group-commit constants ────────────────────────────────────────────────────
144
145/// Maximum submissions coalesced into one group. Caps write-lock hold time
146/// under extreme write bursts.
147const MAX_GROUP_SIZE: usize = 256;
148
149// ── WAL sync function type ────────────────────────────────────────────────────
150
151/// A callable that syncs the WAL at a given directory path.
152///
153/// In production this is always `sync_wal_at`. Tests may inject a failing
154/// implementation via [`SharedDb::open_with_test_sync`] to exercise the
155/// fsync-failure contract through the live drain thread.
156type SyncWalFn = Arc<dyn Fn(&Path) -> std::io::Result<()> + Send + Sync>;
157
158// ── Submission type ───────────────────────────────────────────────────────────
159
160struct Submission {
161 ops: Vec<BatchOp>,
162 /// Compare-and-set preconditions. Empty for plain `submit_batch` calls;
163 /// non-empty for `submit_batch_cas` calls. The drain thread checks these
164 /// under the same write guard as the batch apply (no TOCTOU).
165 preconds: Vec<Precondition>,
166 /// Role name for role-scoped write authz. `Some` only for
167 /// `submit_batch_authz` calls; `None` for full-authority submissions.
168 /// The drain thread resolves mask + scope under `inner.write()` (§5 lock
169 /// discipline: authz check and mutation share one guard lifetime).
170 authz_role: Option<String>,
171 done: std::sync::mpsc::SyncSender<Result<(usize, usize)>>,
172}
173
174// ── WriteQueue ────────────────────────────────────────────────────────────────
175
176struct WriteQueue {
177 pending: Mutex<Vec<Submission>>,
178 notify: Condvar,
179 shutdown: AtomicBool,
180 /// Set by the drain thread on a group fsync failure. Non-None means the
181 /// drain thread has exited; future `submit_batch` calls return Err immediately
182 /// rather than blocking forever on a dead drain thread.
183 degraded_msg: Mutex<Option<String>>,
184}
185
186impl WriteQueue {
187 fn new() -> Arc<Self> {
188 Arc::new(Self {
189 pending: Mutex::new(Vec::new()),
190 notify: Condvar::new(),
191 shutdown: AtomicBool::new(false),
192 degraded_msg: Mutex::new(None),
193 })
194 }
195
196 fn enqueue(&self, sub: Submission) {
197 self.pending
198 .lock()
199 .unwrap_or_else(|e| e.into_inner())
200 .push(sub);
201 self.notify.notify_one();
202 }
203
204 fn signal_shutdown(&self) {
205 // Hold the queue mutex while flagging + notifying: the drain thread
206 // checks `shutdown` only under this mutex, so the notify can no longer
207 // fire between its predicate check and Condvar::wait (missed wakeup).
208 let _lock = self.pending.lock().unwrap_or_else(|e| e.into_inner());
209 self.shutdown.store(true, Ordering::Release);
210 self.notify.notify_all();
211 }
212
213 fn set_degraded(&self, msg: String) {
214 *self.degraded_msg.lock().unwrap_or_else(|e| e.into_inner()) = Some(msg);
215 }
216
217 fn degraded_message(&self) -> Option<String> {
218 self.degraded_msg
219 .lock()
220 .unwrap_or_else(|e| e.into_inner())
221 .clone()
222 }
223
224 /// Block until work is available or shutdown; return at most `MAX_GROUP_SIZE`
225 /// submissions. Returns an empty `Vec` only when shutdown is set AND the
226 /// queue is empty.
227 fn wait_and_drain(&self) -> Vec<Submission> {
228 let mut lock = self.pending.lock().unwrap_or_else(|e| e.into_inner());
229 loop {
230 if !lock.is_empty() {
231 let n = lock.len().min(MAX_GROUP_SIZE);
232 return lock.drain(..n).collect();
233 }
234 if self.shutdown.load(Ordering::Acquire) {
235 return vec![];
236 }
237 lock = self.notify.wait(lock).unwrap_or_else(|e| e.into_inner());
238 }
239 }
240}
241
242// ── DrainHandle ───────────────────────────────────────────────────────────────
243
244/// Signals the drain thread and joins it when dropped. Owned inside an
245/// `Arc` so the last `SharedDb` clone triggers the join.
246struct DrainHandle {
247 queue: Arc<WriteQueue>,
248 handle: Option<thread::JoinHandle<()>>,
249}
250
251impl Drop for DrainHandle {
252 fn drop(&mut self) {
253 self.queue.signal_shutdown();
254 if let Some(h) = self.handle.take() {
255 let _ = h.join();
256 }
257 }
258}
259
260// ── WriteGuard ────────────────────────────────────────────────────────────────
261
262/// Compound write guard returned by [`SharedDb::write`].
263///
264/// Holds both the WAL mutex and the RwLock write guard. Fields are declared
265/// in drop order — `inner` (RwLock) releases first, then `_wal` (WAL mutex)
266/// — preserving the lock-release ordering required by the WAL I/O discipline.
267///
268/// # Lock order
269///
270/// Acquisition: `wal_mu` → cross-process `LOCK` → `inner` (RwLock write). The
271/// cross-process lock is polled with no RwLock write guard held, so a peer
272/// process holding it never stalls this process's readers.
273/// Release: cross-process `LOCK` (in `Drop`), then `inner`, then `wal_mu`
274/// (RAII, struct field declaration order).
275///
276/// # Cross-process lock
277///
278/// Constructing the guard also takes the store's advisory cross-process write
279/// lock and refreshes, so mutations made through it land on top of every other
280/// process's commits. If the lock could not be taken within the caller's wait
281/// budget, the guard is still returned but every mutation on it fails with
282/// [`GraphError::Busy`] — use [`SharedDb::write_with_wait`] to see the failure
283/// up front instead.
284pub struct WriteGuard<'a> {
285 /// RwLock write guard — dropped first (field declared first).
286 inner: std::sync::RwLockWriteGuard<'a, GraphDb<RealFs>>,
287 /// WAL mutex guard — dropped second (field declared second).
288 _wal: std::sync::MutexGuard<'a, ()>,
289}
290
291impl<'a> Drop for WriteGuard<'a> {
292 fn drop(&mut self) {
293 // Release the cross-process lock before either in-process lock: another
294 // process may be polling for it, and there is nothing left to serialise.
295 self.inner.end_write_lock();
296 }
297}
298
299impl<'a> Deref for WriteGuard<'a> {
300 type Target = GraphDb<RealFs>;
301 fn deref(&self) -> &GraphDb<RealFs> {
302 &self.inner
303 }
304}
305
306impl<'a> DerefMut for WriteGuard<'a> {
307 fn deref_mut(&mut self) -> &mut GraphDb<RealFs> {
308 &mut self.inner
309 }
310}
311
312// ── SharedDb ──────────────────────────────────────────────────────────────────
313
314/// Shared handle to an on-disk [`GraphDb`]. [`Clone`] is cheap and shares state.
315///
316/// # Group-commit write path
317///
318/// [`SharedDb::submit_batch`] routes mutations through a group-commit queue.
319/// A background drain thread batches concurrent submissions under a single WAL
320/// fsync, yielding throughput that scales with concurrency.
321///
322/// # Direct write path
323///
324/// [`SharedDb::write`] gives exclusive `&mut GraphDb` access for callers that
325/// need complex multi-step mutations (e.g. Cypher write queries). It acquires
326/// the WAL mutex first, then the RwLock write guard, satisfying the lock-order
327/// discipline described in the module doc.
328///
329/// # Event-sink deadlock
330///
331/// [`GraphDb::set_event_sink`] runs inside `log_then_apply` while the write
332/// guard is held. A sink must never call [`SharedDb::read`] or
333/// [`SharedDb::write`] on the same handle.
334#[derive(Clone)]
335pub struct SharedDb {
336 inner: Arc<RwLock<GraphDb<RealFs>>>,
337 queue: Arc<WriteQueue>,
338 /// Keeps the drain thread alive; signals + joins on last drop.
339 _drain: Arc<DrainHandle>,
340 /// WAL I/O mutex. Serialises all WAL appends, fsyncs, and truncations
341 /// across the drain thread and direct writers. See module-level doc for
342 /// the required acquisition order.
343 wal_mu: Arc<Mutex<()>>,
344}
345
346const _: () = {
347 fn assert_send_sync<T: Send + Sync>() {}
348 let _ = assert_send_sync::<SharedDb>;
349};
350
351impl SharedDb {
352 /// Open the store at `dir` as a shared, multi-reader handle.
353 ///
354 /// Unlike a plain [`GraphDb`], this does not hold the store's cross-process
355 /// write lock for the handle's lifetime — a server would otherwise lock out
356 /// every other process for as long as it runs. The lock is taken per write
357 /// instead, and reads follow other processes' commits automatically.
358 pub fn open(dir: &Path) -> Result<Self> {
359 let db = GraphDb::open_unlocked(dir)?;
360 Ok(Self::from_db_and_dir_with_sync(
361 db,
362 dir.to_path_buf(),
363 Arc::new(sync_wal_at),
364 ))
365 }
366
367 /// Open with an injectable WAL sync function.
368 ///
369 /// Allows tests to inject fsync failures through the live drain thread
370 /// without requiring real filesystem manipulation. Not intended for
371 /// production use; the `test_sync` name signals its purpose.
372 pub fn open_with_test_sync(
373 dir: &Path,
374 sync: impl Fn(&Path) -> std::io::Result<()> + Send + Sync + 'static,
375 ) -> Result<Self> {
376 let db = GraphDb::open_unlocked(dir)?;
377 Ok(Self::from_db_and_dir_with_sync(
378 db,
379 dir.to_path_buf(),
380 Arc::new(sync),
381 ))
382 }
383
384 fn from_db_and_dir_with_sync(
385 db: GraphDb<RealFs>,
386 dir: std::path::PathBuf,
387 sync_fn: SyncWalFn,
388 ) -> Self {
389 let inner = Arc::new(RwLock::new(db));
390 let queue = WriteQueue::new();
391 let wal_mu = Arc::new(Mutex::new(()));
392 let dir_arc = Arc::new(dir);
393
394 let drain_inner = Arc::clone(&inner);
395 let drain_queue = Arc::clone(&queue);
396 let drain_dir = Arc::clone(&dir_arc);
397 let drain_wal_mu = Arc::clone(&wal_mu);
398 let drain_sync_fn = Arc::clone(&sync_fn);
399
400 let handle = thread::Builder::new()
401 .name("groupcommit-drain".into())
402 .spawn(move || {
403 drain_loop(
404 drain_inner,
405 drain_queue,
406 drain_dir,
407 drain_wal_mu,
408 drain_sync_fn,
409 )
410 })
411 .expect("failed to spawn group-commit drain thread");
412
413 SharedDb {
414 inner,
415 queue: Arc::clone(&queue),
416 _drain: Arc::new(DrainHandle {
417 queue,
418 handle: Some(handle),
419 }),
420 wal_mu,
421 }
422 }
423
424 /// Check whether another process has committed, and if so absorb its
425 /// commits.
426 ///
427 /// Runs on every read. The check is metadata-only — one `stat` of the WAL,
428 /// and a second of the snapshot when the WAL length is unchanged — which
429 /// is the price of a handle that follows its peers.
430 ///
431 /// It is deliberately not rate-limited by a timer. A wall-clock window
432 /// makes visibility depend on how fast the peer happened to be: a read
433 /// landing inside the window returns a view that predates a commit which
434 /// had already completed, while the same read on a slower machine would
435 /// have seen it. The child process in `multiprocess.rs` test 1 writes its
436 /// 100 nodes in ~11 ms on Linux — well inside the 50 ms window this check
437 /// used to keep, and outside it on macOS, where spawning the child costs
438 /// more than the window itself.
439 ///
440 /// Readers never touch the cross-process lock and never block on it: the
441 /// worst a reader does is take the in-process write lock briefly to apply
442 /// the WAL tail. Applying the tail under that lock is what makes the check
443 /// safe against a concurrent group commit — the drain thread appends,
444 /// applies, and advances the cursor under the same lock, so a refresh never
445 /// sees a half-committed group and never replays one twice.
446 ///
447 /// A staleness check that fails (an unreadable store directory, say) is
448 /// treated as "not stale": a read must not fail because the store might
449 /// have moved on.
450 fn refresh_if_stale(&self) {
451 let stale = self
452 .inner
453 .read()
454 .unwrap_or_else(|e| e.into_inner())
455 .is_stale()
456 .unwrap_or(false);
457 if stale {
458 let mut db = self.inner.write().unwrap_or_else(|e| e.into_inner());
459 if let Err(e) = db.refresh() {
460 // `refresh` degrades the handle on failure, so mutations
461 // already refuse. Tell the write queue too, so a submitter gets
462 // the error immediately instead of blocking on a drain thread
463 // whose next group will fail anyway.
464 self.queue.set_degraded(format!(
465 "refresh failed while following another process's commits: {e}; \
466 reopen required"
467 ));
468 }
469 }
470 }
471
472 /// Shared read access. Many readers may hold this concurrently.
473 ///
474 /// Readers never acquire the WAL mutex and never wait on the cross-process
475 /// write lock, so neither a concurrent fsync nor a peer process writing the
476 /// store lengthens a read. A reader can still wait on another thread's
477 /// RwLock write guard, including the refresh below.
478 ///
479 /// # Following other processes
480 ///
481 /// Every read checks whether another process has committed and, if so,
482 /// applies its commits before handing out the guard. A handle therefore
483 /// stays current without reopening, and the guarantee is not a timing
484 /// accident: a read started after a peer's commit completed sees that
485 /// commit. The check is metadata-only — one or two `stat` calls, no file
486 /// contents — so a read loop pays a syscall, not a reload.
487 ///
488 /// # Deadlock warning
489 ///
490 /// Do not hold a returned guard while calling any method on the same
491 /// [`SharedDb`]; the [`RwLock`] is not re-entrant; doing so deadlocks.
492 pub fn read(&self) -> impl Deref<Target = GraphDb<RealFs>> + '_ {
493 self.refresh_if_stale();
494 self.inner.read().unwrap_or_else(|e| e.into_inner())
495 }
496
497 /// Exclusive write access, waiting up to [`WRITE_LOCK_WAIT`] for the
498 /// store's cross-process write lock.
499 ///
500 /// Acquires the WAL mutex first, then the cross-process lock, then the
501 /// RwLock write guard — the order required by the fsync-failure contract
502 /// (see module doc). Waiting for the cross-process lock happens with no
503 /// RwLock write guard held, which is what keeps a busy peer process off
504 /// this process's read path. The returned [`WriteGuard`] releases the
505 /// cross-process lock, then the RwLock, then the WAL mutex on drop.
506 ///
507 /// # When another process holds the lock
508 ///
509 /// The guard is still returned, but every mutation through it fails with
510 /// [`GraphError::Busy`] and writes nothing. Call
511 /// [`write_with_wait`](SharedDb::write_with_wait) when you would rather see
512 /// that up front, or choose your own wait budget.
513 ///
514 /// # Deadlock warning
515 ///
516 /// Do not hold a returned guard while calling any method on the same
517 /// [`SharedDb`]; the [`RwLock`] is not re-entrant; doing so deadlocks.
518 pub fn write(&self) -> WriteGuard<'_> {
519 let _wal = self.wal_mu.lock().unwrap_or_else(|e| e.into_inner());
520 let acquired = self
521 .poll_cross_process_lock(WRITE_LOCK_WAIT)
522 .unwrap_or(false);
523 // The outcome is latched on the db itself, so it surfaces on the first
524 // mutation: a denied lock as `Busy`, and a refresh failure under a
525 // taken lock as the degraded error (`refresh` marks the handle).
526 self.enter_write_scope(_wal, acquired).0
527 }
528
529 /// Like [`write`](SharedDb::write) but with an explicit wait budget, and
530 /// [`GraphError::Busy`] returned up front when the cross-process write lock
531 /// is not free within it.
532 ///
533 /// A zero wait makes exactly one attempt. Nothing is written on failure, so
534 /// retrying later is always safe.
535 pub fn write_with_wait(&self, wait: Duration) -> Result<WriteGuard<'_>> {
536 let _wal = self.wal_mu.lock().unwrap_or_else(|e| e.into_inner());
537 if !self.poll_cross_process_lock(wait)? {
538 return Err(GraphError::Busy { holder: None });
539 }
540 let (guard, entered) = self.enter_write_scope(_wal, true);
541 entered?;
542 Ok(guard)
543 }
544
545 /// Poll for the store's cross-process write lock, holding **no** in-process
546 /// guard while waiting.
547 ///
548 /// Lock order is `wal_mu` → cross-process `LOCK` → `inner` (RwLock write).
549 /// The caller already holds `wal_mu`, which is what keeps two writers in
550 /// this process from polling at once. Each attempt takes the RwLock in
551 /// *read* mode for the length of one `try_lock` syscall and releases it, so
552 /// concurrent readers are never blocked by a peer process: waiting for a
553 /// busy peer costs readers nothing.
554 fn poll_cross_process_lock(&self, wait: Duration) -> Result<bool> {
555 let deadline = Instant::now() + wait;
556 loop {
557 {
558 let db = self.inner.read().unwrap_or_else(|e| e.into_inner());
559 if db.try_cross_process_lock()? {
560 return Ok(true);
561 }
562 }
563 let now = Instant::now();
564 if now >= deadline {
565 return Ok(false);
566 }
567 std::thread::sleep(LOCK_POLL_INTERVAL.min(deadline.saturating_duration_since(now)));
568 }
569 }
570
571 /// Take the RwLock write guard, wrap both in-process guards in a
572 /// [`WriteGuard`], and open the write scope with the lock outcome the
573 /// caller already obtained.
574 ///
575 /// The guard is returned even when the scope failed to open, so its `Drop`
576 /// always releases the cross-process lock; the failure rides on the second
577 /// tuple element and is latched on the handle for `write()`'s benefit.
578 fn enter_write_scope<'a>(
579 &'a self,
580 wal: std::sync::MutexGuard<'a, ()>,
581 acquired: bool,
582 ) -> (WriteGuard<'a>, Result<()>) {
583 let mut inner = self.inner.write().unwrap_or_else(|e| e.into_inner());
584 let entered = inner.enter_write_scope(acquired);
585 (WriteGuard { inner, _wal: wal }, entered)
586 }
587
588 /// Capture a lock-free [`ReaderSnapshot`] of the current db state.
589 ///
590 /// Acquires the read lock only long enough to clone a handful of `Arc`
591 /// handles. Subsequent reads on the returned snapshot are lock-free.
592 pub fn reader(&self) -> ReaderSnapshot {
593 self.read().reader()
594 }
595
596 /// Enqueue a mutation batch for the group-committing writer.
597 ///
598 /// Blocks until the **containing group** is durably committed (one WAL
599 /// fsync per group under `Strict` policy). Submissions from concurrent
600 /// callers are coalesced into groups of up to 256 items.
601 ///
602 /// # Durability semantics
603 ///
604 /// Under `Strict` policy (the default):
605 /// - Each submission becomes a separate WAL `Batch` frame.
606 /// - All frames in a group share one fsync — the caller unblocks only
607 /// after that fsync.
608 /// - **Fsync failure**: the drain thread truncates the WAL back to the
609 /// pre-group offset and marks the database degraded. All submitters in
610 /// the failed group and all subsequent callers receive `Err`. Data that
611 /// was already in readers' snapshots (observed between write-lock release
612 /// and truncation) is not rolled back — equivalent to the `Relaxed`
613 /// window for in-flight readers. Reopen the database to recover.
614 /// - A crash between group fsyncs loses the entire unfsynced group, but
615 /// never tears an individual submission (CRC-protected frame boundaries).
616 ///
617 /// Under `Relaxed` policy (set via `db.write().set_fsync_policy`):
618 /// - WAL frames are appended but NOT synced; caller unblocks after apply.
619 ///
620 /// # Event ordering
621 ///
622 /// Under `Strict` / `Batched` policy, subscription events fire AFTER the
623 /// group fsync (durability before notification). Under `Relaxed`, events
624 /// fire immediately after apply.
625 ///
626 /// # FIFO ordering
627 ///
628 /// Submissions from the same caller arrive FIFO at the queue. Across
629 /// concurrent callers, drain order within a group is arbitrary, but each
630 /// submission's commit sequence is monotonically increasing.
631 ///
632 /// # Returns
633 ///
634 /// `(nodes_inserted, edges_inserted)` on success. An all-noop batch
635 /// returns `(0, 0)`.
636 pub fn submit_batch(&self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
637 // Fast-path rejection: if the drain thread already exited due to a
638 // fsync failure, return Err immediately rather than blocking forever.
639 if let Some(msg) = self.queue.degraded_message() {
640 return Err(GraphError::Io(std::io::Error::other(msg)));
641 }
642 let (tx, rx) = std::sync::mpsc::sync_channel(1);
643 self.queue.enqueue(Submission {
644 ops,
645 preconds: Vec::new(),
646 authz_role: None,
647 done: tx,
648 });
649 rx.recv().unwrap_or_else(|_| {
650 Err(GraphError::Io(std::io::Error::other(
651 "group-commit drain thread terminated unexpectedly",
652 )))
653 })
654 }
655
656 /// Like [`submit_batch`] but with compare-and-set preconditions.
657 ///
658 /// The preconditions are evaluated by the drain thread under the **same**
659 /// write guard as the batch apply — there is no TOCTOU window. If any
660 /// precondition fails, the entire batch is rejected with
661 /// [`core_storage::GraphError::CasConflict`] and no WAL frame is written.
662 ///
663 /// See [`crate::Precondition`] for the full semantics.
664 pub fn submit_batch_cas(
665 &self,
666 preconds: Vec<Precondition>,
667 ops: Vec<BatchOp>,
668 ) -> Result<(usize, usize)> {
669 if let Some(msg) = self.queue.degraded_message() {
670 return Err(GraphError::Io(std::io::Error::other(msg)));
671 }
672 let (tx, rx) = std::sync::mpsc::sync_channel(1);
673 self.queue.enqueue(Submission {
674 ops,
675 preconds,
676 authz_role: None,
677 done: tx,
678 });
679 rx.recv().unwrap_or_else(|_| {
680 Err(GraphError::Io(std::io::Error::other(
681 "group-commit drain thread terminated unexpectedly",
682 )))
683 })
684 }
685
686 /// Like [`submit_batch`] but with role-scoped write authorization.
687 ///
688 /// The drain thread resolves `mask_for_role` + scope under the same write
689 /// guard as the mutation (§5 lock discipline: authz BEFORE any CAS
690 /// preconditions, BEFORE the WAL write).
691 ///
692 /// - Role with `write: None` → `GraphError::RoleWriteDenied` (endpoint not
693 /// permitted) — maps to HTTP 403.
694 /// - Scope / visibility violations inside the batch → `GraphError::RoleWriteDenied`
695 /// with the appropriate §4.3 reason string.
696 ///
697 /// All-or-nothing semantics: a single denied op rejects the entire batch
698 /// with no WAL frame written.
699 pub fn submit_batch_authz(&self, role: String, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
700 if let Some(msg) = self.queue.degraded_message() {
701 return Err(GraphError::Io(std::io::Error::other(msg)));
702 }
703 let (tx, rx) = std::sync::mpsc::sync_channel(1);
704 self.queue.enqueue(Submission {
705 ops,
706 preconds: Vec::new(),
707 authz_role: Some(role),
708 done: tx,
709 });
710 rx.recv().unwrap_or_else(|_| {
711 Err(GraphError::Io(std::io::Error::other(
712 "group-commit drain thread terminated unexpectedly",
713 )))
714 })
715 }
716}
717
718// ── Drain thread ──────────────────────────────────────────────────────────────
719
720/// Poll for the store's cross-process write lock without holding the RwLock
721/// write guard, so a busy peer never stalls this process's readers.
722///
723/// The free-function twin of [`SharedDb::poll_cross_process_lock`]; the drain
724/// thread has the `Arc<RwLock<..>>` but no `SharedDb`. The caller must already
725/// hold `wal_mu`.
726fn poll_cross_process_lock(inner: &Arc<RwLock<GraphDb<RealFs>>>, wait: Duration) -> Result<bool> {
727 let deadline = Instant::now() + wait;
728 loop {
729 {
730 let db = inner.read().unwrap_or_else(|e| e.into_inner());
731 if db.try_cross_process_lock()? {
732 return Ok(true);
733 }
734 }
735 let now = Instant::now();
736 if now >= deadline {
737 return Ok(false);
738 }
739 std::thread::sleep(LOCK_POLL_INTERVAL.min(deadline.saturating_duration_since(now)));
740 }
741}
742
743fn drain_loop(
744 inner: Arc<RwLock<GraphDb<RealFs>>>,
745 queue: Arc<WriteQueue>,
746 dir: Arc<std::path::PathBuf>,
747 wal_mu: Arc<Mutex<()>>,
748 sync_fn: SyncWalFn,
749) {
750 loop {
751 // Wait for work (or shutdown with empty queue).
752 let mut group = queue.wait_and_drain();
753 if group.is_empty() {
754 return; // shutdown + nothing pending
755 }
756
757 // Extract ops, preconditions, and authz_role together; keep alignment
758 // with group index.
759 let submissions: Vec<(Vec<Precondition>, Vec<BatchOp>, Option<String>)> = group
760 .iter_mut()
761 .map(|s| {
762 (
763 std::mem::take(&mut s.preconds),
764 std::mem::take(&mut s.ops),
765 s.authz_role.take(),
766 )
767 })
768 .collect();
769
770 // ── Step 1: Acquire WAL mutex BEFORE the write lock ─────────────────
771 //
772 // Lock order: wal_mu → RwLock write guard.
773 //
774 // Holding wal_mu from here through [fsync OR truncation resolution]
775 // closes the truncation race: no concurrent db.write() caller can
776 // append WAL frames between the group's own appends and its fsync
777 // outcome. truncate_wal_at(pre_len) is therefore always a safe
778 // tail-trim with no risk of wiping acknowledged direct writes.
779 let wal_guard = wal_mu.lock().unwrap_or_else(|e| e.into_inner());
780
781 // ── Step 1b: Take the cross-process write lock for this group ────────
782 //
783 // The drain thread appends to the WAL without going through
784 // `SharedDb::write`, so it must serialise against other processes
785 // itself. Polling happens with NO RwLock guard held (lock order:
786 // wal_mu → cross-process LOCK → inner.write), so a busy peer cannot
787 // stall this process's readers for the duration of the wait.
788 // `enter_write_scope` then refreshes under the lock, so the group
789 // applies on top of every commit another process has made. The lock is
790 // released only after this group's fsync resolves: until then the tail
791 // contains bytes we have not made durable, and another process's
792 // snapshot must not truncate them away.
793 let lock_outcome = poll_cross_process_lock(&inner, WRITE_LOCK_WAIT).and_then(|acquired| {
794 let mut db = inner.write().unwrap_or_else(|e| e.into_inner());
795 db.enter_write_scope(acquired).map(|()| acquired)
796 });
797 match lock_outcome {
798 Ok(true) => {}
799 Ok(false) => {
800 // Another process is writing. Every submitter in this group is
801 // told so; the drain loop stays alive and the next group tries
802 // again — a busy peer is a transient condition, not a failure
803 // of this database handle.
804 {
805 let mut db = inner.write().unwrap_or_else(|e| e.into_inner());
806 db.end_write_lock();
807 }
808 drop(wal_guard);
809 for sub in group {
810 let _ = sub.done.send(Err(GraphError::Busy { holder: None }));
811 }
812 continue;
813 }
814 Err(e) => {
815 // The lock was taken but the refresh under it failed, which
816 // leaves the handle degraded (see `GraphDb::refresh`). This is
817 // the same class of failure as a group fsync failure: the
818 // database must be reopened, so the drain loop exits rather
819 // than failing every future group identically.
820 let reason = format!("refresh under the write lock failed: {e}; reopen required");
821 {
822 let mut db = inner.write().unwrap_or_else(|e| e.into_inner());
823 db.discard_deferred_events();
824 db.set_deferred_events_mode(false);
825 db.set_degraded();
826 db.end_write_lock();
827 }
828 queue.set_degraded(reason.clone());
829 drop(wal_guard);
830 for sub in group {
831 let _ = sub
832 .done
833 .send(Err(GraphError::Io(std::io::Error::other(reason.clone()))));
834 }
835 return;
836 }
837 }
838
839 // Snapshot WAL size while holding wal_mu — no concurrent WAL append
840 // is possible, so this offset is a stable pre-group boundary.
841 let pre_group_wal_len = std::fs::metadata(dir.join("wal.bin"))
842 .map(|m| m.len())
843 .unwrap_or(0);
844
845 // ── Step 2: Apply all submissions under the write lock, NO fsync ────
846 //
847 // For Strict / Batched policy we enable deferred event mode so that
848 // subscription notifications only fire after the group fsync (R2:
849 // durability before notification). For Relaxed policy events fire
850 // immediately (no fsync to wait for).
851 let (results, should_sync): (Vec<Result<(usize, usize)>>, bool) = {
852 let mut db = inner.write().unwrap_or_else(|e| e.into_inner());
853 let sync_needed = db.fsync_policy() != FsyncPolicy::Relaxed;
854 if sync_needed {
855 db.set_deferred_events_mode(true);
856 }
857 // Apply submissions in FIFO order.
858 //
859 // Non-CAS submissions are coalesced into a single commit_group_nosync
860 // call (preserving the T4b group-write batching intent — one write
861 // boundary per drain group, not per submission). CAS submissions
862 // break the coalescing because their preconditions must be evaluated
863 // AFTER all prior submissions in the group have been applied (no
864 // TOCTOU); they are processed individually between coalesced non-CAS
865 // runs.
866 let mut r: Vec<Result<(usize, usize)>> = Vec::with_capacity(submissions.len());
867 let mut pending_non_cas: Vec<Vec<BatchOp>> = Vec::new();
868
869 for (preconds, ops, authz_role) in submissions {
870 if let Some(role) = authz_role {
871 // Authz submission: process individually (breaks coalescing).
872 // Flush accumulated non-CAS batch first so authz evaluation
873 // sees their writes already applied.
874 if !pending_non_cas.is_empty() {
875 let batch = std::mem::take(&mut pending_non_cas);
876 r.extend(db.commit_group_nosync(batch));
877 }
878 // Resolve WriteAuthz under the write guard (§5 lock discipline:
879 // scope + mask resolved in the same guard as the mutation;
880 // authz check fires BEFORE any WAL write).
881 let result = (|| -> Result<(usize, usize)> {
882 let scope = {
883 // Temporary scope so the borrow on db.roles ends
884 // before write_batch_authz_nosync borrows db mutably.
885 let roles_vec = db.roles();
886 let def = roles_vec
887 .iter()
888 .find(|r| r.name == role)
889 .ok_or_else(|| GraphError::KeyNotFound {
890 key: format!("role:{role}"),
891 })?
892 .clone();
893 // write:None → byte-identical v1 blanket-403 body.
894 def.write.ok_or_else(|| GraphError::RoleWriteDenied {
895 reason: "role-bound token: writes are not permitted".into(),
896 })?
897 };
898 let mask = db.mask_for_role(&role)?;
899 let authz = WriteAuthz { role, scope, mask };
900 db.write_batch_authz_nosync(Some(&authz), ops)
901 })();
902 r.push(result);
903 } else if preconds.is_empty() {
904 // Non-CAS: accumulate for a batched commit_group_nosync call.
905 pending_non_cas.push(ops);
906 } else {
907 // CAS: flush accumulated non-CAS batch first so that precond
908 // evaluation sees their writes already applied.
909 if !pending_non_cas.is_empty() {
910 let batch = std::mem::take(&mut pending_non_cas);
911 r.extend(db.commit_group_nosync(batch));
912 }
913 let result = match db.check_preconditions(&preconds) {
914 Ok(()) => db
915 .commit_group_nosync(vec![ops])
916 .into_iter()
917 .next()
918 .unwrap_or(Ok((0, 0))),
919 Err(e) => Err(e),
920 };
921 r.push(result);
922 }
923 }
924 // Flush any remaining non-CAS submissions.
925 if !pending_non_cas.is_empty() {
926 r.extend(db.commit_group_nosync(pending_non_cas));
927 }
928 (r, sync_needed)
929 // ← RwLock write guard released here; wal_mu still held
930 };
931
932 // ── Step 3: ONE fsync for the group, OUTSIDE the write lock ─────────
933 //
934 // Readers may see committed-but-unfsynced data between here and the
935 // fsync below (same contract as Relaxed). Submitters unblock only
936 // after the fsync, guaranteeing durability from their perspective.
937 // wal_mu remains held so no concurrent writer can extend the WAL tail.
938 let sync_result: Result<()> = if should_sync && results.iter().any(|r| r.is_ok()) {
939 sync_fn(&dir).map_err(GraphError::Io)
940 } else {
941 Ok(()) // Relaxed policy or all submissions failed validation
942 };
943
944 // ── Step 4: Handle fsync failure ─────────────────────────────────────
945 if let Err(ref io_err) = sync_result {
946 if results.iter().any(|r| r.is_ok()) {
947 // Truncate WAL to the pre-group boundary. Safe because wal_mu
948 // is held — no other writer can have appended since pre_len was
949 // measured, so this always removes exactly the failed group's
950 // frames and nothing else.
951 let _ = truncate_wal_at(&dir, pre_group_wal_len);
952 }
953 // Acquire write lock to update in-memory degraded state.
954 // Lock order maintained: wal_mu (held) → RwLock write.
955 {
956 let mut db = inner.write().unwrap_or_else(|e| e.into_inner());
957 db.discard_deferred_events();
958 db.set_deferred_events_mode(false);
959 // Mark degraded so db.write().insert_node(...) etc. also fail.
960 db.set_degraded();
961 // The truncation shortened the WAL: bring the frame cursor back
962 // to the file's real length so a later staleness check does not
963 // read our own in-memory state as a peer's rollback.
964 db.set_wal_consumed(pre_group_wal_len);
965 db.end_write_lock();
966 }
967 // Propagate failure to queue BEFORE releasing wal_mu so any
968 // direct writer waiting for wal_mu sees the degraded flag when
969 // it wakes (and log_then_apply_with will return Err(degraded)).
970 queue.set_degraded(io_err.to_string());
971 // Release WAL mutex — no more WAL I/O will happen.
972 drop(wal_guard);
973 // Signal each submitter with an IO error.
974 for sub in group {
975 let _ = sub.done.send(Err(GraphError::Io(std::io::Error::other(
976 "group-commit fsync failed; database is degraded, reopen required",
977 ))));
978 }
979 return; // drain loop exits; no further groups accepted
980 }
981
982 // ── Step 5: Flush deferred events AFTER successful fsync (R2) ────────
983 //
984 // Flush events while wal_mu is still held so no direct writer can slip
985 // between this group's fsync and its event delivery. Lock order is
986 // wal_mu (held) → inner.write() — the same order enforced everywhere
987 // else; no deadlock: the drain thread never holds inner while waiting
988 // for wal_mu. Event delivery is pure in-memory (subscriber callbacks
989 // only); no WAL I/O occurs in flush_deferred_events.
990 if should_sync {
991 let mut db = inner.write().unwrap_or_else(|e| e.into_inner());
992 db.flush_deferred_events();
993 db.set_deferred_events_mode(false);
994 // inner.write() released here (RAII) before wal_mu below.
995 }
996
997 // ── Step 5b: Release the cross-process lock ──────────────────────────
998 //
999 // The group's bytes are now fsynced, so another process may safely
1000 // snapshot or truncate around them.
1001 {
1002 let mut db = inner.write().unwrap_or_else(|e| e.into_inner());
1003 db.end_write_lock();
1004 }
1005
1006 // Release WAL mutex after events are flushed. Unblocks any direct
1007 // writer that was waiting for wal_mu; they will observe the correct
1008 // event order when they subsequently deliver their own events.
1009 drop(wal_guard);
1010
1011 // ── Step 6: Signal each submitter ────────────────────────────────────
1012 for (sub, result) in group.into_iter().zip(results) {
1013 let _ = sub.done.send(result);
1014 }
1015 }
1016}