Skip to main content

graph_r/
writer.rs

1// Author: spicyzhug@gmail.com
2// Copyright: lituus-io, all rights reserved
3//
4//! Writers and transactions. Mutations are encoded straight into one log
5//! record as they are staged, made durable on [`Writer::commit`] (commit
6//! lock → catch up → seal → one append → fsync per policy → unlock), and
7//! that very buffer is then published into the current generation's
8//! overlay, where new snapshots see the whole transaction at once. Freshness
9//! math runs *here*, at touch time, and the computed state travels inside
10//! the op — replay never re-derives policy. [`Txn`] is the serializable
11//! form: the lock is held from its read view to its append.
12
13use crate::error::{Error, Result};
14use crate::format::base::nflags;
15use crate::format::wal::{self, OpView, RecordBuf, TouchRecord};
16use crate::key::{EdgeType, UrlKey};
17use crate::snapshot::Snapshot;
18use crate::store::{CommitLock, Durability, Held, Seen, Store};
19use crate::ttl::Outcome;
20use std::io::Write as _;
21use std::sync::atomic::Ordering;
22use std::sync::MutexGuard;
23
24/// Maximum outbound edges per node (wide joins and conditions included).
25pub use crate::format::wal::MAX_EDGES;
26/// Maximum attributes per node, and the longest attribute value.
27pub use crate::format::wal::{MAX_ATTRS, MAX_ATTR_LEN};
28/// Maximum label length persisted (bytes of UTF-8), and segments per document.
29pub use crate::format::wal::{MAX_LABEL, MAX_SEGS};
30/// Most value bytes one node's attribute set may hold in total: what fits a
31/// single log op ([`wal::MAX_OP`], its fixed fields and per-value headers
32/// included).
33pub const MAX_ATTR_BYTES: usize = wal::MAX_OP - (wal::OP_HEADER_LEN + 8) - MAX_ATTRS * 8;
34/// Longest attribute name accepted by [`Writer::set_named_attrs`].
35pub const MAX_ATTR_NAME: usize = 255;
36
37/// A document to upsert — borrowed, produced by the caller or the link-r
38/// bridge. Everything here is compact lookup metadata; bodies and vectors
39/// are deliberately not representable.
40#[derive(Clone, Copy, Debug)]
41pub struct DocRecord<'a> {
42    /// Canonical URL.
43    pub url: &'a str,
44    /// Durable key (xxh3 of `url`).
45    pub url_key: UrlKey,
46    /// xxh3 of the fetched body.
47    pub content_hash: u64,
48    /// Fetch stamp, epoch ms.
49    pub fetched_at_ms: u64,
50    /// Title, if extracted.
51    pub title: Option<&'a str>,
52    /// Distilled snippet (≤ ~180 chars by convention).
53    pub snippet: Option<&'a str>,
54    /// Entity tag from the fetch, for conditional revalidation.
55    pub etag: Option<&'a str>,
56    /// Exempt from eviction sweeps.
57    pub pinned: bool,
58}
59
60/// A sub-document segment to record — an anchor reference, never content.
61#[derive(Clone, Copy, Debug)]
62pub struct SegmentRecord<'a> {
63    /// Heading-path label (e.g. `"Install > Linux"`).
64    pub label: &'a str,
65    /// Byte range in the source document, when known.
66    pub byte_range: Option<(u32, u32)>,
67    /// Heading depth (1 = H1 …).
68    pub depth: u8,
69    /// Importance in 1/65535 units (deterministic prior or enrichment score).
70    pub importance: u16,
71}
72
73/// A revalidation observation to record against a node.
74#[derive(Clone, Copy, Debug)]
75pub struct Touch<'a> {
76    /// When the check happened, epoch ms.
77    pub checked_at_ms: u64,
78    /// What was observed.
79    pub outcome: Outcome,
80    /// New content hash when `outcome == Changed`.
81    pub content_hash: Option<u64>,
82    /// New entity tag, if the server sent one.
83    pub etag: Option<&'a str>,
84}
85
86pub(crate) struct WriteState {
87    next_seq: u64,
88    /// The pending transaction, encoded as it is staged.
89    staged: RecordBuf,
90    pub(crate) ops_since_compact: usize,
91    pub(crate) wal_bytes: u64,
92    commits_since_sync: u32,
93    wal: Option<std::fs::File>,
94    /// Generation of the newest base written to disk. A compaction whose
95    /// in-memory install was refused (every ring slot pinned) still leaves a
96    /// durable base behind; numbering from this, not from the served
97    /// snapshot, means a generation number is never reused.
98    pub(crate) disk_generation: u64,
99    /// What this handle last saw of the files (see `Store::sync`).
100    pub(crate) seen: Seen,
101    /// The lock file, kept open between commits (unlocked) so a commit costs
102    /// one lock call, not an open as well.
103    pub(crate) lock_file: Option<std::fs::File>,
104    /// The wait-queue file (`graph.wait`), kept open likewise.
105    pub(crate) wait_file: Option<std::fs::File>,
106}
107
108#[allow(clippy::missing_fields_in_debug)] // file handle and op buffer are noise
109impl std::fmt::Debug for WriteState {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        f.debug_struct("WriteState")
112            .field("next_seq", &self.next_seq)
113            .field("staged", &self.staged.ops())
114            .finish()
115    }
116}
117
118impl WriteState {
119    pub(crate) fn new(
120        next_seq: u64,
121        replayed_ops: usize,
122        wal_bytes: u64,
123        disk_generation: u64,
124        seen: Seen,
125    ) -> Self {
126        Self {
127            next_seq,
128            staged: RecordBuf::new(),
129            ops_since_compact: replayed_ops,
130            wal_bytes,
131            commits_since_sync: 0,
132            wal: None,
133            disk_generation,
134            seen,
135            lock_file: None,
136            wait_file: None,
137        }
138    }
139
140    /// Forget the open log handle: the log may have been replaced.
141    pub(crate) fn drop_wal_handle(&mut self) {
142        self.wal = None;
143    }
144
145    /// Advance to what a catch-up found: the served generation's next
146    /// sequence number and overlay size, and the log offset consumed.
147    pub(crate) fn adopt(&mut self, next_seq: u64, overlay_ops: usize, generation: u64) {
148        self.next_seq = self.next_seq.max(next_seq);
149        self.ops_since_compact = overlay_ops;
150        self.wal_bytes = self.seen.wal_pos;
151        self.disk_generation = self.disk_generation.max(generation);
152    }
153
154    /// Unlock and keep the descriptor for the next commit.
155    pub(crate) fn release(&mut self, held: Held) {
156        let _ = held.0.unlock();
157        self.lock_file = Some(held.0);
158    }
159
160    pub(crate) fn reset_after_compact(&mut self, next_seq: u64) {
161        self.next_seq = next_seq;
162        self.ops_since_compact = 0;
163        self.wal_bytes = u64::try_from(crate::format::wal::HEADER_LEN).unwrap_or(64);
164        self.commits_since_sync = 0;
165        self.wal = None; // reopen against the fresh log on next commit
166    }
167
168    pub(crate) fn staged_is_empty(&self) -> bool {
169        self.staged.is_empty()
170    }
171
172    pub(crate) fn last_committed_seq(&self) -> u64 {
173        self.next_seq.saturating_sub(1)
174    }
175}
176
177/// A handle's writer; holds the handle's writer mutex for its lifetime (so
178/// threads sharing one handle take turns). Stage mutations, then
179/// [`Writer::commit`] to make them durable and visible. Writers on other
180/// handles stage concurrently; commits take turns on the commit lock.
181#[derive(Debug)]
182pub struct Writer<'s> {
183    store: &'s Store,
184    state: MutexGuard<'s, WriteState>,
185    /// Committed-state view, cached for the duration of one staging batch.
186    ///
187    /// Several staging methods read committed state (segment identity for
188    /// enrichment carry, the other tier's edges, freshness counters, node
189    /// existence). Taking a fresh snapshot per staged op folds the overlay
190    /// arena every time — during a bulk ingest that made staging O(batch ×
191    /// pending-ops). Nothing on this handle can commit while this writer holds
192    /// the mutex, so one snapshot is as current as a fresh one for the whole
193    /// batch (other handles' commits arrive at the next catch-up; a
194    /// read-modify-write that must see them is a [`Store::transact`]).
195    /// [`Writer::commit`] drops it, both because the world changes there and
196    /// so the pinned generation slot is free before a catch-up or an
197    /// auto-compaction installs another.
198    snap: Option<Snapshot<'s>>,
199}
200
201fn check_label(name: &str, s: &str) -> Result<()> {
202    if s.len() > MAX_LABEL {
203        return Err(Error::format(format!("{name} label exceeds {MAX_LABEL} bytes")));
204    }
205    Ok(())
206}
207
208impl<'s> Writer<'s> {
209    pub(crate) fn new(store: &'s Store, state: MutexGuard<'s, WriteState>) -> Self {
210        Self { store, state, snap: None }
211    }
212
213    /// The cached committed-state view (created on first use per batch).
214    fn view(&mut self) -> &Snapshot<'s> {
215        if self.snap.is_none() {
216            self.snap = Some(self.store.snapshot());
217        }
218        self.snap.as_ref().expect("just filled")
219    }
220
221    /// Stage a node create/replace.
222    pub fn upsert_node(&mut self, doc: &DocRecord<'_>) -> Result<()> {
223        check_label("url", doc.url)?;
224        if doc.url.is_empty() {
225            return Err(Error::format("url must not be empty"));
226        }
227        for (name, v) in [("title", doc.title), ("snippet", doc.snippet), ("etag", doc.etag)] {
228            if let Some(v) = v {
229                check_label(name, v)?;
230            }
231        }
232        let flags = if doc.pinned { nflags::PINNED } else { 0 };
233        self.state.staged.push_upsert(doc, flags)
234    }
235
236    /// Stage a whole-set segment replacement for `key`.
237    pub fn set_segments(&mut self, key: UrlKey, segs: &[SegmentRecord<'_>]) -> Result<()> {
238        self.state.staged.push_segments(key, segs)
239    }
240
241    /// Stage a whole-set outbound-edge replacement for `key`. Edges are
242    /// deduplicated by destination (first occurrence wins — document order is
243    /// salience order) and stored sorted by destination key.
244    pub fn set_edges(&mut self, key: UrlKey, edges: &[(UrlKey, EdgeType, u16)]) {
245        self.stage_edges(key, edges.iter().map(|&(d, t, w)| (d, t, w, 0)));
246    }
247
248    /// [`Writer::set_edges`] with per-edge flags; only caller-settable ones
249    /// are kept (`eflags::EXACT`: an unchanged copy along `Derives`).
250    pub fn set_edges_with_flags(&mut self, key: UrlKey, edges: &[(UrlKey, EdgeType, u16, u8)]) {
251        let mask = crate::format::base::eflags::EXACT;
252        self.stage_edges(key, edges.iter().map(|&(d, t, w, f)| (d, t, w, f & mask)));
253    }
254
255    fn stage_edges(
256        &mut self,
257        key: UrlKey,
258        edges: impl Iterator<Item = (UrlKey, EdgeType, u16, u8)>,
259    ) {
260        let mut seen = std::collections::HashSet::new();
261        let mut owned: Vec<wal::Edge> = Vec::new();
262        for (dst, etype, weight, mut flags) in edges {
263            if dst == key || !seen.insert(dst.0) {
264                continue;
265            }
266            if matches!(etype, EdgeType::Related) {
267                flags |= crate::format::base::eflags::INFERRED;
268            }
269            owned.push((dst, etype.as_tag(), flags, weight));
270            if owned.len() == MAX_EDGES {
271                break;
272            }
273        }
274        owned.sort_unstable_by_key(|(dst, _, _, _)| dst.0);
275        // Deduplicated, capped and sorted above: the encoder cannot refuse it.
276        let staged = self.state.staged.push_edges(key, &owned);
277        debug_assert!(staged.is_ok(), "normalized edges refused");
278    }
279
280    /// Stage a whole-set replacement of `key`'s attributes: small keyed
281    /// values beside the node (a column's type, a table's producing SQL, a
282    /// description's provenance). Keys are the caller's; a later key wins
283    /// over an earlier duplicate. Values longer than [`MAX_ATTR_LEN`], more
284    /// than [`MAX_ATTRS`] keys, or a set whose encoding would not fit one log
285    /// frame ([`MAX_ATTR_BYTES`] in total) are refused.
286    pub fn set_attrs(&mut self, key: UrlKey, attrs: &[(u8, &str)]) -> Result<()> {
287        if attrs.len() > MAX_ATTRS {
288            return Err(Error::format("too many attributes"));
289        }
290        let mut owned: Vec<(u8, [&str; 3])> = Vec::with_capacity(attrs.len());
291        for &(k, v) in attrs.iter().rev() {
292            if v.len() > MAX_ATTR_LEN {
293                return Err(Error::format("attribute too long"));
294            }
295            if !owned.iter().any(|(o, _)| *o == k) {
296                owned.push((k, [v, "", ""]));
297            }
298        }
299        // Bound the whole op, so it always fits one record entry.
300        if owned.iter().map(|(_, v)| v[0].len()).sum::<usize>() > MAX_ATTR_BYTES {
301            return Err(Error::format(format!(
302                "attributes exceed {MAX_ATTR_BYTES} bytes in total"
303            )));
304        }
305        owned.sort_unstable_by_key(|(k, _)| *k);
306        self.state.staged.push_attr_parts(key, &owned)
307    }
308
309    /// [`Writer::set_attrs`] with string names: a whole-set replacement of
310    /// `key`'s attributes as `name → value`. Each record is stored as
311    /// `name NUL value` under keys `0..n` in name order, so
312    /// [`crate::Snapshot::named_attrs`] reads names and values back as
313    /// borrowed slices and any producer (Rust or a binding) agrees on the
314    /// encoding. A later duplicate name wins. Names must be non-empty, at
315    /// most [`MAX_ATTR_NAME`] bytes and free of NUL; each `name NUL value`
316    /// must fit [`MAX_ATTR_LEN`], and the whole set [`MAX_ATTR_BYTES`].
317    pub fn set_named_attrs(&mut self, key: UrlKey, attrs: &[(&str, &str)]) -> Result<()> {
318        if attrs.len() > MAX_ATTRS {
319            return Err(Error::format(format!("more than {MAX_ATTRS} attributes")));
320        }
321        let mut named: Vec<(&str, &str)> = Vec::with_capacity(attrs.len());
322        for &(name, value) in attrs.iter().rev() {
323            if name.is_empty() || name.len() > MAX_ATTR_NAME || name.contains('\0') {
324                return Err(Error::format(format!(
325                    "attribute name must be 1..={MAX_ATTR_NAME} bytes without NUL"
326                )));
327            }
328            if name.len() + 1 + value.len() > MAX_ATTR_LEN {
329                return Err(Error::format(format!(
330                    "attribute {name:?} exceeds {MAX_ATTR_LEN} bytes"
331                )));
332            }
333            if !named.iter().any(|(n, _)| *n == name) {
334                named.push((name, value));
335            }
336        }
337        named.sort_unstable_by_key(|(n, _)| *n);
338        let total: usize = named.iter().map(|(n, v)| n.len() + 1 + v.len()).sum();
339        if total > MAX_ATTR_BYTES {
340            return Err(Error::format(format!(
341                "attributes exceed {MAX_ATTR_BYTES} bytes in total"
342            )));
343        }
344        // Each record is `name NUL value`, written straight into the log
345        // record: no value is assembled anywhere first.
346        let parts: Vec<(u8, [&str; 3])> =
347            named.iter().enumerate().map(|(k, &(n, v))| (k as u8, [n, "\0", v])).collect();
348        self.state.staged.push_attr_parts(key, &parts)
349    }
350
351    /// Record a revalidation outcome. Freshness policy (interval growth/cut,
352    /// counters) is computed here from the node's current state and the store's
353    /// [`crate::TtlConfig`], and the resulting op carries the node's *full*
354    /// effective state so replay never re-derives policy.
355    ///
356    /// Returns `false`, staging nothing, when `key` is not a **committed** node.
357    /// That is the important word: the state is read through a snapshot, which
358    /// sees committed ops only, so a node staged earlier in this same batch is
359    /// not yet visible. Commit the upsert before touching it — the link-r bridge
360    /// already does.
361    ///
362    /// The same rule means two touches of one key inside a single uncommitted
363    /// batch do not accumulate: both are computed from the pre-batch state and
364    /// the second wins under last-wins overlay folding. Commit between
365    /// observations of the same document. Pinned by `touch_requires_a_committed_node`
366    /// in `tests/regression.rs`.
367    pub fn touch(&mut self, key: UrlKey, t: Touch<'_>) -> Result<bool> {
368        if let Some(e) = t.etag {
369            check_label("etag", e)?;
370        }
371        // An upsert staged earlier in this batch is the node's newest hash
372        // and etag. Its etag is copied out (onto the stack) because staging
373        // the touch may move the buffer it lies in.
374        let mut etag_buf = [0u8; MAX_LABEL];
375        let mut staged: Option<(u64, Option<usize>)> = None;
376        for op in self.state.staged.op_views() {
377            if let OpView::Upsert(u) = op {
378                if u.key() == key {
379                    let etag = u.etag().map(|e| {
380                        etag_buf[..e.len()].copy_from_slice(e.as_bytes());
381                        e.len()
382                    });
383                    staged = Some((u.content_hash(), etag));
384                }
385            }
386        }
387        let staged_etag = |len: Option<usize>| {
388            len.map(|n| std::str::from_utf8(&etag_buf[..n]).unwrap_or_default())
389        };
390        let snap = self.snap.get_or_insert_with(|| self.store.snapshot());
391        let Some(node) = snap.node(key) else { return Ok(false) };
392        let cfg = snap.ttl();
393        let interval_s = cfg.next_interval(node.interval_s.max(1), t.outcome);
394        let changed = t.outcome == Outcome::Changed;
395        // The op carries the node's *full* effective state, not a delta:
396        // touches fold last-wins in the overlay, so an unchanged check must
397        // not erase the hash/etag a prior change observed — nor the ones an
398        // upsert staged earlier in this same batch.
399        let (base_hash, base_etag) = match staged {
400            Some((h, e)) => (h, staged_etag(e)),
401            None => (node.content_hash, node.etag),
402        };
403        let rec = TouchRecord {
404            checked_at_ms: t.checked_at_ms,
405            outcome: t.outcome.as_tag(),
406            content_hash: Some(t.content_hash.unwrap_or(base_hash)),
407            etag: t.etag.or(base_etag),
408            interval_s,
409            checks: node.checks.saturating_add(1),
410            changes: if changed { node.changes.saturating_add(1) } else { node.changes },
411            last_change_ms: if changed { t.checked_at_ms } else { node.last_change_ms },
412            tombstone: t.outcome == Outcome::Gone,
413        };
414        self.state.staged.push_touch(key, &rec)?;
415        Ok(true)
416    }
417
418    /// Stage a pin/unpin. Returns `false` (staging nothing) when `key` is
419    /// neither committed nor upserted earlier in this same batch — a pin on a
420    /// document that does not exist must not lie in wait for a future upsert.
421    pub fn set_pinned(&mut self, key: UrlKey, pinned: bool) -> Result<bool> {
422        let exists = self
423            .state
424            .staged
425            .op_views()
426            .any(|op| matches!(op, OpView::Upsert(u) if u.key() == key))
427            || self.view().node(key).is_some();
428        if !exists {
429            return Ok(false);
430        }
431        self.state.staged.push_pin(key, pinned);
432        Ok(true)
433    }
434
435    /// Stage a hard delete.
436    pub fn remove(&mut self, key: UrlKey) -> Result<()> {
437        self.state.staged.push_remove(key);
438        Ok(())
439    }
440
441    /// Drop every op staged on this handle and not yet committed. Staged
442    /// ops otherwise outlive a failed commit (a retry commits them) and a
443    /// dropped writer (the handle's next commit includes them); a
444    /// transaction refused for good — larger than [`wal::MAX_RECORD`] — is
445    /// discarded with this, then staged again in parts.
446    pub fn discard(&mut self) {
447        self.state.staged = RecordBuf::new();
448    }
449
450    /// Make staged ops durable and visible: take the commit lock (waiting up
451    /// to [`crate::Config::lock_wait`], else [`Error::Locked`] with the ops
452    /// still staged), catch up with other writers, seal the staged ops into
453    /// one record, append it to the WAL in one write, fsync per the
454    /// configured [`Durability`], publish it to the overlay, auto-compact
455    /// past thresholds, unlock. Returns the last committed sequence number.
456    ///
457    /// `Err` means nothing was appended and nothing published, and the ops
458    /// stay staged: a transaction larger than [`wal::MAX_RECORD`] is a
459    /// typed [`Error::Format`] before anything is written. `Ok` means the
460    /// whole transaction is durable (per policy) and visible at once. An
461    /// inline compaction that cannot run leaves [`Store::compaction_due`]
462    /// set instead of failing the commit.
463    pub fn commit(&mut self) -> Result<u64> {
464        // The cached view is about to go stale, and its pinned generation slot
465        // must be free before a catch-up or auto-compaction installs another.
466        self.snap = None;
467        if self.state.staged.is_empty() {
468            return Ok(self.state.next_seq.saturating_sub(1));
469        }
470        let held = self.store.acquire(&mut self.state, self.store.cfg.lock_wait)?;
471        let r = self.store.sync(&mut self.state, true).and_then(|_| self.append_held());
472        self.state.release(held);
473        r
474    }
475
476    /// The commit itself, with the commit lock held and the handle caught
477    /// up: one record, one write, one fsync. Everything that can refuse runs
478    /// before the first byte is written; a failed write is cut back off the
479    /// log. Steady state, nothing here allocates: the staged buffer is
480    /// sealed in place, written, and moved into the overlay as is.
481    fn append_held(&mut self) -> Result<u64> {
482        if self.state.staged.is_empty() {
483            return Ok(self.state.next_seq.saturating_sub(1));
484        }
485        let count = self.state.staged.ops();
486        let first_seq = self.state.next_seq;
487        // Over the record cap: refused here, nothing written, ops kept.
488        self.state.staged.seal(first_seq)?;
489        // The current generation is stable: installs on this handle happen
490        // only under its writer mutex, which we hold. Room in its overlay is
491        // made now, so publishing after the append cannot fail: a durable
492        // commit that this handle did not account for is what reuses
493        // sequence numbers.
494        let cur = self.store.current.load(Ordering::Acquire);
495        let guard = self.store.gens[cur]
496            .inner
497            .try_read()
498            .map_err(|_| Error::busy("current slot mid-install"))?;
499        let inner = guard.as_ref().ok_or_else(|| Error::corrupt("current slot empty"))?;
500        if !inner.log.reserve(count as usize, self.state.staged.len()) {
501            return Err(Error::busy("overlay arena full"));
502        }
503        if self.state.wal.is_none() {
504            self.state.wal =
505                Some(std::fs::OpenOptions::new().append(true).open(&self.store.wal_file)?);
506        }
507        let durability = self.store.cfg.durability;
508        let state = &mut *self.state;
509        let Some(f) = state.wal.as_mut() else { return Err(Error::corrupt("log handle missing")) };
510        let sync = match durability {
511            Durability::Always => true,
512            Durability::Batch(n) => state.commits_since_sync + 1 >= n.max(1),
513            Durability::Never => false,
514        };
515        let record = state.staged.as_bytes();
516        let appended = f.write_all(record).and_then(|()| f.flush()).and_then(|()| {
517            #[cfg(test)]
518            crate::store::probe::bump(&crate::store::probe::WRITES, 1);
519            if !sync {
520                return Ok(());
521            }
522            #[cfg(test)]
523            crate::store::probe::bump(&crate::store::probe::FSYNCS, 1);
524            f.sync_all()
525        });
526        // A test can fail an append whose bytes are already durable and
527        // cannot be cut back off: the worst a failing disk does.
528        #[cfg(test)]
529        let (appended, stuck) = match appended {
530            Ok(()) if crate::store::probe::fail_append() => (
531                Err(std::io::Error::other("injected append failure")),
532                crate::store::probe::stuck(),
533            ),
534            other => (other, false),
535        };
536        #[cfg(not(test))]
537        let stuck = false;
538        if let Err(e) = appended {
539            // Nothing of a failed append may survive: cut the log back to
540            // where it was (best effort; the next committer's repair cuts a
541            // torn record anyway), and forget what this handle knows of the
542            // file, so its next commit re-reads it whole rather than trust a
543            // length the cut may not have restored.
544            state.wal = None;
545            if !stuck {
546                if let Ok(f) = std::fs::OpenOptions::new().write(true).open(&self.store.wal_file) {
547                    let _ = f.set_len(state.seen.wal_pos);
548                    let _ = f.sync_all();
549                }
550            }
551            state.seen.stamp = None;
552            return Err(e.into());
553        }
554        // Durable: account for it, then publish. Nothing below can fail.
555        let len = record.len() as u64;
556        state.commits_since_sync = if sync { 0 } else { state.commits_since_sync + 1 };
557        state.seen.appended(len);
558        state.wal_bytes += len;
559        state.next_seq += u64::from(count);
560        state.ops_since_compact += count as usize;
561        let record = std::mem::take(&mut state.staged).into_vec();
562        crate::overlay::publish_records(&inner.log, record, 0, first_seq - 1);
563        drop(guard);
564        let last = self.state.next_seq - 1;
565        if self.state.ops_since_compact >= self.store.cfg.compact_after_ops
566            || self.state.wal_bytes >= self.store.cfg.compact_after_wal_bytes
567        {
568            // The commit is durable and visible whatever happens here: a
569            // compaction that cannot run now (every ring slot pinned, a full
570            // disk) leaves it due for the next commit or [`Store::compact`].
571            if !self.store.compact_inline.load(Ordering::Relaxed)
572                || crate::compact::compact_locked(self.store, &mut self.state).is_err()
573            {
574                self.store.due.store(true, Ordering::Release);
575            }
576        }
577        Ok(last)
578    }
579}
580
581/// A serializable transaction: the commit lock is held from before its read
582/// view is taken until its single append lands. Made by [`Store::transact`];
583/// stage through it like a [`Writer`], read through [`Txn::snapshot`].
584#[derive(Debug)]
585pub struct Txn<'t> {
586    w: Writer<'t>,
587}
588
589impl<'t> Txn<'t> {
590    /// The caught-up committed state — every writer's commits up to the
591    /// moment the lock was taken, and nothing can commit after it until this
592    /// transaction does. Ops staged in this transaction are not in it.
593    #[must_use]
594    pub fn snapshot(&self) -> &Snapshot<'t> {
595        self.w.snap.as_ref().expect("a transaction holds its view for its whole life")
596    }
597
598    /// [`Writer::upsert_node`].
599    pub fn upsert_node(&mut self, doc: &DocRecord<'_>) -> Result<()> {
600        self.w.upsert_node(doc)
601    }
602
603    /// [`Writer::set_segments`].
604    pub fn set_segments(&mut self, key: UrlKey, segs: &[SegmentRecord<'_>]) -> Result<()> {
605        self.w.set_segments(key, segs)
606    }
607
608    /// [`Writer::set_edges`].
609    pub fn set_edges(&mut self, key: UrlKey, edges: &[(UrlKey, EdgeType, u16)]) {
610        self.w.set_edges(key, edges);
611    }
612
613    /// [`Writer::set_edges_with_flags`].
614    pub fn set_edges_with_flags(&mut self, key: UrlKey, edges: &[(UrlKey, EdgeType, u16, u8)]) {
615        self.w.set_edges_with_flags(key, edges);
616    }
617
618    /// [`Writer::set_attrs`].
619    pub fn set_attrs(&mut self, key: UrlKey, attrs: &[(u8, &str)]) -> Result<()> {
620        self.w.set_attrs(key, attrs)
621    }
622
623    /// [`Writer::set_named_attrs`].
624    pub fn set_named_attrs(&mut self, key: UrlKey, attrs: &[(&str, &str)]) -> Result<()> {
625        self.w.set_named_attrs(key, attrs)
626    }
627
628    /// [`Writer::touch`], computed from this transaction's caught-up view.
629    pub fn touch(&mut self, key: UrlKey, t: Touch<'_>) -> Result<bool> {
630        self.w.touch(key, t)
631    }
632
633    /// [`Writer::set_pinned`].
634    pub fn set_pinned(&mut self, key: UrlKey, pinned: bool) -> Result<bool> {
635        self.w.set_pinned(key, pinned)
636    }
637
638    /// [`Writer::remove`].
639    pub fn remove(&mut self, key: UrlKey) -> Result<()> {
640        self.w.remove(key)
641    }
642}
643
644/// [`Store::transact`] / [`Store::transact_with`]: lock (unless the caller
645/// holds `lock`), catch up, run `f` on a caught-up view, then commit what it
646/// staged in one append — or, on `Err`, drop it with nothing written.
647pub(crate) fn transact<F, R>(
648    store: &Store,
649    lock: Option<&CommitLock>,
650    wait: std::time::Duration,
651    f: F,
652) -> Result<R>
653where
654    F: for<'t> FnOnce(&mut Txn<'t>) -> Result<R>,
655{
656    let mut txn = Txn { w: store.writer()? };
657    let held = match lock {
658        Some(_) => None,
659        None => Some(store.acquire(&mut txn.w.state, wait)?),
660    };
661    let r = run(store, &mut txn, f);
662    if let Some(h) = held {
663        txn.w.state.release(h);
664    }
665    r
666}
667
668fn run<'t, F, R>(store: &'t Store, txn: &mut Txn<'t>, f: F) -> Result<R>
669where
670    F: for<'x> FnOnce(&mut Txn<'x>) -> Result<R>,
671{
672    store.sync(&mut txn.w.state, true)?;
673    txn.w.snap = Some(store.snapshot());
674    // Ops a dropped, uncommitted writer left staged on this handle are kept
675    // (a writer's next commit includes them); an abort removes only its own.
676    let mark = txn.w.state.staged.mark();
677    let out = f(txn);
678    txn.w.snap = None; // free the pinned slot before any auto-compaction
679                       // Either way a failed transaction leaves nothing of its own staged: a
680                       // caller retries by running it again.
681    match out.and_then(|r| txn.w.append_held().map(|_| r)) {
682        Ok(r) => Ok(r),
683        Err(e) => {
684            txn.w.state.staged.truncate(mark);
685            Err(e)
686        }
687    }
688}