Skip to main content

graph_r/
writer.rs

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