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