macrame/connection.rs
1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3use tokio::sync::{mpsc, oneshot};
4
5use crate::error::{classify, DbError, Result, WriteOp};
6use crate::graph::edge::EdgeAssertion;
7use crate::integrity::{rebuild_current, RebuildReport};
8use crate::schema::migrations;
9use crate::temporal::archive::{archive, rehydrate, ArchiveReport, RehydrateReport};
10use crate::temporal::interval::Interval;
11use crate::temporal::snapshot::{self, SnapshotCadence};
12use crate::util::clock::{Clock, SystemClock};
13use crate::util::timestamp;
14use crate::vector::ModelName;
15
16/// Rows per chunk on the background write paths (§5.1.5, D-011, D-014, D-058).
17///
18/// The Write Actor holds the sole write connection, so a single large statement
19/// blocks every other writer for its duration. Chunking bounds that stall; the
20/// cost is that a bulk import is *not* atomic across chunks, which is why it is
21/// a separate command from [`HighPriCommand::WriteBulkAtomic`] rather than a
22/// tuning parameter on it.
23///
24/// # Why these are four constants and not one
25///
26/// Through 0.5.5 this was a single `CHUNK_ROWS = 1000` for all four bulk paths.
27/// The golden rule it was meant to serve is a bound on *duration* — a background
28/// chunk must commit fast enough that an interactive write queued behind it is
29/// not made to wait — and one row count cannot express one duration across paths
30/// whose measured per-row costs differ by 60× (D-058). At 1,000 rows the four
31/// paths took 3.5 ms, 24 ms, 89 ms and 143 ms: the same constant, four answers,
32/// three of them far outside the bound.
33///
34/// Each size below is derived from `benches/budgets.rs`'s `chunk_scaling`
35/// sweep against [`CHUNK_BUDGET`], then verified by measuring that size directly.
36/// They are *measurements of this machine*, not universal constants — D-055's
37/// reasoning about reference hardware applies here too, and re-deriving them on
38/// materially different storage is a `cargo bench` away.
39///
40/// # Sized for the tail, not the median
41///
42/// The first derivation solved `f + c·n = 3 ms` exactly and produced sizes whose
43/// *median* commit was 2.93 ms and whose upper estimate was 2.96 — inside the
44/// bound as reported and outside it for any chunk slower than typical. A latency
45/// bound is a statement about the chunk an unlucky interactive write actually
46/// queues behind, so these solve for ≈2.5 ms instead, leaving the remainder as
47/// headroom for the tail. That costs a few percent of throughput on the two
48/// linear paths and nothing on the two superlinear ones.
49///
50/// As measured by `chunk_budget`, each at its own size: edges **2.39 ms**,
51/// concepts **2.35 ms**, annotations **2.36 ms**, embeddings **2.06 ms**, no
52/// upper estimate above 2.42.
53///
54/// # Known limitation: these are empty-database figures
55///
56/// `chunk_budget` seeds concepts and starts with **no links and no vectors**,
57/// and D-059 established that per-row cost on the edge and embedding paths grows
58/// with the size of the structure being written, not with the chunk. The same
59/// 90-edge chunk takes **9.06 ms** into an 8,000-edge table. So the bound is met
60/// as measured here and *not* met on a populated database.
61///
62/// That gap was published as 47.7 ms until 0.10.0 and attributed to the schema
63/// defect D-059 documents. The defect was fixed by the `v5 → v6` rung and the
64/// figure was never updated. 9.08 ms is a 0.10.0 measurement, not D-059's 8.0 ms
65/// carried forward: `chunk_budget` gained a seeded arm, because until it did,
66/// nothing in the bench suite wrote a chunk into a populated table and this
67/// number was unfalsifiable. It agrees with D-059 once the session is accounted
68/// for — the empty arm read 2.69 and 2.65 ms beside it against the 2.39 ms
69/// published above, so the *ratio* is 3.4× here and 3.35× there.
70///
71/// **The residual is unattributed.** It is not the missing index, which shipped
72/// in 0.5.6, and nothing has measured what it is. Re-deriving these constants
73/// against a realistic fixture needs a decision about what "realistic" is, which
74/// is why it has not been done silently.
75pub mod chunk_rows {
76 /// Edge assertions (`bulk_import`).
77 ///
78 /// Per-row cost on this path rises with the size of `links_current`, not
79 /// with the chunk (D-059) — so cutting the chunk buys latency and costs
80 /// throughput, ~11% for 1,000 edges. An earlier version of this comment
81 /// claimed it was 3.3× *faster*; that came from multiplying eleven copies of
82 /// a chunk measured into an empty database.
83 ///
84 /// **This size does not meet the 3 ms bound on a populated database.** 90
85 /// edges into an 8,000-edge table take **9.06 ms** — measured, two sessions
86 /// at 9.08 and 9.05, against an empty-table arm of 2.69 and 2.65 beside
87 /// them (D-136).
88 ///
89 /// The reason given here until 0.10.0 — that `trg_links_single_open`'s
90 /// `EXISTS` scans the whole out-degree, "a schema defect with a proven fix,
91 /// recorded in D-059 and not applied here" — described 0.5.5. The fix *was*
92 /// applied, as the `v5 → v6` rung, and took this from 47.7 ms to ~8 ms.
93 /// What survives is the miss, not its cause: the bound is exceeded ~3×, by
94 /// something nobody has attributed.
95 ///
96 /// D-134 retired the growth claim on the neighbouring *single-assertion*
97 /// path and did not measure this one; D-136 is why this line now carries a
98 /// measurement rather than a figure quoted from 0.5.6.
99 pub const EDGES: usize = 90;
100
101 /// Concept upserts (`write_concepts`).
102 ///
103 /// Linear at ~23 µs per row, so unlike [`EDGES`] this size *is* a genuine
104 /// throughput sacrifice: 1,000-row chunks ran at 23.6 µs per row against
105 /// ~35 µs here. Paid deliberately — a 1,000-row chunk takes 24 ms, eight
106 /// times the bound.
107 pub const CONCEPTS: usize = 70;
108
109 /// Analytics annotations (`write_analytics_annotations`).
110 ///
111 /// The one path where the old constant was nearly right, and the only bulk
112 /// table with no triggers at all: ~2.5 µs per row, linear, so the bound buys
113 /// a large chunk. 1,000 rows would be 3.5 ms — over, but only just.
114 pub const ANNOTATIONS: usize = 600;
115
116 /// Embedding vectors (`upsert_embeddings`).
117 ///
118 /// The smallest by a wide margin, because DiskANN index maintenance makes an
119 /// embedding the most expensive row in the system. That cost grows with the
120 /// **corpus**, not the chunk (D-059): a fixed 30-vector chunk costs 49 µs per
121 /// vector into an empty corpus and 224 µs into an 8,000-vector one. Graph
122 /// insertion getting dearer as the graph grows is what DiskANN is, so unlike
123 /// [`EDGES`] there is nothing here to fix — but it does mean this size buys
124 /// latency at some throughput, not for free.
125 pub const EMBEDDINGS: usize = 30;
126}
127
128/// The latency bound [`chunk_rows`] is derived from (§5.1.5, D-058).
129///
130/// This is the golden rule's actual content. §9 has carried it as a row count
131/// with a duration attached — "chunk commit, 500 rows ≤ 3 ms" — which reads as
132/// two requirements and is one: the duration is the requirement, and the row
133/// count is whatever satisfies it on a given path and machine.
134///
135/// 3 ms is §9's number, kept rather than renegotiated. What it buys, end to end:
136/// an interactive assertion arriving at the worst possible moment waits for the
137/// chunk in flight (≤ 3 ms, because the SQLite write lock is not preemptible —
138/// see [`HighPriCommand`]) and then runs its own write (≤ 5 ms, §9), so ≤ 8 ms
139/// worst case. That fits inside a 60 Hz frame with room, which is the standard
140/// this bound is ultimately answerable to.
141///
142/// # Three operations are exempt, and the exemption is a contract, not an oversight
143///
144/// This was recorded in three separate rustdoc notes and nowhere near the bound
145/// itself, which is where a reader looks for its scope (§8.6). Stated here, with
146/// Wave 3's measurements:
147///
148/// | Path | Bound | Why it cannot be chunked |
149/// |---|---|---|
150/// | [`Database::write_bulk_atomic`] | none — caller-sized `Vec` | D-014: the batch is *one act* under one stamp. Splitting it is the thing the method exists not to do |
151/// | [`Database::archive`] | measured **26.8 ms** for 2,000 archivable edges; see [`Database::archive_windowed`] | D-012: copy-then-delete must be atomic, or a crash between the phases duplicates or loses rows |
152/// | `rebuild_current` | measured **24.6 / 104 / 318 ms** at 4K / 16K / 40K rows in `links` (was "~50 s per 10M edges", which nothing had measured) | D-023: the window between `DELETE` and `INSERT` is the whole of current belief; a reader landing in it sees a graph with no edges and no error |
153///
154/// The `archive` figure is end-to-end through this method, so it **includes**
155/// the re-derivation `archive()` runs inside its transaction — but it does not
156/// attribute it, and until D-077 more than half of that re-derivation was an
157/// audit comparing `links_current` against the query that had just filled it.
158/// Note also which variable that cost scales with: `rebuild_within` reprojects
159/// **all of `links`**, so the archive's repair term grows with the *surviving*
160/// table and not with the batch being archived. A budget stated per "100K closed
161/// intervals" ([§9](../docs/architecture/s6-s10-flows-to-dependencies.md)) is
162/// therefore parameterised on the wrong quantity.
163///
164/// All three are atomic **by contract**, which is why "cap the batch" and "add a
165/// third tier" were both considered and neither was taken: capping breaks the
166/// guarantee the operation exists to provide, and a third tier changes which
167/// caller waits without changing how long the lock is held. What was wrong was
168/// never the exemption — it was that the bound was stated as though it had none.
169///
170/// A caller who needs the latency bound and not the atomicity has
171/// [`Database::bulk_import`], which is the same write chunked at
172/// [`chunk_rows::EDGES`] and explicitly *not* atomic overall (D-011).
173///
174/// # One of the three is no longer unbounded (T1.1, D-080)
175///
176/// `archive` was the worst of them, because its hold is a function of *how long
177/// since the last archive* rather than of anything the caller chose.
178/// [`Database::archive_windowed`] runs the same work as N sessions, each
179/// atomic, each its own actor turn. Measured on an 8,000-key fixture with four
180/// generations of superseded history: the longest single hold falls from
181/// **3.3 s to 0.77 s** at one-hour windows, for total wall time that is flat
182/// within this cycle's noise.
183///
184/// The same measurement at 2,000 keys goes the other way — the hold falls
185/// 260 ms → 117 ms while total time rises 260 ms → 671 ms — so windowing is a
186/// trade and not a free improvement. It pays when the backlog is large, which
187/// is when the unwindowed hold is a problem in the first place. `archive` is
188/// kept, not deprecated, for exactly that reason.
189pub const CHUNK_BUDGET: std::time::Duration = std::time::Duration::from_millis(3);
190
191/// Predicted hold above which [`Database::write_bulk_atomic`] warns (T1.3).
192///
193/// 250 ms is fifteen frames at 60 Hz: not a hitch, a visible freeze. It is well
194/// above [`CHUNK_BUDGET`] on purpose — this path is exempt from that bound by
195/// contract, so warning at 3 ms would fire on batches that are working exactly
196/// as designed and train the reader to filter the message out.
197pub const BULK_ATOMIC_WARN_HOLD: std::time::Duration = std::time::Duration::from_millis(250);
198
199/// Roughly how long [`Database::write_bulk_atomic`] will hold the actor for
200/// this batch (T1.3, D-081).
201///
202/// # Three terms, because the cost is neither linear nor a function of size
203///
204/// T1.3 asks for "rows × measured per-row cost". That model is wrong twice over,
205/// and both corrections came out of measuring it.
206///
207/// First, the cost is not linear. `write_edges_atomic` opens with
208/// `reject_overlaps_within`, which compares **every pair** in the batch before a
209/// row is written. Second — and this is the one that matters — the quadratic
210/// term's constant depends on the batch's *shape*, not its size. The pairwise
211/// loop starts with an early `continue` on mismatched `(source, target,
212/// edge_type)`; pairs that share all three fall through to `Interval::new` and
213/// `overlaps`, which is **sixteen times** dearer per pair.
214///
215/// ```text
216/// hold ≈ 73 µs · rows + 5.5 ns · mismatched pairs + 86 ns · matching pairs
217/// ```
218///
219/// Two batches of 20,000 edges, measured on the same machine: one fanning out to
220/// distinct targets holds the actor for **2.5 s**, and one asserting 20,000
221/// corrections to a single relationship's history holds it for **18.6 s**. A
222/// size-only model is off by 7× between those two, in the direction that
223/// matters — it under-predicts the bad case. So this counts the matching pairs
224/// rather than guessing, with one `HashMap` pass over the batch. That pass is
225/// O(rows) against an operation about to spend milliseconds per row.
226///
227/// # What this is calibrated against, and where it will be wrong
228///
229/// libSQL 0.9.30, one machine, best of three, over 100–20,000 rows in both
230/// shapes; within 5% across that range except below ~500 rows, where fixed costs
231/// dominate and it over-predicts by 3× — harmless, since nothing that small can
232/// approach [`BULK_ATOMIC_WARN_HOLD`].
233///
234/// It is machine-specific and says nothing about disk. It exists to turn
235/// "uncapped" into an order of magnitude a caller can act on — the difference
236/// between 30 ms and 18 s — and should not be read more precisely than that.
237/// `examples/bulk_atomic_diag.rs` prints predicted against measured, so the
238/// model's drift is visible rather than assumed.
239pub fn estimated_bulk_hold(edges: &[EdgeAssertion]) -> std::time::Duration {
240 let rows = edges.len() as u64;
241 let all_pairs = rows.saturating_mul(rows.saturating_sub(1)) / 2;
242
243 // Pairs sharing all three key columns, which is exactly the set that reaches
244 // the guard's expensive path. Grouped rather than sorted: the batch is
245 // borrowed, and sorting would either clone it or reorder the caller's data.
246 let mut groups: std::collections::HashMap<(&str, &str, &str), u64> =
247 std::collections::HashMap::new();
248 for e in edges {
249 *groups
250 .entry((&e.source, &e.target, &e.edge_type))
251 .or_insert(0) += 1;
252 }
253 let matching: u64 = groups.values().map(|&g| g * (g - 1) / 2).sum();
254 let mismatched = all_pairs - matching;
255
256 // Nanoseconds throughout, saturating: a caller who passes a batch large
257 // enough to overflow this has a problem the arithmetic cannot express, and
258 // saturating to ~584 years still crosses every threshold above.
259 std::time::Duration::from_nanos(
260 (73_000u64.saturating_mul(rows))
261 .saturating_add(mismatched.saturating_mul(11) / 2)
262 .saturating_add(matching.saturating_mul(86)),
263 )
264}
265
266/// Most sessions [`Database::archive_windowed`] will run for one call (T1.1).
267///
268/// A limit exists because the session count is a function of *transaction-time
269/// span divided by window*, and both come from the caller — a one-second window
270/// over a decade of history is ten million actor turns, each opening a
271/// transaction and writing a horizon row. That is not a slow archive, it is a
272/// caller who meant something else.
273///
274/// 4,096 is chosen against the operation it bounds rather than against a clock:
275/// at the measured 26.8 ms for a session with work in it, a full run of this
276/// many is about two minutes of background writing, and the whole point of
277/// windowing is that those two minutes are interruptible. It is a refusal
278/// rather than a clamp — see [`DbError::ArchiveWindow`] for why.
279pub const MAX_ARCHIVE_SESSIONS: usize = 4_096;
280
281/// A concept assertion: the payload of an upsert.
282#[derive(Debug, Clone, PartialEq)]
283pub struct ConceptUpsert {
284 pub id: String,
285 pub title: String,
286 pub content: String,
287 pub embedding_model: Option<String>,
288 pub valid_from: String,
289 pub valid_to: String,
290 pub retired: bool,
291}
292
293impl ConceptUpsert {
294 pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
295 Self {
296 id: id.into(),
297 title: title.into(),
298 content: String::new(),
299 embedding_model: None,
300 valid_from: String::new(),
301 valid_to: timestamp::OPEN_SENTINEL.to_string(),
302 retired: false,
303 }
304 }
305
306 pub fn content(mut self, content: impl Into<String>) -> Self {
307 self.content = content.into();
308 self
309 }
310
311 pub fn embedding_model(mut self, model: impl Into<String>) -> Self {
312 self.embedding_model = Some(model.into());
313 self
314 }
315
316 pub fn valid_from(mut self, ts: impl Into<String>) -> Self {
317 self.valid_from = ts.into();
318 self
319 }
320
321 pub fn valid_to(mut self, ts: impl Into<String>) -> Self {
322 self.valid_to = ts.into();
323 self
324 }
325
326 pub fn retired(mut self, retired: bool) -> Self {
327 self.retired = retired;
328 self
329 }
330
331 /// Put the timestamps in canonical form (D-029) before they cross the channel.
332 pub fn normalized(mut self) -> Result<Self> {
333 crate::util::ids::validate_id(&self.id)?;
334 self.valid_from = timestamp::normalize(&self.valid_from)?;
335 self.valid_to = timestamp::normalize(&self.valid_to)?;
336 Ok(self)
337 }
338}
339
340/// One derived analytics result for one concept (§5.4, D-041).
341///
342/// Not a `ConceptUpsert`. The distinction is the whole of D-041: a concept
343/// upsert is a statement about the world and belongs in the ledger, while an
344/// annotation is a function of an algorithm applied to a graph and belongs in
345/// `analytics_annotations`, which carries no log trigger. Writing one as the
346/// other overwrote the concept's `content` with the label and recorded every
347/// analytics rerun as a fresh version of the world.
348#[derive(Debug, Clone, PartialEq, Eq)]
349pub struct Annotation {
350 pub concept_id: String,
351 /// Namespaced by convention, e.g. `louvain.community`, `kcore.shell`.
352 pub label: String,
353 /// JSON-encoded payload. Opaque to this crate.
354 pub value: String,
355}
356
357impl Annotation {
358 pub fn new(
359 concept_id: impl Into<String>,
360 label: impl Into<String>,
361 value: impl Into<String>,
362 ) -> Self {
363 Self {
364 concept_id: concept_id.into(),
365 label: label.into(),
366 value: value.into(),
367 }
368 }
369}
370
371/// Commands sent to the Write Actor on the high-priority channel (UI-driven work).
372pub enum HighPriCommand {
373 AssertEdge {
374 edge: EdgeAssertion,
375 responder: oneshot::Sender<Result<()>>,
376 },
377 RetireEdge {
378 source: String,
379 target: String,
380 edge_type: String,
381 valid_from: String,
382 valid_to: String,
383 responder: oneshot::Sender<Result<()>>,
384 },
385 UpsertConcept {
386 concept: ConceptUpsert,
387 responder: oneshot::Sender<Result<()>>,
388 },
389 WriteBulkAtomic {
390 edges: Vec<EdgeAssertion>,
391 responder: oneshot::Sender<Result<usize>>,
392 },
393 RebuildCurrent {
394 responder: oneshot::Sender<Result<RebuildReport>>,
395 },
396 /// Create a model's embedding table and its DiskANN index (D-037, D-048).
397 ///
398 /// High priority despite being setup work: it is one small transaction, and
399 /// every embedding write for the model blocks on it, so queueing it behind a
400 /// bulk job would stall the thing it gates.
401 RegisterModel {
402 model: ModelName,
403 dim: usize,
404 responder: oneshot::Sender<Result<()>>,
405 },
406 Shutdown {
407 responder: oneshot::Sender<Result<()>>,
408 },
409}
410
411/// Commands sent to the Write Actor on the low-priority channel (background work).
412pub enum LowPriCommand {
413 /// One chunk of **concepts** — a ledger write, logged and versioned.
414 WriteConceptsChunk {
415 chunk: Vec<ConceptUpsert>,
416 responder: oneshot::Sender<Result<usize>>,
417 },
418 /// One chunk of **derived annotations** — off-ledger, no log trigger (D-041).
419 ///
420 /// The pair is named apart deliberately: this variant was `WriteAnalyticsChunk`
421 /// beside a `WriteAnnotationsChunk` that carried concepts, which is the
422 /// crossing D-075 undid.
423 WriteAnalyticsChunk {
424 chunk: Vec<Annotation>,
425 responder: oneshot::Sender<Result<usize>>,
426 },
427 /// One chunk of vectors for one model (§5.9, D-048).
428 ///
429 /// Low priority: embedding is bulk derived work and must never preempt an
430 /// interactive assertion.
431 UpsertEmbeddingChunk {
432 model: ModelName,
433 chunk: Vec<(String, Vec<f32>)>,
434 responder: oneshot::Sender<Result<usize>>,
435 },
436 BulkImportChunk {
437 chunk: Vec<EdgeAssertion>,
438 responder: oneshot::Sender<Result<usize>>,
439 },
440 Archive {
441 cutoff: String,
442 archive_path: PathBuf,
443 responder: oneshot::Sender<Result<ArchiveReport>>,
444 },
445 /// Move named concepts back out of the cold file (0.9.0, C3).
446 ///
447 /// Low priority for the same reason `Archive` is: it is bulk physical
448 /// movement with no latency bound, and it holds the write lock for its whole
449 /// transaction.
450 Rehydrate {
451 ids: Vec<String>,
452 archive_path: PathBuf,
453 responder: oneshot::Sender<Result<RehydrateReport>>,
454 },
455 /// Reconstruct the FTS index from `concepts` (§5.9, D-036, D-051).
456 ///
457 /// Low priority: it is maintenance on a derivative table, and a search index
458 /// that is a few seconds stale is a smaller cost than an interactive write
459 /// that waits behind a full reindex.
460 RebuildFts {
461 responder: oneshot::Sender<Result<()>>,
462 },
463 /// One step of a chunked shadow rebuild (§5.8, T1.2, D-082).
464 ///
465 /// Low priority, and one command per step rather than one per rebuild: the
466 /// whole value of building beside the live table is that the actor returns
467 /// here between chunks. See [`Database::rebuild_current_chunked`].
468 ShadowRebuild {
469 step: crate::integrity::ShadowStep,
470 responder: oneshot::Sender<Result<crate::integrity::ShadowOutcome>>,
471 },
472}
473
474enum LoopCtl {
475 Continue,
476 Break,
477}
478
479/// Primary database handle for Macrame bitemporal ledger.
480pub struct Database {
481 db: libsql::Database,
482 /// The file this handle opened, kept so [`Database::diagnostic_conn`] can
483 /// open it again under different flags (T5.1, D-091). `archive_path` and
484 /// `snapshots_dir` are derived from it and were previously the only trace
485 /// of it on the struct.
486 path: PathBuf,
487 read_conn: libsql::Connection,
488 highpri_tx: mpsc::Sender<HighPriCommand>,
489 lowpri_tx: mpsc::Sender<LowPriCommand>,
490 clock: Arc<dyn Clock>,
491 archive_path: PathBuf,
492 snapshots_dir: PathBuf,
493 schema_version: u32,
494 writer: Option<tokio::task::JoinHandle<Result<()>>>,
495 /// Stops the snapshot cadence. Dropping it stops the task too, which is what
496 /// keeps a `Database` that is dropped rather than closed from leaving a task
497 /// running against a connection whose database is going away.
498 cadence_stop: Option<tokio::sync::watch::Sender<bool>>,
499 cadence: Option<tokio::task::JoinHandle<()>>,
500 /// Set by [`Database::close`]. Read only by [`Drop`], which warns when it is
501 /// still false — see that impl for why the omission is worth a warning.
502 closed: bool,
503 /// Shared with the actor (T1.4, T1.2). Held here rather than behind
504 /// `#[cfg(feature = "metrics")]` so `open_inner` has one shape; with the
505 /// feature off the metrics half is a zero-sized type and only
506 /// [`Database::metrics`] is gated — which is also why the field is unread in
507 /// the default build: the actor holds the other `Arc` and does the writing.
508 #[cfg_attr(not(feature = "metrics"), allow(dead_code))]
509 shared: Arc<ActorShared>,
510}
511
512impl Database {
513 /// Open a database file at `path`, configuring pragmas, running migrations, and spawning the Write Actor.
514 ///
515 /// The snapshot cadence runs with [`SnapshotCadence::default`]. Use
516 /// [`Database::open_with_cadence`] to tune or disable it.
517 pub async fn open(path: impl AsRef<Path>) -> Result<Self> {
518 Self::open_with_cadence(path, Some(SnapshotCadence::default())).await
519 }
520
521 /// Open with an explicit snapshot cadence, or `None` to run without one
522 /// (§5.5, D-053).
523 ///
524 /// `None` restores the pre-0.5.5 behaviour, where `close()` is the only
525 /// thing that ever writes an anchor. That is the right setting for a
526 /// short-lived process that will not accumulate a delta worth bounding, and
527 /// for tests that assert on the contents of the snapshot directory.
528 pub async fn open_with_cadence(
529 path: impl AsRef<Path>,
530 cadence: Option<SnapshotCadence>,
531 ) -> Result<Self> {
532 Self::open_inner(path.as_ref(), cadence, None).await
533 }
534
535 /// Open with an injected clock (§5.1.2, **defect K**, D-062).
536 ///
537 /// The reason this exists is testing: `recorded_at` is the transaction-time
538 /// axis, and until now every test that wanted to assert on one had to either
539 /// avoid it or drive a raw connection, because `open()` hardcoded
540 /// [`SystemClock`]. `FakeClock` has been public and constructed in the test
541 /// harness since 0.5.2 with nothing to inject it into — the compiler warned
542 /// about the dead field on every build for three releases.
543 ///
544 /// **The clock is floored against the database before the actor starts.**
545 /// [`Clock::raise_floor`] is called with the newest `recorded_at` in the
546 /// ledger, so an injected clock cannot issue a stamp below what is already
547 /// stored — which would abort the next concept write on
548 /// `trg_concepts_monotonic_ra` rather than merely being odd. This is the
549 /// step whose absence kept the defect open: the obvious implementation
550 /// (take an `Arc<dyn Clock>`, use it) produces a `Database` that fails on
551 /// its first write against any non-empty file.
552 ///
553 /// On a fresh database there is no floor, so an injected `FakeClock` issues
554 /// exactly the stamps it was given.
555 pub async fn open_with_clock(
556 path: impl AsRef<Path>,
557 cadence: Option<SnapshotCadence>,
558 clock: Arc<dyn Clock>,
559 ) -> Result<Self> {
560 Self::open_inner(path.as_ref(), cadence, Some(clock)).await
561 }
562
563 async fn open_inner(
564 path: &Path,
565 cadence: Option<SnapshotCadence>,
566 injected: Option<Arc<dyn Clock>>,
567 ) -> Result<Self> {
568 let db = libsql::Builder::new_local(path).build().await?;
569 let write_conn = configure(db.connect()?).await?;
570 let read_conn = configure(db.connect()?).await?;
571
572 // PRAGMA query_only = ON on reader connection (§5.1.2)
573 read_conn.execute("PRAGMA query_only = ON", ()).await?;
574
575 let migration = migrations::run(&write_conn).await?;
576
577 let (highpri_tx, highpri_rx) = mpsc::channel(256);
578 let (lowpri_tx, lowpri_rx) = mpsc::channel(64);
579
580 // Floored after `migrations::run`, so the tables the floor is read from
581 // are guaranteed to exist.
582 let clock: Arc<dyn Clock> = match injected {
583 Some(clock) => {
584 if let Some(floor) = crate::util::clock::recorded_at_floor(&read_conn).await? {
585 clock.raise_floor(floor);
586 }
587 clock
588 }
589 None => Arc::new(SystemClock::new(&read_conn).await?),
590 };
591 let shared = Arc::new(ActorShared::default());
592 let writer = tokio::spawn(run_writer_actor(
593 write_conn,
594 Arc::clone(&clock),
595 highpri_rx,
596 lowpri_rx,
597 Arc::clone(&shared),
598 ));
599
600 let archive_path = derive_archive_path(path);
601 let snapshots_dir = derive_snapshots_dir(path);
602
603 // **The cadence gets its own connection (Wave 4.1).** It used to share
604 // `read_conn`, on the reasoning that `libsql::Connection` is an
605 // Arc-backed handle and R15 makes every extra local connection a cost worth
606 // not paying for nothing. The cost it was not paying for turned out to be
607 // real: `reconstruct` brackets a fold with `ATTACH cold … DETACH cold`,
608 // that region is per-connection state, and it is not synchronised. Two
609 // folds on one connection can therefore interleave so that one DETACHes
610 // the handle the other is mid-fold on.
611 //
612 // Recorded in §8.5 as a hazard rather than a defect because it **did not
613 // reproduce**: 200 concurrent reconstructions against a 1 ms cadence with
614 // an archive present produced zero errors, since the cadence anchors at
615 // `MAX(recorded_at)` and so almost always takes the hot path. Narrow, and
616 // real — a write landing between `log_head` and the fold opens it.
617 //
618 // Separate connections remove the interleaving rather than ordering it,
619 // which is why this is preferred to a mutex around the region: there is
620 // no shared state left to race on, and nothing to remember to hold. The
621 // R15 objection does not apply — that fault is about *concurrent* opens,
622 // and this is one more sequential open during `open()`.
623 let (cadence_stop, cadence) = match cadence {
624 Some(cadence) => {
625 let cadence_conn = configure(db.connect()?).await?;
626 cadence_conn.execute("PRAGMA query_only = ON", ()).await?;
627 let (tx, rx) = tokio::sync::watch::channel(false);
628 let handle = tokio::spawn(snapshot::run_cadence(
629 cadence_conn,
630 snapshots_dir.clone(),
631 archive_path.clone(),
632 cadence,
633 rx,
634 ));
635 (Some(tx), Some(handle))
636 }
637 None => (None, None),
638 };
639
640 let handle = Self {
641 db,
642 path: path.to_path_buf(),
643 read_conn,
644 highpri_tx,
645 lowpri_tx,
646 clock,
647 archive_path,
648 snapshots_dir,
649 schema_version: migrations::current_version(),
650 writer: Some(writer),
651 cadence_stop,
652 cadence,
653 closed: false,
654 shared,
655 };
656
657 // **Re-anchor after a migration (Wave 4.4).**
658 //
659 // D-043 makes a `SCHEMA_VERSION` bump invalidate every snapshot on disk,
660 // which is correct — a snapshot is a serialised `MaterializedState` and a
661 // schema change can change what that means. What was missing is the other
662 // half: nothing wrote a replacement, so the first `reconstruct` after an
663 // upgrade skipped every file as incompatible and folded from genesis. On
664 // a database with a large log that is the difference between reading one
665 // snapshot and folding the whole history, and the only trace was a
666 // `warn!` per skipped file.
667 //
668 // Written here rather than left to the cadence because the cadence fires
669 // on log *growth* (D-053): an upgraded database that is then read but not
670 // written would never re-anchor at all.
671 //
672 // Failure is logged, not returned. A missing anchor costs time and no
673 // information — snapshots are derivative under Doctrine VI — so refusing
674 // to open a database because its optimisation could not be rebuilt would
675 // trade a real capability for a performance one.
676 //
677 // Gated on the cadence being enabled, as well as on an actual upgrade:
678 // `open_with_cadence(None)` means *this handle writes no snapshots except
679 // at close()*, and a one-off write at open would contradict that for a
680 // caller who asked for the quiet mode precisely to control when files
681 // appear. They still get an anchor from `close()`.
682 if migration.upgraded() && handle.cadence.is_some() {
683 let ts = handle.clock.now();
684 let archive = handle
685 .archive_path
686 .exists()
687 .then_some(handle.archive_path.as_path());
688 match snapshot::write_final(&handle.read_conn, &handle.snapshots_dir, &ts, archive)
689 .await
690 {
691 Ok(path) => tracing::info!(
692 "schema moved v{} -> v{}; re-anchored snapshots at {:?}",
693 migration.from,
694 migration.to,
695 path
696 ),
697 Err(e) => tracing::warn!(
698 "schema moved v{} -> v{} but the re-anchor failed: {e}. \
699 Reconstruction stays correct and folds from genesis until the \
700 cadence writes one.",
701 migration.from,
702 migration.to
703 ),
704 }
705 }
706
707 Ok(handle)
708 }
709
710 /// Read connection handle for queries, traversals, and folds.
711 pub fn read_conn(&self) -> &libsql::Connection {
712 &self.read_conn
713 }
714
715 /// The file this handle opened.
716 pub fn path(&self) -> &Path {
717 &self.path
718 }
719
720 /// A **new, independently owned, OS-level read-only** connection to this
721 /// database, for diagnostics (§4.7, T5.1, D-091).
722 ///
723 /// # Why this exists when `read_conn()` already does
724 ///
725 /// Two different things, and the difference is the point:
726 ///
727 /// * `read_conn()` returns a shared `&Connection` carrying
728 /// `PRAGMA query_only = ON`. That pragma is **per-connection and
729 /// reversible by its holder in one statement**, so it is a guardrail
730 /// against accident, not a capability boundary. And because the reference
731 /// is shared, a caller who runs a long reporting query on it is competing
732 /// with every traversal and fold in the process.
733 /// * This returns a connection opened with `SQLITE_OPEN_READ_ONLY`, which is
734 /// enforced by the engine below the pragma layer, and it is the caller's
735 /// own.
736 ///
737 /// **Measured on libSQL 0.9.30 rather than assumed**
738 /// (`examples/readonly_open_probe.rs`), against a live WAL database with the
739 /// write actor running:
740 ///
741 /// | | `read_conn()` | `diagnostic_conn()` |
742 /// |---|---|---|
743 /// | `SELECT`, `EXPLAIN QUERY PLAN` | allowed | allowed |
744 /// | `INSERT` | refused | refused |
745 /// | `PRAGMA query_only = OFF` | **allowed** | allowed |
746 /// | `INSERT` after that | **allowed** | **refused** |
747 /// | `ATTACH` an existing file | allowed | allowed |
748 /// | `INSERT` into the attachment | refused¹ | **refused** |
749 /// | `ATTACH` a path that does not exist | — | refused (`SQLITE_CANTOPEN`) |
750 ///
751 /// The third and fourth rows are the whole difference: turning the pragma
752 /// off restores writes on `read_conn()` and does not here. That is what
753 /// "boundary rather than guardrail" means, and it is now a number rather
754 /// than a claim.
755 ///
756 /// ¹ On `read_conn()` that refusal is `query_only` — the same reversible
757 /// thing as row 2. On `diagnostic_conn()` it is the open flags, and the
758 /// probe runs it *after* `query_only = OFF` so that the pragma cannot be
759 /// what is doing the work.
760 ///
761 /// # `ATTACH` is permitted, and does not widen the write boundary
762 ///
763 /// Checked because `diagnostic_query` (Python) is the only arbitrary-SQL
764 /// surface this crate exposes, and an attachment is a second `open` whose
765 /// flags it does not obviously inherit. It does inherit them: the
766 /// attachment is read-only, and a nonexistent path is `SQLITE_CANTOPEN`
767 /// rather than a new file, because `SQLITE_OPEN_CREATE` is dropped for the
768 /// attachment as it is for `main`. So `SQLITE_OPEN_READ_ONLY` bounds the
769 /// **connection**, not just the one file it names (0.10.0, W4.3).
770 ///
771 /// What it does widen is *reading*: an `ATTACH` can name any file the
772 /// process can open, so this connection is a read surface over the
773 /// filesystem, not over this database. That is a property of arbitrary SQL
774 /// rather than of the flags, and it is unchanged by them.
775 ///
776 /// # One way this is *more* permissive, which is worth knowing
777 ///
778 /// `CREATE TEMP TABLE` **succeeds** here and is refused by `read_conn()`.
779 /// Temp tables live in a separate temporary database that is writable
780 /// regardless of how the main one was opened, whereas `query_only` refuses
781 /// them outright — which is the mechanism [D-050] measured when it removed
782 /// `TwoPhaseTempTable` for returning `SQLITE_READONLY (8)` on the read
783 /// connection. So the stronger boundary is not uniformly stronger, and a
784 /// strategy that needs a temp table has a connection it could run on. That
785 /// is recorded, not acted on: D-050 removed the strategy for two reasons and
786 /// this addresses one of them.
787 ///
788 /// # Calling this concurrently is R15's shape
789 ///
790 /// **This is the one method on `Database` that opens the file.** Everything
791 /// else runs on connections established once, at `open`. Each call here is
792 /// a fresh `libsql::Builder::…build()`, so *N* threads calling it at once
793 /// are *N* concurrent opens — which is exactly the pattern behind
794 /// [R15](https://github.com/opticsWolf/Macrame#known-risks), the upstream
795 /// libSQL access violation (`0xC0000005`) that `examples/r15_soak.rs`
796 /// reproduces and `RUST_TEST_THREADS=1` exists to avoid in the suite.
797 ///
798 /// **This is measured, not inferred.** 48 threads sharing one handle and
799 /// calling only this method: 7 bad runs in 18 — two access violations and
800 /// five *returned* SQLite errors (`database is locked`, `bad parameter or
801 /// other API misuse`). With the calls serialised, 0 in 18
802 /// (`tests_py/probes/r15_diagnostic_path.py`). The returned-error mode is
803 /// the one to watch for: it looks like a fact about the database, on the
804 /// method a caller reaches for when they already doubt the typed answer.
805 ///
806 /// **Bound this yourself if you call it from more than one thread.** One
807 /// outstanding open at a time is enough; a mutex around the call costs
808 /// nothing on a diagnostic path. This method does not do it for you on
809 /// purpose: serialising behind a lock the caller cannot see would
810 /// contradict the thing above it — that the connection is *the caller's
811 /// own* — and it would put a hidden queue in front of the one surface whose
812 /// job is to answer questions when the typed path is already suspect. The
813 /// Python binding does bound it, because it wraps this in a method a caller
814 /// cannot see into (`PyDatabase::diagnostic_rows`); a Rust caller can.
815 ///
816 /// # Errors
817 ///
818 /// The file must already exist. `SQLITE_OPEN_READ_ONLY` drops
819 /// `SQLITE_OPEN_CREATE` with it, so a missing file is `SQLITE_CANTOPEN`
820 /// rather than a fresh empty database — which is the right failure, and is
821 /// surfaced as a typed error rather than as libSQL's error 14.
822 pub async fn diagnostic_conn(&self) -> Result<libsql::Connection> {
823 let fail = |reason: String| DbError::DiagnosticConn {
824 path: self.path.display().to_string(),
825 reason,
826 };
827 if !self.path.exists() {
828 return Err(fail(
829 "the file does not exist, and a read-only open cannot create it".to_string(),
830 ));
831 }
832 let db = libsql::Builder::new_local(&self.path)
833 .flags(libsql::OpenFlags::SQLITE_OPEN_READ_ONLY)
834 .build()
835 .await
836 .map_err(|e| fail(e.to_string()))?;
837 db.connect().map_err(|e| fail(e.to_string()))
838 }
839
840 /// Cross-check the snapshot chain against a fold from genesis (§5.5, T5.3,
841 /// D-092).
842 ///
843 /// `write_final` composes onto the previous snapshot, so snapshot *n* is
844 /// derived from snapshot *n−1* and nothing in the chain ever folds the whole
845 /// log. An error at any link propagates forward forever and every read
846 /// agrees with it, because every read descends from it. This is the check
847 /// that would notice.
848 ///
849 /// # When to run it
850 ///
851 /// **Not on a schedule this crate chooses.** A genesis fold is precisely the
852 /// cost snapshots exist to avoid, so running it periodically by default
853 /// would give every application the bill snapshots were bought to remove —
854 /// on a database whose log is large enough for snapshots to matter, which is
855 /// the only kind where this is worth doing. The plan calls it a scheduling
856 /// problem and it is the caller's schedule: an idle period, a nightly job,
857 /// or once per *N* anchors, chosen against a log size this crate cannot see.
858 ///
859 /// The cadence is deliberately left alone for the same reason — it runs on a
860 /// connection shared with nothing and a fold there would compete with
861 /// interactive reads at a moment nobody chose.
862 ///
863 /// # It reports; it does not repair
864 ///
865 /// A divergence means the snapshots are a wrong **cache**, not that the
866 /// ledger is corrupt: [Doctrine VI] makes them disposable, so deleting
867 /// [`Self::snapshots_dir`] restores correctness and costs only speed.
868 /// Rewriting the file here would destroy the evidence that composition has a
869 /// defect, which is the only thing this can tell you that you did not
870 /// already know.
871 ///
872 /// Pair it with the actor counters ([`Self::metrics`], D-079) so a
873 /// divergence found by a scheduled run is visible beside the write latency
874 /// of the period that produced it.
875 ///
876 /// [Doctrine VI]: ../../docs/architecture/s0-s3-foundations.md#doctrine-vi
877 pub async fn verify_snapshot_chain(&self, ts: &str) -> Result<crate::temporal::ChainCheck> {
878 let archive = self
879 .archive_path
880 .exists()
881 .then_some(self.archive_path.as_path());
882 crate::temporal::verify_snapshot_chain(&self.read_conn, ts, archive, &self.snapshots_dir)
883 .await
884 }
885
886 /// The clock every write is stamped with (§5.1.1).
887 pub fn clock(&self) -> &Arc<dyn Clock> {
888 &self.clock
889 }
890
891 /// Schema version this handle opened against.
892 pub fn schema_version(&self) -> u32 {
893 self.schema_version
894 }
895
896 /// Cold database path, derived by convention from the main file.
897 pub fn archive_path(&self) -> &Path {
898 &self.archive_path
899 }
900
901 /// Snapshot directory, derived by convention from the main file.
902 pub fn snapshots_dir(&self) -> &Path {
903 &self.snapshots_dir
904 }
905
906 /// What the write actor has done since this handle was opened (T1.4, D-079).
907 ///
908 /// Requires the `metrics` feature. The counters are per-handle and start at
909 /// zero on `open()` — they are not read from the database, because the thing
910 /// being measured is *this process's* actor and merging two processes'
911 /// histograms would produce a number about neither.
912 ///
913 /// The intended first question is [`crate::metrics::MetricsSnapshot::budget_violations`]:
914 ///
915 /// ```no_run
916 /// # async fn f(db: ¯ame::Database) {
917 /// # #[cfg(feature = "metrics")] {
918 /// for k in db.metrics().budget_violations() {
919 /// eprintln!("{} broke the 3 ms bound {} times", k.kind, k.over_budget);
920 /// }
921 /// # }
922 /// # }
923 /// ```
924 ///
925 /// Reading this does not stop the actor — see
926 /// [`crate::metrics::ActorMetrics::snapshot`] for what that costs in
927 /// consistency, and why the trade goes that way.
928 #[cfg(feature = "metrics")]
929 pub fn metrics(&self) -> crate::metrics::MetricsSnapshot {
930 self.shared.metrics.snapshot()
931 }
932
933 /// The underlying libSQL database, for callers that need their own connection.
934 ///
935 /// # Actor containment is a convention above this line, not a guarantee
936 ///
937 /// **Kept public, and the honest statement of what that costs (Wave 4.3).**
938 /// §5.1 says the write actor is the sole writer, and two mechanisms make that
939 /// true of the handle: every write method goes through a channel, and
940 /// [`Self::read_conn`] carries `PRAGMA query_only = ON`. **Nothing protects a
941 /// connection obtained from here.** A caller can open one, write to `links`
942 /// directly, and the actor will not know — the triggers still fire and the
943 /// ledger stays internally consistent, but the single-writer property that
944 /// [`crate::CHUNK_BUDGET`]'s latency argument rests on is gone, and so is the
945 /// serialisation the overlap guard (D-060) relies on.
946 ///
947 /// This is the same shape as the limit stated in §4.2 for that guard, and it
948 /// is one fact rather than two: **the storage layer permits what this API
949 /// refuses.** Making it private would not change that — the database file is
950 /// reachable by any SQLite client on the machine — it would only remove the
951 /// supported way to do the thing, which is how escape hatches become
952 /// `unsafe`-adjacent folklore.
953 ///
954 /// The free functions [`crate::register_model`] and
955 /// [`crate::upsert_embedding`] take a bare connection for the same reason and
956 /// carry the same caveat; prefer [`Self::register_model`] and
957 /// [`Self::upsert_embeddings`], which go through the actor.
958 ///
959 /// # The legitimate-use list is now one item long (T5.1, D-091)
960 ///
961 /// It used to read: `EXPLAIN QUERY PLAN` and other diagnostics, read-only
962 /// reporting queries wanting their own connection rather than sharing the
963 /// reader, and provoking a guard in a test. The first two are exactly what
964 /// [`Self::diagnostic_conn`] now does, and it does them behind an OS-level
965 /// read-only open rather than on a handle that can write. **Use that.**
966 ///
967 /// What is left is the one use that genuinely requires write access through
968 /// a connection the actor does not own: *provoking a guard* — writing the
969 /// state §4.7 says the storage layer permits and this API refuses, so a test
970 /// can assert the gap is still where the document says it is. That is the
971 /// only thing this crate's own suite uses it for.
972 ///
973 /// # Why `#[doc(hidden)]` and not a `raw-access` feature
974 ///
975 /// T5.1 offers either. The feature is the stronger declaration — it shows up
976 /// in the consumer's `Cargo.toml`, where a reviewer sees it — and it was
977 /// **not** taken, for a reason specific to what uses this:
978 ///
979 /// Cargo features are additive and cannot be *required* by a test target
980 /// except through `required-features`, which makes a plain `cargo test`
981 /// **skip** that binary silently. The binaries that call this are
982 /// `storage_boundary_tests` and `wave1_regression_tests` — the §4.7
983 /// tripwires, whose entire job is to fail when a documented gap moves. Gating
984 /// them behind a feature would mean the ordinary `cargo test` stopped running
985 /// the tests that enforce the section this item is about, to make a
986 /// declaration about a hatch. That trade is the wrong way round, and it is
987 /// the same failure the project already names: a suite that quietly does less
988 /// than it appears to.
989 ///
990 /// So the hatch stays reachable and stops being *discoverable*: it is absent
991 /// from the docs, and the documented path for every non-write use is
992 /// [`Self::diagnostic_conn`]. [D-068] is unchanged — removing it would buy
993 /// the appearance of a guarantee, since the file is reachable by any SQLite
994 /// client on the machine.
995 ///
996 /// [D-068]: ../../docs/architecture/s13-decision-register.md#d-068
997 // convention (D-068/D-091): `raw()` is #[doc(hidden)] and is NOT exposed by
998 // any binding. Everything above this line is invisible on docs.rs and
999 // invisible to a contributor reading the Python surface list, which is where
1000 // the decision to expose it would actually be taken — hence this sentinel and
1001 // its twin in `bindings/python/src/lib.rs` (0.10.0, W4.10). The documented
1002 // path for every non-write use is `diagnostic_conn`.
1003 #[doc(hidden)]
1004 pub fn raw(&self) -> &libsql::Database {
1005 &self.db
1006 }
1007
1008 // -- write surface (§5.1, Appendix A) --
1009 //
1010 // Every method here validates and canonicalises before the value crosses the
1011 // channel, so a bad edge type or a second-precision timestamp is a typed
1012 // error at the call site rather than an engine `CHECK` failure surfacing
1013 // from the far side of an actor with no context attached.
1014 //
1015 // NOTE (§5.1.8, D-028): awaiting one of these waits on a Rust channel, not
1016 // in SQLite, so `busy_timeout` does not bound it. During an in-flight
1017 // `rebuild_current` or `archive` the caller stalls for that transaction's
1018 // duration. Wrap in `tokio::time::timeout` if you need a bound — but a
1019 // timeout is not a cancellation: the command stays queued and commits when
1020 // the actor reaches it.
1021
1022 /// Assert an edge (Doctrine III: a new row, never an update).
1023 pub async fn assert_edge(&self, edge: EdgeAssertion) -> Result<()> {
1024 let edge = edge.normalized()?;
1025 self.high(|responder| HighPriCommand::AssertEdge { edge, responder })
1026 .await
1027 }
1028
1029 /// Close an open interval by asserting its replacement (Doctrine III).
1030 pub async fn retire_edge(
1031 &self,
1032 source: impl Into<String>,
1033 target: impl Into<String>,
1034 edge_type: impl Into<String>,
1035 valid_from: &str,
1036 valid_to: &str,
1037 ) -> Result<()> {
1038 let edge_type = edge_type.into();
1039 crate::graph::edge::validate_edge_type(&edge_type)?;
1040 let valid_from = timestamp::normalize(valid_from)?;
1041 let valid_to = timestamp::normalize(valid_to)?;
1042 let (source, target) = (source.into(), target.into());
1043
1044 self.high(|responder| HighPriCommand::RetireEdge {
1045 source,
1046 target,
1047 edge_type,
1048 valid_from,
1049 valid_to,
1050 responder,
1051 })
1052 .await
1053 }
1054
1055 /// Insert or update a concept.
1056 pub async fn upsert_concept(&self, concept: ConceptUpsert) -> Result<()> {
1057 let concept = concept.normalized()?;
1058 self.high(|responder| HighPriCommand::UpsertConcept { concept, responder })
1059 .await
1060 }
1061
1062 /// Assert many edges in one transaction under one stamp (D-014).
1063 ///
1064 /// # This is the one write with no latency bound, and here is what it costs
1065 ///
1066 /// The batch is one act under one `recorded_at`, so it cannot be chunked —
1067 /// splitting it is the thing this method exists not to do. That makes the
1068 /// actor's hold a function of `edges.len()`, and until now the only
1069 /// statement of that anywhere was the prose "uncapped" in
1070 /// [`CHUNK_BUDGET`]'s table. A caller who stalls every other writer for
1071 /// eight seconds should have been able to predict it from the signature.
1072 ///
1073 /// Measured on libSQL 0.9.30 (T1.3, D-081), holding the actor for:
1074 ///
1075 /// | rows | hold |
1076 /// |---|---|
1077 /// | 500 | ~34 ms |
1078 /// | 2,000 | ~155 ms |
1079 /// | 10,000 | ~1.0 s |
1080 /// | 20,000 | ~2.6 s |
1081 ///
1082 /// [`estimated_bulk_hold`] is that curve as a function, and this method
1083 /// emits a `tracing::warn!` when it predicts more than
1084 /// [`BULK_ATOMIC_WARN_HOLD`]. **The estimate is a shape, not a promise** —
1085 /// see [`estimated_bulk_hold`] for what it is calibrated against and where
1086 /// it will be wrong.
1087 ///
1088 /// A caller who needs the latency bound and not the atomicity wants
1089 /// [`Self::bulk_import`], which is the same write chunked and explicitly not
1090 /// atomic overall (D-011).
1091 pub async fn write_bulk_atomic(&self, edges: Vec<EdgeAssertion>) -> Result<usize> {
1092 let estimate = estimated_bulk_hold(&edges);
1093 if estimate > BULK_ATOMIC_WARN_HOLD {
1094 // Warned here rather than in the actor, and before the send: this is
1095 // the caller's own task, so the log line lands with their span
1096 // attached and names the call site that chose the batch size. By the
1097 // time the actor has it, the only context left is "a large batch".
1098 tracing::warn!(
1099 rows = edges.len(),
1100 estimated_hold_ms = estimate.as_millis() as u64,
1101 "write_bulk_atomic will hold the write actor for roughly \
1102 {estimate:?} — it is atomic by contract (D-014) and cannot be \
1103 chunked. Every other writer waits that long. Use bulk_import \
1104 if the batch does not need to be all-or-nothing."
1105 );
1106 }
1107
1108 let edges = normalize_all(edges)?;
1109 self.high(|responder| HighPriCommand::WriteBulkAtomic { edges, responder })
1110 .await
1111 }
1112
1113 /// Rebuild `links_current` from `links` and verify zero drift (§5.8).
1114 pub async fn rebuild_current(&self) -> Result<RebuildReport> {
1115 self.high(|responder| HighPriCommand::RebuildCurrent { responder })
1116 .await
1117 }
1118
1119 /// Rebuild `links_current` beside itself, in chunks (§5.8, T1.2, D-082).
1120 ///
1121 /// Same result as [`Self::rebuild_current`], different latency profile.
1122 /// `rebuild_current` is one transaction holding the write lock for its whole
1123 /// duration, because D-023 will not let the `DELETE` and the `INSERT` be
1124 /// split: a reader landing between them sees a graph with no edges and no
1125 /// error. This builds the replacement in a shadow table instead — the live
1126 /// table stays live and trigger-maintained throughout — and swaps it in at
1127 /// the end.
1128 ///
1129 /// Each step is its own actor turn, so an interactive assertion can jump the
1130 /// queue between chunks. That is the whole of the improvement, and it is why
1131 /// the loop is here rather than inside the actor's arm (the same reasoning
1132 /// as [`Self::archive_windowed`] and [`Self::bulk_import`]).
1133 ///
1134 /// # What the swap still costs
1135 ///
1136 /// Not microseconds. Index names are global and SQLite has no `ALTER INDEX
1137 /// … RENAME`, so the shadow cannot be built carrying `links_current`'s index
1138 /// names while `links_current` still holds them — and building it under
1139 /// other names would leave the table permanently indexed under names absent
1140 /// from [`CREATE_INDICES`](crate::schema::ddl::CREATE_INDICES), so the next
1141 /// migration would create a second copy of each.
1142 /// `DROP TABLE` frees the names, so the swap transaction is where
1143 /// the three indexes get built. What the chunking moves off the lock is the
1144 /// **projection** — the window function over all of `links` — which is the
1145 /// O(E log E) term.
1146 ///
1147 /// # When this returns an error rather than a repair
1148 ///
1149 /// [`DbError::RebuildInterrupted`] means an archive committed while the
1150 /// shadow was being built. Its deletions are invisible to a catch-up pass
1151 /// keyed on `recorded_at` — a deleted row has no `recorded_at` left to find
1152 /// it by — so the work is discarded rather than swapped in. `links_current`
1153 /// is untouched and the call can simply be retried.
1154 ///
1155 /// Use [`Self::rebuild_current`] when the repair must be one atomic act, or
1156 /// when nothing else is contending for the actor and the extra turns are
1157 /// pure overhead.
1158 pub async fn rebuild_current_chunked(&self) -> Result<RebuildReport> {
1159 use crate::integrity::{ShadowOutcome, ShadowStep};
1160
1161 // Each `else` arm is unreachable: the actor maps each step to its own
1162 // outcome variant. Written as a refutable pattern rather than an
1163 // `unwrap` so that adding a step cannot turn a mismatch into a panic on
1164 // the write path — and `WriterDroppedResponder` is the honest name for
1165 // "the actor answered with something this cannot use".
1166 let ShadowOutcome::Started { build_start, epoch } =
1167 self.shadow_step(ShadowStep::Begin).await?
1168 else {
1169 return Err(DbError::WriterDroppedResponder);
1170 };
1171
1172 let mut after: Option<String> = None;
1173 loop {
1174 let ShadowOutcome::Filled { last } = self
1175 .shadow_step(ShadowStep::Fill {
1176 after: after.take(),
1177 })
1178 .await?
1179 else {
1180 return Err(DbError::WriterDroppedResponder);
1181 };
1182 match last {
1183 Some(last) => after = Some(last),
1184 None => break,
1185 }
1186 }
1187
1188 let ShadowOutcome::Swapped { rows } = self
1189 .shadow_step(ShadowStep::Swap { build_start, epoch })
1190 .await?
1191 else {
1192 return Err(DbError::WriterDroppedResponder);
1193 };
1194
1195 Ok(RebuildReport {
1196 rows_rebuilt: rows,
1197 // Not audited. The chunked path's whole argument is that the
1198 // expensive work happens off the lock, and `audit_current` is two
1199 // `EXCEPT` passes over the projection — the cost D-077 removed from
1200 // the archive for the same reason. A caller who wants the check has
1201 // `audit_current` on the read connection, where it costs nobody the
1202 // write lock.
1203 drift_after: 0,
1204 })
1205 }
1206
1207 /// Run one step of a chunked rebuild, for a caller doing its own scheduling.
1208 ///
1209 /// [`Self::rebuild_current_chunked`] is this in a loop and is what almost
1210 /// everyone wants. This exists because that loop offers no seam: it drives
1211 /// `Begin`, then `Fill` to exhaustion, then `Swap`, and a caller who needs to
1212 /// do something *between* steps — pace them against a frame budget, abandon
1213 /// a rebuild that has run long enough, or provoke the archive interlock in a
1214 /// test — cannot get in.
1215 ///
1216 /// The obligation that comes with it: `epoch` from
1217 /// [`ShadowOutcome::Started`](crate::integrity::ShadowOutcome) must be handed
1218 /// back to [`ShadowStep::Swap`](crate::integrity::ShadowStep), or the
1219 /// archive interlock is defeated and a stale projection can be swapped in.
1220 /// The looping version cannot get that wrong; this one can.
1221 pub async fn shadow_step(
1222 &self,
1223 step: crate::integrity::ShadowStep,
1224 ) -> Result<crate::integrity::ShadowOutcome> {
1225 self.low(|responder| LowPriCommand::ShadowRebuild { step, responder })
1226 .await
1227 }
1228
1229 /// Import edges on the background channel, chunked (D-011).
1230 ///
1231 /// Atomic *per chunk*, not overall: a failure partway leaves earlier chunks
1232 /// committed. That is the tradeoff [`chunk_rows`] documents — use
1233 /// [`Database::write_bulk_atomic`] when the batch must be all-or-nothing.
1234 ///
1235 /// Chunked at [`chunk_rows::EDGES`], which is also faster in total than the
1236 /// larger chunks this used through 0.5.5 (D-058).
1237 pub async fn bulk_import(&self, edges: Vec<EdgeAssertion>) -> Result<usize> {
1238 let edges = normalize_all(edges)?;
1239 let chunks: Vec<_> = edges.chunks(chunk_rows::EDGES).map(<[_]>::to_vec).collect();
1240 self.low_chunked(chunks, |chunk, responder| LowPriCommand::BulkImportChunk {
1241 chunk,
1242 responder,
1243 })
1244 .await
1245 }
1246
1247 /// Upsert many **concepts** on the background channel, chunked (D-011).
1248 ///
1249 /// This is the bulk concept path, and every row it writes is a ledger write:
1250 /// it versions the concept and lands in `transaction_log`. Derived analytics
1251 /// output does not belong here — see
1252 /// [`Database::write_analytics_annotations`] and D-041.
1253 ///
1254 /// Called `write_annotations` through 0.5.6, from when the two writes were
1255 /// one call. D-041 split them and the name stayed on the wrong one for three
1256 /// releases, so the crate had a `write_annotations` that wrote concepts
1257 /// sitting beside a `write_analytics_annotations` that wrote annotations
1258 /// (D-075).
1259 pub async fn write_concepts(&self, concepts: Vec<ConceptUpsert>) -> Result<usize> {
1260 let concepts: Vec<ConceptUpsert> = concepts
1261 .into_iter()
1262 .map(ConceptUpsert::normalized)
1263 .collect::<Result<_>>()?;
1264 let chunks: Vec<_> = concepts
1265 .chunks(chunk_rows::CONCEPTS)
1266 .map(<[_]>::to_vec)
1267 .collect();
1268 self.low_chunked(chunks, |chunk, responder| {
1269 LowPriCommand::WriteConceptsChunk { chunk, responder }
1270 })
1271 .await
1272 }
1273
1274 /// State as believed at `ts` (§5.5, D-026, D-049).
1275 ///
1276 /// A read: it runs on `read_conn` and never touches the Write Actor, so a
1277 /// reconstruction and a full-speed write-back do not slow each other.
1278 ///
1279 /// Prefer this to calling [`crate::temporal::reconstruct`] directly. The
1280 /// free function takes the archive path and the snapshot directory as
1281 /// arguments, and a caller who passes `None` for the second gets a correct
1282 /// answer that folds the whole log every time — the composition is opt-in
1283 /// at that layer and easy to leave off by accident. Here both come from the
1284 /// handle, so the fast path is the default one.
1285 pub async fn reconstruct(&self, ts: &str) -> Result<crate::temporal::MaterializedState> {
1286 let ts = timestamp::normalize(ts)?;
1287 crate::temporal::reconstruct(
1288 &self.read_conn,
1289 &ts,
1290 Some(&self.archive_path),
1291 Some(&self.snapshots_dir),
1292 )
1293 .await
1294 }
1295
1296 /// Create a model's embedding table and DiskANN index (§5.9, D-048).
1297 ///
1298 /// Idempotent: registering a model that already exists at the same
1299 /// dimension succeeds, and at a different dimension fails with
1300 /// [`DbError::DimMismatch`] naming both, rather than no-opping through
1301 /// `IF NOT EXISTS` and leaving the caller believing the dimension they
1302 /// asked for is the one in force.
1303 ///
1304 /// This issues DDL, which everywhere else in the crate is the migration
1305 /// runner's exclusive business (D-032). The exception is bounded and
1306 /// deliberate: a model's table is created once, by an explicit call, and
1307 /// the alternative — a caller-supplied write connection — is the very thing
1308 /// the Write Actor exists to make impossible.
1309 ///
1310 /// # Latency
1311 ///
1312 /// One small transaction, but it queues like any other write: see §5.1.8.
1313 pub async fn register_model(&self, model: &ModelName, dim: usize) -> Result<()> {
1314 let model = model.clone();
1315 self.high(|responder| HighPriCommand::RegisterModel {
1316 model,
1317 dim,
1318 responder,
1319 })
1320 .await
1321 }
1322
1323 /// Store or replace vectors for `model`, chunked (§5.9, D-011, D-048).
1324 ///
1325 /// The write path for embeddings. Before 0.5.4 there was none:
1326 /// [`crate::vector::upsert_embedding`] takes a raw connection, `read_conn`
1327 /// is `query_only`, and the write connection lives inside the actor — so an
1328 /// application could search vectors it had no way to store.
1329 ///
1330 /// Low priority and chunked at [`chunk_rows::EMBEDDINGS`], because embedding
1331 /// is bulk derived work: a 50,000-vector backfill must yield to an
1332 /// interactive assertion at every chunk boundary. That constant is the
1333 /// smallest of the four by a wide margin — DiskANN index maintenance makes an
1334 /// embedding the most expensive row in the system (D-058). Atomic per chunk, not overall, which
1335 /// is the same trade [`Database::bulk_import`] makes and is safer here than
1336 /// there — an embedding is derived (Doctrine VII), so a partially written
1337 /// batch is recoverable by re-embedding.
1338 ///
1339 /// Fails with [`DbError::ModelNotRegistered`] if `model` has no table, and
1340 /// [`DbError::DimMismatch`] if a vector's length is not the declared
1341 /// dimension. The dimension is read from the schema once per chunk (D-037):
1342 /// the crate keeps no registry of its own to fall out of date.
1343 pub async fn upsert_embeddings(
1344 &self,
1345 model: &ModelName,
1346 rows: Vec<(String, Vec<f32>)>,
1347 ) -> Result<usize> {
1348 let chunks: Vec<_> = rows
1349 .chunks(chunk_rows::EMBEDDINGS)
1350 .map(<[_]>::to_vec)
1351 .collect();
1352 self.low_chunked(chunks, |chunk, responder| {
1353 LowPriCommand::UpsertEmbeddingChunk {
1354 model: model.clone(),
1355 chunk,
1356 responder,
1357 }
1358 })
1359 .await
1360 }
1361
1362 /// Reconstruct the concept-text search index from the ledger (§5.9, D-036).
1363 ///
1364 /// The FTS index is derivative: D-036 promises every derivative table can be
1365 /// rebuilt from the ledger tables, and this is that promise made callable
1366 /// for `concepts_fts`. Needed after a restore that skipped the shadow
1367 /// tables, or if the index is ever suspected of drifting from the text —
1368 /// and, as a matter of policy, cheaper to run than to reason about.
1369 ///
1370 /// The work is `INSERT INTO concepts_fts(concepts_fts) VALUES('rebuild')`,
1371 /// which is FTS5's own operation over the content table, so this is not a
1372 /// second implementation of the sync triggers that could disagree with them.
1373 pub async fn rebuild_fts(&self) -> Result<()> {
1374 self.low(|responder| LowPriCommand::RebuildFts { responder })
1375 .await
1376 }
1377
1378 // **There is deliberately no `verify_fts()` (§5.9, D-071).**
1379 //
1380 // `rebuild_fts` is the repair with no way to ask whether it is needed, and
1381 // Wave 5 set out to add the missing half. FTS5 offers `'integrity-check'`,
1382 // which looked like exactly the engine-provided answer this crate prefers.
1383 // It is not: on libSQL 0.9.30 it verifies the index's *internal* consistency
1384 // and not its agreement with the content table. Measured — after
1385 // `'delete-all'` the index matches nothing where it matched ten rows, and
1386 // both `'integrity-check'` and `'integrity-check', 0` still report success.
1387 //
1388 // A `verify_fts()` on that footing would answer "healthy" for an empty
1389 // index, which is worse than having no method at all: it is the shape of
1390 // defect AC, a function that looks like it checks something and does not.
1391 // `an_emptied_fts_index_still_passes_integrity_check` pins the limitation so
1392 // that if a later libSQL fixes it, the test fails and says so.
1393
1394 /// Write derived analytics results on the background channel, chunked
1395 /// (§5.4, D-041).
1396 ///
1397 /// Rows go to `analytics_annotations`, which has no log trigger, so nothing
1398 /// written here reaches `transaction_log` and nothing here versions a
1399 /// concept. Rerunning an algorithm replaces the previous pass rather than
1400 /// recording that the world changed.
1401 ///
1402 /// Low priority and chunked at [`chunk_rows::ANNOTATIONS`] — the largest of
1403 /// the four, because this is the only bulk table carrying no triggers at all
1404 /// and its rows are correspondingly cheap (D-058) — so a 50,000-label Louvain
1405 /// save yields to interactive writes at every chunk boundary and carries the
1406 /// per-chunk fidelity boundary of §5.1.6 — a partially written pass is
1407 /// recoverable by rerunning, which is the property that makes derived state
1408 /// safe to write this way and assertions not.
1409 pub async fn write_analytics_annotations(&self, annotations: Vec<Annotation>) -> Result<usize> {
1410 let chunks: Vec<_> = annotations
1411 .chunks(chunk_rows::ANNOTATIONS)
1412 .map(<[_]>::to_vec)
1413 .collect();
1414 self.low_chunked(chunks, |chunk, responder| {
1415 LowPriCommand::WriteAnalyticsChunk { chunk, responder }
1416 })
1417 .await
1418 }
1419
1420 /// Move closed intervals and superseded log rows older than `cutoff` to the
1421 /// cold database (§5.7, D-012).
1422 pub async fn archive(&self, cutoff: &str) -> Result<ArchiveReport> {
1423 let cutoff = timestamp::normalize(cutoff)?;
1424 let archive_path = self.archive_path.clone();
1425 self.low(|responder| LowPriCommand::Archive {
1426 cutoff,
1427 archive_path,
1428 responder,
1429 })
1430 .await
1431 }
1432
1433 /// Move the named concepts back from the cold database into the hot tables
1434 /// (§2.3, C3).
1435 ///
1436 /// Rehydration is a **physical move back, not a write**: it mints no
1437 /// transaction-time facts and is invisible to both clocks. An id that is not
1438 /// in the cold file is skipped rather than being an error — the caller
1439 /// generally has a list from a cold-side query, and a partially-stale list is
1440 /// the normal case rather than a mistake. The report says how many actually
1441 /// moved.
1442 ///
1443 /// See [`RehydrateReport::rowids_reassigned`] for the one way a rehydrated
1444 /// row can differ from the row that was archived.
1445 pub async fn rehydrate(&self, ids: &[&str]) -> Result<RehydrateReport> {
1446 let ids: Vec<String> = ids.iter().map(|s| (*s).to_string()).collect();
1447 let archive_path = self.archive_path.clone();
1448 self.low(|responder| LowPriCommand::Rehydrate {
1449 ids,
1450 archive_path,
1451 responder,
1452 })
1453 .await
1454 }
1455
1456 /// Archive up to `cutoff` as a sequence of sessions, each covering at most
1457 /// `window` of **transaction** time (T1.1, D-080).
1458 ///
1459 /// `archive(cutoff)` is one transaction whose size is set by how long it has
1460 /// been since the last one, which makes it the least bounded of the three
1461 /// operations exempt from [`CHUNK_BUDGET`] — its hold is a function of
1462 /// operational history rather than of anything a caller chose. This runs the
1463 /// same work as *N* complete sessions, each with its own marker, horizon row
1464 /// and rebuild, and returns one [`ArchiveReport`] per session in order.
1465 ///
1466 /// # D-012 is satisfied per session, and that is what it requires
1467 ///
1468 /// The atomicity D-012 demands is that copy-then-delete never be split — a
1469 /// crash between the phases duplicates or loses rows. *N* small sessions
1470 /// satisfy that exactly as one large one does. The obligation windowing adds
1471 /// is that a partial run leave a coherent intermediate state, which it does:
1472 /// each session commits a valid horizon, so a failure at window *k* leaves a
1473 /// database archived up to boundary *k−1* and nothing in between. **The
1474 /// sequence is not atomic and does not claim to be** — on error, the reports
1475 /// for the sessions that did commit are lost with it, but their effect is
1476 /// not, and re-running with the same `cutoff` completes the job.
1477 ///
1478 /// # Each session is its own actor turn, and that is the entire point
1479 ///
1480 /// This loop lives here, on the handle, rather than inside the actor's
1481 /// `Archive` arm. Putting it there would have produced *N* small
1482 /// transactions inside **one** hold, which shrinks the transaction and
1483 /// changes the latency not at all: the actor is single-threaded, so nothing
1484 /// else writes until its turn returns regardless of how many `COMMIT`s the
1485 /// turn contains. Sending *N* commands returns the actor to its `select!`
1486 /// between sessions, which is where an interactive assertion gets to jump
1487 /// the queue — and it is high-priority, so it does.
1488 ///
1489 /// The same reasoning is why [`Self::bulk_import`] chunks here and not
1490 /// there, and it is the trap T1.2 names for `CREATE TABLE … AS SELECT`.
1491 ///
1492 /// # Choosing a window
1493 ///
1494 /// The bound is on *transaction* time, so the session count is set by how
1495 /// far back the hot file goes, not by how much it holds. A window is
1496 /// rejected rather than clamped if it would need more than
1497 /// [`MAX_ARCHIVE_SESSIONS`] sessions — see [`DbError::ArchiveWindow`].
1498 ///
1499 /// Windows containing nothing archivable are cheap but not free: each still
1500 /// opens a transaction and writes a horizon row. What they no longer do is
1501 /// re-project `links_current`, which `archive_session` now skips when its
1502 /// `DELETE` removed no rows — without that, windowing costs *more* in total
1503 /// than not windowing, because the repair term scales with the surviving
1504 /// table and not with the batch (D-077).
1505 pub async fn archive_windowed(
1506 &self,
1507 cutoff: &str,
1508 window: std::time::Duration,
1509 ) -> Result<Vec<ArchiveReport>> {
1510 let cutoff = timestamp::normalize(cutoff)?;
1511 let boundaries = self.archive_boundaries(&cutoff, window).await?;
1512
1513 let mut reports = Vec::with_capacity(boundaries.len());
1514 for boundary in boundaries {
1515 let archive_path = self.archive_path.clone();
1516 reports.push(
1517 self.low(|responder| LowPriCommand::Archive {
1518 cutoff: boundary,
1519 archive_path,
1520 responder,
1521 })
1522 .await?,
1523 );
1524 }
1525 Ok(reports)
1526 }
1527
1528 /// The cutoffs [`Self::archive_windowed`] will run, ascending, ending at
1529 /// `cutoff` exactly.
1530 ///
1531 /// Read on `read_conn`, not on the actor: this is two `MIN`s and the actor
1532 /// has no reason to hold its lock for them.
1533 ///
1534 /// The lower end comes from the data rather than from the clock. Stepping
1535 /// from some fixed epoch would make the session count a function of the
1536 /// calendar — a database opened yesterday would still be asked to archive
1537 /// 1970 — whereas the oldest `recorded_at` actually present is the earliest
1538 /// boundary that can contain anything.
1539 async fn archive_boundaries(
1540 &self,
1541 cutoff: &str,
1542 window: std::time::Duration,
1543 ) -> Result<Vec<String>> {
1544 // A single session at `cutoff` is exactly `archive(cutoff)`, and it is
1545 // the right answer for an empty hot file: it still writes the horizon
1546 // row, so windowed and unwindowed runs leave the same observable state.
1547 let Some(oldest) = self.oldest_hot_stamp(cutoff).await? else {
1548 return Ok(vec![cutoff.to_string()]);
1549 };
1550
1551 let start = timestamp::parse(&oldest)?;
1552 let end = timestamp::parse(cutoff)?;
1553 let Ok(span) = end.duration_since(start) else {
1554 // Everything in the hot file is at or after the cutoff, so there is
1555 // nothing in range to divide.
1556 return Ok(vec![cutoff.to_string()]);
1557 };
1558
1559 if window.is_zero() {
1560 return Err(DbError::ArchiveWindow {
1561 window,
1562 reason: "a zero-length window never advances past the first boundary".into(),
1563 });
1564 }
1565
1566 // `div_ceil` on nanos: a span of 90 minutes in 60-minute windows is two
1567 // sessions, not one. `as_nanos` is u128, so neither the division nor the
1568 // span can overflow for any timestamp this crate can store.
1569 let sessions = span.as_nanos().div_ceil(window.as_nanos());
1570 if sessions > MAX_ARCHIVE_SESSIONS as u128 {
1571 return Err(DbError::ArchiveWindow {
1572 window,
1573 reason: format!(
1574 "a span of {span:?} would need {sessions} sessions (limit \
1575 {MAX_ARCHIVE_SESSIONS}); widen the window"
1576 ),
1577 });
1578 }
1579
1580 let mut boundaries = Vec::with_capacity(sessions as usize);
1581 for k in 1..sessions {
1582 boundaries.push(timestamp::format(start + window * k as u32));
1583 }
1584 // The last boundary is `cutoff` itself and not `start + n*window`, which
1585 // would overshoot and archive rows the caller excluded.
1586 boundaries.push(cutoff.to_string());
1587 Ok(boundaries)
1588 }
1589
1590 /// Oldest `recorded_at` below `cutoff` in either hot table, or `None`.
1591 async fn oldest_hot_stamp(&self, cutoff: &str) -> Result<Option<String>> {
1592 let mut oldest: Option<String> = None;
1593 for table in ["links", "transaction_log"] {
1594 let found: Option<String> = self
1595 .read_conn
1596 .query(
1597 &format!("SELECT MIN(recorded_at) FROM {table} WHERE recorded_at < ?1"),
1598 libsql::params![cutoff],
1599 )
1600 .await?
1601 .next()
1602 .await?
1603 .and_then(|row| row.get(0).ok());
1604 if let Some(found) = found {
1605 if oldest.as_ref().is_none_or(|o| found < *o) {
1606 oldest = Some(found);
1607 }
1608 }
1609 }
1610 Ok(oldest)
1611 }
1612
1613 /// Send a high-priority command and wait for its answer.
1614 ///
1615 /// The two error mappings here are the whole reason this helper exists.
1616 /// `send` failing means the actor is gone — `WriterUnavailable`. The
1617 /// responder being dropped without an answer means the actor took the
1618 /// command and never replied — `WriterDroppedResponder`, which is a bug in
1619 /// the actor rather than a condition the caller can retry. Both variants
1620 /// existed in `error.rs` from 0.4.5 and neither was ever constructed, so a
1621 /// dead actor and a hung one were both just a caller waiting forever.
1622 async fn high<T>(
1623 &self,
1624 make: impl FnOnce(oneshot::Sender<Result<T>>) -> HighPriCommand,
1625 ) -> Result<T> {
1626 let (tx, rx) = oneshot::channel();
1627 self.highpri_tx
1628 .send(make(tx))
1629 .await
1630 .map_err(|_| DbError::WriterUnavailable)?;
1631 rx.await.map_err(|_| DbError::WriterDroppedResponder)?
1632 }
1633
1634 /// Send each chunk in turn and sum the counts — the shape all four bulk
1635 /// paths share (T3.4, D-086).
1636 ///
1637 /// # This is sequential on purpose, and the purpose is a measurement
1638 ///
1639 /// T3.4 proposed pipelining: send *k* chunks ahead so the actor never finds
1640 /// an empty queue. The reasoning is that awaiting each chunk before building
1641 /// the next leaves the actor idle for a channel round trip every time, which
1642 /// on a 1M-edge import is ~11,000 idle gaps.
1643 ///
1644 /// Both halves of that are true and the conclusion does not follow. The gaps
1645 /// are real; they are also **four orders of magnitude smaller than the work
1646 /// they interrupt**. A tokio mpsc hop is sub-microsecond and a chunk takes
1647 /// 13–21 ms. Implemented and swept at depths 1, 2, 4, 8 and 16 over 20K and
1648 /// 100K edges: every cell landed within 1% of sequential, in both directions
1649 /// — see `examples/pipeline_diag.rs`, which is kept precisely so this is not
1650 /// re-proposed from the same reasoning.
1651 ///
1652 /// So the pipelining was removed and the deduplication kept. It was not free
1653 /// to hold: with chunks in flight, a failure at chunk `i` no longer leaves a
1654 /// **prefix** committed, because `i+1 ..= i+k-1` were already sent and commit
1655 /// anyway. D-011 promises "earlier chunks committed", and paying for that
1656 /// with a weaker recovery story in exchange for nothing measurable is the
1657 /// wrong trade.
1658 ///
1659 /// Sending stops at the first error, so what commits is exactly the prefix
1660 /// before the failure.
1661 async fn low_chunked<C>(
1662 &self,
1663 chunks: Vec<C>,
1664 make: impl Fn(C, oneshot::Sender<Result<usize>>) -> LowPriCommand,
1665 ) -> Result<usize> {
1666 let mut written = 0usize;
1667 for chunk in chunks {
1668 let (tx, rx) = oneshot::channel();
1669 self.lowpri_tx
1670 .send(make(chunk, tx))
1671 .await
1672 .map_err(|_| DbError::WriterUnavailable)?;
1673 written += rx.await.map_err(|_| DbError::WriterDroppedResponder)??;
1674 }
1675 Ok(written)
1676 }
1677
1678 async fn low<T>(
1679 &self,
1680 make: impl FnOnce(oneshot::Sender<Result<T>>) -> LowPriCommand,
1681 ) -> Result<T> {
1682 let (tx, rx) = oneshot::channel();
1683 self.lowpri_tx
1684 .send(make(tx))
1685 .await
1686 .map_err(|_| DbError::WriterUnavailable)?;
1687 rx.await.map_err(|_| DbError::WriterDroppedResponder)?
1688 }
1689
1690 /// Clean shutdown: stop the Write Actor, then write the final snapshot (§5.1.7).
1691 ///
1692 /// Order matters. The snapshot is taken *after* the actor has stopped and
1693 /// been joined, so no write can land between the fold and the file — the
1694 /// anchor it records is the last thing that happened, not the last thing
1695 /// that happened to be visible.
1696 ///
1697 /// A failed snapshot is reported rather than swallowed. It is not a
1698 /// durability loss — the ledger is in the WAL and the log replays without
1699 /// it — but it means the next open starts from an older anchor, and a caller
1700 /// that never hears about it cannot know why startup got slower.
1701 ///
1702 /// **The cadence stops first (§5.5, D-053).** Both it and `write_final` end
1703 /// by running retention over the snapshot directory, and retention deletes
1704 /// files. Letting them overlap would mean one pass enumerating the directory
1705 /// while the other removes from it — not a correctness problem for the
1706 /// ledger, which is why the ordering is stated rather than locked, but a
1707 /// source of spurious warnings and of a final anchor that could be deleted
1708 /// by a cleanup that started before it existed. Stopping the cadence, then
1709 /// the actor, then taking the snapshot leaves exactly one writer at each
1710 /// step.
1711 pub async fn close(mut self) -> Result<()> {
1712 if let Some(stop) = self.cadence_stop.take() {
1713 let _ = stop.send(true);
1714 }
1715 if let Some(handle) = self.cadence.take() {
1716 let _ = handle.await;
1717 }
1718
1719 let (tx, rx) = oneshot::channel();
1720 let _ = self
1721 .highpri_tx
1722 .send(HighPriCommand::Shutdown { responder: tx })
1723 .await;
1724 let _ = rx.await;
1725
1726 // **The writer's `Result` is propagated, not discarded (Wave 4.2).**
1727 // It used to be `let _ = handle.await`, so an actor that had panicked or
1728 // returned an error closed "successfully" and the caller's last chance to
1729 // learn that the write path had died was spent silently. A `JoinError`
1730 // here means the actor panicked; the inner `Result` is whatever it
1731 // returned.
1732 //
1733 // Ordered before the final snapshot on purpose: a snapshot written after
1734 // a failed writer records a state the caller has no reason to trust, and
1735 // returning the writer's error while also having written that file is
1736 // worse than not writing it.
1737 if let Some(handle) = self.writer.take() {
1738 match handle.await {
1739 Ok(res) => res?,
1740 Err(e) => {
1741 return Err(DbError::WriterStopped(format!(
1742 "the write actor did not exit cleanly: {e}"
1743 )))
1744 }
1745 }
1746 }
1747
1748 let ts = self.clock.now();
1749 let archive = self
1750 .archive_path
1751 .exists()
1752 .then_some(self.archive_path.as_path());
1753 snapshot::write_final(&self.read_conn, &self.snapshots_dir, &ts, archive).await?;
1754
1755 // Marks the handle closed so `Drop` knows not to complain.
1756 self.closed = true;
1757 Ok(())
1758 }
1759}
1760
1761/// Notes a missed `close()` at `warn!`, and deliberately does **not** assert.
1762///
1763/// **§7.3 offered option B — document `close()` as mandatory and `debug_assert`
1764/// in `Drop` — and Wave 4.2 implemented it, measured the consequence, and
1765/// reduced it to a warning.** The assert fired on roughly thirty tests on its
1766/// first run. That is the signal it was built to produce, and the right reading
1767/// of it was not "thirty tests are wrong".
1768///
1769/// What dropping actually costs is one final snapshot. Nothing else: every
1770/// public write method awaits its responder, so by the time a caller *can* drop
1771/// the handle, every write it issued has already committed; and the cadence stops
1772/// on its own, because `cadence_stop` is a `watch::Sender` whose drop signals the
1773/// task. A snapshot is derivative state under Doctrine VI — disposable,
1774/// reconstructible, and never the only copy of anything. Losing one makes the
1775/// next `reconstruct` fold from an older anchor, which is **slower, not wrong**.
1776///
1777/// A `debug_assert` aborts a test run. Spending that on a performance loss, in a
1778/// project whose own notes say a suite that fails for reasons unrelated to the
1779/// code under test trains people to ignore red, is the wrong trade — and paying
1780/// it in thirty places would have made `close()` look mandatory by ceremony
1781/// rather than by consequence. `close()` remains the right thing to call, and
1782/// the two reasons to call it are now stated where they can be acted on: the
1783/// snapshot, and the writer's `Result`, which only `close()` can return.
1784///
1785/// Option A ("abort the actor and log") stays rejected, for the reason it was
1786/// rejected twice before: `Drop` cannot await, so it cannot drain, and cleanup
1787/// that cannot clean up is worse than none — it looks like cleanup.
1788impl Drop for Database {
1789 fn drop(&mut self) {
1790 if !self.closed {
1791 tracing::warn!(
1792 "Database dropped without close(): the final snapshot was not written, \
1793 so the next reconstruct folds from an older anchor, and the write \
1794 actor's exit status was not checked. Prefer close().await."
1795 );
1796 }
1797 }
1798}
1799
1800fn normalize_all(edges: Vec<EdgeAssertion>) -> Result<Vec<EdgeAssertion>> {
1801 edges.into_iter().map(EdgeAssertion::normalized).collect()
1802}
1803
1804/// Identical pragma configuration on every connection.
1805async fn configure(conn: libsql::Connection) -> Result<libsql::Connection> {
1806 // NOTE: `journal_mode` and `busy_timeout` return their resulting value as a
1807 // row, and libsql's `execute()` rejects any statement that yields rows
1808 // ("Execute returned rows"). They must be issued through `query()`.
1809 let _ = conn.query("PRAGMA journal_mode = WAL", ()).await?;
1810 let _ = conn.query("PRAGMA busy_timeout = 5000", ()).await?;
1811 conn.execute("PRAGMA synchronous = NORMAL", ()).await?;
1812 conn.execute("PRAGMA foreign_keys = ON", ()).await?;
1813 conn.execute("PRAGMA recursive_triggers = OFF", ()).await?;
1814 Ok(conn)
1815}
1816
1817/// Helper to derive the snapshot directory by convention: foo.db -> foo_snapshots/
1818fn derive_snapshots_dir(path: &Path) -> PathBuf {
1819 let mut dir = path.to_path_buf();
1820 let stem = path
1821 .file_stem()
1822 .and_then(|s| s.to_str())
1823 .unwrap_or("macrame");
1824 dir.set_file_name(format!("{stem}_snapshots"));
1825 dir
1826}
1827
1828/// Helper to derive archive database path by convention: foo.db -> foo_archive.db
1829fn derive_archive_path(path: &Path) -> PathBuf {
1830 let mut archive = path.to_path_buf();
1831 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
1832 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("db");
1833 archive.set_file_name(format!("{stem}_archive.{ext}"));
1834 } else {
1835 archive.set_extension("archive.db");
1836 }
1837 archive
1838}
1839
1840/// Dedicated Write Actor event loop prioritizing high-priority UI requests over low-priority background work.
1841///
1842/// # The turn is the unit, not the statement (T1.4)
1843///
1844/// One iteration of this loop is one *hold*: the actor is single-threaded and
1845/// the SQLite write lock is not preemptible, so from the moment a command starts
1846/// executing until it returns, nothing else writes. That is the quantity
1847/// [`CHUNK_BUDGET`] bounds, and so it is the quantity
1848/// [`crate::metrics::ActorMetrics`] measures — deliberately around the whole
1849/// `execute` call rather than inside it. Timing the SQL alone would have
1850/// reported a bound that held while callers waited.
1851///
1852/// Queue depth is sampled *before* the `select!`, so it is the backlog the turn
1853/// found on arrival rather than the one it left behind.
1854async fn run_writer_actor(
1855 conn: libsql::Connection,
1856 clock: Arc<dyn Clock>,
1857 mut highpri_rx: mpsc::Receiver<HighPriCommand>,
1858 mut lowpri_rx: mpsc::Receiver<LowPriCommand>,
1859 shared: Arc<ActorShared>,
1860) -> Result<()> {
1861 loop {
1862 shared
1863 .metrics
1864 .record_turn(highpri_rx.len(), lowpri_rx.len());
1865
1866 let ctl = tokio::select! {
1867 biased;
1868 Some(cmd) = highpri_rx.recv() => {
1869 let turn = Turn::start(cmd.kind(), &shared);
1870 cmd.execute(&conn, &*clock, &turn).await
1871 }
1872 Some(cmd) = lowpri_rx.recv() => {
1873 let turn = Turn::start(cmd.kind(), &shared);
1874 cmd.execute(&conn, &*clock, &turn).await
1875 }
1876 else => LoopCtl::Break,
1877 };
1878 if matches!(ctl, LoopCtl::Break) {
1879 break;
1880 }
1881 }
1882 Ok(())
1883}
1884
1885/// One command's hold: the timer, its label, and the counters it reports to.
1886///
1887/// # The hold is recorded *before* the caller is answered, and it has to be
1888///
1889/// The obvious placement — time the whole `execute` call from the loop — is
1890/// wrong in a way that only shows up under test. Every arm of `execute` ends by
1891/// sending on a `oneshot`, which wakes the waiting caller; the actor then
1892/// returns to the loop and records. Those are two tasks, so a caller that awaits
1893/// its own write and immediately reads [`Database::metrics`] can be scheduled
1894/// first and see a turn count that does not include the write it just did.
1895///
1896/// Not a correctness bug in the ledger, and it would never have been noticed in
1897/// production — a dashboard sampling every few seconds cannot see the window.
1898/// It makes every test and diagnostic of the counters flaky, which is worse: the
1899/// instrumentation would have been *believed* while being wrong exactly when
1900/// someone tried to check it. `examples/bulk_atomic_diag.rs` was the thing that
1901/// caught it, reporting a 20,000-row batch as a 0 ms hold.
1902///
1903/// So `answer` records and then sends, in that order, and the ordering is the
1904/// method's whole reason to exist. What it costs is that the `oneshot::send`
1905/// itself falls outside the measurement, which is a few nanoseconds against a
1906/// turn measured in microseconds at best.
1907struct Turn<'a> {
1908 kind: crate::metrics::CommandKind,
1909 timer: crate::metrics::HoldTimer,
1910 shared: &'a ActorShared,
1911}
1912
1913/// State the actor owns and a `Turn` needs to reach.
1914///
1915/// `archive_epoch` is here rather than in [`crate::metrics::ActorMetrics`]
1916/// because it is **not** a metric: T1.2's shadow rebuild reads it to decide
1917/// whether its work is still valid, so it has to be present in every build, not
1918/// only under the `metrics` feature. Counting archives happens to be what both
1919/// want; only one of them is allowed to be compiled out.
1920#[derive(Default)]
1921struct ActorShared {
1922 metrics: crate::metrics::ActorMetrics,
1923 archive_epoch: std::sync::atomic::AtomicU64,
1924}
1925
1926impl<'a> Turn<'a> {
1927 fn start(kind: crate::metrics::CommandKind, shared: &'a ActorShared) -> Self {
1928 Self {
1929 kind,
1930 timer: crate::metrics::HoldTimer::start(),
1931 shared,
1932 }
1933 }
1934
1935 fn epoch(&self) -> u64 {
1936 self.shared
1937 .archive_epoch
1938 .load(std::sync::atomic::Ordering::Relaxed)
1939 }
1940
1941 /// Record that an archive session committed.
1942 ///
1943 /// Bumped on **success only**: a failed archive rolls back, so it deletes
1944 /// nothing and invalidates no shadow build.
1945 fn archive_committed(&self) {
1946 self.shared
1947 .archive_epoch
1948 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1949 }
1950
1951 /// Close the hold and hand the result back. Never the other way round.
1952 ///
1953 /// The `let _ =` on the send is deliberate and predates this: a caller that
1954 /// dropped its receiver — `tokio::time::timeout` around a write, which
1955 /// [`Database`]'s write surface explicitly documents — is not an actor
1956 /// error, and the command committed regardless.
1957 fn answer<T>(&self, responder: oneshot::Sender<Result<T>>, res: Result<T>) {
1958 self.shared
1959 .metrics
1960 .record_hold(self.kind, self.timer.elapsed());
1961 let _ = responder.send(res);
1962 }
1963}
1964
1965const INSERT_LINK: &str = "INSERT INTO links \
1966 (source_id, target_id, edge_type, valid_from, valid_to, weight, properties, recorded_at) \
1967 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)";
1968
1969/// Shared by the single-concept write and the chunked one, so the two paths
1970/// cannot drift into upserting different column sets — and so the chunk has a
1971/// statement text it can prepare once (D-056).
1972const UPSERT_CONCEPT: &str = "INSERT INTO concepts \
1973 (id, title, content, embedding_model, valid_from, valid_to, recorded_at, retired) \
1974 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \
1975 ON CONFLICT(id) DO UPDATE SET \
1976 title = excluded.title, \
1977 content = excluded.content, \
1978 embedding_model = excluded.embedding_model, \
1979 valid_from = excluded.valid_from, \
1980 valid_to = excluded.valid_to, \
1981 recorded_at = excluded.recorded_at, \
1982 retired = excluded.retired";
1983
1984/// The parameter row for [`UPSERT_CONCEPT`], in one place for the same reason.
1985fn concept_params<'a>(concept: &'a ConceptUpsert, stamp: &'a str) -> [libsql::Value; 8] {
1986 [
1987 concept.id.as_str().into(),
1988 concept.title.as_str().into(),
1989 concept.content.as_str().into(),
1990 concept
1991 .embedding_model
1992 .as_deref()
1993 .map_or(libsql::Value::Null, Into::into),
1994 concept.valid_from.as_str().into(),
1995 concept.valid_to.as_str().into(),
1996 stamp.into(),
1997 (concept.retired as i64).into(),
1998 ]
1999}
2000
2001impl HighPriCommand {
2002 /// The metrics label for this variant (T1.4).
2003 ///
2004 /// Exhaustive for the same reason `execute` is: a new variant that silently
2005 /// borrowed another's label would attribute its holds to the wrong command,
2006 /// and the one question the counters exist to answer is *which* command
2007 /// broke the budget.
2008 fn kind(&self) -> crate::metrics::CommandKind {
2009 use crate::metrics::CommandKind as K;
2010 match self {
2011 HighPriCommand::AssertEdge { .. } => K::AssertEdge,
2012 HighPriCommand::RetireEdge { .. } => K::RetireEdge,
2013 HighPriCommand::UpsertConcept { .. } => K::UpsertConcept,
2014 HighPriCommand::WriteBulkAtomic { .. } => K::WriteBulkAtomic,
2015 HighPriCommand::RebuildCurrent { .. } => K::RebuildCurrent,
2016 HighPriCommand::RegisterModel { .. } => K::RegisterModel,
2017 HighPriCommand::Shutdown { .. } => K::Shutdown,
2018 }
2019 }
2020
2021 /// Run one command and answer its caller.
2022 ///
2023 /// Deliberately exhaustive — there is no `_` arm. The 0.4.5–0.5.4 actor
2024 /// matched `Shutdown` and `AssertEdge` and sent everything else to
2025 /// `_ => LoopCtl::Continue`, which **dropped the responder**: the caller's
2026 /// `rx.await` resolved to a `RecvError` that no code mapped, so four of six
2027 /// commands were indistinguishable from a hung database. An exhaustive match
2028 /// makes that failure a compile error instead of a runtime silence, which is
2029 /// why adding a variant should break this function.
2030 async fn execute(
2031 self,
2032 conn: &libsql::Connection,
2033 clock: &dyn Clock,
2034 turn: &Turn<'_>,
2035 ) -> LoopCtl {
2036 match self {
2037 HighPriCommand::Shutdown { responder } => {
2038 turn.answer(responder, Ok(()));
2039 return LoopCtl::Break;
2040 }
2041 HighPriCommand::AssertEdge { edge, responder } => {
2042 let stamp = clock.now();
2043 if let Err(e) = reject_overlapping_interval(conn, &edge).await {
2044 turn.answer(responder, Err(e));
2045 return LoopCtl::Continue;
2046 }
2047 let res = match conn
2048 .execute(
2049 INSERT_LINK,
2050 libsql::params![
2051 edge.source.as_str(),
2052 edge.target.as_str(),
2053 edge.edge_type.as_str(),
2054 edge.valid_from.as_str(),
2055 edge.valid_to.as_str(),
2056 edge.weight,
2057 edge.properties.as_str(),
2058 stamp.as_str()
2059 ],
2060 )
2061 .await
2062 {
2063 Ok(_) => Ok(()),
2064 Err(e) => Err(classify(
2065 conn,
2066 e,
2067 WriteOp::Edge {
2068 source_id: &edge.source,
2069 target_id: &edge.target,
2070 edge_type: &edge.edge_type,
2071 },
2072 )
2073 .await),
2074 };
2075 turn.answer(responder, res);
2076 }
2077 HighPriCommand::RetireEdge {
2078 source,
2079 target,
2080 edge_type,
2081 valid_from,
2082 valid_to,
2083 responder,
2084 } => {
2085 let stamp = clock.now();
2086 let res = retire_edge(
2087 conn,
2088 &source,
2089 &target,
2090 &edge_type,
2091 &valid_from,
2092 &valid_to,
2093 &stamp,
2094 )
2095 .await;
2096 turn.answer(responder, res);
2097 }
2098 HighPriCommand::UpsertConcept { concept, responder } => {
2099 let stamp = clock.now();
2100 let res = upsert_concept(conn, &concept, &stamp).await;
2101 turn.answer(responder, res);
2102 }
2103 HighPriCommand::WriteBulkAtomic { edges, responder } => {
2104 // One stamp for the whole batch (D-014): the rows were asserted
2105 // by one act, and giving them different transaction times would
2106 // invent an ordering the caller never expressed.
2107 let stamp = clock.now();
2108 let res = write_edges_atomic(conn, &edges, &stamp).await;
2109 turn.answer(responder, res);
2110 }
2111 HighPriCommand::RebuildCurrent { responder } => {
2112 turn.answer(responder, rebuild_current(conn).await);
2113 }
2114 HighPriCommand::RegisterModel {
2115 model,
2116 dim,
2117 responder,
2118 } => {
2119 turn.answer(
2120 responder,
2121 crate::vector::register_model(conn, &model, dim).await,
2122 );
2123 }
2124 }
2125 LoopCtl::Continue
2126 }
2127}
2128
2129impl LowPriCommand {
2130 /// The metrics label for this variant (T1.4). See [`HighPriCommand::kind`].
2131 fn kind(&self) -> crate::metrics::CommandKind {
2132 use crate::metrics::CommandKind as K;
2133 match self {
2134 LowPriCommand::WriteConceptsChunk { .. } => K::WriteConceptsChunk,
2135 LowPriCommand::WriteAnalyticsChunk { .. } => K::WriteAnalyticsChunk,
2136 LowPriCommand::UpsertEmbeddingChunk { .. } => K::UpsertEmbeddingChunk,
2137 LowPriCommand::BulkImportChunk { .. } => K::BulkImportChunk,
2138 LowPriCommand::Archive { .. } => K::Archive,
2139 // No counter of its own: rehydration is the archive path run
2140 // backwards and shares its budget, and a `CommandKind` variant is a
2141 // public enum addition (D-036 periphery, but still a break).
2142 LowPriCommand::Rehydrate { .. } => K::Archive,
2143 LowPriCommand::RebuildFts { .. } => K::RebuildFts,
2144 LowPriCommand::ShadowRebuild { .. } => K::ShadowRebuild,
2145 }
2146 }
2147
2148 /// Run one background command and answer its caller.
2149 ///
2150 /// Also exhaustive. The pre-0.5.4 version was a single `LoopCtl::Continue`
2151 /// for *every* variant — every background write silently discarded, its
2152 /// caller waiting forever.
2153 async fn execute(
2154 self,
2155 conn: &libsql::Connection,
2156 clock: &dyn Clock,
2157 turn: &Turn<'_>,
2158 ) -> LoopCtl {
2159 match self {
2160 LowPriCommand::BulkImportChunk { chunk, responder } => {
2161 // A stamp per chunk, not per batch: the chunks commit
2162 // separately, so a shared stamp would claim a simultaneity the
2163 // storage does not have.
2164 let stamp = clock.now();
2165 turn.answer(responder, write_edges_atomic(conn, &chunk, &stamp).await);
2166 }
2167 LowPriCommand::WriteConceptsChunk { chunk, responder } => {
2168 let stamp = clock.now();
2169 turn.answer(responder, write_concepts_atomic(conn, &chunk, &stamp).await);
2170 }
2171 LowPriCommand::WriteAnalyticsChunk { chunk, responder } => {
2172 let stamp = clock.now();
2173 turn.answer(
2174 responder,
2175 write_annotations_atomic(conn, &chunk, &stamp).await,
2176 );
2177 }
2178 LowPriCommand::UpsertEmbeddingChunk {
2179 model,
2180 chunk,
2181 responder,
2182 } => {
2183 // No clock reading: an embedding carries no timestamp on either
2184 // axis. It is a derived artifact of a model applied to content
2185 // (Doctrine VII), and the ledger already records when the
2186 // content changed.
2187 turn.answer(
2188 responder,
2189 crate::vector::search::upsert_embedding_chunk(conn, &model, &chunk).await,
2190 );
2191 }
2192 LowPriCommand::Archive {
2193 cutoff,
2194 archive_path,
2195 responder,
2196 } => {
2197 // The archive *time*, not the cutoff. `archive_horizon` records
2198 // both and they are different facts — see `archive()` (Wave 4.5).
2199 let archived_at = clock.now();
2200 let res = archive(conn, &cutoff, &archived_at, &archive_path).await;
2201 // Before the answer, so a shadow rebuild that reads the epoch on
2202 // its next turn cannot miss an archive that has already deleted
2203 // rows out from under it (T1.2).
2204 if res.is_ok() {
2205 turn.archive_committed();
2206 }
2207 turn.answer(responder, res);
2208 }
2209 LowPriCommand::Rehydrate {
2210 ids,
2211 archive_path,
2212 responder,
2213 } => {
2214 let refs: Vec<&str> = ids.iter().map(String::as_str).collect();
2215 let res = rehydrate(conn, &refs, &archive_path).await;
2216 // Same reason as `Archive`: rehydration moves rows into `links`'
2217 // parent table, so a shadow rebuild in flight must see the epoch
2218 // move before the caller is answered (T1.2).
2219 if res.is_ok() {
2220 turn.archive_committed();
2221 }
2222 turn.answer(responder, res);
2223 }
2224 LowPriCommand::ShadowRebuild { step, responder } => {
2225 use crate::integrity::{shadow, ShadowOutcome, ShadowStep};
2226 let res = match step {
2227 ShadowStep::Begin => {
2228 shadow::begin(conn)
2229 .await
2230 .map(|build_start| ShadowOutcome::Started {
2231 build_start,
2232 epoch: turn.epoch(),
2233 })
2234 }
2235 ShadowStep::Fill { after } => shadow::fill_chunk(conn, after.as_deref())
2236 .await
2237 .map(|last| ShadowOutcome::Filled { last }),
2238 ShadowStep::Swap { build_start, epoch } => {
2239 shadow::swap(conn, &build_start, epoch, turn.epoch())
2240 .await
2241 .map(|rows| ShadowOutcome::Swapped { rows })
2242 }
2243 };
2244 turn.answer(responder, res);
2245 }
2246 LowPriCommand::RebuildFts { responder } => {
2247 let res = conn
2248 .execute(crate::schema::ddl::REBUILD_CONCEPTS_FTS, ())
2249 .await
2250 .map(|_| ())
2251 .map_err(Into::into);
2252 turn.answer(responder, res);
2253 }
2254 }
2255 LoopCtl::Continue
2256 }
2257}
2258
2259/// Close an open interval by asserting its successor (Doctrine III).
2260///
2261/// Never an `UPDATE`. The replacement row copies weight and properties from
2262/// current belief and differs only in `valid_to` and `recorded_at`, so the
2263/// original assertion survives intact and `reconstruct` at an earlier instant
2264/// still sees the interval open — which is the entire point of a bitemporal
2265/// ledger.
2266async fn retire_edge(
2267 conn: &libsql::Connection,
2268 source: &str,
2269 target: &str,
2270 edge_type: &str,
2271 valid_from: &str,
2272 valid_to: &str,
2273 stamp: &str,
2274) -> Result<()> {
2275 let affected = conn
2276 .execute(
2277 "INSERT INTO links \
2278 (source_id, target_id, edge_type, valid_from, valid_to, weight, properties, recorded_at) \
2279 SELECT source_id, target_id, edge_type, valid_from, ?5, weight, properties, ?6 \
2280 FROM links_current \
2281 WHERE source_id = ?1 AND target_id = ?2 AND edge_type = ?3 AND valid_from = ?4",
2282 libsql::params![source, target, edge_type, valid_from, valid_to, stamp],
2283 )
2284 .await
2285 .map_err(DbError::Engine)?;
2286
2287 if affected == 0 {
2288 return Err(DbError::NotFound(format!(
2289 "{source} -> {target} ({edge_type}) at {valid_from}"
2290 )));
2291 }
2292 Ok(())
2293}
2294
2295async fn upsert_concept(
2296 conn: &libsql::Connection,
2297 concept: &ConceptUpsert,
2298 stamp: &str,
2299) -> Result<()> {
2300 let res = conn
2301 .execute(UPSERT_CONCEPT, concept_params(concept, stamp))
2302 .await;
2303
2304 match res {
2305 Ok(_) => Ok(()),
2306 Err(e) => Err(classify(
2307 conn,
2308 e,
2309 WriteOp::Concept {
2310 id: &concept.id,
2311 recorded_at: stamp,
2312 },
2313 )
2314 .await),
2315 }
2316}
2317
2318/// Every recorded interval for one relationship key, for [`Interval::overlaps`]
2319/// to judge.
2320///
2321/// **Three equalities and nothing else, deliberately — and the "and nothing
2322/// else" was measured, not assumed.** The first version added
2323/// `AND valid_from < :new_valid_to`, a provably safe narrowing (overlap requires
2324/// `max(start) < min(end)`, so an interval starting at or after the new one's end
2325/// cannot overlap it). It cost **9.8 ms on a 90-edge chunk into a 2,000-edge
2326/// hub**, because it walked the planner straight into D-059's trap:
2327///
2328/// ```text
2329/// with the range: SEARCH links_current USING COVERING INDEX
2330/// idx_lc_traversal_cover (source_id=? AND valid_from<?)
2331/// without it: SEARCH links_current USING COVERING INDEX
2332/// idx_lc_open_interval (source_id=? AND target_id=? AND edge_type=?)
2333/// ```
2334///
2335/// `idx_lc_traversal_cover` leads on `(source_id, valid_from, …)` and contains
2336/// every column this query mentions, so with a `valid_from` range available it
2337/// wins as a covering index while binding **one** equality column — and the
2338/// guard scans the source's entire out-degree. That is the same shape as the
2339/// defect D-059 diagnosed in `trg_links_single_open`, reintroduced by an
2340/// optimisation, one wave after it was fixed.
2341///
2342/// Dropping the range makes the query a pure three-column point lookup that
2343/// `idx_lc_open_interval` serves exactly, and the rows it returns are the
2344/// intervals recorded for one `(source, target, edge_type)` — a version count,
2345/// not an out-degree. **A narrowing predicate is not free if it changes the
2346/// plan**, which is the general lesson and the reason this constant carries its
2347/// own `EXPLAIN` output.
2348const OVERLAP_CANDIDATES: &str = "SELECT valid_from, valid_to FROM links_current \
2349 WHERE source_id = ?1 AND target_id = ?2 AND edge_type = ?3 \
2350 AND valid_from <> ?4";
2351
2352/// Whether this pair is the storage layer's case rather than this guard's.
2353///
2354/// Two **open** intervals overlap — they share every instant from the later
2355/// start onwards — so a naive overlap check reports them, and reporting them
2356/// here would leave `DbError::SingleOpenViolation` constructible by nothing.
2357/// That variant is the more specific error, it is enforced by
2358/// `trg_links_single_open` rather than by this function, and its field names
2359/// were ratified in §1.2. Shadowing it with a general one would be defect Q's
2360/// shape reintroduced by a fix: a typed error that no code path can produce.
2361///
2362/// So the two guards partition the space rather than overlapping it. Both open
2363/// belongs to the trigger. Everything else — open against closed, closed against
2364/// closed — is unguarded at the storage layer and belongs here. That the split
2365/// is exactly the trigger's `WHEN` clause is not a coincidence; it is the
2366/// definition of what was missing.
2367fn defer_to_single_open(proposed: &Interval, existing: &Interval) -> bool {
2368 proposed.is_open() && existing.is_open()
2369}
2370
2371/// Refuse an assertion whose valid-time interval overlaps one already recorded
2372/// for the same `(source, target, edge_type)` — **defect AA, D-060**.
2373///
2374/// `trg_links_single_open` fires only `WHEN NEW.valid_to = '9999-…'`, so it
2375/// guards the open sentinel and nothing else. Two *closed* intervals that
2376/// overlap were accepted without complaint, and `query_as_of_edges` at an
2377/// instant inside both returned one relationship as two edges.
2378///
2379/// **This runs in the write actor, which is what makes it sound.** The obvious
2380/// place is `EdgeAssertion::normalized`, and it cannot go there — `normalized`
2381/// is a pure function with no connection, and doing the read at the API boundary
2382/// instead would leave a check-then-write race between the read and the actor's
2383/// insert. Inside the actor there is one writer by construction (D-014), and for
2384/// the batch paths this runs inside the same transaction as the insert, so the
2385/// window does not exist rather than being small.
2386///
2387/// **What it does not cover, and §4.2 now says so:** raw SQL against the same
2388/// file. The storage layer permits what this API refuses, which is the honest
2389/// cost of not putting the check in a trigger. The alternative was a second
2390/// index probe inside `trg_links_single_open` on every insert — on the path
2391/// D-059 has just finished making fast — for a guarantee that only holds against
2392/// callers who were going through the actor anyway.
2393///
2394/// `valid_from <> ?4` excludes the row being re-asserted. Re-assertion at the
2395/// same `valid_from` is Doctrine III's ordinary case — a new belief about the
2396/// same interval — and is settled by the primary key and the single-open
2397/// trigger, not here.
2398/// The single-assertion path prepares one statement for one check, which is what
2399/// `AssertEdge` needs; the batch path prepares once and calls
2400/// [`check_prepared`] per row.
2401async fn reject_overlapping_interval(
2402 conn: &libsql::Connection,
2403 edge: &EdgeAssertion,
2404) -> Result<()> {
2405 let stmt = conn.prepare(OVERLAP_CANDIDATES).await?;
2406 check_prepared(&stmt, edge).await
2407}
2408
2409/// The guard's body, against a statement the caller has already prepared.
2410///
2411/// **Split out because preparing per row was worth 10.4 ms on a 90-edge chunk**
2412/// (§8.8) — the same defect D-056 and D-057 diagnosed and fixed for
2413/// `INSERT_LINK`, reintroduced by the Wave 2 guard that was written beside it.
2414/// Measured with and without the guard, on a 2,000-edge hub: 8.65 ms → 19.25 ms,
2415/// and *identical* with and without `idx_lc_open_interval`, which is what
2416/// identified preparation rather than a scan as the cost. A guard that reads an
2417/// index correctly and prepares its statement 90 times is indistinguishable, at
2418/// the call site, from one that scans.
2419///
2420/// `reset()` between rows is not optional: libsql binds and steps without
2421/// resetting, so a reused statement must be returned to its initial state.
2422async fn check_prepared(stmt: &libsql::Statement, edge: &EdgeAssertion) -> Result<()> {
2423 let proposed = Interval::new(edge.valid_from.clone(), edge.valid_to.clone());
2424
2425 stmt.reset();
2426 let mut rows = stmt
2427 .query(libsql::params![
2428 edge.source.as_str(),
2429 edge.target.as_str(),
2430 edge.edge_type.as_str(),
2431 edge.valid_from.as_str()
2432 ])
2433 .await?;
2434
2435 while let Some(row) = rows.next().await? {
2436 let existing = Interval::new(row.get::<String>(0)?, row.get::<String>(1)?);
2437 if defer_to_single_open(&proposed, &existing) {
2438 continue;
2439 }
2440 if proposed.overlaps(&existing) {
2441 return Err(DbError::OverlappingInterval {
2442 overlap: Box::new(crate::error::Overlap {
2443 source_id: edge.source.clone(),
2444 target_id: edge.target.clone(),
2445 edge_type: edge.edge_type.clone(),
2446 valid_from: edge.valid_from.clone(),
2447 valid_to: edge.valid_to.clone(),
2448 existing_from: existing.valid_from,
2449 existing_to: existing.valid_to,
2450 }),
2451 });
2452 }
2453 }
2454
2455 Ok(())
2456}
2457
2458/// The same guard applied *within* a batch, before any of it is written.
2459///
2460/// The database check cannot see rows that are not in the database yet, so a
2461/// batch carrying two overlapping intervals for one relationship would pass
2462/// every per-row check and commit the overlap in one transaction. Quadratic in
2463/// the batch, which is affordable because the chunk is bounded at
2464/// [`chunk_rows::EDGES`] = 90 and because the comparison is a pair of string
2465/// compares — and because grouping first means the inner loop only ever runs
2466/// over edges sharing a key, which is normally one.
2467fn reject_overlaps_within(edges: &[EdgeAssertion]) -> Result<()> {
2468 for (i, a) in edges.iter().enumerate() {
2469 let ia = Interval::new(a.valid_from.clone(), a.valid_to.clone());
2470 for b in &edges[i + 1..] {
2471 if a.source != b.source || a.target != b.target || a.edge_type != b.edge_type {
2472 continue;
2473 }
2474 // Identical valid_from is re-assertion within one batch: the last
2475 // writer wins by seq_id, as it does across batches. Not an overlap.
2476 if a.valid_from == b.valid_from {
2477 continue;
2478 }
2479 let ib = Interval::new(b.valid_from.clone(), b.valid_to.clone());
2480 // Both open is the trigger's case; it fires during the insert and
2481 // rolls the batch back with the more specific error.
2482 if defer_to_single_open(&ia, &ib) {
2483 continue;
2484 }
2485 if ia.overlaps(&ib) {
2486 return Err(DbError::OverlappingInterval {
2487 overlap: Box::new(crate::error::Overlap {
2488 source_id: a.source.clone(),
2489 target_id: a.target.clone(),
2490 edge_type: a.edge_type.clone(),
2491 valid_from: a.valid_from.clone(),
2492 valid_to: a.valid_to.clone(),
2493 existing_from: ib.valid_from,
2494 existing_to: ib.valid_to,
2495 }),
2496 });
2497 }
2498 }
2499 }
2500 Ok(())
2501}
2502
2503/// Write every edge or none, under a single stamp.
2504///
2505/// **The statement is prepared once for the whole chunk (§9, D-056).** It used to
2506/// be `tx.execute(INSERT_LINK, …)` per row, which re-prepares on every call — and
2507/// `links` carries two triggers, so each preparation compiles their bodies along
2508/// with the insert.
2509///
2510/// Measured at 500 rows: **≈62 ms → ≈37 ms, a 41% saving.** Preparation was a
2511/// large cost and *not* the dominant one, which the first guess had it as. The
2512/// residual is the triggers themselves: the same 500 rows with
2513/// `trg_links_log_insert` and `trg_links_current_sync` dropped commit in **2.96
2514/// ms**, so trigger amplification is ~92% of what remains. There is no further
2515/// win available here without changing what the ledger records, and Doctrine IV
2516/// is what says it must be recorded. See D-056 for what that implies about §9's
2517/// ≤ 3 ms budget — briefly, 2.96 ms *is* the un-amplified figure, so the budget
2518/// appears to have been set without the amplification its own preamble says is
2519/// included.
2520///
2521/// `reset()` between rows is not optional: libsql's `execute` binds and steps
2522/// without resetting, so a reused statement must be returned to its initial state
2523/// or the second row steps a completed statement.
2524async fn write_edges_atomic(
2525 conn: &libsql::Connection,
2526 edges: &[EdgeAssertion],
2527 stamp: &str,
2528) -> Result<usize> {
2529 if edges.is_empty() {
2530 return Ok(0);
2531 }
2532
2533 // Before the transaction opens: a batch that contradicts itself is refused
2534 // without taking the write lock at all (D-060).
2535 reject_overlaps_within(edges)?;
2536
2537 let tx = conn
2538 .transaction_with_behavior(libsql::TransactionBehavior::Immediate)
2539 .await?;
2540
2541 // Inside the transaction, so the rows this checks against cannot change
2542 // between the check and the insert.
2543 // One preparation for the whole chunk, not one per row — see
2544 // `check_prepared`, and D-056 for the same lesson learned on `INSERT_LINK`.
2545 let guard = tx.prepare(OVERLAP_CANDIDATES).await?;
2546 for edge in edges {
2547 if let Err(e) = check_prepared(&guard, edge).await {
2548 // Released before the rollback: a live statement on the connection
2549 // is what makes SQLite refuse to end a transaction.
2550 drop(guard);
2551 let _ = tx.rollback().await;
2552 return Err(e);
2553 }
2554 }
2555 drop(guard);
2556
2557 let stmt = tx.prepare(INSERT_LINK).await?;
2558
2559 for edge in edges {
2560 stmt.reset();
2561 let res = stmt
2562 .execute(libsql::params![
2563 edge.source.as_str(),
2564 edge.target.as_str(),
2565 edge.edge_type.as_str(),
2566 edge.valid_from.as_str(),
2567 edge.valid_to.as_str(),
2568 edge.weight,
2569 edge.properties.as_str(),
2570 stamp
2571 ])
2572 .await;
2573
2574 if let Err(e) = res {
2575 let typed = classify(
2576 &tx,
2577 e,
2578 WriteOp::Edge {
2579 source_id: &edge.source,
2580 target_id: &edge.target,
2581 edge_type: &edge.edge_type,
2582 },
2583 )
2584 .await;
2585 // Released before the rollback: a live statement on the connection
2586 // is exactly what makes SQLite refuse to end a transaction.
2587 drop(stmt);
2588 let _ = tx.rollback().await;
2589 return Err(typed);
2590 }
2591 }
2592
2593 drop(stmt);
2594 tx.commit().await?;
2595 Ok(edges.len())
2596}
2597
2598/// Write every concept or none, under a single stamp.
2599/// Upsert one chunk of derived annotations in a single transaction (D-041).
2600///
2601/// `stamp` is the actor's clock reading, exactly as for every other chunk — but
2602/// it lands in `computed_at`, not in a `recorded_at`, and the difference is not
2603/// cosmetic. `recorded_at` is the transaction-time axis and is subject to
2604/// Doctrine II and the monotonicity guard; `computed_at` is a note about when a
2605/// derivation last ran, on a table the ledger does not see. Rerunning an
2606/// algorithm therefore replaces the row and advances the note, rather than
2607/// versioning a concept the world did not change.
2608async fn write_annotations_atomic(
2609 conn: &libsql::Connection,
2610 annotations: &[Annotation],
2611 stamp: &str,
2612) -> Result<usize> {
2613 if annotations.is_empty() {
2614 return Ok(0);
2615 }
2616
2617 let tx = conn
2618 .transaction_with_behavior(libsql::TransactionBehavior::Immediate)
2619 .await?;
2620
2621 let stmt = tx
2622 .prepare(
2623 "INSERT INTO analytics_annotations (concept_id, label, value, computed_at) \
2624 VALUES (?1, ?2, ?3, ?4) \
2625 ON CONFLICT(concept_id, label) DO UPDATE SET \
2626 value = excluded.value, computed_at = excluded.computed_at",
2627 )
2628 .await?;
2629
2630 for a in annotations {
2631 stmt.reset();
2632 let res = stmt
2633 .execute(libsql::params![
2634 a.concept_id.as_str(),
2635 a.label.as_str(),
2636 a.value.as_str(),
2637 stamp
2638 ])
2639 .await;
2640 if let Err(e) = res {
2641 drop(stmt);
2642 let _ = tx.rollback().await;
2643 return Err(DbError::Engine(e));
2644 }
2645 }
2646
2647 drop(stmt);
2648 tx.commit().await?;
2649 Ok(annotations.len())
2650}
2651
2652async fn write_concepts_atomic(
2653 conn: &libsql::Connection,
2654 concepts: &[ConceptUpsert],
2655 stamp: &str,
2656) -> Result<usize> {
2657 if concepts.is_empty() {
2658 return Ok(0);
2659 }
2660
2661 let tx = conn
2662 .transaction_with_behavior(libsql::TransactionBehavior::Immediate)
2663 .await?;
2664
2665 // Prepared once, like the edge chunk (D-056). This no longer routes through
2666 // [`upsert_concept`] — that function prepares per call by construction — but
2667 // it shares that function's statement text and parameter row, so the two
2668 // cannot upsert different columns.
2669 let stmt = tx.prepare(UPSERT_CONCEPT).await?;
2670
2671 for concept in concepts {
2672 stmt.reset();
2673 let res = stmt.execute(concept_params(concept, stamp)).await;
2674
2675 if let Err(e) = res {
2676 let typed = classify(
2677 &tx,
2678 e,
2679 WriteOp::Concept {
2680 id: &concept.id,
2681 recorded_at: stamp,
2682 },
2683 )
2684 .await;
2685 drop(stmt);
2686 let _ = tx.rollback().await;
2687 return Err(typed);
2688 }
2689 }
2690
2691 drop(stmt);
2692 tx.commit().await?;
2693 Ok(concepts.len())
2694}
2695
2696#[cfg(test)]
2697mod tests {
2698 use super::*;
2699
2700 fn edge(target: &str, micros: usize) -> EdgeAssertion {
2701 EdgeAssertion::new("src", target, "LINKS")
2702 .valid_from(format!("2026-01-01T00:00:00.{micros:06}Z"))
2703 .valid_to(format!("2026-01-01T00:00:00.{:06}Z", micros + 1))
2704 }
2705
2706 /// The estimate must depend on the batch's **shape**, not only its size.
2707 ///
2708 /// This is the correction T1.3's "rows × per-row cost" needed. Two batches
2709 /// of the same length whose measured holds differ by 7× must not be
2710 /// predicted identically, and the direction matters: a model that averages
2711 /// the two under-predicts the expensive shape, which is the only one anyone
2712 /// needs warning about.
2713 #[test]
2714 fn two_batches_of_one_size_are_not_predicted_alike() {
2715 const N: usize = 20_000;
2716 let fanout: Vec<_> = (0..N).map(|i| edge(&format!("t{i:07}"), i)).collect();
2717 let history: Vec<_> = (0..N).map(|i| edge("t0", i)).collect();
2718
2719 let (a, b) = (estimated_bulk_hold(&fanout), estimated_bulk_hold(&history));
2720 assert!(
2721 b > a * 5,
2722 "the guard's expensive path is 16x dearer per pair and this batch \
2723 takes it on every pair, but the estimates are {a:?} and {b:?}"
2724 );
2725 }
2726
2727 /// Measured on libSQL 0.9.30: 2.5 s and 18.6 s for those two batches. The
2728 /// estimator tracked both within 5%, and this pins that it still does — a
2729 /// coefficient edited without re-measuring fails here.
2730 #[test]
2731 fn the_estimate_matches_what_was_measured() {
2732 const N: usize = 20_000;
2733 let fanout: Vec<_> = (0..N).map(|i| edge(&format!("t{i:07}"), i)).collect();
2734 let history: Vec<_> = (0..N).map(|i| edge("t0", i)).collect();
2735
2736 for (batch, measured_ms, label) in
2737 [(fanout, 2_618u128, "fanout"), (history, 18_057, "history")]
2738 {
2739 let predicted = estimated_bulk_hold(&batch).as_millis();
2740 let ratio = predicted as f64 / measured_ms as f64;
2741 assert!(
2742 (0.8..1.25).contains(&ratio),
2743 "{label}: predicted {predicted} ms against a measured \
2744 {measured_ms} ms ({ratio:.2}x). Re-run \
2745 examples/bulk_atomic_diag.rs before changing the coefficients."
2746 );
2747 }
2748 }
2749
2750 /// An empty or single-edge batch has no pairs, and the arithmetic must not
2751 /// underflow computing it.
2752 #[test]
2753 fn a_batch_too_small_to_have_pairs_still_estimates() {
2754 assert_eq!(estimated_bulk_hold(&[]), std::time::Duration::ZERO);
2755 let one = [edge("t0", 0)];
2756 assert_eq!(
2757 estimated_bulk_hold(&one),
2758 std::time::Duration::from_nanos(73_000)
2759 );
2760 }
2761
2762 /// The warning threshold sits well above the bound this path is exempt from.
2763 ///
2764 /// Warning at `CHUNK_BUDGET` would fire on batches working exactly as
2765 /// designed — the exemption is a contract (D-014), not a failure — and a
2766 /// warning that fires on correct behaviour gets filtered out, taking the
2767 /// 18-second case with it.
2768 #[test]
2769 fn the_warning_threshold_is_not_the_chunk_budget() {
2770 assert!(BULK_ATOMIC_WARN_HOLD > CHUNK_BUDGET * 10);
2771 }
2772}