Skip to main content

graph_r/
writer.rs

1// Author: spicyzhug@gmail.com
2// Copyright: lituus-io, all rights reserved
3//
4//! The single writer. Mutations are staged as WAL ops, made durable on
5//! [`Writer::commit`] (encode → append → fsync per policy), then published
6//! into the current generation's overlay where new snapshots see them
7//! immediately. Freshness math runs *here*, at touch time, and the computed
8//! state travels inside the op — replay never re-derives policy.
9
10use crate::error::{Error, Result};
11use crate::format::base::{nflags, sflags};
12use crate::format::wal::{encode_frame, Op, OwnedSeg};
13use crate::key::{EdgeType, UrlKey};
14use crate::store::{wal_path, Durability, Store};
15use crate::ttl::Outcome;
16use compact_str::CompactString;
17use std::io::Write as _;
18use std::sync::atomic::Ordering;
19use std::sync::MutexGuard;
20
21/// Maximum label length persisted (bytes of UTF-8).
22pub const MAX_LABEL: usize = 2048;
23/// Maximum segments per document.
24pub const MAX_SEGS: usize = 128;
25/// Maximum outbound edges per node (wide joins and conditions included).
26pub use crate::format::wal::MAX_EDGES;
27/// Maximum attributes per node, and the longest attribute value.
28pub use crate::format::wal::{MAX_ATTRS, MAX_ATTR_LEN};
29/// Most value bytes one node's attribute set may hold in total: what fits a
30/// single log frame (the op's fixed fields and per-value headers included).
31pub const MAX_ATTR_BYTES: usize = crate::format::wal::MAX_FRAME - (1 + 8 + 1) - MAX_ATTRS * (1 + 4);
32/// Longest attribute name accepted by [`Writer::set_named_attrs`].
33pub const MAX_ATTR_NAME: usize = 255;
34
35/// A document to upsert — borrowed, produced by the caller or the link-r
36/// bridge. Everything here is compact lookup metadata; bodies and vectors
37/// are deliberately not representable.
38#[derive(Clone, Copy, Debug)]
39pub struct DocRecord<'a> {
40    /// Canonical URL.
41    pub url: &'a str,
42    /// Durable key (xxh3 of `url`).
43    pub url_key: UrlKey,
44    /// xxh3 of the fetched body.
45    pub content_hash: u64,
46    /// Fetch stamp, epoch ms.
47    pub fetched_at_ms: u64,
48    /// Title, if extracted.
49    pub title: Option<&'a str>,
50    /// Distilled snippet (≤ ~180 chars by convention).
51    pub snippet: Option<&'a str>,
52    /// Entity tag from the fetch, for conditional revalidation.
53    pub etag: Option<&'a str>,
54    /// Exempt from eviction sweeps.
55    pub pinned: bool,
56}
57
58/// A sub-document segment to record — an anchor reference, never content.
59#[derive(Clone, Copy, Debug)]
60pub struct SegmentRecord<'a> {
61    /// Heading-path label (e.g. `"Install > Linux"`).
62    pub label: &'a str,
63    /// Byte range in the source document, when known.
64    pub byte_range: Option<(u32, u32)>,
65    /// Heading depth (1 = H1 …).
66    pub depth: u8,
67    /// Importance in 1/65535 units (deterministic prior or enrichment score).
68    pub importance: u16,
69}
70
71/// A revalidation observation to record against a node.
72#[derive(Clone, Copy, Debug)]
73pub struct Touch<'a> {
74    /// When the check happened, epoch ms.
75    pub checked_at_ms: u64,
76    /// What was observed.
77    pub outcome: Outcome,
78    /// New content hash when `outcome == Changed`.
79    pub content_hash: Option<u64>,
80    /// New entity tag, if the server sent one.
81    pub etag: Option<&'a str>,
82}
83
84pub(crate) struct WriteState {
85    next_seq: u64,
86    staged: Vec<Op>,
87    pub(crate) ops_since_compact: usize,
88    pub(crate) wal_bytes: u64,
89    commits_since_sync: u32,
90    wal: Option<std::fs::File>,
91    /// Generation of the newest base written to disk. A compaction whose
92    /// in-memory install was refused (every ring slot pinned) still leaves a
93    /// durable base behind; numbering from this, not from the served
94    /// snapshot, means a generation number is never reused.
95    pub(crate) disk_generation: u64,
96}
97
98#[allow(clippy::missing_fields_in_debug)] // file handle and op buffer are noise
99impl std::fmt::Debug for WriteState {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        f.debug_struct("WriteState")
102            .field("next_seq", &self.next_seq)
103            .field("staged", &self.staged.len())
104            .finish()
105    }
106}
107
108impl WriteState {
109    pub(crate) fn new(
110        next_seq: u64,
111        replayed_ops: usize,
112        wal_bytes: u64,
113        disk_generation: u64,
114    ) -> Self {
115        Self {
116            next_seq,
117            staged: Vec::new(),
118            ops_since_compact: replayed_ops,
119            wal_bytes,
120            commits_since_sync: 0,
121            wal: None,
122            disk_generation,
123        }
124    }
125
126    pub(crate) fn reset_after_compact(&mut self, next_seq: u64) {
127        self.next_seq = next_seq;
128        self.ops_since_compact = 0;
129        self.wal_bytes = u64::try_from(crate::format::wal::HEADER_LEN).unwrap_or(64);
130        self.commits_since_sync = 0;
131        self.wal = None; // reopen against the fresh log on next commit
132    }
133
134    pub(crate) fn staged_is_empty(&self) -> bool {
135        self.staged.is_empty()
136    }
137
138    pub(crate) fn last_committed_seq(&self) -> u64 {
139        self.next_seq.saturating_sub(1)
140    }
141}
142
143/// The exclusive write handle; holds the store's writer mutex for its
144/// lifetime. Stage mutations, then [`Writer::commit`] to make them durable
145/// and visible.
146#[derive(Debug)]
147pub struct Writer<'s> {
148    store: &'s Store,
149    state: MutexGuard<'s, WriteState>,
150    /// Committed-state view, cached for the duration of one staging batch.
151    ///
152    /// Several staging methods read committed state (segment identity for
153    /// enrichment carry, the other tier's edges, freshness counters, node
154    /// existence). Taking a fresh snapshot per staged op folds the overlay
155    /// arena every time — during a bulk ingest that made staging O(batch ×
156    /// pending-ops). Nothing can commit while this writer holds the mutex, so
157    /// one snapshot is exactly as current as a fresh one for the whole batch;
158    /// [`Writer::commit`] drops it, both because the world changes there and so
159    /// the pinned generation slot is free before any auto-compaction installs.
160    snap: Option<crate::snapshot::Snapshot<'s>>,
161}
162
163fn check_label(name: &str, s: &str) -> Result<()> {
164    if s.len() > MAX_LABEL {
165        return Err(Error::format(format!("{name} label exceeds {MAX_LABEL} bytes")));
166    }
167    Ok(())
168}
169
170impl<'s> Writer<'s> {
171    pub(crate) fn new(store: &'s Store, state: MutexGuard<'s, WriteState>) -> Self {
172        Self { store, state, snap: None }
173    }
174
175    /// The cached committed-state view (created on first use per batch).
176    fn view(&mut self) -> &crate::snapshot::Snapshot<'s> {
177        if self.snap.is_none() {
178            self.snap = Some(self.store.snapshot());
179        }
180        self.snap.as_ref().expect("just filled")
181    }
182
183    /// Stage a node create/replace.
184    pub fn upsert_node(&mut self, doc: &DocRecord<'_>) -> Result<()> {
185        check_label("url", doc.url)?;
186        if doc.url.is_empty() {
187            return Err(Error::format("url must not be empty"));
188        }
189        for (name, v) in [("title", doc.title), ("snippet", doc.snippet), ("etag", doc.etag)] {
190            if let Some(v) = v {
191                check_label(name, v)?;
192            }
193        }
194        let flags = if doc.pinned { nflags::PINNED } else { 0 };
195        self.state.staged.push(Op::UpsertNode {
196            key: doc.url_key,
197            content_hash: doc.content_hash,
198            fetched_at_ms: doc.fetched_at_ms,
199            flags,
200            url: doc.url.into(),
201            title: doc.title.map(Into::into),
202            snippet: doc.snippet.map(Into::into),
203            etag: doc.etag.map(Into::into),
204        });
205        Ok(())
206    }
207
208    /// Stage a whole-set segment replacement for `key`.
209    pub fn set_segments(&mut self, key: UrlKey, segs: &[SegmentRecord<'_>]) -> Result<()> {
210        if segs.len() > MAX_SEGS {
211            return Err(Error::format(format!("more than {MAX_SEGS} segments")));
212        }
213        let mut owned = Vec::with_capacity(segs.len());
214        for s in segs {
215            check_label("segment", s.label)?;
216            let (importance, extra_flags) = (s.importance, 0);
217            let (byte_start, byte_len, range_flag) = match s.byte_range {
218                Some((start, len)) => (start, len, 0),
219                None => (0, 0, sflags::NO_RANGE),
220            };
221            owned.push(OwnedSeg {
222                label: s.label.into(),
223                byte_start,
224                byte_len,
225                depth: s.depth.max(1),
226                importance,
227                flags: range_flag | extra_flags,
228            });
229        }
230        self.state.staged.push(Op::SetSegments { key, segs: owned });
231        Ok(())
232    }
233
234    /// Stage a whole-set outbound-edge replacement for `key`. Edges are
235    /// deduplicated by destination (first occurrence wins — document order is
236    /// salience order) and stored sorted by destination key.
237    pub fn set_edges(&mut self, key: UrlKey, edges: &[(UrlKey, EdgeType, u16)]) {
238        self.stage_edges(key, edges.iter().map(|&(d, t, w)| (d, t, w, 0)));
239    }
240
241    /// [`Writer::set_edges`] with per-edge flags; only caller-settable ones
242    /// are kept (`eflags::EXACT`: an unchanged copy along `Derives`).
243    pub fn set_edges_with_flags(&mut self, key: UrlKey, edges: &[(UrlKey, EdgeType, u16, u8)]) {
244        let mask = crate::format::base::eflags::EXACT;
245        self.stage_edges(key, edges.iter().map(|&(d, t, w, f)| (d, t, w, f & mask)));
246    }
247
248    fn stage_edges(
249        &mut self,
250        key: UrlKey,
251        edges: impl Iterator<Item = (UrlKey, EdgeType, u16, u8)>,
252    ) {
253        let mut seen = std::collections::HashSet::new();
254        let mut owned: Vec<(UrlKey, u8, u8, u16)> = Vec::new();
255        for (dst, etype, weight, mut flags) in edges {
256            if dst == key || !seen.insert(dst.0) {
257                continue;
258            }
259            if matches!(etype, EdgeType::Related) {
260                flags |= crate::format::base::eflags::INFERRED;
261            }
262            owned.push((dst, etype.as_tag(), flags, weight));
263            if owned.len() == MAX_EDGES {
264                break;
265            }
266        }
267        owned.sort_unstable_by_key(|(dst, _, _, _)| dst.0);
268        self.state.staged.push(Op::SetEdges { key, edges: owned });
269    }
270
271    /// Stage a whole-set replacement of `key`'s attributes: small keyed
272    /// values beside the node (a column's type, a table's producing SQL, a
273    /// description's provenance). Keys are the caller's; a later key wins
274    /// over an earlier duplicate. Values longer than [`MAX_ATTR_LEN`], more
275    /// than [`MAX_ATTRS`] keys, or a set whose encoding would not fit one log
276    /// frame ([`MAX_ATTR_BYTES`] in total) are refused.
277    pub fn set_attrs(&mut self, key: UrlKey, attrs: &[(u8, &str)]) -> Result<()> {
278        if attrs.len() > MAX_ATTRS {
279            return Err(Error::format("too many attributes"));
280        }
281        let mut owned: Vec<(u8, CompactString)> = Vec::with_capacity(attrs.len());
282        for &(k, v) in attrs.iter().rev() {
283            if v.len() > MAX_ATTR_LEN {
284                return Err(Error::format("attribute too long"));
285            }
286            if !owned.iter().any(|(o, _)| *o == k) {
287                owned.push((k, CompactString::from(v)));
288            }
289        }
290        // Replay reads an oversized frame as a torn tail and a writable open
291        // truncates there, so an op that does not fit one frame would lose
292        // itself and every later commit on reopen. Bound the whole op.
293        if owned.iter().map(|(_, v)| v.len()).sum::<usize>() > MAX_ATTR_BYTES {
294            return Err(Error::format(format!(
295                "attributes exceed {MAX_ATTR_BYTES} bytes in total"
296            )));
297        }
298        owned.sort_unstable_by_key(|(k, _)| *k);
299        self.state.staged.push(Op::SetAttrs { key, attrs: owned });
300        Ok(())
301    }
302
303    /// [`Writer::set_attrs`] with string names: a whole-set replacement of
304    /// `key`'s attributes as `name → value`. Each record is stored as
305    /// `name NUL value` under keys `0..n` in name order, so
306    /// [`crate::Snapshot::named_attrs`] reads names and values back as
307    /// borrowed slices and any producer (Rust or a binding) agrees on the
308    /// encoding. A later duplicate name wins. Names must be non-empty, at
309    /// most [`MAX_ATTR_NAME`] bytes and free of NUL; each `name NUL value`
310    /// must fit [`MAX_ATTR_LEN`], and the whole set [`MAX_ATTR_BYTES`].
311    pub fn set_named_attrs(&mut self, key: UrlKey, attrs: &[(&str, &str)]) -> Result<()> {
312        if attrs.len() > MAX_ATTRS {
313            return Err(Error::format(format!("more than {MAX_ATTRS} attributes")));
314        }
315        let mut named: Vec<(&str, &str)> = Vec::with_capacity(attrs.len());
316        for &(name, value) in attrs.iter().rev() {
317            if name.is_empty() || name.len() > MAX_ATTR_NAME || name.contains('\0') {
318                return Err(Error::format(format!(
319                    "attribute name must be 1..={MAX_ATTR_NAME} bytes without NUL"
320                )));
321            }
322            if name.len() + 1 + value.len() > MAX_ATTR_LEN {
323                return Err(Error::format(format!(
324                    "attribute {name:?} exceeds {MAX_ATTR_LEN} bytes"
325                )));
326            }
327            if !named.iter().any(|(n, _)| *n == name) {
328                named.push((name, value));
329            }
330        }
331        named.sort_unstable_by_key(|(n, _)| *n);
332        let mut owned: Vec<(u8, CompactString)> = Vec::with_capacity(named.len());
333        let mut total = 0usize;
334        for (k, (name, value)) in named.into_iter().enumerate() {
335            let mut rec = CompactString::with_capacity(name.len() + 1 + value.len());
336            rec.push_str(name);
337            rec.push('\0');
338            rec.push_str(value);
339            total += rec.len();
340            owned.push((k as u8, rec));
341        }
342        if total > MAX_ATTR_BYTES {
343            return Err(Error::format(format!(
344                "attributes exceed {MAX_ATTR_BYTES} bytes in total"
345            )));
346        }
347        self.state.staged.push(Op::SetAttrs { key, attrs: owned });
348        Ok(())
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        let staged = self.state.staged.iter().rev().find_map(|op| match op {
372            Op::UpsertNode { key: k, content_hash, etag, .. } if *k == key => {
373                Some((*content_hash, etag.clone()))
374            }
375            _ => None,
376        });
377        let (interval_s, checks, changes, last_change_ms, content_hash, etag) = {
378            let snap = self.view();
379            let Some(node) = snap.node(key) else { return Ok(false) };
380            let cfg = snap.ttl();
381            let next = cfg.next_interval(node.interval_s.max(1), t.outcome);
382            let checks = node.checks.saturating_add(1);
383            let changes = if t.outcome == Outcome::Changed {
384                node.changes.saturating_add(1)
385            } else {
386                node.changes
387            };
388            let last_change =
389                if t.outcome == Outcome::Changed { t.checked_at_ms } else { node.last_change_ms };
390            // The op carries the node's *full* effective state, not a delta:
391            // touches fold last-wins in the overlay, so an unchanged check
392            // must not erase the hash/etag a prior change observed — nor the
393            // ones an upsert staged earlier in this same batch.
394            let (base_hash, base_etag) = match staged {
395                Some((h, e)) => (h, e),
396                None => (node.content_hash, node.etag.map(Into::into)),
397            };
398            let hash = t.content_hash.unwrap_or(base_hash);
399            let etag: Option<compact_str::CompactString> = t.etag.map(Into::into).or(base_etag);
400            (next, checks, changes, last_change, hash, etag)
401        };
402        self.state.staged.push(Op::Touch {
403            key,
404            checked_at_ms: t.checked_at_ms,
405            outcome: t.outcome.as_tag(),
406            content_hash: Some(content_hash),
407            etag,
408            interval_s,
409            checks,
410            changes,
411            last_change_ms,
412            tombstone: t.outcome == Outcome::Gone,
413        });
414        Ok(true)
415    }
416
417    /// Stage a pin/unpin. Returns `false` (staging nothing) when `key` is
418    /// neither committed nor upserted earlier in this same batch — a pin on a
419    /// document that does not exist must not lie in wait for a future upsert.
420    pub fn set_pinned(&mut self, key: UrlKey, pinned: bool) -> Result<bool> {
421        let exists = self
422            .state
423            .staged
424            .iter()
425            .any(|op| matches!(op, Op::UpsertNode { key: k, .. } if *k == key))
426            || self.view().node(key).is_some();
427        if !exists {
428            return Ok(false);
429        }
430        self.state.staged.push(Op::SetPinned { key, pinned });
431        Ok(true)
432    }
433
434    /// Stage a hard delete.
435    pub fn remove(&mut self, key: UrlKey) -> Result<()> {
436        self.state.staged.push(Op::Remove { key });
437        Ok(())
438    }
439
440    /// Make staged ops durable and visible: encode frames, append to the
441    /// WAL, fsync per the configured [`Durability`], publish to the overlay,
442    /// then auto-compact past thresholds. Returns the last committed
443    /// sequence number.
444    pub fn commit(&mut self) -> Result<u64> {
445        // The cached view is about to go stale, and its pinned generation slot
446        // must be free before any auto-compaction below installs a new one.
447        self.snap = None;
448        if self.state.staged.is_empty() {
449            return Ok(self.state.next_seq.saturating_sub(1));
450        }
451        let staged = std::mem::take(&mut self.state.staged);
452        let count = staged.len() as u64;
453        let first_seq = self.state.next_seq;
454        let mut buf = Vec::new();
455        for (i, op) in staged.iter().enumerate() {
456            buf.extend_from_slice(&encode_frame(first_seq + i as u64, op));
457        }
458        // Append + flush + fsync-per-policy.
459        if self.state.wal.is_none() {
460            self.state.wal =
461                Some(std::fs::OpenOptions::new().append(true).open(wal_path(&self.store.dir))?);
462        }
463        {
464            let state = &mut *self.state;
465            let f = state.wal.as_mut().expect("opened above");
466            f.write_all(&buf)?;
467            f.flush()?;
468            let sync = match self.store.cfg.durability {
469                Durability::Always => true,
470                Durability::Batch(n) => {
471                    state.commits_since_sync += 1;
472                    state.commits_since_sync >= n.max(1)
473                }
474                Durability::Never => false,
475            };
476            if sync {
477                f.sync_all()?;
478                state.commits_since_sync = 0;
479            }
480        }
481        // Publish into the current generation (stable while we hold the
482        // writer mutex — only the writer compacts).
483        let cur = self.store.current.load(Ordering::Acquire);
484        {
485            let guard = self.store.gens[cur]
486                .inner
487                .try_read()
488                .map_err(|_| Error::busy("current slot mid-install"))?;
489            let inner = guard.as_ref().ok_or_else(|| Error::corrupt("current slot empty"))?;
490            for (i, op) in staged.into_iter().enumerate() {
491                if !inner.arena.publish(first_seq + i as u64, op) {
492                    return Err(Error::busy("overlay arena full"));
493                }
494            }
495        }
496        self.state.wal_bytes += buf.len() as u64;
497        self.state.next_seq += count;
498        self.state.ops_since_compact += count as usize;
499        let last = self.state.next_seq - 1;
500        if self.state.ops_since_compact >= self.store.cfg.compact_after_ops
501            || self.state.wal_bytes >= self.store.cfg.compact_after_wal_bytes
502        {
503            if self.store.compact_inline.load(Ordering::Relaxed) {
504                crate::compact::compact_locked(self.store, &mut self.state)?;
505            } else {
506                // Off the hot path: the owner compacts when it chooses.
507                self.store.due.store(true, Ordering::Release);
508            }
509        }
510        Ok(last)
511    }
512}