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}