Skip to main content

macrame/
connection.rs

1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3use tokio::sync::{mpsc, oneshot};
4
5use crate::error::{classify, BulkInterrupted, BulkResult, 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::FutureStampPolicy;
13use crate::util::clock::{Clock, SystemClock};
14use crate::util::timestamp;
15use crate::vector::ModelName;
16
17/// Rows per chunk on the background write paths (§5.1.5, D-011, D-014, D-058).
18///
19/// The Write Actor holds the sole write connection, so a single large statement
20/// blocks every other writer for its duration. Chunking bounds that stall; the
21/// cost is that a bulk import is *not* atomic across chunks, which is why
22/// all-or-nothing is [`Database::write_bulk_atomic`] — a separate entry point,
23/// with its own command on the actor's protocol — rather than a tuning
24/// parameter here.
25///
26/// # Why these are four constants and not one
27///
28/// Through 0.5.5 this was a single `CHUNK_ROWS = 1000` for all four bulk paths.
29/// The golden rule it was meant to serve is a bound on *duration* — a background
30/// chunk must commit fast enough that an interactive write queued behind it is
31/// not made to wait — and one row count cannot express one duration across paths
32/// whose measured per-row costs differ by 60× (D-058). At 1,000 rows the four
33/// paths took 3.5 ms, 24 ms, 89 ms and 143 ms: the same constant, four answers,
34/// three of them far outside the bound.
35///
36/// Each size below is derived from `benches/budgets.rs`'s `chunk_scaling`
37/// sweep against [`CHUNK_BUDGET`], then verified by measuring that size directly.
38/// They are *measurements of this machine*, not universal constants — D-055's
39/// reasoning about reference hardware applies here too, and re-deriving them on
40/// materially different storage is a `cargo bench` away.
41///
42/// # Sized for the tail, not the median
43///
44/// The first derivation solved `f + c·n = 3 ms` exactly and produced sizes whose
45/// *median* commit was 2.93 ms and whose upper estimate was 2.96 — inside the
46/// bound as reported and outside it for any chunk slower than typical. A latency
47/// bound is a statement about the chunk an unlucky interactive write actually
48/// queues behind, so these solve for ≈2.5 ms instead, leaving the remainder as
49/// headroom for the tail. That costs a few percent of throughput on the two
50/// linear paths and nothing on the two superlinear ones.
51///
52/// As measured by `chunk_budget`, each at its own size: edges **2.39 ms**,
53/// concepts **2.35 ms**, annotations **2.36 ms**, embeddings **2.06 ms**, no
54/// upper estimate above 2.42.
55///
56/// # Known limitation: these are empty-database figures
57///
58/// `chunk_budget` seeds concepts and starts with **no links and no vectors**,
59/// and D-059 established that per-row cost on the edge and embedding paths grows
60/// with the size of the structure being written, not with the chunk. The same
61/// 90-edge chunk takes **9.06 ms** into an 8,000-edge table. So the bound is met
62/// as measured here and *not* met on a populated database.
63///
64/// That gap was published as 47.7 ms until 0.10.0 and attributed to the schema
65/// defect D-059 documents. The defect was fixed by the `v5 → v6` rung and the
66/// figure was never updated. 9.08 ms is a 0.10.0 measurement, not D-059's 8.0 ms
67/// carried forward: `chunk_budget` gained a seeded arm, because until it did,
68/// nothing in the bench suite wrote a chunk into a populated table and this
69/// number was unfalsifiable. It agrees with D-059 once the session is accounted
70/// for — the empty arm read 2.69 and 2.65 ms beside it against the 2.39 ms
71/// published above, so the *ratio* is 3.4× here and 3.35× there.
72///
73/// **The residual is attributed as of 0.11.0 (D-142).** It is not the missing
74/// index, which shipped in 0.5.6; it is the `links_current` write. Dropping the
75/// three `links` insert triggers one at a time puts effectively all of the
76/// growth in `trg_links_current_sync` — the single-open guard contributes none,
77/// the log trigger and the base insert ~0.35 ms of a 4.15 ms rise — and within
78/// that trigger, 89% of the growth is maintenance of `idx_lc_traversal_cover`
79/// and `idx_lc_open_interval` rather than the upsert itself, which costs 0.49 ms
80/// run directly against the same table. Page-cache size, foreign keys and the
81/// fixture's key distribution were each tested and are each not the cause.
82///
83/// Knowing the cause does not by itself change the constant: the expensive index
84/// is D-042's covering index for the traversal, so narrowing it moves cost onto
85/// the read path it exists to protect. Re-deriving these constants against the
86/// D-088 fixture matrix is the named successor.
87///
88/// # These are ceilings as of 0.12.0, not sizes
89///
90/// D-143 re-derived all four against the D-088 matrix and the edge path came
91/// back **20** against a shipped 90 — and 20 would have been wrong at 80,000
92/// edges for the same reason 90 is wrong at 8,000, because per-row cost there
93/// grows with `links_current`. The finding was that no row count can bound a
94/// duration on such a path.
95///
96/// So the chunk loop stopped trying to pick one ahead of time. Each chunk is
97/// timed by the actor and its measured hold chooses the next size; these
98/// constants are the **largest** size that will ever be asked for, and every
99/// derivation below still applies to them as such. A path may run well under its
100/// constant on a populated database and at exactly it on an empty one, and both
101/// are the bound being met rather than a size being missed.
102pub mod chunk_rows {
103    /// Edge assertions (`bulk_import`).
104    ///
105    /// Per-row cost on this path rises with the size of `links_current`, not
106    /// with the chunk (D-059) — so cutting the chunk buys latency and costs
107    /// throughput, ~11% for 1,000 edges. An earlier version of this comment
108    /// claimed it was 3.3× *faster*; that came from multiplying eleven copies of
109    /// a chunk measured into an empty database.
110    ///
111    /// **This size does not meet the 3 ms bound on a populated database.** 90
112    /// edges into an 8,000-edge table take **9.06 ms** — measured, two sessions
113    /// at 9.08 and 9.05, against an empty-table arm of 2.69 and 2.65 beside
114    /// them (D-136).
115    ///
116    /// The reason given here until 0.10.0 — that `trg_links_single_open`'s
117    /// `EXISTS` scans the whole out-degree, "a schema defect with a proven fix,
118    /// recorded in D-059 and not applied here" — described 0.5.5. The fix *was*
119    /// applied, as the `v5 → v6` rung, and took this from 47.7 ms to ~8 ms.
120    /// What survives is the miss: the bound is still exceeded ~3×. Its cause is
121    /// no longer unknown — D-142 attributes it to `trg_links_current_sync`, and
122    /// within that to secondary-index maintenance on `links_current` — and the
123    /// guard this comment used to blame contributes **no** growth at all.
124    ///
125    /// **The constant is unchanged, and that is now a measured decision**
126    /// (D-143). Re-derived against all four D-088 shapes at 8,000 edges, they
127    /// agree that the largest size meeting the bound is **20**. It stays at 90
128    /// because 20 is the same miss at a larger population — per-row cost grows
129    /// with `links_current`, so a constant fitted at 8,000 edges is wrong at
130    /// 80,000 — while the throughput cost of turning eleven chunks into fifty
131    /// is certain and immediate (D-058). The fix is not a row count: it is for
132    /// the chunk loop to stop on elapsed time, **delivered in 0.12.0**. This
133    /// number is now the ceiling that loop starts from and never exceeds; on a
134    /// populated table it converges below it within a chunk or two.
135    ///
136    /// D-134 retired the growth claim on the neighbouring *single-assertion*
137    /// path and did not measure this one; D-136 is why this line now carries a
138    /// measurement rather than a figure quoted from 0.5.6.
139    pub const EDGES: usize = 90;
140
141    /// Concept upserts (`write_concepts`).
142    ///
143    /// Linear at ~23 µs per row, so unlike [`EDGES`] this size *is* a genuine
144    /// throughput sacrifice: 1,000-row chunks ran at 23.6 µs per row against
145    /// ~35 µs here. Paid deliberately — a 1,000-row chunk takes 24 ms, eight
146    /// times the bound.
147    pub const CONCEPTS: usize = 70;
148
149    /// Analytics annotations (`write_analytics_annotations`).
150    ///
151    /// The one path where the old constant was nearly right, and the only bulk
152    /// table with no triggers at all: ~2.5 µs per row, linear, so the bound buys
153    /// a large chunk. 1,000 rows would be 3.5 ms — over, but only just.
154    pub const ANNOTATIONS: usize = 600;
155
156    /// Embedding vectors (`upsert_embeddings`).
157    ///
158    /// The smallest by a wide margin, because DiskANN index maintenance makes an
159    /// embedding the most expensive row in the system. That cost grows with the
160    /// **corpus**, not the chunk (D-059): a fixed 30-vector chunk costs 49 µs per
161    /// vector into an empty corpus and 224 µs into an 8,000-vector one. Graph
162    /// insertion getting dearer as the graph grows is what DiskANN is, so unlike
163    /// [`EDGES`] there is nothing here to fix — but it does mean this size buys
164    /// latency at some throughput, not for free.
165    pub const EMBEDDINGS: usize = 30;
166}
167
168/// What one chunk transaction cost, reported by the actor to the caller-side
169/// chunk loop (0.12.0, W1).
170///
171/// `held` is measured **inside** the actor, around its own transaction, and
172/// therefore excludes the time the command spent queued. That exclusion is the
173/// point: queue time is what strict preemption *does*, and a controller fed
174/// `send + await` would shrink chunks as punishment for the actor correctly
175/// serving an interactive write first.
176///
177/// Crate-internal, along with the command enums that carry it. It was `pub`
178/// through 0.13.32 only because they were (D-206).
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub(crate) struct ChunkOutcome {
181    /// Rows the transaction actually wrote.
182    pub rows: usize,
183    /// How long the actor held the write lock for them.
184    pub held: std::time::Duration,
185}
186
187/// A flag a caller can raise to stop a chunked bulk write (0.13.8, W7.6, D-181).
188///
189/// Cheap to clone and safe to set from any thread, which is the whole point: the
190/// task running the import is the one thing that cannot cancel it. Hand a clone
191/// to whatever *can* — a signal handler, a UI thread, a timeout task — and it
192/// takes effect at the next chunk boundary.
193///
194/// **A boundary, not an abort.** Nothing rolls back and no in-flight
195/// transaction is interrupted: the loop notices between chunks and stops
196/// sending. The chunks that committed stay committed, and
197/// [`BulkInterrupted::written`](crate::BulkInterrupted::written) says how many
198/// rows those were. That is the same per-chunk boundary
199/// [`Database::bulk_import`] already documents, so cancellation adds a reason to
200/// stop and no new failure mode.
201///
202/// Setting it after the last chunk has committed does nothing — a finished
203/// write reports success, because it succeeded.
204#[derive(Clone, Debug, Default)]
205pub struct CancelToken(Arc<std::sync::atomic::AtomicBool>);
206
207impl CancelToken {
208    /// A token that has not been cancelled.
209    pub fn new() -> Self {
210        Self::default()
211    }
212
213    /// Ask the bulk write holding a clone of this token to stop at its next
214    /// chunk boundary. Idempotent; a token never un-cancels.
215    pub fn cancel(&self) {
216        // `Relaxed` on both sides is sufficient and deliberate: nothing is
217        // published *through* this flag. The rows are ordered by the database
218        // and the chunk results by the response channel, so the only thing the
219        // reader needs is to observe the store eventually, which every ordering
220        // guarantees.
221        self.0.store(true, std::sync::atomic::Ordering::Relaxed);
222    }
223
224    /// Whether [`Self::cancel`] has been called on this token or any clone.
225    pub fn is_cancelled(&self) -> bool {
226        self.0.load(std::sync::atomic::Ordering::Relaxed)
227    }
228}
229
230/// One chunk's worth of progress, handed to the callback on
231/// [`BulkControl::on_progress`] (0.13.8, W7.6).
232///
233/// Reported *after* the chunk has committed, so `written` is a count of rows
234/// that are in the database and will stay there even if the next chunk fails.
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236pub struct BulkProgress {
237    /// Rows committed so far, across every chunk including this one.
238    pub written: usize,
239    /// Rows in the batch the caller passed. `written` reaching this means the
240    /// last chunk has committed.
241    pub total: usize,
242    /// Rows this chunk wrote. Not a constant: the loop resizes chunks against
243    /// [`CHUNK_BUDGET`] as it measures them (D-058).
244    pub rows: usize,
245    /// How long the actor held the write lock for this chunk — the same figure
246    /// the controller steers on. Measured inside the actor, around its own
247    /// transaction, so it excludes the time the command spent queued.
248    pub held: std::time::Duration,
249}
250
251/// Cancellation and progress for the four chunked bulk paths (0.13.8, W7.6,
252/// D-181).
253///
254/// Default is "neither", which is what [`Database::bulk_import`] and its three
255/// siblings pass. The `_with` variants take one of these:
256///
257/// ```no_run
258/// # use macrame::{BulkControl, CancelToken, Database};
259/// # async fn f(db: &Database, edges: Vec<macrame::prelude::EdgeAssertion>) {
260/// let token = CancelToken::new();
261/// let stopper = token.clone();
262/// tokio::spawn(async move {
263///     tokio::time::sleep(std::time::Duration::from_secs(30)).await;
264///     stopper.cancel();
265/// });
266///
267/// let control = BulkControl::new()
268///     .cancel_with(token)
269///     .on_progress(|p| println!("{}/{} rows", p.written, p.total));
270///
271/// match db.bulk_import_with(edges, control).await {
272///     Ok(n) => println!("imported {n}"),
273///     Err(e) => println!("stopped after {}: {}", e.written, e.cause),
274/// }
275/// # }
276/// ```
277///
278/// **The callback runs on the importing task, between chunks.** It is therefore
279/// on the critical path: whatever it does is time the next chunk is not being
280/// sent in. Printing or updating a counter is what it is for; a blocking write
281/// is not, and neither is anything that calls back into the same `Database`,
282/// which would deadlock the loop against a channel it is itself draining.
283#[derive(Default, Clone)]
284pub struct BulkControl {
285    cancel: Option<CancelToken>,
286    on_progress: Option<Arc<dyn Fn(BulkProgress) + Send + Sync>>,
287}
288
289impl std::fmt::Debug for BulkControl {
290    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
291        f.debug_struct("BulkControl")
292            .field("cancel", &self.cancel)
293            .field("on_progress", &self.on_progress.is_some())
294            .finish()
295    }
296}
297
298impl BulkControl {
299    /// Neither cancellation nor progress — what the plain bulk methods pass.
300    pub fn new() -> Self {
301        Self::default()
302    }
303
304    /// Stop at the next chunk boundary when `token` is cancelled.
305    pub fn cancel_with(mut self, token: CancelToken) -> Self {
306        self.cancel = Some(token);
307        self
308    }
309
310    /// Call `f` after every chunk commits. See the note on [`BulkControl`]
311    /// about what this closure is allowed to do.
312    pub fn on_progress(mut self, f: impl Fn(BulkProgress) + Send + Sync + 'static) -> Self {
313        self.on_progress = Some(Arc::new(f));
314        self
315    }
316
317    fn is_cancelled(&self) -> bool {
318        self.cancel.as_ref().is_some_and(CancelToken::is_cancelled)
319    }
320
321    fn report(&self, progress: BulkProgress) {
322        if let Some(f) = &self.on_progress {
323            f(progress);
324        }
325    }
326}
327
328/// Smallest chunk the adaptive loop will fall to (0.12.0, W2).
329///
330/// # A floor is a deliberate, measured violation of [`CHUNK_BUDGET`]
331///
332/// Feedback alone converges to whatever size meets the budget, and on a
333/// populated `links` table that size keeps falling — per-row cost there grows
334/// with the table (D-059, D-142), so there is no size at which the *fixed* cost
335/// of a transaction stops dominating. Left unbounded the loop reaches chunks of
336/// one or two rows, where nearly all the work is `BEGIN`/`COMMIT` and the import
337/// no longer finishes.
338///
339/// 35 is measured, and **re-measured against the loop that uses it** — the
340/// difference matters, because the figure this constant shipped with was an
341/// extrapolation. `examples/chunk_matrix.rs -- converge` runs a 900-edge
342/// `bulk_import` into each of the four D-088 shapes at 8,000 edges and reports
343/// the actor's own per-transaction readings. A 35-row chunk costs **3.11–3.43 ms**
344/// across the four shapes, two sessions, excluding the run-up. The floor misses
345/// the 3 ms bound by 0.1–0.4 ms, not by the ~1.1 ms predicted from the sweep.
346///
347/// The miss is **steady state** — not a one-chunk transient on the way down —
348/// and the defense is the argument [`CHUNK_BUDGET`] is answerable to rather than
349/// the number itself: an interactive assertion arriving at the worst moment
350/// waits ~3.2 ms for the chunk in flight and then runs its own ≤ 5 ms write, so
351/// ~8.2 ms against a 16.7 ms frame.
352///
353/// What the same measurement says about the *size*: on this path at this
354/// population the loop goes `[90, 35, 35, …]` on all four shapes and never picks
355/// anything between. The proportional shrink from a 90-row chunk proposes ~31
356/// rows, which clamps here — so on the edge path the floor is not a safety net
357/// under the controller, it **is** the operating point, and this number is
358/// carrying more weight than a backstop normally would. Re-measure it, not the
359/// controller, when the edge path's per-row cost changes.
360const CHUNK_FLOOR: usize = 35;
361
362/// Size of the next chunk, from what the last one cost (0.12.0, W2).
363///
364/// Pure on purpose — no clock, no database, no actor — so the control law can be
365/// tested for the properties that matter without a fixture. Three regimes:
366///
367/// | last hold | response | why |
368/// |---|---|---|
369/// | 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 |
370/// | 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 |
371/// | otherwise | hold | in band, and moving costs more than it buys |
372///
373/// The asymmetry is the whole design. Proportional shrinking converges from
374/// above in one or two steps, which matters because every step over budget is a
375/// latency miss a caller can feel; additive growth cannot overshoot by more than
376/// 25%, which matters because the ceiling is a throughput preference and not a
377/// bound.
378///
379/// `ceiling` is the path's [`chunk_rows`] constant, which is why those constants
380/// keep their values and their derivations: they are no longer the size, they
381/// are the largest size this will ever ask for. `floor` is [`CHUNK_FLOOR`] —
382/// see there for the budget it knowingly misses.
383///
384/// Never returns 0, at any input, including `held == 0` or `current == 0`.
385fn next_chunk_size(
386    current: usize,
387    held: std::time::Duration,
388    budget: std::time::Duration,
389    floor: usize,
390    ceiling: usize,
391) -> usize {
392    let held = held.as_nanos().max(1);
393    let budget_ns = budget.as_nanos().max(1);
394    let current = current.max(1);
395
396    let next = if held > budget_ns {
397        // Integer math, and the `max(1)` matters: a chunk 200× over budget
398        // would otherwise propose 0 and the loop would stop making progress.
399        let scaled = (current as u128) * budget_ns * 9 / (held * 10);
400        (scaled as usize).max(1)
401    } else if held * 2 < budget_ns {
402        // Saturating because `current` is a `usize` and this is the one branch
403        // that adds to it. Nothing sane reaches the boundary; the clamp below
404        // makes the answer correct anyway rather than a debug panic.
405        current.saturating_add((current / 4).max(1))
406    } else {
407        current
408    };
409
410    // Applied last and unconditionally, so a caller that passes a reversed pair
411    // gets the floor rather than a panic — and `max(1)` last of all, because a
412    // chunk of zero rows is the single answer no loop can make progress from.
413    next.clamp(floor.min(ceiling), ceiling).max(1)
414}
415
416/// The latency bound [`chunk_rows`] is derived from (§5.1.5, D-058).
417///
418/// This is the golden rule's actual content. §9 has carried it as a row count
419/// with a duration attached — "chunk commit, 500 rows ≤ 3 ms" — which reads as
420/// two requirements and is one: the duration is the requirement, and the row
421/// count is whatever satisfies it on a given path and machine.
422///
423/// 3 ms is §9's number, kept rather than renegotiated. What it buys, end to end:
424/// an interactive assertion arriving at the worst possible moment waits for the
425/// chunk in flight (≤ 3 ms — the SQLite write lock is not preemptible, so
426/// priority buys the *next* turn and not this one) and then runs its own write
427/// (≤ 5 ms, §9), so ≤ 8 ms
428/// worst case. That fits inside a 60 Hz frame with room, which is the standard
429/// this bound is ultimately answerable to.
430///
431/// # Some operations are exempt, and the exemption is a contract, not an oversight
432///
433/// This was recorded in three separate rustdoc notes and nowhere near the bound
434/// itself, which is where a reader looks for its scope (§8.6). Stated here, with
435/// Wave 3's measurements:
436///
437/// | Path | Bound | Why it cannot be chunked |
438/// |---|---|---|
439/// | [`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 |
440/// | [`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 |
441/// | `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 |
442/// | [`Database::rehydrate`] | unmeasured; a function of how many rows the caller named | D-012 backwards: the same copy-then-delete atomicity, in the other direction. **A row here since 0.12.9 only because it was previously invisible** — rehydration reported as `archive` and inherited its exemption without anyone deciding on it (W4.3, D-152) |
443/// | [`Database::checkpoint`] | a function of the WAL's size, which is a function of how long since the last checkpoint — not of anything the caller passes | It is not a transaction at all. `PRAGMA wal_checkpoint` copies frames back into the main file and there is no unit smaller than the frame it is already working in; the caller asked for exactly this, and the alternative to a long checkpoint is a WAL that keeps growing (0.12.13, W5.2, D-156) |
444///
445/// The `archive` figure is end-to-end through this method, so it **includes**
446/// the re-derivation `archive()` runs inside its transaction — but it does not
447/// attribute it, and until D-077 more than half of that re-derivation was an
448/// audit comparing `links_current` against the query that had just filled it.
449/// Note also which variable that cost scales with: `rebuild_within` reprojects
450/// **all of `links`**, so the archive's repair term grows with the *surviving*
451/// table and not with the batch being archived. A budget stated per "100K closed
452/// intervals" ([§9](../docs/architecture/s6-s10-flows-to-dependencies.md)) is
453/// therefore parameterised on the wrong quantity.
454///
455/// The first four are atomic **by contract**, which is why "cap the batch" and
456/// "add a third tier" were both considered and neither was taken: capping breaks the
457/// guarantee the operation exists to provide, and a third tier changes which
458/// caller waits without changing how long the lock is held. What was wrong was
459/// never the exemption — it was that the bound was stated as though it had none.
460///
461/// A caller who needs the latency bound and not the atomicity has
462/// [`Database::bulk_import`], which is the same write chunked at
463/// [`chunk_rows::EDGES`] and explicitly *not* atomic overall (D-011).
464///
465/// # One of them is no longer unbounded (T1.1, D-080)
466///
467/// `archive` was the worst of them, because its hold is a function of *how long
468/// since the last archive* rather than of anything the caller chose.
469/// [`Database::archive_windowed`] runs the same work as N sessions, each
470/// atomic, each its own actor turn. Measured on an 8,000-key fixture with four
471/// generations of superseded history: the longest single hold falls from
472/// **3.3 s to 0.77 s** at one-hour windows, for total wall time that is flat
473/// within this cycle's noise.
474///
475/// The same measurement at 2,000 keys goes the other way — the hold falls
476/// 260 ms → 117 ms while total time rises 260 ms → 671 ms — so windowing is a
477/// trade and not a free improvement. It pays when the backlog is large, which
478/// is when the unwindowed hold is a problem in the first place. `archive` is
479/// kept, not deprecated, for exactly that reason.
480pub const CHUNK_BUDGET: std::time::Duration = std::time::Duration::from_millis(3);
481
482/// Predicted hold above which [`Database::write_bulk_atomic`] warns (T1.3).
483///
484/// 250 ms is fifteen frames at 60 Hz: not a hitch, a visible freeze. It is well
485/// above [`CHUNK_BUDGET`] on purpose — this path is exempt from that bound by
486/// contract, so warning at 3 ms would fire on batches that are working exactly
487/// as designed and train the reader to filter the message out.
488pub const BULK_ATOMIC_WARN_HOLD: std::time::Duration = std::time::Duration::from_millis(250);
489
490/// Roughly how long [`Database::write_bulk_atomic`] will hold the actor for
491/// this batch (T1.3, D-081; re-fitted 0.13.6, W7.5, D-179).
492///
493/// # Two terms, and the batch's shape is no longer one of them
494///
495/// T1.3 asks for "rows × measured per-row cost". Through 0.13.5 that was wrong
496/// in a way worth a paragraph: `write_edges_atomic` opened with a
497/// `reject_overlaps_within` that compared **every pair**, and the quadratic
498/// term's constant depended on the batch's *shape* rather than its size, so two
499/// 20,000-edge batches held the actor for **2.6 s** and **18.1 s** — a size-only
500/// model was off by 7× between them, in the under-predicting direction.
501///
502/// W7.5 sorts and sweeps instead, and the 18.1 s batch now holds for **2.2 s**.
503/// The shape term is gone from the code and therefore from here: measured on
504/// the same machine, the two shapes are within 15% of each other at every size
505/// from 100 to 20,000 rows, which is inside the noise this model claims.
506///
507/// What is left is not flat either, and the second term is why. Per-row cost
508/// rises from ~36 µs at 100 rows to ~111 µs at 20,000, because each insert
509/// maintains indexes and two triggers against a table the batch is itself
510/// growing:
511///
512/// ```text
513/// hold ≈ rows · (7.4 µs + 7.24 µs · ⌊log₂ rows⌋)
514/// ```
515///
516/// # What this is calibrated against, and where it will be wrong
517///
518/// libSQL 0.9.30, one machine, best of three, 100–20,000 rows in both shapes;
519/// within 15% from 500 rows up. Below that it under-predicts by up to 3×, which
520/// is harmless in the same way the old 3× over-prediction was — nothing that
521/// small approaches [`BULK_ATOMIC_WARN_HOLD`].
522///
523/// **The log term reads the batch because the batch is all it has.** It stands
524/// for the depth of a structure the batch is loading, and this signature never
525/// sees the table. That is exact for the bulk import this warns about, and
526/// optimistic for a small batch appended to an already-large table — the same
527/// blind spot the flat per-row model had, now visible instead of averaged away.
528///
529/// It is machine-specific and says nothing about disk. It exists to turn
530/// "uncapped" into an order of magnitude a caller can act on, and should not be
531/// read more precisely than that. `examples/bulk_atomic_diag.rs` prints
532/// predicted against measured, so the model's drift is visible rather than
533/// assumed.
534pub fn estimated_bulk_hold(edges: &[EdgeAssertion]) -> std::time::Duration {
535    let rows = edges.len() as u64;
536    if rows == 0 {
537        return std::time::Duration::ZERO;
538    }
539
540    // Nanoseconds throughout, saturating: a caller who passes a batch large
541    // enough to overflow this has a problem the arithmetic cannot express, and
542    // saturating to ~584 years still crosses every threshold above.
543    let per_row = 7_400u64.saturating_add((rows.ilog2() as u64).saturating_mul(7_240));
544    std::time::Duration::from_nanos(rows.saturating_mul(per_row))
545}
546
547/// Most sessions [`Database::archive_windowed`] will run for one call (T1.1).
548///
549/// A limit exists because the session count is a function of *transaction-time
550/// span divided by window*, and both come from the caller — a one-second window
551/// over a decade of history is ten million actor turns, each opening a
552/// transaction and writing a horizon row. That is not a slow archive, it is a
553/// caller who meant something else.
554///
555/// 4,096 is chosen against the operation it bounds rather than against a clock:
556/// at the measured 26.8 ms for a session with work in it, a full run of this
557/// many is about two minutes of background writing, and the whole point of
558/// windowing is that those two minutes are interruptible. It is a refusal
559/// rather than a clamp — see [`DbError::ArchiveWindow`] for why.
560pub const MAX_ARCHIVE_SESSIONS: usize = 4_096;
561
562/// A concept assertion: the payload of an upsert.
563#[derive(Debug, Clone, PartialEq)]
564pub struct ConceptUpsert {
565    pub id: String,
566    pub title: String,
567    pub content: String,
568    pub embedding_model: Option<String>,
569    pub valid_from: String,
570    pub valid_to: String,
571    pub retired: bool,
572}
573
574impl ConceptUpsert {
575    pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
576        Self {
577            id: id.into(),
578            title: title.into(),
579            content: String::new(),
580            embedding_model: None,
581            valid_from: String::new(),
582            valid_to: timestamp::OPEN_SENTINEL.to_string(),
583            retired: false,
584        }
585    }
586
587    pub fn content(mut self, content: impl Into<String>) -> Self {
588        self.content = content.into();
589        self
590    }
591
592    pub fn embedding_model(mut self, model: impl Into<String>) -> Self {
593        self.embedding_model = Some(model.into());
594        self
595    }
596
597    pub fn valid_from(mut self, ts: impl Into<String>) -> Self {
598        self.valid_from = ts.into();
599        self
600    }
601
602    pub fn valid_to(mut self, ts: impl Into<String>) -> Self {
603        self.valid_to = ts.into();
604        self
605    }
606
607    pub fn retired(mut self, retired: bool) -> Self {
608        self.retired = retired;
609        self
610    }
611
612    /// Put the timestamps in canonical form (D-029) before they cross the channel.
613    pub fn normalized(mut self) -> Result<Self> {
614        crate::util::ids::validate_id(&self.id)?;
615        self.valid_from = timestamp::normalize(&self.valid_from)?;
616        self.valid_to = timestamp::normalize(&self.valid_to)?;
617        Ok(self)
618    }
619}
620
621/// One derived analytics result for one concept (§5.4, D-041).
622///
623/// Not a `ConceptUpsert`. The distinction is the whole of D-041: a concept
624/// upsert is a statement about the world and belongs in the ledger, while an
625/// annotation is a function of an algorithm applied to a graph and belongs in
626/// `analytics_annotations`, which carries no log trigger. Writing one as the
627/// other overwrote the concept's `content` with the label and recorded every
628/// analytics rerun as a fresh version of the world.
629#[derive(Debug, Clone, PartialEq, Eq)]
630pub struct Annotation {
631    pub concept_id: String,
632    /// Namespaced by convention, e.g. `louvain.community`, `kcore.shell`.
633    pub label: String,
634    /// JSON-encoded payload. Opaque to this crate.
635    pub value: String,
636}
637
638impl Annotation {
639    pub fn new(
640        concept_id: impl Into<String>,
641        label: impl Into<String>,
642        value: impl Into<String>,
643    ) -> Self {
644        Self {
645            concept_id: concept_id.into(),
646            label: label.into(),
647            value: value.into(),
648        }
649    }
650}
651
652/// Commands sent to the Write Actor on the high-priority channel (UI-driven work).
653pub(crate) enum HighPriCommand {
654    AssertEdge {
655        edge: EdgeAssertion,
656        responder: oneshot::Sender<Result<()>>,
657    },
658    RetireEdge {
659        source: String,
660        target: String,
661        edge_type: String,
662        valid_from: String,
663        valid_to: String,
664        responder: oneshot::Sender<Result<()>>,
665    },
666    UpsertConcept {
667        concept: ConceptUpsert,
668        responder: oneshot::Sender<Result<()>>,
669    },
670    WriteBulkAtomic {
671        edges: Vec<EdgeAssertion>,
672        responder: oneshot::Sender<Result<usize>>,
673    },
674    RebuildCurrent {
675        responder: oneshot::Sender<Result<RebuildReport>>,
676    },
677    /// Create a model's embedding table and its DiskANN index (D-037, D-048).
678    ///
679    /// High priority despite being setup work: it is one small transaction, and
680    /// every embedding write for the model blocks on it, so queueing it behind a
681    /// bulk job would stall the thing it gates.
682    RegisterModel {
683        model: ModelName,
684        dim: usize,
685        responder: oneshot::Sender<Result<()>>,
686    },
687    /// Move WAL frames back into the main database file (§4.5, F-30, D-156).
688    ///
689    /// High priority, and for once the reason is not latency: a caller asking
690    /// for a checkpoint is asking for it *now*, usually at the end of a bulk
691    /// load or before taking a copy of the file, and queueing it behind the
692    /// background work it was meant to follow inverts the intent. It is also
693    /// the only command here that is not a transaction.
694    Checkpoint {
695        responder: oneshot::Sender<Result<CheckpointReport>>,
696    },
697    Shutdown {
698        responder: oneshot::Sender<Result<()>>,
699    },
700}
701
702/// What `PRAGMA wal_checkpoint` returned (0.12.13, W5.2, D-156).
703///
704/// The three columns SQLite gives back, named, rather than `()` — a checkpoint
705/// that did nothing and a checkpoint that reclaimed a 400 MB WAL are the same
706/// `Ok(())`, and the difference is the entire reason a caller asked.
707#[derive(Debug, Clone, Copy, PartialEq, Eq)]
708pub struct CheckpointReport {
709    /// `true` when SQLite could not complete the requested mode because a
710    /// reader or writer was in the way.
711    ///
712    /// **This is not an error, and it is not ignorable.** `TRUNCATE` waits for
713    /// readers only as long as `busy_timeout` allows; past that it gives up and
714    /// says so, having possibly still copied frames. A caller checkpointing
715    /// before copying the file away must read this, because a busy checkpoint
716    /// means the main file is not self-contained yet.
717    pub busy: bool,
718    /// Frames left in the WAL at the end. `0` when the checkpoint completed,
719    /// since the mode run is `TRUNCATE`.
720    pub log_frames: u64,
721    /// Frames moved back into the database file.
722    ///
723    /// Read from a `FULL` pass rather than from the `TRUNCATE` — see
724    /// `run_checkpoint` for why a truncating checkpoint cannot report this
725    /// number itself.
726    pub checkpointed_frames: u64,
727}
728
729impl CheckpointReport {
730    /// The WAL was fully reclaimed: nothing blocked, and nothing is left.
731    pub fn is_complete(&self) -> bool {
732        !self.busy && self.log_frames == 0
733    }
734}
735
736/// Commands sent to the Write Actor on the low-priority channel (background work).
737pub(crate) enum LowPriCommand {
738    /// One chunk of **concepts** — a ledger write, logged and versioned.
739    WriteConceptsChunk {
740        chunk: Vec<ConceptUpsert>,
741        responder: oneshot::Sender<Result<ChunkOutcome>>,
742    },
743    /// One chunk of **derived annotations** — off-ledger, no log trigger (D-041).
744    ///
745    /// The pair is named apart deliberately: this variant was `WriteAnalyticsChunk`
746    /// beside a `WriteAnnotationsChunk` that carried concepts, which is the
747    /// crossing D-075 undid.
748    WriteAnalyticsChunk {
749        chunk: Vec<Annotation>,
750        responder: oneshot::Sender<Result<ChunkOutcome>>,
751    },
752    /// One chunk of vectors for one model (§5.9, D-048).
753    ///
754    /// Low priority: embedding is bulk derived work and must never preempt an
755    /// interactive assertion.
756    UpsertEmbeddingChunk {
757        model: ModelName,
758        chunk: Vec<(String, Vec<f32>)>,
759        responder: oneshot::Sender<Result<ChunkOutcome>>,
760    },
761    BulkImportChunk {
762        chunk: Vec<EdgeAssertion>,
763        responder: oneshot::Sender<Result<ChunkOutcome>>,
764    },
765    Archive {
766        cutoff: String,
767        archive_path: PathBuf,
768        responder: oneshot::Sender<Result<ArchiveReport>>,
769    },
770    /// Move named concepts back out of the cold file (0.9.0, C3).
771    ///
772    /// Low priority for the same reason `Archive` is: it is bulk physical
773    /// movement with no latency bound, and it holds the write lock for its whole
774    /// transaction.
775    Rehydrate {
776        ids: Vec<String>,
777        archive_path: PathBuf,
778        responder: oneshot::Sender<Result<RehydrateReport>>,
779    },
780    /// Reconstruct the FTS index from `concepts` (§5.9, D-036, D-051).
781    ///
782    /// Low priority: it is maintenance on a derivative table, and a search index
783    /// that is a few seconds stale is a smaller cost than an interactive write
784    /// that waits behind a full reindex.
785    RebuildFts {
786        responder: oneshot::Sender<Result<()>>,
787    },
788    /// Refresh or top up the query planner's statistics (0.12.4, D-149).
789    ///
790    /// Low priority, and not a close call: statistics being a few seconds stale
791    /// costs a plan that was already the plan a moment ago, where preempting an
792    /// interactive assertion costs a caller their latency bound. It is a write —
793    /// it writes `sqlite_stat1` — so it takes the write lock like anything else,
794    /// and `PRAGMA analysis_limit` in `configure` is what keeps the hold a
795    /// function of the index count instead of the table size.
796    Analyze {
797        /// `true` runs `PRAGMA optimize`, which re-analyses only what SQLite
798        /// believes has gone stale; `false` runs `ANALYZE` unconditionally.
799        incremental: bool,
800        responder: oneshot::Sender<Result<()>>,
801    },
802    /// One step of a chunked shadow rebuild (§5.8, T1.2, D-082).
803    ///
804    /// Low priority, and one command per step rather than one per rebuild: the
805    /// whole value of building beside the live table is that the actor returns
806    /// here between chunks. See [`Database::rebuild_current_chunked`].
807    ShadowRebuild {
808        step: crate::integrity::ShadowStep,
809        responder: oneshot::Sender<Result<crate::integrity::ShadowOutcome>>,
810    },
811}
812
813enum LoopCtl {
814    Continue,
815    Break,
816}
817
818/// Primary database handle for Macrame bitemporal ledger.
819///
820/// # Why this is not `Clone`, and what a multi-consumer caller uses instead
821///
822/// **Share it as `Arc<Database>`.** Every method here but one takes `&self`, so
823/// an `Arc` is a complete handle and not a workaround: reads run concurrently
824/// off `read_conn`, writes queue behind the actor's channel exactly as they do
825/// through a `&Database`, and nothing becomes serialised that was not
826/// serialised already. The exception is [`Database::close`], which takes `self`,
827/// so the last owner closes with
828/// `Arc::into_inner(db).expect("last handle").close().await`.
829///
830/// That exception is the whole reason `Clone` is absent. Cloning would have to
831/// duplicate **the right to shut down**, and each field carrying that right
832/// breaks differently when duplicated:
833///
834/// - `writer` is a [`tokio::task::JoinHandle`], which is not `Clone` at all —
835///   so a hand-written impl would have to give the copy a `None`, and
836///   `close()` on that copy returns `Ok(())` without ever checking the actor's
837///   exit status. That status is one of the two reasons [`Drop`] tells callers
838///   to prefer `close()`.
839/// - `cadence_stop` is a [`tokio::sync::watch::Sender`], which **is** `Clone`,
840///   and that is the worse case. Its contract is that *dropping* it stops the
841///   snapshot task; a watch channel closes when the last sender goes, so one
842///   surviving copy keeps that task running against a database that is going
843///   away. Nothing returns an error, which is why this is the argument rather
844///   than the `JoinHandle`.
845/// - `closed` is per-handle, so two copies disagree about whether the ledger
846///   was closed: `Drop` warns about a database that *was* closed, or stays
847///   silent about one that was not.
848///
849/// And the ordering `close()` documents — cadence stopped, actor joined, *then*
850/// the final snapshot, so that no write can land between the fold and the file
851/// — is only enforceable while one handle can perform it. A second `close()`
852/// writes a "final" snapshot with the actor still alive.
853///
854/// So the missing impl is the type saying shutdown has exactly one owner. The
855/// Python binding reached the same shape from the other side and for the same
856/// reason: `PyDatabase` holds a `RwLock<Option<Database>>` rather than a copy
857/// per caller (0.13.30, W11.1, D-203).
858pub struct Database {
859    db: libsql::Database,
860    /// The file this handle opened, kept so [`Database::diagnostic_conn`] can
861    /// open it again under different flags (T5.1, D-091). `archive_path` and
862    /// `snapshots_dir` are derived from it and were previously the only trace
863    /// of it on the struct.
864    path: PathBuf,
865    read_conn: libsql::Connection,
866    highpri_tx: mpsc::Sender<HighPriCommand>,
867    lowpri_tx: mpsc::Sender<LowPriCommand>,
868    clock: Arc<dyn Clock>,
869    archive_path: PathBuf,
870    snapshots_dir: PathBuf,
871    schema_version: u32,
872    /// Kept so [`Database::diagnostic_conn`] can configure the connections it
873    /// mints the same way `open()` configured the internal readers (0.12.16,
874    /// W5.5, D-159). Before that split, each one ran with SQLite's defaults.
875    reader_cache_size: Option<i32>,
876    writer: Option<tokio::task::JoinHandle<()>>,
877    /// Stops the snapshot cadence. Dropping it stops the task too, which is what
878    /// keeps a `Database` that is dropped rather than closed from leaving a task
879    /// running against a connection whose database is going away.
880    cadence_stop: Option<tokio::sync::watch::Sender<bool>>,
881    cadence: Option<tokio::task::JoinHandle<()>>,
882    /// Set by [`Database::close`]. Read only by [`Drop`], which warns when it is
883    /// still false — see that impl for why the omission is worth a warning.
884    closed: bool,
885    /// Shared with the actor (T1.4, T1.2). Held here rather than behind
886    /// `#[cfg(feature = "metrics")]` so `open_inner` has one shape; with the
887    /// feature off the metrics half is a zero-sized type and only
888    /// [`Database::metrics`] is gated — which is also why the field is unread in
889    /// the default build: the actor holds the other `Arc` and does the writing.
890    #[cfg_attr(not(feature = "metrics"), allow(dead_code))]
891    shared: Arc<ActorShared>,
892}
893
894/// What the snapshot cadence should do, for [`Tuning::cadence`].
895///
896/// # Why this is not `Option<SnapshotCadence>`
897///
898/// [`Database::open_with_cadence`] takes `Option<SnapshotCadence>`, where `None`
899/// means *no cadence at all*. Carrying that field into [`Tuning`] unchanged
900/// would have made it the one field in the struct whose `None` is a request to
901/// change the behaviour rather than a request to leave it alone — and since
902/// `Tuning` derives `Default`, `open_tuned(path, Tuning::default())` would then
903/// have silently disabled snapshots, while `open(path)` runs them. Two calls
904/// that read as synonyms, one of which stops writing anchors.
905///
906/// So the tri-state is written out. `Default` is the default cadence, matching
907/// [`Database::open`]; `Disabled` is `open_with_cadence(path, None)`, and has to
908/// be asked for by name.
909#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
910#[non_exhaustive]
911pub enum CadencePolicy {
912    /// [`SnapshotCadence::default`], as [`Database::open`] uses.
913    #[default]
914    Default,
915    /// No cadence task. `close()` is then the only thing that writes an anchor
916    /// (§5.5, D-053).
917    Disabled,
918    /// An explicit cadence.
919    Every(SnapshotCadence),
920}
921
922impl CadencePolicy {
923    /// Collapse to the `Option` the open path has always taken.
924    fn resolve(self) -> Option<SnapshotCadence> {
925        match self {
926            Self::Default => Some(SnapshotCadence::default()),
927            Self::Disabled => None,
928            Self::Every(cadence) => Some(cadence),
929        }
930    }
931}
932
933/// When SQLite should checkpoint the WAL on its own, for
934/// [`Tuning::wal_autocheckpoint`] (0.12.14, W5.3, D-157).
935///
936/// # Why this is not `Option<u32>`
937///
938/// The same reason [`CadencePolicy`] is not `Option<SnapshotCadence>`, and the
939/// plan for this wave specified `Option<u32>` here too. In a struct that derives
940/// `Default`, a field whose `None` means *turn the mechanism off* is a field
941/// that turns the mechanism off for everyone who did not mention it. Absence
942/// means "leave it alone" everywhere in [`Tuning`], and disabling the automatic
943/// checkpointer — which is not safe without an explicit
944/// [`Database::checkpoint`] to replace it — has to be asked for by name.
945///
946/// **The default does not change.** 1,000 pages is SQLite's default and stays
947/// SQLite's default; F-30 is a control-loop perturbation, not a correctness bug,
948/// and changing a default is a behaviour change for every existing caller.
949///
950/// # What disabling it actually buys, measured (0.12.14, W5.3, D-157)
951///
952/// F-30 says the automatic checkpointer is an unbudgeted hold *inside* 0.12.0's
953/// adaptive chunk controller: a checkpoint firing during a chunk transaction is
954/// charged to that chunk, and since D-146 made the measured hold the input to
955/// `next_chunk_size`, the controller shrinks in response to work the chunk did
956/// not do. Three rounds, 6,000 concepts of 1 KB each through `write_concepts`,
957/// release build:
958///
959/// | | longest chunk hold | mean | chunks | over budget | wall |
960/// |---|---|---|---|---|---|
961/// | autocheckpoint on (default) | **9.3–10.3 ms** | 2.40–2.44 ms | 125–130 | 24–28 | 304–321 ms |
962/// | autocheckpoint off | **4.50 ms** | 2.08–2.20 ms | 142–153 | 18–27 | 298–339 ms |
963///
964/// **The tail is the finding, and it is real and reproducible.** The longest
965/// hold roughly halves, and the >10 ms histogram bucket is populated only with
966/// the checkpointer on — that bucket is the checkpoint, landing inside somebody
967/// else's transaction and being charged to it. Every round agrees.
968///
969/// **What it does not buy is a calmer controller.** `over_budget` overlaps
970/// between the arms, and total wall time is the same within noise. The
971/// controller works near the budget boundary either way, because
972/// [D-090](../docs/architecture/s13-decision-register.md)'s ~0.8 ms
973/// per-transaction floor and the convergence cost do not go anywhere. So the
974/// honest statement is that disabling autocheckpoint removes an outlier, not an
975/// oscillation.
976///
977/// **And the cost is deferred, not removed.** The explicit
978/// [`Database::checkpoint`] at the end of the same fixture moved **8,400–9,100
979/// frames in 41–45 ms** with the checkpointer off, against **~860 frames in
980/// 5.5–6.2 ms** with it on. That is the whole trade in one line: the same work,
981/// moved out of the latency-bounded path and into one hold the caller chose the
982/// moment for. It is a good trade for a bulk importer and a bad one for an
983/// interactive process, which is why this is a knob and not a new default.
984#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
985#[non_exhaustive]
986pub enum WalCheckpointPolicy {
987    /// SQLite's own default: checkpoint once the WAL passes 1,000 pages.
988    #[default]
989    Default,
990    /// No automatic checkpointing.
991    ///
992    /// **Only correct if you call [`Database::checkpoint`] yourself.** Without
993    /// one, the WAL grows for the life of the process and the database file is
994    /// never brought up to date.
995    Disabled,
996    /// Checkpoint once the WAL passes this many pages.
997    ///
998    /// `0` is not special-cased to [`Self::Disabled`] even though SQLite treats
999    /// it that way, because a caller who computed a threshold and got zero has
1000    /// a bug, and inheriting SQLite's overload would turn it into a silently
1001    /// unbounded WAL.
1002    EveryPages(u32),
1003}
1004
1005impl WalCheckpointPolicy {
1006    /// The pragma to run, or `None` to leave the connection at SQLite's
1007    /// default.
1008    fn pragma(self) -> Option<String> {
1009        match self {
1010            Self::Default => None,
1011            Self::Disabled => Some("PRAGMA wal_autocheckpoint = 0".to_string()),
1012            Self::EveryPages(pages) => Some(format!("PRAGMA wal_autocheckpoint = {pages}")),
1013        }
1014    }
1015}
1016
1017/// Everything [`Database::open_tuned`] can be told, in one growable struct
1018/// (0.12.12, W5.1, D-155).
1019///
1020/// # Why a struct rather than a fourth constructor
1021///
1022/// There were three — [`Database::open`], [`Database::open_with_cadence`],
1023/// [`Database::open_with_clock`] — and each new knob added one more, with the
1024/// combinatorics of the ones before it. 0.13.0 alone wanted three knobs
1025/// (`wal_autocheckpoint`, and a page cache each for the writer and the
1026/// readers), which is the point at which the naming stops being possible.
1027///
1028/// **`Default` plus functional update is the whole design.** They make a new
1029/// knob an additive change: callers construct with `..Default::default()` and
1030/// keep compiling, and the fields that arrive after them are the ones they did
1031/// not ask about. That is not a hypothetical — W5.1 ships this struct with two
1032/// fields, and W5.3/W5.4 add the three tuning knobs to it without touching a
1033/// caller.
1034///
1035/// # Why this is *not* `#[non_exhaustive]`
1036///
1037/// The plan for this wave specified `#[non_exhaustive]` alongside `Default`,
1038/// on the usual reasoning that the attribute is what makes a struct growable.
1039/// It does not compile: a `#[non_exhaustive]` **struct** cannot be built with
1040/// literal syntax outside its own crate *at all*, and the functional-update
1041/// form is literal syntax, so `Tuning { cadence, ..Default::default() }` is
1042/// `E0639` for every external caller — the exact expression the attribute was
1043/// added to protect. (The rule differs from `#[non_exhaustive]` on an enum,
1044/// which only forces a wildcard arm; [`CadencePolicy`] keeps it for that
1045/// reason.) The two ways to have both are a builder with setters, or plain
1046/// `Default` — and `Default` is chosen because the field-literal form is the
1047/// legible one, and because the growth this needs to survive is *additive*
1048/// fields, which `..Default::default()` already absorbs.
1049///
1050/// The cost is real and worth stating: a caller who writes an exhaustive
1051/// literal, with no `..Default::default()`, breaks when a field is added. That
1052/// is a compile error at the call site with an obvious fix, not a silent
1053/// behaviour change, and it is the price of the readable form.
1054///
1055/// # The three constructors stay
1056///
1057/// They delegate here and are not deprecated. `open(path)` is the right call for
1058/// most callers and should not acquire a warning for being the common case; the
1059/// consolidation is about where the *next* knob goes, not about moving anyone.
1060///
1061/// ```no_run
1062/// # use macrame::prelude::*;
1063/// # async fn f() -> macrame::Result<()> {
1064/// let db = Database::open_tuned(
1065///     "graph.db",
1066///     Tuning {
1067///         cadence: CadencePolicy::Disabled,
1068///         ..Default::default()
1069///     },
1070/// )
1071/// .await?;
1072/// # Ok(()) }
1073/// ```
1074#[derive(Clone, Default)]
1075pub struct Tuning {
1076    /// What the snapshot cadence should do. Defaults to
1077    /// [`SnapshotCadence::default`], as [`Database::open`] does.
1078    pub cadence: CadencePolicy,
1079    /// A clock to stamp `recorded_at` with, for tests (§5.1.2, D-062). `None`
1080    /// is [`SystemClock`]. Floored against the database exactly as
1081    /// [`Database::open_with_clock`] describes — read that before injecting
1082    /// one against a non-empty file.
1083    pub clock: Option<Arc<dyn Clock>>,
1084    /// When SQLite checkpoints the WAL on its own (0.12.14, W5.3, F-30).
1085    ///
1086    /// Applied to the **write connection**, which is the only connection in
1087    /// this crate that commits, and therefore the only one whose autocheckpoint
1088    /// setting can ever fire. Pair [`WalCheckpointPolicy::Disabled`] with an
1089    /// explicit [`Database::checkpoint`] or the WAL grows without bound.
1090    pub wal_autocheckpoint: WalCheckpointPolicy,
1091    /// Page cache for the **write** connection, as SQLite's `cache_size`
1092    /// (0.12.15, W5.4).
1093    ///
1094    /// `None` leaves SQLite's default of −2000, which is −2000 *kibibytes*, or
1095    /// 2 MB. **Negative values are KiB and positive values are pages** — that
1096    /// is SQLite's convention and it is preserved rather than smoothed over,
1097    /// because a caller who knows the pragma should not have to discover that
1098    /// this crate redefined it. `Some(-64_000)` is 64 MB; `Some(64_000)` is
1099    /// 64,000 pages, which at the 4 KiB page size this crate gets is 256 MB.
1100    ///
1101    /// The writer wants a large cache: it is one connection, it holds the write
1102    /// lock while it works, and every page it has to re-read from disk is time
1103    /// no other writer can use.
1104    ///
1105    /// # Unlike the two above, `None` here is not a policy enum
1106    ///
1107    /// Because SQLite's default is a *value* rather than a mechanism. Absence
1108    /// still means "leave it alone" — it just happens that leaving this alone
1109    /// is expressible as not running a pragma, where leaving the automatic
1110    /// checkpointer alone required saying which of two things "alone" meant.
1111    pub writer_cache_size: Option<i32>,
1112    /// Page cache for every **read-only** connection: the shared
1113    /// [`Database::read_conn`], the snapshot cadence's own connection, and
1114    /// (since W5.5) each [`Database::diagnostic_conn`] (0.12.15, W5.4).
1115    ///
1116    /// Same units as [`Self::writer_cache_size`], and the same `None`.
1117    ///
1118    /// Split from the writer's because the profiles are opposite and one number
1119    /// cannot serve both. There is exactly one writer and it is long-lived, so
1120    /// its cache is a fixed cost paid once. Read-only connections are plural —
1121    /// `diagnostic_conn` mints a new one per call — so a large value here is
1122    /// multiplied by however many a caller opens, and the R15 hazard that
1123    /// method documents is about concurrent opens. A single shared number
1124    /// therefore has to be small enough for the multiplied case, which is the
1125    /// wrong size for the one connection that holds the write lock.
1126    pub reader_cache_size: Option<i32>,
1127    /// What to do about a stored `recorded_at` in the future (0.13.5, W7.4,
1128    /// §3.4).
1129    ///
1130    /// The clock floors itself at `MAX(recorded_at)` so stamps stay strictly
1131    /// increasing across restarts, which means one row from the future becomes
1132    /// this process's floor and every stamp it issues inherits it — into rows
1133    /// the next open reads back. Defaults to refusing beyond
1134    /// [`crate::DEFAULT_FUTURE_STAMP_TOLERANCE`], a day.
1135    ///
1136    /// Like [`Self::wal_autocheckpoint`] and unlike the two cache sizes, this
1137    /// is a policy enum rather than an `Option`, for
1138    /// [D-155](../../docs/architecture/s13-decision-register.md)'s reason: it
1139    /// guards an invariant, and a `None` that switches it off would switch it
1140    /// off for every caller who never heard of it.
1141    pub future_stamps: FutureStampPolicy,
1142}
1143
1144// `Clock` is not `Debug` — it is a behavioural trait with two methods and
1145// requiring `Debug` of every implementor to print a handle here would be the
1146// tail wagging the dog. So the field is reported as present-or-absent, which is
1147// the only part of it a reader of a `Tuning` dump can act on.
1148impl Tuning {
1149    /// The `Option<SnapshotCadence>` the three older constructors take, mapped
1150    /// onto the tri-state. `None` there means *disabled*, which is why
1151    /// [`CadencePolicy`] exists — see its docs.
1152    fn from_legacy(cadence: Option<SnapshotCadence>, clock: Option<Arc<dyn Clock>>) -> Self {
1153        Self {
1154            cadence: match cadence {
1155                Some(cadence) => CadencePolicy::Every(cadence),
1156                None => CadencePolicy::Disabled,
1157            },
1158            clock,
1159            wal_autocheckpoint: WalCheckpointPolicy::default(),
1160            writer_cache_size: None,
1161            reader_cache_size: None,
1162            future_stamps: FutureStampPolicy::default(),
1163        }
1164    }
1165}
1166
1167impl std::fmt::Debug for Tuning {
1168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1169        f.debug_struct("Tuning")
1170            .field("cadence", &self.cadence)
1171            .field("clock", &self.clock.as_ref().map(|_| "<injected>"))
1172            .field("wal_autocheckpoint", &self.wal_autocheckpoint)
1173            .field("writer_cache_size", &self.writer_cache_size)
1174            .field("reader_cache_size", &self.reader_cache_size)
1175            .finish()
1176    }
1177}
1178
1179impl Database {
1180    /// Open a database file at `path`, configuring pragmas, running migrations, and spawning the Write Actor.
1181    ///
1182    /// The snapshot cadence runs with [`SnapshotCadence::default`]. Use
1183    /// [`Database::open_with_cadence`] to tune or disable it.
1184    pub async fn open(path: impl AsRef<Path>) -> Result<Self> {
1185        Self::open_with_cadence(path, Some(SnapshotCadence::default())).await
1186    }
1187
1188    /// Open with an explicit snapshot cadence, or `None` to run without one
1189    /// (§5.5, D-053).
1190    ///
1191    /// `None` restores the pre-0.5.5 behaviour, where `close()` is the only
1192    /// thing that ever writes an anchor. That is the right setting for a
1193    /// short-lived process that will not accumulate a delta worth bounding, and
1194    /// for tests that assert on the contents of the snapshot directory.
1195    pub async fn open_with_cadence(
1196        path: impl AsRef<Path>,
1197        cadence: Option<SnapshotCadence>,
1198    ) -> Result<Self> {
1199        Self::open_inner(path.as_ref(), Tuning::from_legacy(cadence, None)).await
1200    }
1201
1202    /// Open with an injected clock (§5.1.2, **defect K**, D-062).
1203    ///
1204    /// The reason this exists is testing: `recorded_at` is the transaction-time
1205    /// axis, and until now every test that wanted to assert on one had to either
1206    /// avoid it or drive a raw connection, because `open()` hardcoded
1207    /// [`SystemClock`]. `FakeClock` has been public and constructed in the test
1208    /// harness since 0.5.2 with nothing to inject it into — the compiler warned
1209    /// about the dead field on every build for three releases.
1210    ///
1211    /// **The clock is floored against the database before the actor starts.**
1212    /// [`Clock::raise_floor`] is called with the newest `recorded_at` in the
1213    /// ledger, so an injected clock cannot issue a stamp below what is already
1214    /// stored — which would abort the next concept write on
1215    /// `trg_concepts_monotonic_ra` rather than merely being odd. This is the
1216    /// step whose absence kept the defect open: the obvious implementation
1217    /// (take an `Arc<dyn Clock>`, use it) produces a `Database` that fails on
1218    /// its first write against any non-empty file.
1219    ///
1220    /// On a fresh database there is no floor, so an injected `FakeClock` issues
1221    /// exactly the stamps it was given.
1222    pub async fn open_with_clock(
1223        path: impl AsRef<Path>,
1224        cadence: Option<SnapshotCadence>,
1225        clock: Arc<dyn Clock>,
1226    ) -> Result<Self> {
1227        Self::open_inner(path.as_ref(), Tuning::from_legacy(cadence, Some(clock))).await
1228    }
1229
1230    /// Open with an explicit [`Tuning`] (0.12.12, W5.1, D-155).
1231    ///
1232    /// The consolidated form of the three constructors above, and the one that
1233    /// grows: every knob 0.13.0 adds arrives as a field here rather than as a
1234    /// fourth `open_*`. See [`Tuning`] for why the struct is
1235    /// `#[non_exhaustive]` and why that makes the growth additive.
1236    pub async fn open_tuned(path: impl AsRef<Path>, tuning: Tuning) -> Result<Self> {
1237        Self::open_inner(path.as_ref(), tuning).await
1238    }
1239
1240    async fn open_inner(path: &Path, tuning: Tuning) -> Result<Self> {
1241        let Tuning {
1242            cadence,
1243            clock: injected,
1244            wal_autocheckpoint,
1245            writer_cache_size,
1246            reader_cache_size,
1247            future_stamps,
1248        } = tuning;
1249        let cadence = cadence.resolve();
1250        let db = libsql::Builder::new_local(path).build().await?;
1251        let write_conn = configure(db.connect()?, writer_cache_size).await?;
1252        // The writer is the only connection that commits, so it is the only one
1253        // whose `wal_autocheckpoint` can ever fire. Setting it on the readers
1254        // would be a pragma with no path to running (0.12.14, W5.3, D-157).
1255        if let Some(pragma) = wal_autocheckpoint.pragma() {
1256            let _ = write_conn.query(&pragma, ()).await?;
1257        }
1258        let read_conn = configure(db.connect()?, reader_cache_size).await?;
1259
1260        // PRAGMA query_only = ON on reader connection (§5.1.2)
1261        read_conn.execute("PRAGMA query_only = ON", ()).await?;
1262
1263        let migration = migrations::run(&write_conn).await?;
1264
1265        let (highpri_tx, highpri_rx) = mpsc::channel(256);
1266        let (lowpri_tx, lowpri_rx) = mpsc::channel(64);
1267
1268        // Floored after `migrations::run`, so the tables the floor is read from
1269        // are guaranteed to exist.
1270        let clock: Arc<dyn Clock> = match injected {
1271            Some(clock) => {
1272                if let Some(floor) =
1273                    crate::util::clock::recorded_at_floor(&read_conn, future_stamps).await?
1274                {
1275                    clock.raise_floor(floor);
1276                }
1277                clock
1278            }
1279            None => Arc::new(SystemClock::new(&read_conn, future_stamps).await?),
1280        };
1281        let shared = Arc::new(ActorShared::default());
1282        let writer = tokio::spawn(run_writer_actor(
1283            write_conn,
1284            Arc::clone(&clock),
1285            highpri_rx,
1286            lowpri_rx,
1287            Arc::clone(&shared),
1288        ));
1289
1290        let archive_path = derive_archive_path(path);
1291        let snapshots_dir = derive_snapshots_dir(path);
1292
1293        // **The cadence gets its own connection (Wave 4.1).** It used to share
1294        // `read_conn`, on the reasoning that `libsql::Connection` is an
1295        // Arc-backed handle and R15 makes every extra local connection a cost worth
1296        // not paying for nothing. The cost it was not paying for turned out to be
1297        // real: `reconstruct` brackets a fold with `ATTACH cold … DETACH cold`,
1298        // that region is per-connection state, and it is not synchronised. Two
1299        // folds on one connection can therefore interleave so that one DETACHes
1300        // the handle the other is mid-fold on.
1301        //
1302        // Recorded in §8.5 as a hazard rather than a defect because it **did not
1303        // reproduce**: 200 concurrent reconstructions against a 1 ms cadence with
1304        // an archive present produced zero errors, since the cadence anchors at
1305        // `MAX(recorded_at)` and so almost always takes the hot path. Narrow, and
1306        // real — a write landing between `log_head` and the fold opens it.
1307        //
1308        // Separate connections remove the interleaving rather than ordering it,
1309        // which is why this is preferred to a mutex around the region: there is
1310        // no shared state left to race on, and nothing to remember to hold. The
1311        // R15 objection does not apply — that fault is about *concurrent* opens,
1312        // and this is one more sequential open during `open()`.
1313        let (cadence_stop, cadence) = match cadence {
1314            Some(cadence) => {
1315                let cadence_conn = configure(db.connect()?, reader_cache_size).await?;
1316                cadence_conn.execute("PRAGMA query_only = ON", ()).await?;
1317                let (tx, rx) = tokio::sync::watch::channel(false);
1318                let handle = tokio::spawn(snapshot::run_cadence(
1319                    cadence_conn,
1320                    snapshots_dir.clone(),
1321                    archive_path.clone(),
1322                    cadence,
1323                    rx,
1324                ));
1325                (Some(tx), Some(handle))
1326            }
1327            None => (None, None),
1328        };
1329
1330        let handle = Self {
1331            db,
1332            path: path.to_path_buf(),
1333            read_conn,
1334            highpri_tx,
1335            lowpri_tx,
1336            clock,
1337            archive_path,
1338            snapshots_dir,
1339            schema_version: migrations::current_version(),
1340            reader_cache_size,
1341            writer: Some(writer),
1342            cadence_stop,
1343            cadence,
1344            closed: false,
1345            shared,
1346        };
1347
1348        // **Re-anchor after a migration (Wave 4.4).**
1349        //
1350        // D-043 makes a `SCHEMA_VERSION` bump invalidate every snapshot on disk,
1351        // which is correct — a snapshot is a serialised `MaterializedState` and a
1352        // schema change can change what that means. What was missing is the other
1353        // half: nothing wrote a replacement, so the first `reconstruct` after an
1354        // upgrade skipped every file as incompatible and folded from genesis. On
1355        // a database with a large log that is the difference between reading one
1356        // snapshot and folding the whole history, and the only trace was a
1357        // `warn!` per skipped file.
1358        //
1359        // Written here rather than left to the cadence because the cadence fires
1360        // on log *growth* (D-053): an upgraded database that is then read but not
1361        // written would never re-anchor at all.
1362        //
1363        // Failure is logged, not returned. A missing anchor costs time and no
1364        // information — snapshots are derivative under Doctrine VI — so refusing
1365        // to open a database because its optimisation could not be rebuilt would
1366        // trade a real capability for a performance one.
1367        //
1368        // Gated on the cadence being enabled, as well as on an actual upgrade:
1369        // `open_with_cadence(None)` means *this handle writes no snapshots except
1370        // at close()*, and a one-off write at open would contradict that for a
1371        // caller who asked for the quiet mode precisely to control when files
1372        // appear. They still get an anchor from `close()`.
1373        if migration.upgraded() && handle.cadence.is_some() {
1374            let ts = handle.clock.now();
1375            let archive = handle
1376                .archive_path
1377                .exists()
1378                .then_some(handle.archive_path.as_path());
1379            match snapshot::write_final(&handle.read_conn, &handle.snapshots_dir, &ts, archive)
1380                .await
1381            {
1382                Ok(path) => tracing::info!(
1383                    "schema moved v{} -> v{}; re-anchored snapshots at {:?}",
1384                    migration.from,
1385                    migration.to,
1386                    path
1387                ),
1388                Err(e) => tracing::warn!(
1389                    "schema moved v{} -> v{} but the re-anchor failed: {e}. \
1390                     Reconstruction stays correct and folds from genesis until the \
1391                     cadence writes one.",
1392                    migration.from,
1393                    migration.to
1394                ),
1395            }
1396        }
1397
1398        Ok(handle)
1399    }
1400
1401    /// Read connection handle for queries, traversals, and folds.
1402    pub fn read_conn(&self) -> &libsql::Connection {
1403        &self.read_conn
1404    }
1405
1406    /// The file this handle opened.
1407    pub fn path(&self) -> &Path {
1408        &self.path
1409    }
1410
1411    /// A **new, independently owned, OS-level read-only** connection to this
1412    /// database, for diagnostics (§4.7, T5.1, D-091).
1413    ///
1414    /// # Why this exists when `read_conn()` already does
1415    ///
1416    /// Two different things, and the difference is the point:
1417    ///
1418    /// * `read_conn()` returns a shared `&Connection` carrying
1419    ///   `PRAGMA query_only = ON`. That pragma is **per-connection and
1420    ///   reversible by its holder in one statement**, so it is a guardrail
1421    ///   against accident, not a capability boundary. And because the reference
1422    ///   is shared, a caller who runs a long reporting query on it is competing
1423    ///   with every traversal and fold in the process.
1424    /// * This returns a connection opened with `SQLITE_OPEN_READ_ONLY`, which is
1425    ///   enforced by the engine below the pragma layer, and it is the caller's
1426    ///   own.
1427    ///
1428    /// **Measured on libSQL 0.9.30 rather than assumed**
1429    /// (`examples/readonly_open_probe.rs`), against a live WAL database with the
1430    /// write actor running:
1431    ///
1432    /// | | `read_conn()` | `diagnostic_conn()` |
1433    /// |---|---|---|
1434    /// | `SELECT`, `EXPLAIN QUERY PLAN` | allowed | allowed |
1435    /// | `INSERT` | refused | refused |
1436    /// | `PRAGMA query_only = OFF` | **allowed** | allowed |
1437    /// | `INSERT` after that | **allowed** | **refused** |
1438    /// | `ATTACH` an existing file | allowed | allowed |
1439    /// | `INSERT` into the attachment | refused¹ | **refused** |
1440    /// | `ATTACH` a path that does not exist | — | refused (`SQLITE_CANTOPEN`) |
1441    ///
1442    /// The third and fourth rows are the whole difference: turning the pragma
1443    /// off restores writes on `read_conn()` and does not here. That is what
1444    /// "boundary rather than guardrail" means, and it is now a number rather
1445    /// than a claim.
1446    ///
1447    /// ¹ On `read_conn()` that refusal is `query_only` — the same reversible
1448    /// thing as row 2. On `diagnostic_conn()` it is the open flags, and the
1449    /// probe runs it *after* `query_only = OFF` so that the pragma cannot be
1450    /// what is doing the work.
1451    ///
1452    /// # `ATTACH` is permitted, and does not widen the write boundary
1453    ///
1454    /// Checked because `diagnostic_query` (Python) is the only arbitrary-SQL
1455    /// surface this crate exposes, and an attachment is a second `open` whose
1456    /// flags it does not obviously inherit. It does inherit them: the
1457    /// attachment is read-only, and a nonexistent path is `SQLITE_CANTOPEN`
1458    /// rather than a new file, because `SQLITE_OPEN_CREATE` is dropped for the
1459    /// attachment as it is for `main`. So `SQLITE_OPEN_READ_ONLY` bounds the
1460    /// **connection**, not just the one file it names (0.10.0, W4.3).
1461    ///
1462    /// What it does widen is *reading*: an `ATTACH` can name any file the
1463    /// process can open, so this connection is a read surface over the
1464    /// filesystem, not over this database. That is a property of arbitrary SQL
1465    /// rather than of the flags, and it is unchanged by them.
1466    ///
1467    /// # One way this is *more* permissive, which is worth knowing
1468    ///
1469    /// `CREATE TEMP TABLE` **succeeds** here and is refused by `read_conn()`.
1470    /// Temp tables live in a separate temporary database that is writable
1471    /// regardless of how the main one was opened, whereas `query_only` refuses
1472    /// them outright — which is the mechanism [D-050] measured when it removed
1473    /// `TwoPhaseTempTable` for returning `SQLITE_READONLY (8)` on the read
1474    /// connection. So the stronger boundary is not uniformly stronger, and a
1475    /// strategy that needs a temp table has a connection it could run on. That
1476    /// is recorded, not acted on: D-050 removed the strategy for two reasons and
1477    /// this addresses one of them.
1478    ///
1479    /// # Calling this concurrently is R15's shape
1480    ///
1481    /// **This is the one method on `Database` that opens the file.** Everything
1482    /// else runs on connections established once, at `open`. Each call here is
1483    /// a fresh `libsql::Builder::…build()`, so *N* threads calling it at once
1484    /// are *N* concurrent opens — which is exactly the pattern behind
1485    /// [R15](https://github.com/opticsWolf/Macrame#known-risks), the upstream
1486    /// libSQL access violation (`0xC0000005`) that `examples/r15_soak.rs`
1487    /// reproduces and `RUST_TEST_THREADS=1` exists to avoid in the suite.
1488    ///
1489    /// **This is measured, not inferred.** 48 threads sharing one handle and
1490    /// calling only this method: 7 bad runs in 18 — two access violations and
1491    /// five *returned* SQLite errors (`database is locked`, `bad parameter or
1492    /// other API misuse`). With the calls serialised, 0 in 18
1493    /// (`tests_py/probes/r15_diagnostic_path.py`). The returned-error mode is
1494    /// the one to watch for: it looks like a fact about the database, on the
1495    /// method a caller reaches for when they already doubt the typed answer.
1496    ///
1497    /// **Bound this yourself if you call it from more than one thread.** One
1498    /// outstanding open at a time is enough; a mutex around the call costs
1499    /// nothing on a diagnostic path. This method does not do it for you on
1500    /// purpose: serialising behind a lock the caller cannot see would
1501    /// contradict the thing above it — that the connection is *the caller's
1502    /// own* — and it would put a hidden queue in front of the one surface whose
1503    /// job is to answer questions when the typed path is already suspect. The
1504    /// Python binding does bound it, because it wraps this in a method a caller
1505    /// cannot see into (`PyDatabase::diagnostic_rows`); a Rust caller can.
1506    ///
1507    /// # Errors
1508    ///
1509    /// The file must already exist. `SQLITE_OPEN_READ_ONLY` drops
1510    /// `SQLITE_OPEN_CREATE` with it, so a missing file is `SQLITE_CANTOPEN`
1511    /// rather than a fresh empty database — which is the right failure, and is
1512    /// surfaced as a typed error rather than as libSQL's error 14.
1513    pub async fn diagnostic_conn(&self) -> Result<libsql::Connection> {
1514        let fail = |reason: String| DbError::DiagnosticConn {
1515            path: self.path.display().to_string(),
1516            reason,
1517        };
1518        if !self.path.exists() {
1519            return Err(fail(
1520                "the file does not exist, and a read-only open cannot create it".to_string(),
1521            ));
1522        }
1523        let db = libsql::Builder::new_local(&self.path)
1524            .flags(libsql::OpenFlags::SQLITE_OPEN_READ_ONLY)
1525            .build()
1526            .await
1527            .map_err(|e| fail(e.to_string()))?;
1528        let conn = db.connect().map_err(|e| fail(e.to_string()))?;
1529        // Configured since 0.12.16 (W5.5, D-159). Until then this connection
1530        // ran with SQLite's defaults while every other connection in the
1531        // process ran with the crate's — most consequentially a `busy_timeout`
1532        // of 0 against everyone else's 5 s, on the one surface whose job is to
1533        // answer questions when the typed path is already suspect. Only the
1534        // common half: `SQLITE_OPEN_READ_ONLY` cannot set `journal_mode`, and
1535        // the rest govern writes this connection cannot make.
1536        configure_common(&conn, self.reader_cache_size).await?;
1537        Ok(conn)
1538    }
1539
1540    /// Cross-check the snapshot chain against a fold from genesis (§5.5, T5.3,
1541    /// D-092).
1542    ///
1543    /// `write_final` composes onto the previous snapshot, so snapshot *n* is
1544    /// derived from snapshot *n−1* and nothing in the chain ever folds the whole
1545    /// log. An error at any link propagates forward forever and every read
1546    /// agrees with it, because every read descends from it. This is the check
1547    /// that would notice.
1548    ///
1549    /// # When to run it
1550    ///
1551    /// **Not on a schedule this crate chooses.** A genesis fold is precisely the
1552    /// cost snapshots exist to avoid, so running it periodically by default
1553    /// would give every application the bill snapshots were bought to remove —
1554    /// on a database whose log is large enough for snapshots to matter, which is
1555    /// the only kind where this is worth doing. The plan calls it a scheduling
1556    /// problem and it is the caller's schedule: an idle period, a nightly job,
1557    /// or once per *N* anchors, chosen against a log size this crate cannot see.
1558    ///
1559    /// The cadence is deliberately left alone for the same reason — it runs on a
1560    /// connection shared with nothing and a fold there would compete with
1561    /// interactive reads at a moment nobody chose.
1562    ///
1563    /// # It reports; it does not repair
1564    ///
1565    /// A divergence means the snapshots are a wrong **cache**, not that the
1566    /// ledger is corrupt: [Doctrine VI] makes them disposable, so deleting
1567    /// [`Self::snapshots_dir`] restores correctness and costs only speed.
1568    /// Rewriting the file here would destroy the evidence that composition has a
1569    /// defect, which is the only thing this can tell you that you did not
1570    /// already know.
1571    ///
1572    /// Pair it with the actor counters ([`Self::metrics`], D-079) so a
1573    /// divergence found by a scheduled run is visible beside the write latency
1574    /// of the period that produced it.
1575    ///
1576    /// [Doctrine VI]: ../../docs/architecture/s0-s3-foundations.md#doctrine-vi
1577    pub async fn verify_snapshot_chain(&self, ts: &str) -> Result<crate::temporal::ChainCheck> {
1578        let archive = self
1579            .archive_path
1580            .exists()
1581            .then_some(self.archive_path.as_path());
1582        crate::temporal::verify_snapshot_chain(&self.read_conn, ts, archive, &self.snapshots_dir)
1583            .await
1584    }
1585
1586    /// The clock every write is stamped with (§5.1.1).
1587    pub fn clock(&self) -> &Arc<dyn Clock> {
1588        &self.clock
1589    }
1590
1591    /// Schema version this handle opened against.
1592    pub fn schema_version(&self) -> u32 {
1593        self.schema_version
1594    }
1595
1596    /// Cold database path, derived by convention from the main file.
1597    pub fn archive_path(&self) -> &Path {
1598        &self.archive_path
1599    }
1600
1601    /// Snapshot directory, derived by convention from the main file.
1602    pub fn snapshots_dir(&self) -> &Path {
1603        &self.snapshots_dir
1604    }
1605
1606    /// What the write actor has done since this handle was opened (T1.4, D-079).
1607    ///
1608    /// Requires the `metrics` feature. The counters are per-handle and start at
1609    /// zero on `open()` — they are not read from the database, because the thing
1610    /// being measured is *this process's* actor and merging two processes'
1611    /// histograms would produce a number about neither.
1612    ///
1613    /// The intended first question is [`crate::metrics::MetricsSnapshot::budget_violations`]:
1614    ///
1615    /// ```no_run
1616    /// # async fn f(db: &macrame::Database) {
1617    /// # #[cfg(feature = "metrics")] {
1618    /// for k in db.metrics().budget_violations() {
1619    ///     eprintln!("{} broke the 3 ms bound {} times", k.kind, k.over_budget);
1620    /// }
1621    /// # }
1622    /// # }
1623    /// ```
1624    ///
1625    /// Reading this does not stop the actor — see
1626    /// [`crate::metrics::ActorMetrics::snapshot`] for what that costs in
1627    /// consistency, and why the trade goes that way.
1628    #[cfg(feature = "metrics")]
1629    pub fn metrics(&self) -> crate::metrics::MetricsSnapshot {
1630        self.shared.metrics.snapshot()
1631    }
1632
1633    /// The underlying libSQL database, for callers that need their own connection.
1634    ///
1635    /// # Actor containment is a convention above this line, not a guarantee
1636    ///
1637    /// **Kept public, and the honest statement of what that costs (Wave 4.3).**
1638    /// §5.1 says the write actor is the sole writer, and two mechanisms make that
1639    /// true of the handle: every write method goes through a channel, and
1640    /// [`Self::read_conn`] carries `PRAGMA query_only = ON`. **Nothing protects a
1641    /// connection obtained from here.** A caller can open one, write to `links`
1642    /// directly, and the actor will not know — the triggers still fire and the
1643    /// ledger stays internally consistent, but the single-writer property that
1644    /// [`crate::CHUNK_BUDGET`]'s latency argument rests on is gone, and so is the
1645    /// serialisation the overlap guard (D-060) relies on.
1646    ///
1647    /// This is the same shape as the limit stated in §4.2 for that guard, and it
1648    /// is one fact rather than two: **the storage layer permits what this API
1649    /// refuses.** Making it private would not change that — the database file is
1650    /// reachable by any SQLite client on the machine — it would only remove the
1651    /// supported way to do the thing, which is how escape hatches become
1652    /// `unsafe`-adjacent folklore.
1653    ///
1654    /// The free functions [`crate::register_model`] and
1655    /// [`crate::upsert_embedding`] take a bare connection for the same reason and
1656    /// carry the same caveat; prefer [`Self::register_model`] and
1657    /// [`Self::upsert_embeddings`], which go through the actor.
1658    ///
1659    /// # The legitimate-use list is now one item long (T5.1, D-091)
1660    ///
1661    /// It used to read: `EXPLAIN QUERY PLAN` and other diagnostics, read-only
1662    /// reporting queries wanting their own connection rather than sharing the
1663    /// reader, and provoking a guard in a test. The first two are exactly what
1664    /// [`Self::diagnostic_conn`] now does, and it does them behind an OS-level
1665    /// read-only open rather than on a handle that can write. **Use that.**
1666    ///
1667    /// What is left is the one use that genuinely requires write access through
1668    /// a connection the actor does not own: *provoking a guard* — writing the
1669    /// state §4.7 says the storage layer permits and this API refuses, so a test
1670    /// can assert the gap is still where the document says it is. That is the
1671    /// only thing this crate's own suite uses it for.
1672    ///
1673    /// # Why `#[doc(hidden)]` and not a `raw-access` feature
1674    ///
1675    /// T5.1 offers either. The feature is the stronger declaration — it shows up
1676    /// in the consumer's `Cargo.toml`, where a reviewer sees it — and it was
1677    /// **not** taken, for a reason specific to what uses this:
1678    ///
1679    /// Cargo features are additive and cannot be *required* by a test target
1680    /// except through `required-features`, which makes a plain `cargo test`
1681    /// **skip** that binary silently. The binaries that call this are
1682    /// `storage_boundary_tests` and `wave1_regression_tests` — the §4.7
1683    /// tripwires, whose entire job is to fail when a documented gap moves. Gating
1684    /// them behind a feature would mean the ordinary `cargo test` stopped running
1685    /// the tests that enforce the section this item is about, to make a
1686    /// declaration about a hatch. That trade is the wrong way round, and it is
1687    /// the same failure the project already names: a suite that quietly does less
1688    /// than it appears to.
1689    ///
1690    /// So the hatch stays reachable and stops being *discoverable*: it is absent
1691    /// from the docs, and the documented path for every non-write use is
1692    /// [`Self::diagnostic_conn`]. [D-068] is unchanged — removing it would buy
1693    /// the appearance of a guarantee, since the file is reachable by any SQLite
1694    /// client on the machine.
1695    ///
1696    /// [D-068]: ../../docs/architecture/s13-decision-register.md#d-068
1697    // convention (D-068/D-091): `raw()` is #[doc(hidden)] and is NOT exposed by
1698    // any binding. Everything above this line is invisible on docs.rs and
1699    // invisible to a contributor reading the Python surface list, which is where
1700    // the decision to expose it would actually be taken — hence this sentinel and
1701    // its twin in `bindings/python/src/lib.rs` (0.10.0, W4.10). The documented
1702    // path for every non-write use is `diagnostic_conn`.
1703    #[doc(hidden)]
1704    pub fn raw(&self) -> &libsql::Database {
1705        &self.db
1706    }
1707
1708    // -- write surface (§5.1, Appendix A) --
1709    //
1710    // Every method here validates and canonicalises before the value crosses the
1711    // channel, so a bad edge type or a second-precision timestamp is a typed
1712    // error at the call site rather than an engine `CHECK` failure surfacing
1713    // from the far side of an actor with no context attached.
1714    //
1715    // NOTE (§5.1.8, D-028): awaiting one of these waits on a Rust channel, not
1716    // in SQLite, so `busy_timeout` does not bound it. During an in-flight
1717    // `rebuild_current` or `archive` the caller stalls for that transaction's
1718    // duration. Wrap in `tokio::time::timeout` if you need a bound — but a
1719    // timeout is not a cancellation: the command stays queued and commits when
1720    // the actor reaches it.
1721
1722    /// Assert an edge (Doctrine III: a new row, never an update).
1723    ///
1724    /// # One row costs a transaction, so N rows cost N transactions
1725    ///
1726    /// This is the correct method for a caller who genuinely has one edge, and
1727    /// it is the wrong one in a loop. Each call is its own transaction and pays
1728    /// the ~0.8 ms per-transaction floor (D-090) whole, so a thousand edges
1729    /// asserted one at a time spend roughly **0.8 s in transaction overhead
1730    /// alone** — before any of the work — and mint a thousand distinct
1731    /// `recorded_at` stamps for what the caller probably means as one act.
1732    ///
1733    /// There are two bulk forms and the difference between them is the one to
1734    /// get right:
1735    ///
1736    /// - [`Self::bulk_import`] is **chunked** against [`CHUNK_BUDGET`] and
1737    ///   atomic per chunk. It amortises the transaction floor across the batch
1738    ///   while still yielding to interactive work at every chunk boundary. This
1739    ///   is the one a loop should almost always become.
1740    /// - [`Self::write_bulk_atomic`] is one transaction under one stamp and is
1741    ///   **the one write with no latency bound** — the hold is a function of
1742    ///   `edges.len()`, tabulated in its own docs, and is time every other
1743    ///   writer spends waiting. Reach for it when the batch is genuinely one
1744    ///   act that must not be observable half-applied, not for speed.
1745    ///
1746    /// The choice is the caller's and neither form is deprecated. Doctrine III
1747    /// makes "one act, one stamp" a semantic claim rather than a performance
1748    /// one, and only the caller knows whether their thousand edges are one act.
1749    pub async fn assert_edge(&self, edge: EdgeAssertion) -> Result<()> {
1750        let edge = edge.normalized()?;
1751        self.high(|responder| HighPriCommand::AssertEdge { edge, responder })
1752            .await
1753    }
1754
1755    /// Close an open interval by asserting its replacement (Doctrine III).
1756    pub async fn retire_edge(
1757        &self,
1758        source: impl Into<String>,
1759        target: impl Into<String>,
1760        edge_type: impl Into<String>,
1761        valid_from: &str,
1762        valid_to: &str,
1763    ) -> Result<()> {
1764        let edge_type = edge_type.into();
1765        crate::graph::edge::validate_edge_type(&edge_type)?;
1766        let valid_from = timestamp::normalize(valid_from)?;
1767        let valid_to = timestamp::normalize(valid_to)?;
1768        let (source, target) = (source.into(), target.into());
1769
1770        self.high(|responder| HighPriCommand::RetireEdge {
1771            source,
1772            target,
1773            edge_type,
1774            valid_from,
1775            valid_to,
1776            responder,
1777        })
1778        .await
1779    }
1780
1781    /// Insert or update a concept.
1782    ///
1783    /// # One row costs a transaction
1784    ///
1785    /// The same trade [`Self::assert_edge`] describes, for the same reason and
1786    /// with the same ~0.8 ms floor (D-090): correct for one concept, wrong in a
1787    /// loop. [`Self::write_concepts`] takes a `Vec` and commits it as one
1788    /// transaction under one stamp.
1789    ///
1790    /// There is no atomic-across-chunks concept path and none is needed to make
1791    /// the choice: `write_concepts` is chunked against [`CHUNK_BUDGET`] and
1792    /// atomic per chunk, so a large `Vec` is cooperative rather than a stall.
1793    /// The responsiveness argument for writing one row at a time therefore does
1794    /// not apply — the bulk form already yields at every chunk boundary.
1795    pub async fn upsert_concept(&self, concept: ConceptUpsert) -> Result<()> {
1796        let concept = concept.normalized()?;
1797        self.high(|responder| HighPriCommand::UpsertConcept { concept, responder })
1798            .await
1799    }
1800
1801    /// Assert many edges in one transaction under one stamp (D-014).
1802    ///
1803    /// # This is the one write with no latency bound, and here is what it costs
1804    ///
1805    /// The batch is one act under one `recorded_at`, so it cannot be chunked —
1806    /// splitting it is the thing this method exists not to do. That makes the
1807    /// actor's hold a function of `edges.len()`, and until now the only
1808    /// statement of that anywhere was the prose "uncapped" in
1809    /// [`CHUNK_BUDGET`]'s table. A caller who stalls every other writer for
1810    /// eight seconds should have been able to predict it from the signature.
1811    ///
1812    /// Measured on libSQL 0.9.30 (T1.3, D-081), holding the actor for:
1813    ///
1814    /// | rows | hold |
1815    /// |---|---|
1816    /// | 500 | ~34 ms |
1817    /// | 2,000 | ~155 ms |
1818    /// | 10,000 | ~1.0 s |
1819    /// | 20,000 | ~2.6 s |
1820    ///
1821    /// [`estimated_bulk_hold`] is that curve as a function, and this method
1822    /// emits a `tracing::warn!` when it predicts more than
1823    /// [`BULK_ATOMIC_WARN_HOLD`]. **The estimate is a shape, not a promise** —
1824    /// see [`estimated_bulk_hold`] for what it is calibrated against and where
1825    /// it will be wrong.
1826    ///
1827    /// A caller who needs the latency bound and not the atomicity wants
1828    /// [`Self::bulk_import`], which is the same write chunked and explicitly not
1829    /// atomic overall (D-011).
1830    pub async fn write_bulk_atomic(&self, edges: Vec<EdgeAssertion>) -> Result<usize> {
1831        let estimate = estimated_bulk_hold(&edges);
1832        if estimate > BULK_ATOMIC_WARN_HOLD {
1833            // Warned here rather than in the actor, and before the send: this is
1834            // the caller's own task, so the log line lands with their span
1835            // attached and names the call site that chose the batch size. By the
1836            // time the actor has it, the only context left is "a large batch".
1837            tracing::warn!(
1838                rows = edges.len(),
1839                estimated_hold_ms = estimate.as_millis() as u64,
1840                "write_bulk_atomic will hold the write actor for roughly \
1841                 {estimate:?} — it is atomic by contract (D-014) and cannot be \
1842                 chunked. Every other writer waits that long. Use bulk_import \
1843                 if the batch does not need to be all-or-nothing."
1844            );
1845        }
1846
1847        let edges = normalize_all(edges)?;
1848        self.high(|responder| HighPriCommand::WriteBulkAtomic { edges, responder })
1849            .await
1850    }
1851
1852    /// Move the WAL back into the main database file (§4.5, F-30, 0.12.13,
1853    /// W5.2, D-156).
1854    ///
1855    /// Runs `PRAGMA wal_checkpoint(FULL)` and then `(TRUNCATE)` on the write
1856    /// connection, as one actor turn, and returns what SQLite reported. **Read
1857    /// [`CheckpointReport::busy`]** — a checkpoint that could not run is an
1858    /// `Ok` whose WAL is still there.
1859    ///
1860    /// Two passes rather than one because **a truncating checkpoint cannot
1861    /// report its own work**: the counts describe the WAL *after* the
1862    /// operation, and after a truncation there is nothing left to describe, so
1863    /// `TRUNCATE` alone answers `busy=0, log=0, checkpointed=0` on success —
1864    /// indistinguishable from having done nothing. `FULL` supplies the frame
1865    /// count and `TRUNCATE` resets the file; `busy` is the union of the two.
1866    ///
1867    /// # When a caller needs this
1868    ///
1869    /// Three cases, and only three:
1870    ///
1871    /// - **Before copying the database file elsewhere.** In WAL mode the `.db`
1872    ///   file alone is not the database; recent commits live in the `-wal`. A
1873    ///   complete checkpoint is what makes the main file self-contained.
1874    /// - **At the end of a bulk load that turned the automatic checkpointer
1875    ///   off.** That is the pairing this method exists for — see
1876    ///   [`Tuning::wal_autocheckpoint`]. Disabling autocheckpoint without
1877    ///   calling this leaves a WAL that grows for the life of the process.
1878    /// - **Before a long idle period**, to give back the disk.
1879    ///
1880    /// Nobody else should call it on a timer. SQLite checkpoints automatically
1881    /// every 1,000 pages and that default is not changed by this method
1882    /// existing; a periodic explicit checkpoint on top of it buys nothing and
1883    /// takes the write lock to do so.
1884    ///
1885    /// # It takes the write lock, and it is budget-exempt
1886    ///
1887    /// The hold is a function of how many frames have accumulated, which is a
1888    /// function of how long since the last checkpoint — not of anything passed
1889    /// in. It is on [`CHUNK_BUDGET`]'s exemption table for that reason, and it
1890    /// is the one entry there that is not a transaction: there is no smaller
1891    /// unit to chunk into, because the operation *is* the copy.
1892    pub async fn checkpoint(&self) -> Result<CheckpointReport> {
1893        self.high(|responder| HighPriCommand::Checkpoint { responder })
1894            .await
1895    }
1896
1897    /// Rebuild `links_current` from `links` and verify zero drift (§5.8).
1898    ///
1899    /// One transaction holding the write lock for its whole duration, because
1900    /// [D-023] will not let the `DELETE` and the `INSERT` be split: a reader
1901    /// landing between them would see a graph with no edges and no error.
1902    /// [`Self::rebuild_current_chunked`] is the same result with a different
1903    /// latency profile, and is what a populated database wants.
1904    ///
1905    /// The report's `drift_after` is the audit run inside the same transaction,
1906    /// so a repair that did not converge is reported by the call that made it
1907    /// rather than by the next one to look.
1908    ///
1909    /// [D-023]: ../docs/architecture/s13-decision-register.md#d-023
1910    pub async fn rebuild_current(&self) -> Result<RebuildReport> {
1911        self.high(|responder| HighPriCommand::RebuildCurrent { responder })
1912            .await
1913    }
1914
1915    /// Rebuild `links_current` beside itself, in chunks (§5.8, T1.2, D-082).
1916    ///
1917    /// Same result as [`Self::rebuild_current`], different latency profile.
1918    /// `rebuild_current` is one transaction holding the write lock for its whole
1919    /// duration, because D-023 will not let the `DELETE` and the `INSERT` be
1920    /// split: a reader landing between them sees a graph with no edges and no
1921    /// error. This builds the replacement in a shadow table instead — the live
1922    /// table stays live and trigger-maintained throughout — and swaps it in at
1923    /// the end.
1924    ///
1925    /// Each step is its own actor turn, so an interactive assertion can jump the
1926    /// queue between chunks. That is the whole of the improvement, and it is why
1927    /// the loop is here rather than inside the actor's arm (the same reasoning
1928    /// as [`Self::archive_windowed`] and [`Self::bulk_import`]).
1929    ///
1930    /// # What the swap still costs
1931    ///
1932    /// Not microseconds. Index names are global and SQLite has no `ALTER INDEX
1933    /// … RENAME`, so the shadow cannot be built carrying `links_current`'s index
1934    /// names while `links_current` still holds them — and building it under
1935    /// other names would leave the table permanently indexed under names absent
1936    /// from [`CREATE_INDICES`](crate::schema::ddl::CREATE_INDICES), so the next
1937    /// migration would create a second copy of each.
1938    /// `DROP TABLE` frees the names, so the swap transaction is where
1939    /// the three indexes get built. What the chunking moves off the lock is the
1940    /// **projection** — the window function over all of `links` — which is the
1941    /// O(E log E) term.
1942    ///
1943    /// # When this returns an error rather than a repair
1944    ///
1945    /// [`DbError::RebuildInterrupted`] means an archive committed while the
1946    /// shadow was being built. Its deletions are invisible to a catch-up pass
1947    /// keyed on `recorded_at` — a deleted row has no `recorded_at` left to find
1948    /// it by — so the work is discarded rather than swapped in. `links_current`
1949    /// is untouched and the call can simply be retried.
1950    ///
1951    /// Use [`Self::rebuild_current`] when the repair must be one atomic act, or
1952    /// when nothing else is contending for the actor and the extra turns are
1953    /// pure overhead.
1954    pub async fn rebuild_current_chunked(&self) -> Result<RebuildReport> {
1955        use crate::integrity::{ShadowOutcome, ShadowStep};
1956
1957        // Each `else` arm is unreachable: the actor maps each step to its own
1958        // outcome variant. Written as a refutable pattern rather than an
1959        // `unwrap` so that adding a step cannot turn a mismatch into a panic on
1960        // the write path — and `WriterDroppedResponder` is the honest name for
1961        // "the actor answered with something this cannot use".
1962        let ShadowOutcome::Started { build_start, epoch } =
1963            self.shadow_step(ShadowStep::Begin).await?
1964        else {
1965            return Err(DbError::WriterDroppedResponder);
1966        };
1967
1968        let mut after: Option<String> = None;
1969        loop {
1970            let ShadowOutcome::Filled { last } = self
1971                .shadow_step(ShadowStep::Fill {
1972                    after: after.take(),
1973                })
1974                .await?
1975            else {
1976                return Err(DbError::WriterDroppedResponder);
1977            };
1978            match last {
1979                Some(last) => after = Some(last),
1980                None => break,
1981            }
1982        }
1983
1984        let ShadowOutcome::Swapped { rows } = self
1985            .shadow_step(ShadowStep::Swap { build_start, epoch })
1986            .await?
1987        else {
1988            return Err(DbError::WriterDroppedResponder);
1989        };
1990
1991        Ok(RebuildReport {
1992            rows_rebuilt: rows,
1993            // Not audited. The chunked path's whole argument is that the
1994            // expensive work happens off the lock, and `audit_current` is two
1995            // `EXCEPT` passes over the projection — the cost D-077 removed from
1996            // the archive for the same reason. A caller who wants the check has
1997            // `audit_current` on the read connection, where it costs nobody the
1998            // write lock.
1999            drift_after: 0,
2000        })
2001    }
2002
2003    /// Run one step of a chunked rebuild, for a caller doing its own scheduling.
2004    ///
2005    /// [`Self::rebuild_current_chunked`] is this in a loop and is what almost
2006    /// everyone wants. This exists because that loop offers no seam: it drives
2007    /// `Begin`, then `Fill` to exhaustion, then `Swap`, and a caller who needs to
2008    /// do something *between* steps — pace them against a frame budget, abandon
2009    /// a rebuild that has run long enough, or provoke the archive interlock in a
2010    /// test — cannot get in.
2011    ///
2012    /// The obligation that comes with it: `epoch` from
2013    /// [`ShadowOutcome::Started`](crate::integrity::ShadowOutcome) must be handed
2014    /// back to [`ShadowStep::Swap`](crate::integrity::ShadowStep), or the
2015    /// archive interlock is defeated and a stale projection can be swapped in.
2016    /// The looping version cannot get that wrong; this one can.
2017    pub async fn shadow_step(
2018        &self,
2019        step: crate::integrity::ShadowStep,
2020    ) -> Result<crate::integrity::ShadowOutcome> {
2021        self.low(|responder| LowPriCommand::ShadowRebuild { step, responder })
2022            .await
2023    }
2024
2025    /// Import edges on the background channel, chunked (D-011).
2026    ///
2027    /// Atomic *per chunk*, not overall: a failure partway leaves earlier chunks
2028    /// committed. That is the tradeoff [`chunk_rows`] documents — use
2029    /// [`Database::write_bulk_atomic`] when the batch must be all-or-nothing.
2030    ///
2031    /// Chunked adaptively, at most [`chunk_rows::EDGES`] rows at a time: that
2032    /// constant is where the loop starts and the largest chunk it will send, and
2033    /// each chunk's measured hold sizes the next against [`CHUNK_BUDGET`]. It is
2034    /// also faster in total than the larger chunks this used through 0.5.5
2035    /// (D-058).
2036    ///
2037    /// A consequence worth planning for: the chunk boundaries — and so the
2038    /// `recorded_at` stamps this import writes — depend on how fast the machine
2039    /// was, not only on how many edges were passed (§5.1.6).
2040    ///
2041    /// Returns [`BulkInterrupted`] rather than [`DbError`] on failure, because
2042    /// a path that is not all-or-nothing owes its caller the count of what
2043    /// landed (0.13.8, W7.6). `?` into a `Result<_, DbError>` still compiles
2044    /// and drops the count, which is the caller's decision to take.
2045    ///
2046    /// [`Self::bulk_import_with`] adds cancellation and per-chunk progress.
2047    pub async fn bulk_import(&self, edges: Vec<EdgeAssertion>) -> BulkResult<usize> {
2048        self.bulk_import_with(edges, BulkControl::new()).await
2049    }
2050
2051    /// [`Self::bulk_import`] with cancellation and progress (0.13.8, W7.6,
2052    /// D-181).
2053    ///
2054    /// The chunk boundaries this path already has are what make both possible:
2055    /// the loop is between transactions several times a second, which is where
2056    /// a token can be read and a callback run without holding anything.
2057    pub async fn bulk_import_with(
2058        &self,
2059        edges: Vec<EdgeAssertion>,
2060        control: BulkControl,
2061    ) -> BulkResult<usize> {
2062        let edges = normalize_all(edges).map_err(before_any_chunk)?;
2063        self.low_chunked(edges, chunk_rows::EDGES, control, |chunk, responder| {
2064            LowPriCommand::BulkImportChunk { chunk, responder }
2065        })
2066        .await
2067    }
2068
2069    /// Upsert many **concepts** on the background channel, chunked (D-011).
2070    ///
2071    /// This is the bulk concept path, and every row it writes is a ledger write:
2072    /// it versions the concept and lands in `transaction_log`. Derived analytics
2073    /// output does not belong here — see
2074    /// [`Database::write_analytics_annotations`] and D-041.
2075    ///
2076    /// Called `write_annotations` through 0.5.6, from when the two writes were
2077    /// one call. D-041 split them and the name stayed on the wrong one for three
2078    /// releases, so the crate had a `write_annotations` that wrote concepts
2079    /// sitting beside a `write_analytics_annotations` that wrote annotations
2080    /// (D-075).
2081    ///
2082    /// Chunked, so it returns [`BulkInterrupted`] and its `written` count on
2083    /// failure (0.13.8, W7.6); [`Self::write_concepts_with`] adds cancellation
2084    /// and progress.
2085    pub async fn write_concepts(&self, concepts: Vec<ConceptUpsert>) -> BulkResult<usize> {
2086        self.write_concepts_with(concepts, BulkControl::new()).await
2087    }
2088
2089    /// [`Self::write_concepts`] with cancellation and progress (0.13.8, W7.6).
2090    pub async fn write_concepts_with(
2091        &self,
2092        concepts: Vec<ConceptUpsert>,
2093        control: BulkControl,
2094    ) -> BulkResult<usize> {
2095        let concepts: Vec<ConceptUpsert> = concepts
2096            .into_iter()
2097            .map(ConceptUpsert::normalized)
2098            .collect::<Result<_>>()
2099            .map_err(before_any_chunk)?;
2100        self.low_chunked(
2101            concepts,
2102            chunk_rows::CONCEPTS,
2103            control,
2104            |chunk, responder| LowPriCommand::WriteConceptsChunk { chunk, responder },
2105        )
2106        .await
2107    }
2108
2109    /// State as believed at `ts` (§5.5, D-026, D-049).
2110    ///
2111    /// A read: it runs on `read_conn` and never touches the Write Actor, so a
2112    /// reconstruction and a full-speed write-back do not slow each other.
2113    ///
2114    /// Prefer this to calling [`crate::temporal::reconstruct`] directly. The
2115    /// free function takes the archive path and the snapshot directory as
2116    /// arguments, and a caller who passes `None` for the second gets a correct
2117    /// answer that folds the whole log every time — the composition is opt-in
2118    /// at that layer and easy to leave off by accident. Here both come from the
2119    /// handle, so the fast path is the default one.
2120    pub async fn reconstruct(&self, ts: &str) -> Result<crate::temporal::MaterializedState> {
2121        let ts = timestamp::normalize(ts)?;
2122        crate::temporal::reconstruct(
2123            &self.read_conn,
2124            &ts,
2125            Some(&self.archive_path),
2126            Some(&self.snapshots_dir),
2127        )
2128        .await
2129    }
2130
2131    /// Create a model's embedding table and DiskANN index (§5.9, D-048).
2132    ///
2133    /// Idempotent: registering a model that already exists at the same
2134    /// dimension succeeds, and at a different dimension fails with
2135    /// [`DbError::DimMismatch`] naming both, rather than no-opping through
2136    /// `IF NOT EXISTS` and leaving the caller believing the dimension they
2137    /// asked for is the one in force.
2138    ///
2139    /// This issues DDL, which everywhere else in the crate is the migration
2140    /// runner's exclusive business (D-032). The exception is bounded and
2141    /// deliberate: a model's table is created once, by an explicit call, and
2142    /// the alternative — a caller-supplied write connection — is the very thing
2143    /// the Write Actor exists to make impossible.
2144    ///
2145    /// # Latency
2146    ///
2147    /// One small transaction, but it queues like any other write: see §5.1.8.
2148    pub async fn register_model(&self, model: &ModelName, dim: usize) -> Result<()> {
2149        let model = model.clone();
2150        self.high(|responder| HighPriCommand::RegisterModel {
2151            model,
2152            dim,
2153            responder,
2154        })
2155        .await
2156    }
2157
2158    /// Store or replace vectors for `model`, chunked (§5.9, D-011, D-048).
2159    ///
2160    /// The write path for embeddings. Before 0.5.4 there was none:
2161    /// [`crate::vector::upsert_embedding`] takes a raw connection, `read_conn`
2162    /// is `query_only`, and the write connection lives inside the actor — so an
2163    /// application could search vectors it had no way to store.
2164    ///
2165    /// Low priority and chunked at [`chunk_rows::EMBEDDINGS`], because embedding
2166    /// is bulk derived work: a 50,000-vector backfill must yield to an
2167    /// interactive assertion at every chunk boundary. That constant is the
2168    /// smallest of the four by a wide margin — DiskANN index maintenance makes an
2169    /// embedding the most expensive row in the system (D-058). Atomic per chunk, not overall, which
2170    /// is the same trade [`Database::bulk_import`] makes and is safer here than
2171    /// there — an embedding is derived (Doctrine VII), so a partially written
2172    /// batch is recoverable by re-embedding.
2173    ///
2174    /// Fails with [`DbError::ModelNotRegistered`] if `model` has no table, and
2175    /// [`DbError::DimMismatch`] if a vector's length is not the declared
2176    /// dimension. The dimension is read from the schema once per chunk (D-037):
2177    /// the crate keeps no registry of its own to fall out of date.
2178    ///
2179    /// Chunked, so it returns [`BulkInterrupted`] and its `written` count on
2180    /// failure (0.13.8, W7.6). A 50,000-vector backfill is the longest-running
2181    /// write the crate has, which makes it the one most likely to be cancelled
2182    /// — [`Self::upsert_embeddings_with`] is how.
2183    pub async fn upsert_embeddings(
2184        &self,
2185        model: &ModelName,
2186        rows: Vec<(String, Vec<f32>)>,
2187    ) -> BulkResult<usize> {
2188        self.upsert_embeddings_with(model, rows, BulkControl::new())
2189            .await
2190    }
2191
2192    /// [`Self::upsert_embeddings`] with cancellation and progress (0.13.8,
2193    /// W7.6).
2194    pub async fn upsert_embeddings_with(
2195        &self,
2196        model: &ModelName,
2197        rows: Vec<(String, Vec<f32>)>,
2198        control: BulkControl,
2199    ) -> BulkResult<usize> {
2200        self.low_chunked(rows, chunk_rows::EMBEDDINGS, control, |chunk, responder| {
2201            LowPriCommand::UpsertEmbeddingChunk {
2202                model: model.clone(),
2203                chunk,
2204                responder,
2205            }
2206        })
2207        .await
2208    }
2209
2210    /// Reconstruct the concept-text search index from the ledger (§5.9, D-036).
2211    ///
2212    /// The FTS index is derivative: D-036 promises every derivative table can be
2213    /// rebuilt from the ledger tables, and this is that promise made callable
2214    /// for `concepts_fts`. Needed after a restore that skipped the shadow
2215    /// tables, or if the index is ever suspected of drifting from the text —
2216    /// and, as a matter of policy, cheaper to run than to reason about.
2217    ///
2218    /// The work is `INSERT INTO concepts_fts(concepts_fts) VALUES('rebuild')`,
2219    /// which is FTS5's own operation over the content table, so this is not a
2220    /// second implementation of the sync triggers that could disagree with them.
2221    pub async fn rebuild_fts(&self) -> Result<()> {
2222        self.low(|responder| LowPriCommand::RebuildFts { responder })
2223            .await
2224    }
2225
2226    /// Refresh the query planner's statistics (0.12.4, [D-149]).
2227    ///
2228    /// Runs `ANALYZE`, which writes `sqlite_stat1`. **Before 0.12.4 nothing in
2229    /// this crate ever did**, so the planner costed every query against SQLite's
2230    /// built-in defaults — assume ~1M rows, assume each bound equality column
2231    /// divides by ten. That estimate is structural: it depends on how many
2232    /// columns a query binds, not on what the table contains.
2233    ///
2234    /// Which is this schema's own worst defect restated. D-042, D-059 and D-064
2235    /// are three occasions where *a covering index captured a query because it
2236    /// contained the columns, not because it discriminated*, and two of the four
2237    /// declared indices lead on the same column. Statistics are what let the
2238    /// planner tell them apart by measurement instead of by shape.
2239    ///
2240    /// # Cost, and why it is bounded
2241    ///
2242    /// This is a write and it takes the write lock. `PRAGMA analysis_limit`
2243    /// (set per connection, see [`ddl::ANALYSIS_LIMIT`]) caps the rows examined
2244    /// per index. It is scheduled as low-priority work and will not preempt an
2245    /// interactive assertion.
2246    ///
2247    /// **The bound is a constant factor, not an independence** (0.12.23,
2248    /// D-166). This rustdoc said the hold "scales with the number of indices —
2249    /// four — and not with the size of `links_current`", which is measurably
2250    /// wrong: the pragma is worth 3–4× and what remains still grows with the
2251    /// table. Measured, `examples/analyze_hold.rs`: **5.26 ms at 10,000 edges,
2252    /// 19.1 ms at 40,000**, against a 3 ms [`crate::CHUNK_BUDGET`].
2253    ///
2254    /// So this call **misses the budget by ~6× on a moderately sized ledger**,
2255    /// and [`crate::metrics::CommandKind::Analyze`] is deliberately not among
2256    /// the budget-exempt kinds — `metrics().budget_violations()` names it. That
2257    /// is the honest position: the work is low priority and preemptible between
2258    /// commands, but it is one indivisible statement and cannot be chunked, so
2259    /// the hold is what it is. Prefer [`optimize`], which does nothing when
2260    /// nothing has moved.
2261    ///
2262    /// **Since 0.13.24 the counter is this call and not also [`optimize`]**
2263    /// (W10.5, [D-197]). The two shared `CommandKind::Analyze` until then, which
2264    /// is why an `analyze` row in `budget_violations()` used to be unreadable:
2265    /// it could have been an explicit call or a handle close.
2266    ///
2267    /// # When to call it
2268    ///
2269    /// After a bulk import, and after anything that changes a table's shape by
2270    /// an order of magnitude. Prefer [`optimize`] for routine upkeep: it does
2271    /// nothing when nothing has moved, and this does the work unconditionally.
2272    ///
2273    /// Statistics are derived state in the sense Doctrine VI means it — deleting
2274    /// `sqlite_stat1` costs plan quality and no information, and this call
2275    /// rebuilds it.
2276    ///
2277    /// [D-149]: ../docs/architecture/s13-decision-register.md#d-149
2278    /// [`ddl::ANALYSIS_LIMIT`]: crate::schema::ddl::ANALYSIS_LIMIT
2279    /// [`optimize`]: Database::optimize
2280    pub async fn analyze(&self) -> Result<()> {
2281        self.low(|responder| LowPriCommand::Analyze {
2282            incremental: false,
2283            responder,
2284        })
2285        .await
2286    }
2287
2288    /// Re-analyse only what has gone stale (0.12.4, [D-149]).
2289    ///
2290    /// `PRAGMA optimize`. SQLite tracks how far each table has drifted since its
2291    /// last analysis and re-analyses only where it believes the statistics no
2292    /// longer hold — so this is a no-op on an idle database and the full cost of
2293    /// [`analyze`] on one that has changed completely.
2294    ///
2295    /// That property is the whole point: it is safe to call on a schedule, where
2296    /// [`analyze`] is not. `close()` runs it, so a process that opens, works and
2297    /// closes keeps its statistics current without anybody arranging it.
2298    ///
2299    /// # What it costs, measured, and the threshold it applies rather than takes
2300    /// (0.13.24, W10.5, [D-197])
2301    ///
2302    /// `examples/optimize_hold.rs`, on a 40,000-edge ledger: **10.7 ms the
2303    /// first time on a database that has never been analysed** — there is
2304    /// nothing incremental about the first call — and **90–220 µs every time
2305    /// after**, well inside [`crate::CHUNK_BUDGET`].
2306    ///
2307    /// **The staleness test is SQLite's and it is a ratio, not a row count.**
2308    /// Measured by reading `sqlite_stat1` across the call rather than by timing
2309    /// it: growth of 2× and 5× both left the statistics **untouched**, and
2310    /// only at 25× did it re-analyse — for a 460 ms hold. So this is not a
2311    /// cheaper `analyze()` and calling it after a bulk load is not a way to
2312    /// refresh statistics the load invalidated: below the ratio it declines,
2313    /// and above it it costs what [`analyze`] costs. It reports as
2314    /// [`crate::metrics::CommandKind::Optimize`] since 0.13.24, which is what
2315    /// makes those two outcomes distinguishable in the metrics at all.
2316    ///
2317    /// [D-197]: ../docs/architecture/s13-decision-register.md#d-197
2318    ///
2319    /// [D-149]: ../docs/architecture/s13-decision-register.md#d-149
2320    /// [`analyze`]: Database::analyze
2321    pub async fn optimize(&self) -> Result<()> {
2322        self.low(|responder| LowPriCommand::Analyze {
2323            incremental: true,
2324            responder,
2325        })
2326        .await
2327    }
2328
2329    // **There is deliberately no `verify_fts()` (§5.9, D-071).**
2330    //
2331    // `rebuild_fts` is the repair with no way to ask whether it is needed, and
2332    // Wave 5 set out to add the missing half. FTS5 offers `'integrity-check'`,
2333    // which looked like exactly the engine-provided answer this crate prefers.
2334    // It is not: on libSQL 0.9.30 it verifies the index's *internal* consistency
2335    // and not its agreement with the content table. Measured — after
2336    // `'delete-all'` the index matches nothing where it matched ten rows, and
2337    // both `'integrity-check'` and `'integrity-check', 0` still report success.
2338    //
2339    // A `verify_fts()` on that footing would answer "healthy" for an empty
2340    // index, which is worse than having no method at all: it is the shape of
2341    // defect AC, a function that looks like it checks something and does not.
2342    // `an_emptied_fts_index_still_passes_integrity_check` pins the limitation so
2343    // that if a later libSQL fixes it, the test fails and says so.
2344
2345    /// Write derived analytics results on the background channel, chunked
2346    /// (§5.4, D-041).
2347    ///
2348    /// Rows go to `analytics_annotations`, which has no log trigger, so nothing
2349    /// written here reaches `transaction_log` and nothing here versions a
2350    /// concept. Rerunning an algorithm replaces the previous pass rather than
2351    /// recording that the world changed.
2352    ///
2353    /// Low priority and chunked at up to [`chunk_rows::ANNOTATIONS`] — the
2354    /// largest ceiling of the four, because this is the only bulk table carrying
2355    /// no triggers at all
2356    /// and its rows are correspondingly cheap (D-058) — so a 50,000-label Louvain
2357    /// save yields to interactive writes at every chunk boundary and carries the
2358    /// per-chunk fidelity boundary of §5.1.6 — a partially written pass is
2359    /// recoverable by rerunning, which is the property that makes derived state
2360    /// safe to write this way and assertions not.
2361    ///
2362    /// Chunked, so it returns [`BulkInterrupted`] and its `written` count on
2363    /// failure (0.13.8, W7.6); [`Self::write_analytics_annotations_with`] adds
2364    /// cancellation and progress.
2365    pub async fn write_analytics_annotations(
2366        &self,
2367        annotations: Vec<Annotation>,
2368    ) -> BulkResult<usize> {
2369        self.write_analytics_annotations_with(annotations, BulkControl::new())
2370            .await
2371    }
2372
2373    /// [`Self::write_analytics_annotations`] with cancellation and progress
2374    /// (0.13.8, W7.6).
2375    pub async fn write_analytics_annotations_with(
2376        &self,
2377        annotations: Vec<Annotation>,
2378        control: BulkControl,
2379    ) -> BulkResult<usize> {
2380        self.low_chunked(
2381            annotations,
2382            chunk_rows::ANNOTATIONS,
2383            control,
2384            |chunk, responder| LowPriCommand::WriteAnalyticsChunk { chunk, responder },
2385        )
2386        .await
2387    }
2388
2389    /// Move closed intervals and superseded log rows older than `cutoff` to the
2390    /// cold database (§5.7, D-012).
2391    pub async fn archive(&self, cutoff: &str) -> Result<ArchiveReport> {
2392        let cutoff = timestamp::normalize(cutoff)?;
2393        let archive_path = self.archive_path.clone();
2394        self.low(|responder| LowPriCommand::Archive {
2395            cutoff,
2396            archive_path,
2397            responder,
2398        })
2399        .await
2400    }
2401
2402    /// Move the named concepts back from the cold database into the hot tables
2403    /// (§2.3, C3).
2404    ///
2405    /// Rehydration is a **physical move back, not a write**: it mints no
2406    /// transaction-time facts and is invisible to both clocks. An id that is not
2407    /// in the cold file is skipped rather than being an error — the caller
2408    /// generally has a list from a cold-side query, and a partially-stale list is
2409    /// the normal case rather than a mistake. The report says how many actually
2410    /// moved.
2411    ///
2412    /// See [`RehydrateReport::rowids_reassigned`] for the one way a rehydrated
2413    /// row can differ from the row that was archived.
2414    pub async fn rehydrate(&self, ids: &[&str]) -> Result<RehydrateReport> {
2415        let ids: Vec<String> = ids.iter().map(|s| (*s).to_string()).collect();
2416        let archive_path = self.archive_path.clone();
2417        self.low(|responder| LowPriCommand::Rehydrate {
2418            ids,
2419            archive_path,
2420            responder,
2421        })
2422        .await
2423    }
2424
2425    /// Archive up to `cutoff` as a sequence of sessions, each covering at most
2426    /// `window` of **transaction** time (T1.1, D-080).
2427    ///
2428    /// `archive(cutoff)` is one transaction whose size is set by how long it has
2429    /// been since the last one, which makes it the least bounded of the three
2430    /// operations exempt from [`CHUNK_BUDGET`] — its hold is a function of
2431    /// operational history rather than of anything a caller chose. This runs the
2432    /// same work as *N* complete sessions, each with its own marker, horizon row
2433    /// and rebuild, and returns one [`ArchiveReport`] per session in order.
2434    ///
2435    /// # D-012 is satisfied per session, and that is what it requires
2436    ///
2437    /// The atomicity D-012 demands is that copy-then-delete never be split — a
2438    /// crash between the phases duplicates or loses rows. *N* small sessions
2439    /// satisfy that exactly as one large one does. The obligation windowing adds
2440    /// is that a partial run leave a coherent intermediate state, which it does:
2441    /// each session commits a valid horizon, so a failure at window *k* leaves a
2442    /// database archived up to boundary *k−1* and nothing in between. **The
2443    /// sequence is not atomic and does not claim to be** — on error, the reports
2444    /// for the sessions that did commit are lost with it, but their effect is
2445    /// not, and re-running with the same `cutoff` completes the job.
2446    ///
2447    /// # Each session is its own actor turn, and that is the entire point
2448    ///
2449    /// This loop lives here, on the handle, rather than inside the actor's
2450    /// `Archive` arm. Putting it there would have produced *N* small
2451    /// transactions inside **one** hold, which shrinks the transaction and
2452    /// changes the latency not at all: the actor is single-threaded, so nothing
2453    /// else writes until its turn returns regardless of how many `COMMIT`s the
2454    /// turn contains. Sending *N* commands returns the actor to its `select!`
2455    /// between sessions, which is where an interactive assertion gets to jump
2456    /// the queue — and it is high-priority, so it does.
2457    ///
2458    /// The same reasoning is why [`Self::bulk_import`] chunks here and not
2459    /// there, and it is the trap T1.2 names for `CREATE TABLE … AS SELECT`.
2460    ///
2461    /// # Choosing a window
2462    ///
2463    /// The bound is on *transaction* time, so the session count is set by how
2464    /// far back the hot file goes, not by how much it holds. A window is
2465    /// rejected rather than clamped if it would need more than
2466    /// [`MAX_ARCHIVE_SESSIONS`] sessions — see [`DbError::ArchiveWindow`].
2467    ///
2468    /// Windows containing nothing archivable are cheap but not free: each still
2469    /// opens a transaction and writes a horizon row. What they no longer do is
2470    /// re-project `links_current`, which `archive_session` now skips when its
2471    /// `DELETE` removed no rows — without that, windowing costs *more* in total
2472    /// than not windowing, because the repair term scales with the surviving
2473    /// table and not with the batch (D-077).
2474    pub async fn archive_windowed(
2475        &self,
2476        cutoff: &str,
2477        window: std::time::Duration,
2478    ) -> Result<Vec<ArchiveReport>> {
2479        let cutoff = timestamp::normalize(cutoff)?;
2480        let boundaries = self.archive_boundaries(&cutoff, window).await?;
2481
2482        let mut reports = Vec::with_capacity(boundaries.len());
2483        for boundary in boundaries {
2484            let archive_path = self.archive_path.clone();
2485            reports.push(
2486                self.low(|responder| LowPriCommand::Archive {
2487                    cutoff: boundary,
2488                    archive_path,
2489                    responder,
2490                })
2491                .await?,
2492            );
2493        }
2494        Ok(reports)
2495    }
2496
2497    /// The cutoffs [`Self::archive_windowed`] will run, ascending, ending at
2498    /// `cutoff` exactly.
2499    ///
2500    /// Read on `read_conn`, not on the actor: this is two `MIN`s and the actor
2501    /// has no reason to hold its lock for them.
2502    ///
2503    /// The lower end comes from the data rather than from the clock. Stepping
2504    /// from some fixed epoch would make the session count a function of the
2505    /// calendar — a database opened yesterday would still be asked to archive
2506    /// 1970 — whereas the oldest `recorded_at` actually present is the earliest
2507    /// boundary that can contain anything.
2508    async fn archive_boundaries(
2509        &self,
2510        cutoff: &str,
2511        window: std::time::Duration,
2512    ) -> Result<Vec<String>> {
2513        // A single session at `cutoff` is exactly `archive(cutoff)`, and it is
2514        // the right answer for an empty hot file: it still writes the horizon
2515        // row, so windowed and unwindowed runs leave the same observable state.
2516        let Some(oldest) = self.oldest_hot_stamp(cutoff).await? else {
2517            return Ok(vec![cutoff.to_string()]);
2518        };
2519
2520        let start = timestamp::parse(&oldest)?;
2521        let end = timestamp::parse(cutoff)?;
2522        let Ok(span) = end.duration_since(start) else {
2523            // Everything in the hot file is at or after the cutoff, so there is
2524            // nothing in range to divide.
2525            return Ok(vec![cutoff.to_string()]);
2526        };
2527
2528        if window.is_zero() {
2529            return Err(DbError::ArchiveWindow {
2530                window,
2531                reason: "a zero-length window never advances past the first boundary".into(),
2532            });
2533        }
2534
2535        // `div_ceil` on nanos: a span of 90 minutes in 60-minute windows is two
2536        // sessions, not one. `as_nanos` is u128, so neither the division nor the
2537        // span can overflow for any timestamp this crate can store.
2538        let sessions = span.as_nanos().div_ceil(window.as_nanos());
2539        if sessions > MAX_ARCHIVE_SESSIONS as u128 {
2540            return Err(DbError::ArchiveWindow {
2541                window,
2542                reason: format!(
2543                    "a span of {span:?} would need {sessions} sessions (limit \
2544                     {MAX_ARCHIVE_SESSIONS}); widen the window"
2545                ),
2546            });
2547        }
2548
2549        let mut boundaries = Vec::with_capacity(sessions as usize);
2550        for k in 1..sessions {
2551            boundaries.push(timestamp::format(start + window * k as u32));
2552        }
2553        // The last boundary is `cutoff` itself and not `start + n*window`, which
2554        // would overshoot and archive rows the caller excluded.
2555        boundaries.push(cutoff.to_string());
2556        Ok(boundaries)
2557    }
2558
2559    /// Oldest `recorded_at` below `cutoff` in either hot table, or `None`.
2560    async fn oldest_hot_stamp(&self, cutoff: &str) -> Result<Option<String>> {
2561        let mut oldest: Option<String> = None;
2562        for table in ["links", "transaction_log"] {
2563            let found: Option<String> = self
2564                .read_conn
2565                .query(
2566                    &format!("SELECT MIN(recorded_at) FROM {table} WHERE recorded_at < ?1"),
2567                    libsql::params![cutoff],
2568                )
2569                .await?
2570                .next()
2571                .await?
2572                .and_then(|row| row.get(0).ok());
2573            if let Some(found) = found {
2574                if oldest.as_ref().is_none_or(|o| found < *o) {
2575                    oldest = Some(found);
2576                }
2577            }
2578        }
2579        Ok(oldest)
2580    }
2581
2582    /// Send a high-priority command and wait for its answer.
2583    ///
2584    /// The two error mappings here are the whole reason this helper exists.
2585    /// `send` failing means the actor is gone — `WriterUnavailable`. The
2586    /// responder being dropped without an answer means the actor took the
2587    /// command and never replied — `WriterDroppedResponder`, which is a bug in
2588    /// the actor rather than a condition the caller can retry. Both variants
2589    /// existed in `error.rs` from 0.4.5 and neither was ever constructed, so a
2590    /// dead actor and a hung one were both just a caller waiting forever.
2591    async fn high<T>(
2592        &self,
2593        make: impl FnOnce(oneshot::Sender<Result<T>>) -> HighPriCommand,
2594    ) -> Result<T> {
2595        let (tx, rx) = oneshot::channel();
2596        self.highpri_tx
2597            .send(make(tx))
2598            .await
2599            .map_err(|_| DbError::WriterUnavailable)?;
2600        rx.await.map_err(|_| DbError::WriterDroppedResponder)?
2601    }
2602
2603    /// Send each chunk in turn and sum the counts — the shape all four bulk
2604    /// paths share (T3.4, D-086).
2605    ///
2606    /// # This is sequential on purpose, and the purpose is a measurement
2607    ///
2608    /// T3.4 proposed pipelining: send *k* chunks ahead so the actor never finds
2609    /// an empty queue. The reasoning is that awaiting each chunk before building
2610    /// the next leaves the actor idle for a channel round trip every time, which
2611    /// on a 1M-edge import is ~11,000 idle gaps.
2612    ///
2613    /// Both halves of that are true and the conclusion does not follow. The gaps
2614    /// are real; they are also **four orders of magnitude smaller than the work
2615    /// they interrupt**. A tokio mpsc hop is sub-microsecond and a chunk takes
2616    /// 13–21 ms. Implemented and swept at depths 1, 2, 4, 8 and 16 over 20K and
2617    /// 100K edges: every cell landed within 1% of sequential, in both directions
2618    /// — see `examples/pipeline_diag.rs`, which is kept precisely so this is not
2619    /// re-proposed from the same reasoning.
2620    ///
2621    /// So the pipelining was removed and the deduplication kept. It was not free
2622    /// to hold: with chunks in flight, a failure at chunk `i` no longer leaves a
2623    /// **prefix** committed, because `i+1 ..= i+k-1` were already sent and commit
2624    /// anyway. D-011 promises "earlier chunks committed", and paying for that
2625    /// with a weaker recovery story in exchange for nothing measurable is the
2626    /// wrong trade.
2627    ///
2628    /// Sending stops at the first error, so what commits is exactly the prefix
2629    /// before the failure.
2630    /// # The size is now measured, not assumed (0.12.0, W3)
2631    ///
2632    /// Until 0.11.0 the caller pre-split into `chunks(chunk_rows::WHATEVER)` and
2633    /// this loop sent what it was given. That made the constant *the* size, and
2634    /// D-143 is the record of a constant fitted at one population being wrong at
2635    /// another: all four D-088 shapes agreed the largest in-budget edge chunk was
2636    /// **20** against a shipped 90, and 20 would itself have been wrong at 80,000
2637    /// edges, because per-row cost on that path grows with `links_current`.
2638    ///
2639    /// No row count can bound a duration on such a path, so the loop stopped
2640    /// trying to pick one ahead of time. `ceiling` — still the path's
2641    /// [`chunk_rows`] constant, with its derivation intact — is now the largest
2642    /// size this will ever ask for, and each chunk's measured hold chooses the
2643    /// next through `next_chunk_size`.
2644    ///
2645    /// **Feedback, not preemption.** The chunk in flight always commits in full;
2646    /// the SQLite write lock is not preemptible, so nothing here can shorten a
2647    /// transaction already running. A batch of one chunk gets no protection at
2648    /// all, and convergence costs one or two chunks — which is the price of the
2649    /// bound being a duration rather than a promise.
2650    ///
2651    /// The last chunk's outcome is discarded, there being no next chunk to size.
2652    /// The chunk loop behind all four bulk paths.
2653    ///
2654    /// **Every exit carries `written`** (0.13.8, W7.6, D-181). It used to
2655    /// carry it only out of the success arm: the three error paths were `?` on
2656    /// a [`DbError`], which discards the local, so a caller whose 20,000-row
2657    /// import failed in the last chunk learned that it failed and not that
2658    /// 19,000 rows were already in the database. The count was never expensive
2659    /// to keep — it is right there, and the loop needs it anyway to size the
2660    /// next chunk.
2661    async fn low_chunked<T>(
2662        &self,
2663        items: Vec<T>,
2664        ceiling: usize,
2665        control: BulkControl,
2666        make: impl Fn(Vec<T>, oneshot::Sender<Result<ChunkOutcome>>) -> LowPriCommand,
2667    ) -> BulkResult<usize> {
2668        let total = items.len();
2669        let mut items = items.into_iter();
2670        let mut size = ceiling.max(1);
2671        let mut written = 0usize;
2672        loop {
2673            let chunk: Vec<T> = items.by_ref().take(size).collect();
2674            if chunk.is_empty() {
2675                // Emptiness is checked before cancellation on purpose: a token
2676                // raised after the last chunk committed is asking to stop work
2677                // that is already done, and reporting that as a failure would
2678                // make a race between the caller's two threads decide whether a
2679                // complete import counts as one.
2680                return Ok(written);
2681            }
2682            // Between chunks, never inside one. Nothing is rolled back and no
2683            // transaction is interrupted -- the loop simply stops sending, and
2684            // the prefix that committed is the same kind of prefix a failure
2685            // would have left.
2686            if control.is_cancelled() {
2687                return Err(BulkInterrupted {
2688                    written,
2689                    cause: DbError::BulkCancelled,
2690                });
2691            }
2692            let stop = |cause: DbError| BulkInterrupted { written, cause };
2693            let (tx, rx) = oneshot::channel();
2694            self.lowpri_tx
2695                .send(make(chunk, tx))
2696                .await
2697                .map_err(|_| stop(DbError::WriterUnavailable))?;
2698            let outcome = match rx.await {
2699                Err(_) => return Err(stop(DbError::WriterDroppedResponder)),
2700                Ok(Err(e)) => return Err(stop(e)),
2701                Ok(Ok(outcome)) => outcome,
2702            };
2703            written += outcome.rows;
2704            control.report(BulkProgress {
2705                written,
2706                total,
2707                rows: outcome.rows,
2708                held: outcome.held,
2709            });
2710            size = next_chunk_size(size, outcome.held, CHUNK_BUDGET, CHUNK_FLOOR, ceiling);
2711        }
2712    }
2713
2714    async fn low<T>(
2715        &self,
2716        make: impl FnOnce(oneshot::Sender<Result<T>>) -> LowPriCommand,
2717    ) -> Result<T> {
2718        let (tx, rx) = oneshot::channel();
2719        self.lowpri_tx
2720            .send(make(tx))
2721            .await
2722            .map_err(|_| DbError::WriterUnavailable)?;
2723        rx.await.map_err(|_| DbError::WriterDroppedResponder)?
2724    }
2725
2726    /// Clean shutdown: stop the Write Actor, then write the final snapshot (§5.1.7).
2727    ///
2728    /// Order matters. The snapshot is taken *after* the actor has stopped and
2729    /// been joined, so no write can land between the fold and the file — the
2730    /// anchor it records is the last thing that happened, not the last thing
2731    /// that happened to be visible.
2732    ///
2733    /// A failed snapshot is reported rather than swallowed. It is not a
2734    /// durability loss — the ledger is in the WAL and the log replays without
2735    /// it — but it means the next open starts from an older anchor, and a caller
2736    /// that never hears about it cannot know why startup got slower.
2737    ///
2738    /// **The cadence stops first (§5.5, D-053).** Both it and `write_final` end
2739    /// by running retention over the snapshot directory, and retention deletes
2740    /// files. Letting them overlap would mean one pass enumerating the directory
2741    /// while the other removes from it — not a correctness problem for the
2742    /// ledger, which is why the ordering is stated rather than locked, but a
2743    /// source of spurious warnings and of a final anchor that could be deleted
2744    /// by a cleanup that started before it existed. Stopping the cadence, then
2745    /// the actor, then taking the snapshot leaves exactly one writer at each
2746    /// step.
2747    pub async fn close(mut self) -> Result<()> {
2748        if let Some(stop) = self.cadence_stop.take() {
2749            let _ = stop.send(true);
2750        }
2751        if let Some(handle) = self.cadence.take() {
2752            let _ = handle.await;
2753        }
2754
2755        // Top up the planner's statistics while the actor is still alive to do
2756        // it (0.12.4, D-149). `PRAGMA optimize` re-analyses only what SQLite
2757        // believes has gone stale, so on a database that did nothing this costs
2758        // nothing, and on one that was just bulk-loaded it is the difference
2759        // between the next process planning on measurements and planning on
2760        // built-in guesses.
2761        //
2762        // **Deliberately not fatal.** A failure here costs plan quality on the
2763        // next open and nothing else — no ledger state depends on it — and
2764        // `close()` is where a caller learns whether their *writes* survived.
2765        // Turning a stale-statistics problem into a failed close would bury that
2766        // answer under a much less important one.
2767        if let Err(e) = self.optimize().await {
2768            tracing::warn!(
2769                "PRAGMA optimize failed during close(): {e}. Statistics may be \
2770                 stale for the next process; call analyze() to rebuild them. \
2771                 Nothing else is affected."
2772            );
2773        }
2774
2775        let (tx, rx) = oneshot::channel();
2776        let _ = self
2777            .highpri_tx
2778            .send(HighPriCommand::Shutdown { responder: tx })
2779            .await;
2780        let _ = rx.await;
2781
2782        // **The writer's exit status is propagated, not discarded (Wave 4.2).**
2783        // It used to be `let _ = handle.await`, so an actor that had died closed
2784        // "successfully" and the caller's last chance to learn that the write
2785        // path was gone was spent silently.
2786        //
2787        // Through 0.13.3 this awaited a `JoinHandle<Result<()>>` and did
2788        // `Ok(res) => res?`, which looked like two failure paths and was one:
2789        // the actor's `Result` could not be `Err` (W7.3, D-177). What remains is
2790        // the branch that can fire — the actor panicked or was aborted — mapped
2791        // by `writer_exit`, which is tested against a real `JoinError`.
2792        //
2793        // Ordered before the final snapshot on purpose: a snapshot written after
2794        // a dead writer records a state the caller has no reason to trust, and
2795        // returning the error while also having written that file is worse than
2796        // not writing it.
2797        if let Some(handle) = self.writer.take() {
2798            writer_exit(handle.await)?;
2799        }
2800
2801        let ts = self.clock.now();
2802        let archive = self
2803            .archive_path
2804            .exists()
2805            .then_some(self.archive_path.as_path());
2806        snapshot::write_final(&self.read_conn, &self.snapshots_dir, &ts, archive).await?;
2807
2808        // Marks the handle closed so `Drop` knows not to complain.
2809        self.closed = true;
2810        Ok(())
2811    }
2812}
2813
2814/// Notes a missed `close()` at `warn!`, and deliberately does **not** assert.
2815///
2816/// **§7.3 offered option B — document `close()` as mandatory and `debug_assert`
2817/// in `Drop` — and Wave 4.2 implemented it, measured the consequence, and
2818/// reduced it to a warning.** The assert fired on roughly thirty tests on its
2819/// first run. That is the signal it was built to produce, and the right reading
2820/// of it was not "thirty tests are wrong".
2821///
2822/// What dropping actually costs is one final snapshot. Nothing else: every
2823/// public write method awaits its responder, so by the time a caller *can* drop
2824/// the handle, every write it issued has already committed; and the cadence stops
2825/// on its own, because `cadence_stop` is a `watch::Sender` whose drop signals the
2826/// task. A snapshot is derivative state under Doctrine VI — disposable,
2827/// reconstructible, and never the only copy of anything. Losing one makes the
2828/// next `reconstruct` fold from an older anchor, which is **slower, not wrong**.
2829///
2830/// A `debug_assert` aborts a test run. Spending that on a performance loss, in a
2831/// project whose own notes say a suite that fails for reasons unrelated to the
2832/// code under test trains people to ignore red, is the wrong trade — and paying
2833/// it in thirty places would have made `close()` look mandatory by ceremony
2834/// rather than by consequence. `close()` remains the right thing to call, and
2835/// the two reasons to call it are now stated where they can be acted on: the
2836/// snapshot, and the writer's `Result`, which only `close()` can return.
2837///
2838/// Option A ("abort the actor and log") stays rejected, for the reason it was
2839/// rejected twice before: `Drop` cannot await, so it cannot drain, and cleanup
2840/// that cannot clean up is worse than none — it looks like cleanup.
2841impl Drop for Database {
2842    fn drop(&mut self) {
2843        if !self.closed {
2844            tracing::warn!(
2845                "Database dropped without close(): the final snapshot was not written, \
2846                 so the next reconstruct folds from an older anchor, and the write \
2847                 actor's exit status was not checked. Prefer close().await."
2848            );
2849        }
2850    }
2851}
2852
2853/// A failure before the first chunk was sent, which committed nothing.
2854///
2855/// Normalisation runs over the whole batch up front, so its errors are the one
2856/// class the chunk loop never sees — and they are still [`BulkInterrupted`],
2857/// because a caller matching on one error type should not have to match on two
2858/// to find out that nothing landed (0.13.8, W7.6).
2859fn before_any_chunk(cause: DbError) -> BulkInterrupted {
2860    BulkInterrupted { written: 0, cause }
2861}
2862
2863fn normalize_all(edges: Vec<EdgeAssertion>) -> Result<Vec<EdgeAssertion>> {
2864    edges.into_iter().map(EdgeAssertion::normalized).collect()
2865}
2866
2867/// One `FULL` checkpoint for the numbers, then a `TRUNCATE` for the file.
2868///
2869/// # Why `TRUNCATE` and not a mode parameter
2870///
2871/// The four SQLite modes are not four things a caller of *this* crate wants.
2872/// `PASSIVE` is what the automatic checkpointer already runs on its own, so an
2873/// explicit `PASSIVE` asks for something that was going to happen anyway;
2874/// `RESTART` and `FULL` differ from `TRUNCATE` only in whether the WAL file is
2875/// left at its high-water size. The reason W5.2 exists is
2876/// [`Tuning::wal_autocheckpoint`] — a bulk importer turns the automatic
2877/// checkpointer off and calls this once at the end — and what that caller wants
2878/// is the WAL *gone*, not smaller than it was. So the mode is fixed and decided
2879/// here rather than pushed to the caller as a choice they would have to read
2880/// SQLite's documentation to make. If a mode ever needs selecting, that is an
2881/// additive method, not a change to this one.
2882///
2883/// # Why it is two pragmas, which is not the obvious implementation
2884///
2885/// **A successful `TRUNCATE` reports `busy=0, log=0, checkpointed=0`** — the
2886/// counts describe the WAL *after* the operation, and after a truncation there
2887/// is no WAL to describe. Measured, not inferred: on a 387-frame WAL, `PASSIVE`
2888/// returns `0, 387, 387` and `TRUNCATE` on the same file returns `0, 0, 0`. So
2889/// the single-pragma implementation returns a [`CheckpointReport`] whose two
2890/// counts are structurally zero on success, which makes the whole struct a
2891/// less useful `bool`.
2892///
2893/// `FULL` copies every frame back and reports what it moved; the `TRUNCATE`
2894/// that follows finds nothing left to copy and resets the file. The second pass
2895/// is close to free for exactly that reason — it is a file operation, not a
2896/// second copy. `busy` is the **union**: a checkpoint that was blocked in
2897/// either phase did not fully happen, and a caller about to copy the database
2898/// file elsewhere needs the pessimistic answer.
2899///
2900/// # They return rows, so they go through `query()`
2901///
2902/// The same libsql constraint the pragmas in `configure` document: `execute()`
2903/// rejects any statement that yields rows, and these yield the row that is the
2904/// entire point.
2905async fn run_checkpoint(conn: &libsql::Connection) -> Result<CheckpointReport> {
2906    // The columns are `busy, log, checkpointed`. SQLite reports -1 for the two
2907    // counts when the checkpoint could not run; clamped to 0 rather than
2908    // surfaced as a signed count, because `busy` already carries "this did not
2909    // happen" and a negative frame count is not a quantity anyone can use.
2910    //
2911    // A database not in WAL mode returns no row at all. `configure` puts every
2912    // connection this crate opens into WAL, so that is unreachable here — but a
2913    // zeroed report is a better failure than a panic if it stops being.
2914    async fn one(conn: &libsql::Connection, sql: &str) -> Result<(bool, u64, u64)> {
2915        let mut rows = conn.query(sql, ()).await?;
2916        let Some(row) = rows.next().await? else {
2917            return Ok((false, 0, 0));
2918        };
2919        let field = |i: i32| -> u64 { row.get::<i64>(i).unwrap_or(0).max(0) as u64 };
2920        Ok((row.get::<i64>(0).unwrap_or(0) != 0, field(1), field(2)))
2921    }
2922
2923    let (full_busy, _, moved) = one(conn, "PRAGMA wal_checkpoint(FULL)").await?;
2924    let (trunc_busy, log_frames, _) = one(conn, "PRAGMA wal_checkpoint(TRUNCATE)").await?;
2925
2926    Ok(CheckpointReport {
2927        busy: full_busy || trunc_busy,
2928        log_frames,
2929        checkpointed_frames: moved,
2930    })
2931}
2932
2933/// Pragmas that mean something on **any** connection, including one opened
2934/// `SQLITE_OPEN_READ_ONLY` (0.12.16, W5.5, D-159).
2935///
2936/// Both of these are per-connection state that a reader is subject to just as a
2937/// writer is. `busy_timeout` is the one that made this a finding:
2938/// [`Database::diagnostic_conn`] ran with SQLite's default of **0** — return
2939/// `SQLITE_BUSY` immediately — while every other connection in the process
2940/// waited 5 s, so the one surface whose job is to answer questions when the
2941/// typed path is already suspect was also the one most likely to fail with
2942/// "database is locked" under exactly the contention that prompted the
2943/// question.
2944async fn configure_common(conn: &libsql::Connection, cache_size: Option<i32>) -> Result<()> {
2945    // NOTE: `busy_timeout` returns its resulting value as a row, and libsql's
2946    // `execute()` rejects any statement that yields rows ("Execute returned
2947    // rows"). It must be issued through `query()`.
2948    let _ = conn.query("PRAGMA busy_timeout = 5000", ()).await?;
2949    // Per-connection, and split writer from reader since 0.12.15 (W5.4,
2950    // D-158). `None` runs no pragma at all rather than restating SQLite's
2951    // default, so the default remains SQLite's to change.
2952    if let Some(pages) = cache_size {
2953        conn.execute(&format!("PRAGMA cache_size = {pages}"), ())
2954            .await?;
2955    }
2956    Ok(())
2957}
2958
2959/// Pragmas that only mean anything where writes can happen (0.12.16, W5.5).
2960///
2961/// Not run on [`Database::diagnostic_conn`], and the reason is not tidiness:
2962/// `journal_mode = WAL` is a change to the *database file*, which a connection
2963/// opened `SQLITE_OPEN_READ_ONLY` cannot make. The rest —
2964/// `synchronous`, `foreign_keys`, `recursive_triggers`, and the `ANALYZE`
2965/// bound — govern how writes behave, and a connection that cannot write is not
2966/// governed by them.
2967///
2968/// The write connection and the two internal readers all still get these. The
2969/// internal readers are opened from the same read-write `libsql::Database`, so
2970/// the pragmas apply; leaving them out would be a behaviour change made for
2971/// symmetry, which is not a reason.
2972async fn configure_writable(conn: &libsql::Connection) -> Result<()> {
2973    // Returns its resulting value as a row — see the note in `configure_common`.
2974    let _ = conn.query("PRAGMA journal_mode = WAL", ()).await?;
2975    conn.execute("PRAGMA synchronous = NORMAL", ()).await?;
2976    conn.execute("PRAGMA foreign_keys = ON", ()).await?;
2977    conn.execute("PRAGMA recursive_triggers = OFF", ()).await?;
2978    // Bounds every `ANALYZE` this connection will ever run, explicit or
2979    // triggered by `PRAGMA optimize` (D-149). Set here rather than around the
2980    // call sites so the scheduled path is bounded too — that is the half that
2981    // runs with nobody watching. Returns the previous limit as a row, so it goes
2982    // through `query()` for the reason the note above gives.
2983    let _ = conn.query(crate::schema::ddl::ANALYSIS_LIMIT, ()).await?;
2984    Ok(())
2985}
2986
2987/// Full pragma configuration, for a connection that can write.
2988async fn configure(
2989    conn: libsql::Connection,
2990    cache_size: Option<i32>,
2991) -> Result<libsql::Connection> {
2992    configure_writable(&conn).await?;
2993    configure_common(&conn, cache_size).await?;
2994    Ok(conn)
2995}
2996
2997/// Helper to derive the snapshot directory by convention: foo.db -> foo_snapshots/
2998fn derive_snapshots_dir(path: &Path) -> PathBuf {
2999    let mut dir = path.to_path_buf();
3000    let stem = path
3001        .file_stem()
3002        .and_then(|s| s.to_str())
3003        .unwrap_or("macrame");
3004    dir.set_file_name(format!("{stem}_snapshots"));
3005    dir
3006}
3007
3008/// Helper to derive archive database path by convention: foo.db -> foo_archive.db
3009fn derive_archive_path(path: &Path) -> PathBuf {
3010    let mut archive = path.to_path_buf();
3011    if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
3012        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("db");
3013        archive.set_file_name(format!("{stem}_archive.{ext}"));
3014    } else {
3015        archive.set_extension("archive.db");
3016    }
3017    archive
3018}
3019
3020/// Dedicated Write Actor event loop prioritizing high-priority UI requests over low-priority background work.
3021///
3022/// # The turn is the unit, not the statement (T1.4)
3023///
3024/// One iteration of this loop is one *hold*: the actor is single-threaded and
3025/// the SQLite write lock is not preemptible, so from the moment a command starts
3026/// executing until it returns, nothing else writes. That is the quantity
3027/// [`CHUNK_BUDGET`] bounds, and so it is the quantity
3028/// [`crate::metrics::ActorMetrics`] measures — deliberately around the whole
3029/// `execute` call rather than inside it. Timing the SQL alone would have
3030/// reported a bound that held while callers waited.
3031///
3032/// Queue depth is sampled *before* the `select!`, so it is the backlog the turn
3033/// found on arrival rather than the one it left behind.
3034///
3035/// # `biased` has no floor, and since 0.12.10 that is measured (W4.4, D-153)
3036///
3037/// `biased` makes the arms poll in declaration order, so high-priority work is
3038/// taken whenever any is ready. Nothing bounds how long that can continue:
3039/// sustained interactive traffic can hold the low tier off indefinitely, and
3040/// through 0.12.9 nothing in the crate could say whether it ever did.
3041/// `record_priority_choice` counts the turns where the choice went against
3042/// queued low-priority work, and the longest unbroken run of them, which is the
3043/// half that distinguishes "prioritised" from "starved".
3044///
3045/// **No forced yield is added here.** Whether one is needed is the question the
3046/// counter answers, and adding a policy now would be fixing a bound nobody has
3047/// observed being hit — the same mistake D-124 was retracted for.
3048///
3049/// # It returns nothing, and used to return a `Result` it could not fail
3050/// (0.13.4, W7.3, §3.5, [D-177])
3051///
3052/// The two exits are `LoopCtl::Break` from [`HighPriCommand::Shutdown`] and the
3053/// `else` arm when both channels are closed. Neither can fail, and neither
3054/// could before: every command's error goes back on that command's own
3055/// responder, where the caller who issued it can act on it. There was no third
3056/// thing for an actor-level `Err` to carry, and none was ever constructed.
3057///
3058/// A `Result` that is structurally always `Ok` is not free. It reads as a
3059/// failure path under review, so `close()`'s `res?` looked like it was doing
3060/// something, and the branch that actually fires — a **panicked** actor,
3061/// reported as a `JoinError` — sat beside it untested. That is the swap this
3062/// change makes: the unfireable branch is gone and the real one is pinned, in
3063/// [`writer_exit`].
3064async fn run_writer_actor(
3065    conn: libsql::Connection,
3066    clock: Arc<dyn Clock>,
3067    mut highpri_rx: mpsc::Receiver<HighPriCommand>,
3068    mut lowpri_rx: mpsc::Receiver<LowPriCommand>,
3069    shared: Arc<ActorShared>,
3070) {
3071    loop {
3072        // Read once and reused by both the depth sample and the starvation
3073        // counter, so the two cannot disagree about what was queued when this
3074        // turn went looking (W4.4, D-153).
3075        let low_queued = lowpri_rx.len();
3076        shared.metrics.record_turn(highpri_rx.len(), low_queued);
3077
3078        let ctl = tokio::select! {
3079            biased;
3080            Some(cmd) = highpri_rx.recv() => {
3081                shared.metrics.record_priority_choice(true, low_queued);
3082                let turn = Turn::start(cmd.kind(), &shared);
3083                cmd.execute(&conn, &*clock, &turn).await
3084            }
3085            Some(cmd) = lowpri_rx.recv() => {
3086                shared.metrics.record_priority_choice(false, low_queued);
3087                let turn = Turn::start(cmd.kind(), &shared);
3088                cmd.execute(&conn, &*clock, &turn).await
3089            }
3090            else => LoopCtl::Break,
3091        };
3092        if matches!(ctl, LoopCtl::Break) {
3093            break;
3094        }
3095    }
3096}
3097
3098/// Turn the write actor's join status into the error `close()` reports.
3099///
3100/// One line of mapping, given a name so it can be tested against a real
3101/// [`tokio::task::JoinError`]. Before 0.13.4 this was inline beside a `res?` on
3102/// an actor `Result` that could only ever be `Ok`, and the arrangement had the
3103/// coverage exactly backwards: the branch that cannot fire was plumbed through
3104/// two signatures, and the branch that does fire — the actor panicked, and the
3105/// caller's writes are going nowhere — had no test at all (W7.3, D-177).
3106///
3107/// Cancellation is folded in with panics deliberately. `JoinError` distinguishes
3108/// them, and nothing in the crate ever aborts this task, so a cancelled writer
3109/// means something outside the crate reached in and stopped it. That is not a
3110/// gentler condition than a panic and must not read as one.
3111fn writer_exit(joined: std::result::Result<(), tokio::task::JoinError>) -> Result<()> {
3112    joined.map_err(|e| DbError::WriterStopped(format!("the write actor did not exit cleanly: {e}")))
3113}
3114
3115/// One command's hold: the timer, its label, and the counters it reports to.
3116///
3117/// # The hold is recorded *before* the caller is answered, and it has to be
3118///
3119/// The obvious placement — time the whole `execute` call from the loop — is
3120/// wrong in a way that only shows up under test. Every arm of `execute` ends by
3121/// sending on a `oneshot`, which wakes the waiting caller; the actor then
3122/// returns to the loop and records. Those are two tasks, so a caller that awaits
3123/// its own write and immediately reads [`Database::metrics`] can be scheduled
3124/// first and see a turn count that does not include the write it just did.
3125///
3126/// Not a correctness bug in the ledger, and it would never have been noticed in
3127/// production — a dashboard sampling every few seconds cannot see the window.
3128/// It makes every test and diagnostic of the counters flaky, which is worse: the
3129/// instrumentation would have been *believed* while being wrong exactly when
3130/// someone tried to check it. `examples/bulk_atomic_diag.rs` was the thing that
3131/// caught it, reporting a 20,000-row batch as a 0 ms hold.
3132///
3133/// So `answer` records and then sends, in that order, and the ordering is the
3134/// method's whole reason to exist. What it costs is that the `oneshot::send`
3135/// itself falls outside the measurement, which is a few nanoseconds against a
3136/// turn measured in microseconds at best.
3137struct Turn<'a> {
3138    kind: crate::metrics::CommandKind,
3139    timer: crate::metrics::HoldTimer,
3140    shared: &'a ActorShared,
3141}
3142
3143/// State the actor owns and a `Turn` needs to reach.
3144///
3145/// `archive_epoch` is here rather than in [`crate::metrics::ActorMetrics`]
3146/// because it is **not** a metric: T1.2's shadow rebuild reads it to decide
3147/// whether its work is still valid, so it has to be present in every build, not
3148/// only under the `metrics` feature. Counting archives happens to be what both
3149/// want; only one of them is allowed to be compiled out.
3150#[derive(Default)]
3151struct ActorShared {
3152    metrics: crate::metrics::ActorMetrics,
3153    archive_epoch: std::sync::atomic::AtomicU64,
3154}
3155
3156impl<'a> Turn<'a> {
3157    fn start(kind: crate::metrics::CommandKind, shared: &'a ActorShared) -> Self {
3158        Self {
3159            kind,
3160            timer: crate::metrics::HoldTimer::start(),
3161            shared,
3162        }
3163    }
3164
3165    fn epoch(&self) -> u64 {
3166        self.shared
3167            .archive_epoch
3168            .load(std::sync::atomic::Ordering::Relaxed)
3169    }
3170
3171    /// Record that an archive session committed.
3172    ///
3173    /// Bumped on **success only**: a failed archive rolls back, so it deletes
3174    /// nothing and invalidates no shadow build.
3175    fn archive_committed(&self) {
3176        self.shared
3177            .archive_epoch
3178            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3179    }
3180
3181    /// Close the hold and hand the result back. Never the other way round.
3182    ///
3183    /// The `let _ =` on the send is deliberate and predates this: a caller that
3184    /// dropped its receiver — `tokio::time::timeout` around a write, which
3185    /// [`Database`]'s write surface explicitly documents — is not an actor
3186    /// error, and the command committed regardless.
3187    fn answer<T>(&self, responder: oneshot::Sender<Result<T>>, res: Result<T>) {
3188        self.shared
3189            .metrics
3190            .record_hold(self.kind, self.timer.elapsed());
3191        let _ = responder.send(res);
3192    }
3193
3194    /// [`answer`](Self::answer) for a chunk: the same reading, handed back to the
3195    /// caller as well as recorded (0.12.0, W1).
3196    ///
3197    /// One `elapsed()` serves both, so the duration the chunk loop sizes against
3198    /// is *the same number* the histogram shows — a controller and a dashboard
3199    /// disagreeing about what a chunk cost would be a bad way to spend a
3200    /// debugging session.
3201    ///
3202    /// The record-then-send ordering documented on [`Turn`] is preserved, and
3203    /// matters here for the same reason: the send wakes the caller, which may be
3204    /// scheduled before this method returns.
3205    fn answer_chunk(&self, responder: oneshot::Sender<Result<ChunkOutcome>>, res: Result<usize>) {
3206        let held = self.timer.elapsed();
3207        self.shared.metrics.record_hold(self.kind, held);
3208        let _ = responder.send(res.map(|rows| ChunkOutcome { rows, held }));
3209    }
3210}
3211
3212const INSERT_LINK: &str = "INSERT INTO links \
3213     (source_id, target_id, edge_type, valid_from, valid_to, weight, properties, recorded_at) \
3214     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)";
3215
3216/// Shared by the single-concept write and the chunked one, so the two paths
3217/// cannot drift into upserting different column sets — and so the chunk has a
3218/// statement text it can prepare once (D-056).
3219const UPSERT_CONCEPT: &str = "INSERT INTO concepts \
3220     (id, title, content, embedding_model, valid_from, valid_to, recorded_at, retired) \
3221     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \
3222     ON CONFLICT(id) DO UPDATE SET \
3223         title = excluded.title, \
3224         content = excluded.content, \
3225         embedding_model = excluded.embedding_model, \
3226         valid_from = excluded.valid_from, \
3227         valid_to = excluded.valid_to, \
3228         recorded_at = excluded.recorded_at, \
3229         retired = excluded.retired";
3230
3231/// The parameter row for [`UPSERT_CONCEPT`], in one place for the same reason.
3232fn concept_params<'a>(concept: &'a ConceptUpsert, stamp: &'a str) -> [libsql::Value; 8] {
3233    [
3234        concept.id.as_str().into(),
3235        concept.title.as_str().into(),
3236        concept.content.as_str().into(),
3237        concept
3238            .embedding_model
3239            .as_deref()
3240            .map_or(libsql::Value::Null, Into::into),
3241        concept.valid_from.as_str().into(),
3242        concept.valid_to.as_str().into(),
3243        stamp.into(),
3244        (concept.retired as i64).into(),
3245    ]
3246}
3247
3248impl HighPriCommand {
3249    /// The metrics label for this variant (T1.4).
3250    ///
3251    /// Exhaustive for the same reason `execute` is: a new variant that silently
3252    /// borrowed another's label would attribute its holds to the wrong command,
3253    /// and the one question the counters exist to answer is *which* command
3254    /// broke the budget.
3255    fn kind(&self) -> crate::metrics::CommandKind {
3256        use crate::metrics::CommandKind as K;
3257        match self {
3258            HighPriCommand::AssertEdge { .. } => K::AssertEdge,
3259            HighPriCommand::RetireEdge { .. } => K::RetireEdge,
3260            HighPriCommand::UpsertConcept { .. } => K::UpsertConcept,
3261            HighPriCommand::WriteBulkAtomic { .. } => K::WriteBulkAtomic,
3262            HighPriCommand::RebuildCurrent { .. } => K::RebuildCurrent,
3263            HighPriCommand::RegisterModel { .. } => K::RegisterModel,
3264            HighPriCommand::Checkpoint { .. } => K::Checkpoint,
3265            HighPriCommand::Shutdown { .. } => K::Shutdown,
3266        }
3267    }
3268
3269    /// Run one command and answer its caller.
3270    ///
3271    /// Deliberately exhaustive — there is no `_` arm. The 0.4.5–0.5.4 actor
3272    /// matched `Shutdown` and `AssertEdge` and sent everything else to
3273    /// `_ => LoopCtl::Continue`, which **dropped the responder**: the caller's
3274    /// `rx.await` resolved to a `RecvError` that no code mapped, so four of six
3275    /// commands were indistinguishable from a hung database. An exhaustive match
3276    /// makes that failure a compile error instead of a runtime silence, which is
3277    /// why adding a variant should break this function.
3278    async fn execute(
3279        self,
3280        conn: &libsql::Connection,
3281        clock: &dyn Clock,
3282        turn: &Turn<'_>,
3283    ) -> LoopCtl {
3284        match self {
3285            HighPriCommand::Shutdown { responder } => {
3286                turn.answer(responder, Ok(()));
3287                return LoopCtl::Break;
3288            }
3289            HighPriCommand::Checkpoint { responder } => {
3290                let res = run_checkpoint(conn).await;
3291                turn.answer(responder, res);
3292            }
3293            HighPriCommand::AssertEdge { edge, responder } => {
3294                let stamp = clock.now();
3295                if let Err(e) = reject_overlapping_interval(conn, &edge).await {
3296                    turn.answer(responder, Err(e));
3297                    return LoopCtl::Continue;
3298                }
3299                let res = match conn
3300                    .execute(
3301                        INSERT_LINK,
3302                        libsql::params![
3303                            edge.source.as_str(),
3304                            edge.target.as_str(),
3305                            edge.edge_type.as_str(),
3306                            edge.valid_from.as_str(),
3307                            edge.valid_to.as_str(),
3308                            edge.weight,
3309                            edge.properties.as_str(),
3310                            stamp.as_str()
3311                        ],
3312                    )
3313                    .await
3314                {
3315                    Ok(_) => Ok(()),
3316                    Err(e) => Err(classify(
3317                        conn,
3318                        e,
3319                        WriteOp::Edge {
3320                            source_id: &edge.source,
3321                            target_id: &edge.target,
3322                            edge_type: &edge.edge_type,
3323                        },
3324                    )
3325                    .await),
3326                };
3327                turn.answer(responder, res);
3328            }
3329            HighPriCommand::RetireEdge {
3330                source,
3331                target,
3332                edge_type,
3333                valid_from,
3334                valid_to,
3335                responder,
3336            } => {
3337                let stamp = clock.now();
3338                let res = retire_edge(
3339                    conn,
3340                    &source,
3341                    &target,
3342                    &edge_type,
3343                    &valid_from,
3344                    &valid_to,
3345                    &stamp,
3346                )
3347                .await;
3348                turn.answer(responder, res);
3349            }
3350            HighPriCommand::UpsertConcept { concept, responder } => {
3351                let stamp = clock.now();
3352                let res = upsert_concept(conn, &concept, &stamp).await;
3353                turn.answer(responder, res);
3354            }
3355            HighPriCommand::WriteBulkAtomic { edges, responder } => {
3356                // One stamp for the whole batch (D-014): the rows were asserted
3357                // by one act, and giving them different transaction times would
3358                // invent an ordering the caller never expressed.
3359                let stamp = clock.now();
3360                let res = write_edges_atomic(conn, &edges, &stamp).await;
3361                turn.answer(responder, res);
3362            }
3363            HighPriCommand::RebuildCurrent { responder } => {
3364                turn.answer(responder, rebuild_current(conn).await);
3365            }
3366            HighPriCommand::RegisterModel {
3367                model,
3368                dim,
3369                responder,
3370            } => {
3371                turn.answer(
3372                    responder,
3373                    crate::vector::register_model(conn, &model, dim).await,
3374                );
3375            }
3376        }
3377        LoopCtl::Continue
3378    }
3379}
3380
3381impl LowPriCommand {
3382    /// The metrics label for this variant (T1.4). See [`HighPriCommand::kind`].
3383    fn kind(&self) -> crate::metrics::CommandKind {
3384        use crate::metrics::CommandKind as K;
3385        match self {
3386            LowPriCommand::WriteConceptsChunk { .. } => K::WriteConceptsChunk,
3387            LowPriCommand::WriteAnalyticsChunk { .. } => K::WriteAnalyticsChunk,
3388            LowPriCommand::UpsertEmbeddingChunk { .. } => K::UpsertEmbeddingChunk,
3389            LowPriCommand::BulkImportChunk { .. } => K::BulkImportChunk,
3390            LowPriCommand::Archive { .. } => K::Archive,
3391            // Its own counter since 0.12.9 (W4.3, D-152). It reported as
3392            // `K::Archive` from 0.9.0 to 0.12.8 — the budget really is shared,
3393            // but attribution is not budget, and an operator reading a long
3394            // `archive` hold could not tell whether anything had been archived.
3395            // What kept it folded was that a `CommandKind` variant was a
3396            // breaking addition; `#[non_exhaustive]` (W4.2) removed that.
3397            LowPriCommand::Rehydrate { .. } => K::Rehydrate,
3398            LowPriCommand::RebuildFts { .. } => K::RebuildFts,
3399            // Two kinds out of one variant since 0.13.24 (W10.5, D-197). The
3400            // command carries the flag; the counter has to carry it too, or the
3401            // budget exemption for either half is decided about both (D-168).
3402            LowPriCommand::Analyze { incremental, .. } => {
3403                if *incremental {
3404                    K::Optimize
3405                } else {
3406                    K::Analyze
3407                }
3408            }
3409            LowPriCommand::ShadowRebuild { .. } => K::ShadowRebuild,
3410        }
3411    }
3412
3413    /// Run one background command and answer its caller.
3414    ///
3415    /// Also exhaustive. The pre-0.5.4 version was a single `LoopCtl::Continue`
3416    /// for *every* variant — every background write silently discarded, its
3417    /// caller waiting forever.
3418    async fn execute(
3419        self,
3420        conn: &libsql::Connection,
3421        clock: &dyn Clock,
3422        turn: &Turn<'_>,
3423    ) -> LoopCtl {
3424        match self {
3425            LowPriCommand::BulkImportChunk { chunk, responder } => {
3426                // A stamp per chunk, not per batch: the chunks commit
3427                // separately, so a shared stamp would claim a simultaneity the
3428                // storage does not have.
3429                let stamp = clock.now();
3430                turn.answer_chunk(responder, write_edges_atomic(conn, &chunk, &stamp).await);
3431            }
3432            LowPriCommand::WriteConceptsChunk { chunk, responder } => {
3433                let stamp = clock.now();
3434                turn.answer_chunk(responder, write_concepts_atomic(conn, &chunk, &stamp).await);
3435            }
3436            LowPriCommand::WriteAnalyticsChunk { chunk, responder } => {
3437                let stamp = clock.now();
3438                turn.answer_chunk(
3439                    responder,
3440                    write_annotations_atomic(conn, &chunk, &stamp).await,
3441                );
3442            }
3443            LowPriCommand::UpsertEmbeddingChunk {
3444                model,
3445                chunk,
3446                responder,
3447            } => {
3448                // No clock reading: an embedding carries no timestamp on either
3449                // axis. It is a derived artifact of a model applied to content
3450                // (Doctrine VII), and the ledger already records when the
3451                // content changed.
3452                turn.answer_chunk(
3453                    responder,
3454                    crate::vector::search::upsert_embedding_chunk(conn, &model, &chunk).await,
3455                );
3456            }
3457            LowPriCommand::Archive {
3458                cutoff,
3459                archive_path,
3460                responder,
3461            } => {
3462                // The archive *time*, not the cutoff. `archive_horizon` records
3463                // both and they are different facts — see `archive()` (Wave 4.5).
3464                let archived_at = clock.now();
3465                let res = archive(conn, &cutoff, &archived_at, &archive_path).await;
3466                // Before the answer, so a shadow rebuild that reads the epoch on
3467                // its next turn cannot miss an archive that has already deleted
3468                // rows out from under it (T1.2).
3469                if res.is_ok() {
3470                    turn.archive_committed();
3471                }
3472                turn.answer(responder, res);
3473            }
3474            LowPriCommand::Rehydrate {
3475                ids,
3476                archive_path,
3477                responder,
3478            } => {
3479                let refs: Vec<&str> = ids.iter().map(String::as_str).collect();
3480                let res = rehydrate(conn, &refs, &archive_path).await;
3481                // Same reason as `Archive`: rehydration moves rows into `links`'
3482                // parent table, so a shadow rebuild in flight must see the epoch
3483                // move before the caller is answered (T1.2).
3484                if res.is_ok() {
3485                    turn.archive_committed();
3486                }
3487                turn.answer(responder, res);
3488            }
3489            LowPriCommand::ShadowRebuild { step, responder } => {
3490                use crate::integrity::{shadow, ShadowOutcome, ShadowStep};
3491                let res = match step {
3492                    ShadowStep::Begin => {
3493                        shadow::begin(conn)
3494                            .await
3495                            .map(|build_start| ShadowOutcome::Started {
3496                                build_start,
3497                                epoch: turn.epoch(),
3498                            })
3499                    }
3500                    ShadowStep::Fill { after } => shadow::fill_chunk(conn, after.as_deref())
3501                        .await
3502                        .map(|last| ShadowOutcome::Filled { last }),
3503                    ShadowStep::Swap { build_start, epoch } => {
3504                        shadow::swap(conn, &build_start, epoch, turn.epoch())
3505                            .await
3506                            .map(|rows| ShadowOutcome::Swapped { rows })
3507                    }
3508                };
3509                turn.answer(responder, res);
3510            }
3511            LowPriCommand::RebuildFts { responder } => {
3512                let res = conn
3513                    .execute(crate::schema::ddl::REBUILD_CONCEPTS_FTS, ())
3514                    .await
3515                    .map(|_| ())
3516                    .map_err(Into::into);
3517                turn.answer(responder, res);
3518            }
3519            LowPriCommand::Analyze {
3520                incremental,
3521                responder,
3522            } => {
3523                // Both go through `query()`, not `execute()`. `PRAGMA optimize`
3524                // yields rows, and libsql's `execute()` rejects any statement
3525                // that does ("Execute returned rows") — the same trap
3526                // `configure` documents. `ANALYZE` does not yield rows, but is
3527                // issued the same way so the two arms cannot drift into needing
3528                // different call shapes for no visible reason.
3529                let sql = if incremental {
3530                    crate::schema::ddl::OPTIMIZE
3531                } else {
3532                    crate::schema::ddl::ANALYZE
3533                };
3534                let res = conn.query(sql, ()).await.map(|_| ()).map_err(Into::into);
3535                turn.answer(responder, res);
3536            }
3537        }
3538        LoopCtl::Continue
3539    }
3540}
3541
3542/// Close an open interval by asserting its successor (Doctrine III).
3543///
3544/// Never an `UPDATE`. The replacement row copies weight and properties from
3545/// current belief and differs only in `valid_to` and `recorded_at`, so the
3546/// original assertion survives intact and `reconstruct` at an earlier instant
3547/// still sees the interval open — which is the entire point of a bitemporal
3548/// ledger.
3549async fn retire_edge(
3550    conn: &libsql::Connection,
3551    source: &str,
3552    target: &str,
3553    edge_type: &str,
3554    valid_from: &str,
3555    valid_to: &str,
3556    stamp: &str,
3557) -> Result<()> {
3558    let affected = conn
3559        .execute(
3560            "INSERT INTO links \
3561                 (source_id, target_id, edge_type, valid_from, valid_to, weight, properties, recorded_at) \
3562             SELECT source_id, target_id, edge_type, valid_from, ?5, weight, properties, ?6 \
3563             FROM links_current \
3564             WHERE source_id = ?1 AND target_id = ?2 AND edge_type = ?3 AND valid_from = ?4",
3565            libsql::params![source, target, edge_type, valid_from, valid_to, stamp],
3566        )
3567        .await
3568        .map_err(DbError::Engine)?;
3569
3570    if affected == 0 {
3571        return Err(DbError::NotFound(format!(
3572            "{source} -> {target} ({edge_type}) at {valid_from}"
3573        )));
3574    }
3575    Ok(())
3576}
3577
3578async fn upsert_concept(
3579    conn: &libsql::Connection,
3580    concept: &ConceptUpsert,
3581    stamp: &str,
3582) -> Result<()> {
3583    let res = conn
3584        .execute(UPSERT_CONCEPT, concept_params(concept, stamp))
3585        .await;
3586
3587    match res {
3588        Ok(_) => Ok(()),
3589        Err(e) => Err(classify(
3590            conn,
3591            e,
3592            WriteOp::Concept {
3593                id: &concept.id,
3594                recorded_at: stamp,
3595            },
3596        )
3597        .await),
3598    }
3599}
3600
3601/// Every recorded interval for one relationship key, for [`Interval::overlaps`]
3602/// to judge.
3603///
3604/// **Three equalities and nothing else, deliberately — and the "and nothing
3605/// else" was measured, not assumed.** The first version added
3606/// `AND valid_from < :new_valid_to`, a provably safe narrowing (overlap requires
3607/// `max(start) < min(end)`, so an interval starting at or after the new one's end
3608/// cannot overlap it). It cost **9.8 ms on a 90-edge chunk into a 2,000-edge
3609/// hub**, because it walked the planner straight into D-059's trap:
3610///
3611/// ```text
3612/// with the range:     SEARCH links_current USING COVERING INDEX
3613///                     idx_lc_traversal_cover (source_id=? AND valid_from<?)
3614/// without it:         SEARCH links_current USING COVERING INDEX
3615///                     idx_lc_open_interval (source_id=? AND target_id=? AND edge_type=?)
3616/// ```
3617///
3618/// `idx_lc_traversal_cover` leads on `(source_id, valid_from, …)` and contains
3619/// every column this query mentions, so with a `valid_from` range available it
3620/// wins as a covering index while binding **one** equality column — and the
3621/// guard scans the source's entire out-degree. That is the same shape as the
3622/// defect D-059 diagnosed in `trg_links_single_open`, reintroduced by an
3623/// optimisation, one wave after it was fixed.
3624///
3625/// Dropping the range makes the query a pure three-column point lookup that
3626/// `idx_lc_open_interval` serves exactly, and the rows it returns are the
3627/// intervals recorded for one `(source, target, edge_type)` — a version count,
3628/// not an out-degree. **A narrowing predicate is not free if it changes the
3629/// plan**, which is the general lesson and the reason this constant carries its
3630/// own `EXPLAIN` output.
3631const OVERLAP_CANDIDATES: &str = "SELECT valid_from, valid_to FROM links_current \
3632     WHERE source_id = ?1 AND target_id = ?2 AND edge_type = ?3 \
3633       AND valid_from <> ?4";
3634
3635/// Whether this pair is the storage layer's case rather than this guard's.
3636///
3637/// Two **open** intervals overlap — they share every instant from the later
3638/// start onwards — so a naive overlap check reports them, and reporting them
3639/// here would leave `DbError::SingleOpenViolation` constructible by nothing.
3640/// That variant is the more specific error, it is enforced by
3641/// `trg_links_single_open` rather than by this function, and its field names
3642/// were ratified in §1.2. Shadowing it with a general one would be defect Q's
3643/// shape reintroduced by a fix: a typed error that no code path can produce.
3644///
3645/// So the two guards partition the space rather than overlapping it. Both open
3646/// belongs to the trigger. Everything else — open against closed, closed against
3647/// closed — is unguarded at the storage layer and belongs here. That the split
3648/// is exactly the trigger's `WHEN` clause is not a coincidence; it is the
3649/// definition of what was missing.
3650fn defer_to_single_open(proposed: &Interval, existing: &Interval) -> bool {
3651    proposed.is_open() && existing.is_open()
3652}
3653
3654/// Refuse an assertion whose valid-time interval overlaps one already recorded
3655/// for the same `(source, target, edge_type)` — **defect AA, D-060**.
3656///
3657/// `trg_links_single_open` fires only `WHEN NEW.valid_to = '9999-…'`, so it
3658/// guards the open sentinel and nothing else. Two *closed* intervals that
3659/// overlap were accepted without complaint, and `query_as_of_edges` at an
3660/// instant inside both returned one relationship as two edges.
3661///
3662/// **This runs in the write actor, which is what makes it sound.** The obvious
3663/// place is `EdgeAssertion::normalized`, and it cannot go there — `normalized`
3664/// is a pure function with no connection, and doing the read at the API boundary
3665/// instead would leave a check-then-write race between the read and the actor's
3666/// insert. Inside the actor there is one writer by construction (D-014), and for
3667/// the batch paths this runs inside the same transaction as the insert, so the
3668/// window does not exist rather than being small.
3669///
3670/// **What it does not cover, and §4.2 now says so:** raw SQL against the same
3671/// file. The storage layer permits what this API refuses, which is the honest
3672/// cost of not putting the check in a trigger. The alternative was a second
3673/// index probe inside `trg_links_single_open` on every insert — on the path
3674/// D-059 has just finished making fast — for a guarantee that only holds against
3675/// callers who were going through the actor anyway.
3676///
3677/// `valid_from <> ?4` excludes the row being re-asserted. Re-assertion at the
3678/// same `valid_from` is Doctrine III's ordinary case — a new belief about the
3679/// same interval — and is settled by the primary key and the single-open
3680/// trigger, not here.
3681/// The single-assertion path prepares one statement for one check, which is what
3682/// `AssertEdge` needs; the batch path prepares once and calls
3683/// [`check_prepared`] per row.
3684async fn reject_overlapping_interval(
3685    conn: &libsql::Connection,
3686    edge: &EdgeAssertion,
3687) -> Result<()> {
3688    let stmt = conn.prepare(OVERLAP_CANDIDATES).await?;
3689    check_prepared(&stmt, edge).await
3690}
3691
3692/// The guard's body, against a statement the caller has already prepared.
3693///
3694/// **Split out because preparing per row was worth 10.4 ms on a 90-edge chunk**
3695/// (§8.8) — the same defect D-056 and D-057 diagnosed and fixed for
3696/// `INSERT_LINK`, reintroduced by the Wave 2 guard that was written beside it.
3697/// Measured with and without the guard, on a 2,000-edge hub: 8.65 ms → 19.25 ms,
3698/// and *identical* with and without `idx_lc_open_interval`, which is what
3699/// identified preparation rather than a scan as the cost. A guard that reads an
3700/// index correctly and prepares its statement 90 times is indistinguishable, at
3701/// the call site, from one that scans.
3702///
3703/// `reset()` between rows is not optional: libsql binds and steps without
3704/// resetting, so a reused statement must be returned to its initial state.
3705async fn check_prepared(stmt: &libsql::Statement, edge: &EdgeAssertion) -> Result<()> {
3706    let proposed = Interval::new(edge.valid_from.clone(), edge.valid_to.clone());
3707
3708    stmt.reset();
3709    let mut rows = stmt
3710        .query(libsql::params![
3711            edge.source.as_str(),
3712            edge.target.as_str(),
3713            edge.edge_type.as_str(),
3714            edge.valid_from.as_str()
3715        ])
3716        .await?;
3717
3718    while let Some(row) = rows.next().await? {
3719        let existing = Interval::new(row.get::<String>(0)?, row.get::<String>(1)?);
3720        if defer_to_single_open(&proposed, &existing) {
3721            continue;
3722        }
3723        if proposed.overlaps(&existing) {
3724            return Err(DbError::OverlappingInterval {
3725                overlap: Box::new(crate::error::Overlap {
3726                    source_id: edge.source.clone(),
3727                    target_id: edge.target.clone(),
3728                    edge_type: edge.edge_type.clone(),
3729                    valid_from: edge.valid_from.clone(),
3730                    valid_to: edge.valid_to.clone(),
3731                    existing_from: existing.valid_from,
3732                    existing_to: existing.valid_to,
3733                    // This guard reads committed rows, so the interval it names
3734                    // is one the caller can go and look at (D-180).
3735                    within_batch: false,
3736                }),
3737            });
3738        }
3739    }
3740
3741    Ok(())
3742}
3743
3744/// The same guard applied *within* a batch, before any of it is written.
3745///
3746/// The database check cannot see rows that are not in the database yet, so a
3747/// batch carrying two overlapping intervals for one relationship would pass
3748/// every per-row check and commit the overlap in one transaction.
3749///
3750/// # Sorted and swept rather than compared pairwise (0.13.6, W7.5, D-179)
3751///
3752/// This used to compare every pair. At [`chunk_rows::EDGES`] = 90 that is
3753/// nothing, and the chunked paths are the only ones where 90 is the bound —
3754/// [`Database::write_bulk_atomic`] is exempt from [`CHUNK_BUDGET`] by contract,
3755/// so its batch is whatever the caller passed, and the quadratic term is what
3756/// made 20,000 corrections to one relationship's history cost seconds rather
3757/// than milliseconds. Sorting by `(source, target, edge_type, valid_from)` and
3758/// sweeping costs `n log n` and changes nothing a caller can observe except the
3759/// wait.
3760///
3761/// **Adjacent pairs are not sufficient, and that is the whole difficulty.** For
3762/// plain intervals they would be: sort by start, and if any two overlap then
3763/// some neighbouring two overlap. That proof needs every pair to be *eligible*,
3764/// and here two are not — identical `valid_from` is re-assertion rather than
3765/// overlap, and two open intervals belong to `trg_links_single_open`. Skip an
3766/// adjacent pair for either reason and a real overlap can hide behind it:
3767/// `[5,20)`, `[5,6)`, `[7,8)` has the first pair skipped for equal `valid_from`
3768/// and the second not overlapping, while `[5,20)` and `[7,8)` overlap plainly.
3769/// So the sweep carries the widest `valid_to` reached so far instead of looking
3770/// only backwards one step, and carries a second one restricted to closed
3771/// intervals — because an open predecessor is excluded for an open candidate
3772/// and eligible for a closed one, which are different questions with different
3773/// answers.
3774///
3775/// Equal `valid_from` is handled by advancing in runs: everything with the same
3776/// start is checked against the maxima, and only then folded into them, so the
3777/// members of a run never see each other.
3778///
3779/// The report names the *earlier* interval as the existing one, which is the
3780/// pairwise version's input order only by accident. Within a batch neither is
3781/// older in transaction time — they arrive under one stamp — so valid-time order
3782/// is the only ordering that means anything, and it is the one a reader will
3783/// assume the words carry.
3784fn reject_overlaps_within(edges: &[EdgeAssertion]) -> Result<()> {
3785    // Indices, not the edges. The batch is borrowed and its order is the order
3786    // the rows are written in; sorting it would either clone it or reorder the
3787    // caller's data, which is `estimated_bulk_hold`'s reason for grouping too.
3788    let mut order: Vec<u32> = (0..edges.len() as u32).collect();
3789    order.sort_unstable_by(|&i, &j| {
3790        let a = &edges[i as usize];
3791        let b = &edges[j as usize];
3792        (&a.source, &a.target, &a.edge_type, &a.valid_from).cmp(&(
3793            &b.source,
3794            &b.target,
3795            &b.edge_type,
3796            &b.valid_from,
3797        ))
3798    });
3799
3800    fn key(e: &EdgeAssertion) -> (&str, &str, &str) {
3801        (e.source.as_str(), e.target.as_str(), e.edge_type.as_str())
3802    }
3803    let at = |k: usize| &edges[order[k] as usize];
3804
3805    let mut group = 0;
3806    while group < order.len() {
3807        let mut group_end = group + 1;
3808        while group_end < order.len() && key(at(group_end)) == key(at(group)) {
3809            group_end += 1;
3810        }
3811
3812        // The furthest `valid_to` reached by anything already swept in this key
3813        // group, and the edge it came from so the error can name it. The second
3814        // one ignores open intervals: an open candidate may not be compared
3815        // against an open predecessor, and the sentinel would otherwise win the
3816        // maximum every time and make every such pair look like an overlap.
3817        let mut widest: Option<&EdgeAssertion> = None;
3818        let mut widest_closed: Option<&EdgeAssertion> = None;
3819
3820        let mut run = group;
3821        while run < group_end {
3822            let mut run_end = run + 1;
3823            while run_end < group_end && at(run_end).valid_from == at(run).valid_from {
3824                run_end += 1;
3825            }
3826
3827            for k in run..run_end {
3828                let e = at(k);
3829                let existing = if e.valid_to == timestamp::OPEN_SENTINEL {
3830                    widest_closed
3831                } else {
3832                    widest
3833                };
3834                let Some(p) = existing else { continue };
3835                // `Interval::overlaps` is `max(from) < min(to)`, and the sort
3836                // has already settled the max: `p.valid_from <= e.valid_from`.
3837                // What is left is the same predicate with the maximum resolved,
3838                // and it is written out rather than allocating two `Interval`s
3839                // per row to ask the same question.
3840                if e.valid_from < p.valid_to && e.valid_from < e.valid_to {
3841                    return Err(DbError::OverlappingInterval {
3842                        overlap: Box::new(crate::error::Overlap {
3843                            source_id: e.source.clone(),
3844                            target_id: e.target.clone(),
3845                            edge_type: e.edge_type.clone(),
3846                            valid_from: e.valid_from.clone(),
3847                            valid_to: e.valid_to.clone(),
3848                            existing_from: p.valid_from.clone(),
3849                            existing_to: p.valid_to.clone(),
3850                            // Nothing here is in the database, and the batch is
3851                            // refused whole, so nothing here ever will be. The
3852                            // message has to say so (D-180).
3853                            within_batch: true,
3854                        }),
3855                    });
3856                }
3857            }
3858
3859            for k in run..run_end {
3860                let e = at(k);
3861                if widest.is_none_or(|w| e.valid_to > w.valid_to) {
3862                    widest = Some(e);
3863                }
3864                if e.valid_to != timestamp::OPEN_SENTINEL
3865                    && widest_closed.is_none_or(|w| e.valid_to > w.valid_to)
3866                {
3867                    widest_closed = Some(e);
3868                }
3869            }
3870
3871            run = run_end;
3872        }
3873
3874        group = group_end;
3875    }
3876
3877    Ok(())
3878}
3879
3880/// Write every edge or none, under a single stamp.
3881///
3882/// **The statement is prepared once for the whole chunk (§9, D-056).** It used to
3883/// be `tx.execute(INSERT_LINK, …)` per row, which re-prepares on every call — and
3884/// `links` carries two triggers, so each preparation compiles their bodies along
3885/// with the insert.
3886///
3887/// Measured at 500 rows: **≈62 ms → ≈37 ms, a 41% saving.** Preparation was a
3888/// large cost and *not* the dominant one, which the first guess had it as. The
3889/// residual is the triggers themselves: the same 500 rows with
3890/// `trg_links_log_insert` and `trg_links_current_sync` dropped commit in **2.96
3891/// ms**, so trigger amplification is ~92% of what remains. There is no further
3892/// win available here without changing what the ledger records, and Doctrine IV
3893/// is what says it must be recorded. See D-056 for what that implies about §9's
3894/// ≤ 3 ms budget — briefly, 2.96 ms *is* the un-amplified figure, so the budget
3895/// appears to have been set without the amplification its own preamble says is
3896/// included.
3897///
3898/// `reset()` between rows is not optional: libsql's `execute` binds and steps
3899/// without resetting, so a reused statement must be returned to its initial state
3900/// or the second row steps a completed statement.
3901async fn write_edges_atomic(
3902    conn: &libsql::Connection,
3903    edges: &[EdgeAssertion],
3904    stamp: &str,
3905) -> Result<usize> {
3906    if edges.is_empty() {
3907        return Ok(0);
3908    }
3909
3910    // Before the transaction opens: a batch that contradicts itself is refused
3911    // without taking the write lock at all (D-060).
3912    reject_overlaps_within(edges)?;
3913
3914    let tx = conn
3915        .transaction_with_behavior(libsql::TransactionBehavior::Immediate)
3916        .await?;
3917
3918    // Inside the transaction, so the rows this checks against cannot change
3919    // between the check and the insert.
3920    // One preparation for the whole chunk, not one per row — see
3921    // `check_prepared`, and D-056 for the same lesson learned on `INSERT_LINK`.
3922    let guard = tx.prepare(OVERLAP_CANDIDATES).await?;
3923    for edge in edges {
3924        if let Err(e) = check_prepared(&guard, edge).await {
3925            // Released before the rollback: a live statement on the connection
3926            // is what makes SQLite refuse to end a transaction.
3927            drop(guard);
3928            let _ = tx.rollback().await;
3929            return Err(e);
3930        }
3931    }
3932    drop(guard);
3933
3934    let stmt = tx.prepare(INSERT_LINK).await?;
3935
3936    for edge in edges {
3937        stmt.reset();
3938        let res = stmt
3939            .execute(libsql::params![
3940                edge.source.as_str(),
3941                edge.target.as_str(),
3942                edge.edge_type.as_str(),
3943                edge.valid_from.as_str(),
3944                edge.valid_to.as_str(),
3945                edge.weight,
3946                edge.properties.as_str(),
3947                stamp
3948            ])
3949            .await;
3950
3951        if let Err(e) = res {
3952            let typed = classify(
3953                &tx,
3954                e,
3955                WriteOp::Edge {
3956                    source_id: &edge.source,
3957                    target_id: &edge.target,
3958                    edge_type: &edge.edge_type,
3959                },
3960            )
3961            .await;
3962            // Released before the rollback: a live statement on the connection
3963            // is exactly what makes SQLite refuse to end a transaction.
3964            drop(stmt);
3965            let _ = tx.rollback().await;
3966            return Err(typed);
3967        }
3968    }
3969
3970    drop(stmt);
3971    tx.commit().await?;
3972    Ok(edges.len())
3973}
3974
3975/// Write every concept or none, under a single stamp.
3976/// Upsert one chunk of derived annotations in a single transaction (D-041).
3977///
3978/// `stamp` is the actor's clock reading, exactly as for every other chunk — but
3979/// it lands in `computed_at`, not in a `recorded_at`, and the difference is not
3980/// cosmetic. `recorded_at` is the transaction-time axis and is subject to
3981/// Doctrine II and the monotonicity guard; `computed_at` is a note about when a
3982/// derivation last ran, on a table the ledger does not see. Rerunning an
3983/// algorithm therefore replaces the row and advances the note, rather than
3984/// versioning a concept the world did not change.
3985///
3986/// # Failures name the concept (0.13.3, W7.2, D-176)
3987///
3988/// This was the one write path in the crate that returned
3989/// [`DbError::Engine`] raw, and the omission looked harmless: the table
3990/// carries no triggers, so none of [`crate::error::AbortKind`]'s guards can
3991/// fire on it and [`classify`] would have returned the same raw error it was
3992/// given. What that reasoning missed is the foreign key onto `concepts`, which
3993/// the engine enforces itself. Annotating a concept that does not exist is the
3994/// one failure a caller can cause here, and it reported as
3995/// `FOREIGN KEY constraint failed` with no row named — out of a chunk of up to
3996/// [`chunk_rows::ANNOTATIONS`].
3997///
3998/// It now goes through [`classify`] with [`WriteOp::Annotation`] like every
3999/// other write, and a missing concept returns [`DbError::NotFound`] carrying
4000/// its id.
4001async fn write_annotations_atomic(
4002    conn: &libsql::Connection,
4003    annotations: &[Annotation],
4004    stamp: &str,
4005) -> Result<usize> {
4006    if annotations.is_empty() {
4007        return Ok(0);
4008    }
4009
4010    let tx = conn
4011        .transaction_with_behavior(libsql::TransactionBehavior::Immediate)
4012        .await?;
4013
4014    let stmt = tx
4015        .prepare(
4016            "INSERT INTO analytics_annotations (concept_id, label, value, computed_at) \
4017             VALUES (?1, ?2, ?3, ?4) \
4018             ON CONFLICT(concept_id, label) DO UPDATE SET \
4019                 value = excluded.value, computed_at = excluded.computed_at",
4020        )
4021        .await?;
4022
4023    for a in annotations {
4024        stmt.reset();
4025        let res = stmt
4026            .execute(libsql::params![
4027                a.concept_id.as_str(),
4028                a.label.as_str(),
4029                a.value.as_str(),
4030                stamp
4031            ])
4032            .await;
4033        if let Err(e) = res {
4034            let typed = classify(
4035                &tx,
4036                e,
4037                WriteOp::Annotation {
4038                    concept_id: &a.concept_id,
4039                },
4040            )
4041            .await;
4042            drop(stmt);
4043            let _ = tx.rollback().await;
4044            return Err(typed);
4045        }
4046    }
4047
4048    drop(stmt);
4049    tx.commit().await?;
4050    Ok(annotations.len())
4051}
4052
4053async fn write_concepts_atomic(
4054    conn: &libsql::Connection,
4055    concepts: &[ConceptUpsert],
4056    stamp: &str,
4057) -> Result<usize> {
4058    if concepts.is_empty() {
4059        return Ok(0);
4060    }
4061
4062    let tx = conn
4063        .transaction_with_behavior(libsql::TransactionBehavior::Immediate)
4064        .await?;
4065
4066    // Prepared once, like the edge chunk (D-056). This no longer routes through
4067    // [`upsert_concept`] — that function prepares per call by construction — but
4068    // it shares that function's statement text and parameter row, so the two
4069    // cannot upsert different columns.
4070    let stmt = tx.prepare(UPSERT_CONCEPT).await?;
4071
4072    for concept in concepts {
4073        stmt.reset();
4074        let res = stmt.execute(concept_params(concept, stamp)).await;
4075
4076        if let Err(e) = res {
4077            let typed = classify(
4078                &tx,
4079                e,
4080                WriteOp::Concept {
4081                    id: &concept.id,
4082                    recorded_at: stamp,
4083                },
4084            )
4085            .await;
4086            drop(stmt);
4087            let _ = tx.rollback().await;
4088            return Err(typed);
4089        }
4090    }
4091
4092    drop(stmt);
4093    tx.commit().await?;
4094    Ok(concepts.len())
4095}
4096
4097#[cfg(test)]
4098mod tests {
4099    use super::*;
4100
4101    fn edge(target: &str, micros: usize) -> EdgeAssertion {
4102        EdgeAssertion::new("src", target, "LINKS")
4103            .valid_from(format!("2026-01-01T00:00:00.{micros:06}Z"))
4104            .valid_to(format!("2026-01-01T00:00:00.{:06}Z", micros + 1))
4105    }
4106
4107    /// The estimate must **no longer** depend on the batch's shape (0.13.6).
4108    ///
4109    /// Its dependence on shape was correct for as long as the guard was
4110    /// quadratic and the constant differed 16× between the two paths through
4111    /// its inner loop. W7.5 removed that term, and measurement agrees: 1.94 s
4112    /// and 2.22 s for the two 20,000-edge batches that used to differ by 7×.
4113    /// A model that kept predicting a 7× spread would now be wrong in the
4114    /// *expensive* direction — warning loudly about a batch that is fine.
4115    #[test]
4116    fn two_batches_of_one_size_are_predicted_alike() {
4117        const N: usize = 20_000;
4118        let fanout: Vec<_> = (0..N).map(|i| edge(&format!("t{i:07}"), i)).collect();
4119        let history: Vec<_> = (0..N).map(|i| edge("t0", i)).collect();
4120
4121        assert_eq!(
4122            estimated_bulk_hold(&fanout),
4123            estimated_bulk_hold(&history),
4124            "the guard no longer reads the batch's shape, so neither may this"
4125        );
4126    }
4127
4128    /// Measured on libSQL 0.9.30 after W7.5: 1.94 s and 2.22 s for those two
4129    /// batches, against 2.6 s and 18.1 s before it. This pins that the model
4130    /// still tracks them — a coefficient edited without re-measuring fails here.
4131    #[test]
4132    fn the_estimate_matches_what_was_measured() {
4133        const N: usize = 20_000;
4134        let fanout: Vec<_> = (0..N).map(|i| edge(&format!("t{i:07}"), i)).collect();
4135        let history: Vec<_> = (0..N).map(|i| edge("t0", i)).collect();
4136
4137        for (batch, measured_ms, label) in
4138            [(fanout, 1_936u128, "fanout"), (history, 2_220, "history")]
4139        {
4140            let predicted = estimated_bulk_hold(&batch).as_millis();
4141            let ratio = predicted as f64 / measured_ms as f64;
4142            assert!(
4143                (0.8..1.25).contains(&ratio),
4144                "{label}: predicted {predicted} ms against a measured \
4145                 {measured_ms} ms ({ratio:.2}x). Re-run \
4146                 examples/bulk_atomic_diag.rs before changing the coefficients."
4147            );
4148        }
4149    }
4150
4151    /// `ilog2` panics on zero, and an empty batch is the caller asking whether
4152    /// a batch they have not built yet would be slow.
4153    #[test]
4154    fn an_empty_batch_estimates_nothing_rather_than_panicking() {
4155        assert_eq!(estimated_bulk_hold(&[]), std::time::Duration::ZERO);
4156        let one = [edge("t0", 0)];
4157        assert_eq!(
4158            estimated_bulk_hold(&one),
4159            std::time::Duration::from_nanos(7_400)
4160        );
4161    }
4162
4163    /// The model is used as a threshold test, so it must not go backwards.
4164    #[test]
4165    fn a_bigger_batch_never_predicts_a_shorter_hold() {
4166        let mut last = std::time::Duration::ZERO;
4167        for n in [1usize, 2, 3, 7, 8, 100, 511, 512, 513, 5_000, 20_000] {
4168            let batch: Vec<_> = (0..n).map(|i| edge(&format!("t{i:07}"), i)).collect();
4169            let now = estimated_bulk_hold(&batch);
4170            assert!(now >= last, "{n} rows predicts {now:?} after {last:?}");
4171            last = now;
4172        }
4173    }
4174
4175    /// The warning threshold sits well above the bound this path is exempt from.
4176    ///
4177    /// Warning at `CHUNK_BUDGET` would fire on batches working exactly as
4178    /// designed — the exemption is a contract (D-014), not a failure — and a
4179    /// warning that fires on correct behaviour gets filtered out, taking the
4180    /// 18-second case with it.
4181    #[test]
4182    fn the_warning_threshold_is_not_the_chunk_budget() {
4183        assert!(BULK_ATOMIC_WARN_HOLD > CHUNK_BUDGET * 10);
4184    }
4185
4186    // -----------------------------------------------------------------------
4187    // reject_overlaps_within — sorted and swept (0.13.6, W7.5, D-179)
4188    //
4189    // The pairwise version was obviously correct and too slow; this one is
4190    // neither, so what follows pins the cases where the obvious fix is wrong
4191    // rather than only the cases the guard already caught.
4192    // -----------------------------------------------------------------------
4193
4194    /// An edge over an explicit interval, all four key columns spelled out.
4195    fn span(target: &str, edge_type: &str, from: usize, to: Option<usize>) -> EdgeAssertion {
4196        let stamp = |n: usize| format!("2026-01-01T00:00:00.{n:06}Z");
4197        EdgeAssertion::new("src", target, edge_type)
4198            .valid_from(stamp(from))
4199            .valid_to(to.map_or_else(|| timestamp::OPEN_SENTINEL.to_string(), stamp))
4200    }
4201
4202    fn closed(from: usize, to: usize) -> EdgeAssertion {
4203        span("t0", "LINKS", from, Some(to))
4204    }
4205
4206    fn open_at(from: usize) -> EdgeAssertion {
4207        span("t0", "LINKS", from, None)
4208    }
4209
4210    /// The case that makes adjacent pairs insufficient.
4211    ///
4212    /// Sort by start and any overlap shows up between neighbours — but only if
4213    /// every neighbouring pair is eligible to be checked. `[5,20)` and `[5,6)`
4214    /// are not: identical `valid_from` is re-assertion. Skip them, and `[5,6)`
4215    /// against `[7,8)` is a clean gap, and the plain overlap between `[5,20)`
4216    /// and `[7,8)` never gets looked at.
4217    #[test]
4218    fn an_overlap_hidden_behind_an_equal_valid_from_is_still_found() {
4219        let batch = vec![closed(5, 20), closed(5, 6), closed(7, 8)];
4220        assert!(matches!(
4221            reject_overlaps_within(&batch),
4222            Err(DbError::OverlappingInterval { .. })
4223        ));
4224    }
4225
4226    /// The same trap in the other direction: skipped for being open.
4227    ///
4228    /// Two open intervals are `trg_links_single_open`'s case and are passed
4229    /// over here. A running maximum that counted them would take the sentinel
4230    /// as the widest reach and report every later open interval as overlapping
4231    /// it — inventing an error rather than missing one, which is why the sweep
4232    /// carries a second maximum restricted to closed intervals.
4233    #[test]
4234    fn two_open_intervals_are_left_to_the_trigger() {
4235        let batch = vec![closed(1, 5), open_at(10), open_at(20)];
4236        assert!(reject_overlaps_within(&batch).is_ok());
4237    }
4238
4239    /// An open interval still overlaps a closed one that reaches past its start.
4240    #[test]
4241    fn an_open_interval_over_a_closed_one_is_an_overlap() {
4242        let batch = vec![closed(1, 50), open_at(10)];
4243        assert!(matches!(
4244            reject_overlaps_within(&batch),
4245            Err(DbError::OverlappingInterval { .. })
4246        ));
4247    }
4248
4249    /// Same `valid_from`, different `valid_to`: a batch correcting itself.
4250    ///
4251    /// Last writer wins by `seq_id`, exactly as it does across batches. The
4252    /// guard has no opinion.
4253    #[test]
4254    fn equal_valid_from_is_re_assertion_not_overlap() {
4255        let batch = vec![closed(5, 20), closed(5, 6), closed(5, 900)];
4256        assert!(reject_overlaps_within(&batch).is_ok());
4257    }
4258
4259    /// Grouping is what makes the sweep sound, so it is pinned rather than read.
4260    #[test]
4261    fn edges_with_different_keys_do_not_see_each_other() {
4262        let batch = vec![
4263            span("t0", "LINKS", 1, Some(50)),
4264            span("t1", "LINKS", 10, Some(60)),
4265            span("t0", "CITES", 10, Some(60)),
4266            span("t0", "LINKS", 50, Some(60)),
4267        ];
4268        assert!(reject_overlaps_within(&batch).is_ok());
4269    }
4270
4271    /// Which of the two the report calls *existing* (0.13.6).
4272    ///
4273    /// Neither is older in transaction time — a batch lands under one stamp —
4274    /// so the pairwise version's answer was its input order, which means
4275    /// nothing. Valid-time order is the only ordering the two intervals have.
4276    #[test]
4277    fn the_report_names_the_earlier_interval_as_the_existing_one() {
4278        let batch = vec![closed(7, 8), closed(5, 20)];
4279        let Err(DbError::OverlappingInterval { overlap }) = reject_overlaps_within(&batch) else {
4280            panic!("the batch overlaps itself");
4281        };
4282        assert!(overlap.valid_from.ends_with(".000007Z"), "{overlap:?}");
4283        assert!(overlap.existing_from.ends_with(".000005Z"), "{overlap:?}");
4284    }
4285
4286    /// A guard whose answer depended on the caller's ordering would be a worse
4287    /// guard than the one it replaced, and sorting is exactly the change that
4288    /// could introduce that.
4289    #[test]
4290    fn the_answer_does_not_depend_on_the_order_the_caller_passed() {
4291        let mut batch = vec![closed(5, 20), closed(5, 6), closed(7, 8)];
4292        batch.reverse();
4293        assert!(reject_overlaps_within(&batch).is_err());
4294
4295        let mut clean = vec![closed(1, 5), closed(5, 6), closed(7, 8), open_at(8)];
4296        clean.reverse();
4297        assert!(reject_overlaps_within(&clean).is_ok());
4298    }
4299
4300    /// §2.6, and the reason the rewrite happened rather than the doc alone.
4301    ///
4302    /// Every edge shares a key, so the old loop reached `Interval::overlaps`
4303    /// on all n(n−1)/2 pairs — 50 million of them here, which is seconds even
4304    /// in release and considerably worse in the debug profile this runs under.
4305    /// The bound is loose on purpose: it is an order of magnitude, not a
4306    /// benchmark, and the only thing it can fail on is the quadratic term
4307    /// coming back.
4308    #[test]
4309    fn one_relationships_whole_history_is_no_longer_quadratic() {
4310        const N: usize = 10_000;
4311        let batch: Vec<_> = (0..N).map(|i| closed(i * 2, i * 2 + 1)).collect();
4312
4313        let started = std::time::Instant::now();
4314        assert!(reject_overlaps_within(&batch).is_ok());
4315        let took = started.elapsed();
4316
4317        assert!(
4318            took < std::time::Duration::from_secs(2),
4319            "{N} same-key edges took {took:?} in the guard"
4320        );
4321    }
4322
4323    // -----------------------------------------------------------------------
4324    // next_chunk_size — the control law (0.12.0, W2)
4325    //
4326    // All of these run without a database, a clock or an actor, which is why
4327    // W2 comes before W3: the loop that will use this function can only be
4328    // tested against a real write, and the properties below cannot be observed
4329    // there without also observing the machine.
4330    // -----------------------------------------------------------------------
4331
4332    use std::time::Duration;
4333
4334    /// `next_chunk_size` with the shipped budget and floor.
4335    fn step_to(current: usize, held_ms: f64, ceiling: usize) -> usize {
4336        next_chunk_size(
4337            current,
4338            Duration::from_nanos((held_ms * 1_000_000.0) as u64),
4339            CHUNK_BUDGET,
4340            CHUNK_FLOOR,
4341            ceiling,
4342        )
4343    }
4344
4345    /// The edge path, which is every test here that does not say otherwise.
4346    fn step(current: usize, held_ms: f64) -> usize {
4347        step_to(current, held_ms, chunk_rows::EDGES)
4348    }
4349
4350    /// Iterate the law against a machine that costs `per_row_us` per row plus a
4351    /// fixed `overhead_ms` per transaction — the two-term model D-142 measured.
4352    fn converge(
4353        start: usize,
4354        per_row_us: f64,
4355        overhead_ms: f64,
4356        ceiling: usize,
4357        steps: usize,
4358    ) -> Vec<usize> {
4359        let mut size = start;
4360        (0..steps)
4361            .map(|_| {
4362                let held = overhead_ms + per_row_us * size as f64 / 1000.0;
4363                size = step_to(size, held, ceiling);
4364                size
4365            })
4366            .collect()
4367    }
4368
4369    /// The reason the shrink is proportional rather than a halving: at 4× over
4370    /// budget, halving needs three steps and every one of them is a latency
4371    /// miss a caller can feel.
4372    ///
4373    /// Run on the annotations path, because it is the only one whose ceiling
4374    /// leaves room to start far above a size that is reachable — on the edge
4375    /// path a 4× miss lands under [`CHUNK_FLOOR`], which is a different test.
4376    #[test]
4377    fn a_chunk_far_over_budget_converges_from_above_in_at_most_two_steps() {
4378        const CEILING: usize = chunk_rows::ANNOTATIONS;
4379        let (per_row_us, overhead_ms) = (20.0, 0.05);
4380        let held = |n: usize| overhead_ms + per_row_us * n as f64 / 1000.0;
4381        assert!(
4382            held(CEILING) > 4.0 * 3.0,
4383            "the start is not far over budget"
4384        );
4385
4386        let trace = converge(CEILING, per_row_us, overhead_ms, CEILING, 4);
4387        let first_in_budget = trace
4388            .iter()
4389            .position(|&n| held(n) <= 3.0)
4390            .expect("never reached the budget");
4391        assert!(
4392            first_in_budget <= 1,
4393            "took {} steps to get under budget: {trace:?}",
4394            first_in_budget + 1
4395        );
4396    }
4397
4398    /// Growth is additive, so a size that is merely comfortable cannot leap the
4399    /// ceiling — and cannot overshoot the budget by more than a quarter.
4400    #[test]
4401    fn growth_is_slow_and_shrinking_is_fast() {
4402        let grown = step(40, 1.0);
4403        assert!(
4404            (41..=50).contains(&grown),
4405            "40 rows at 1 ms should grow by about a quarter, got {grown}"
4406        );
4407        let shrunk = step(90, 9.0);
4408        assert!(
4409            shrunk <= 40,
4410            "90 rows at 3x the budget should shrink proportionally, got {shrunk}"
4411        );
4412    }
4413
4414    /// The dead band. Between `budget / 2` and `budget` the size is right and
4415    /// moving it only costs a re-measurement; without this the law oscillates
4416    /// across the bound forever.
4417    #[test]
4418    fn a_chunk_inside_the_band_is_left_alone() {
4419        for held_ms in [1.6, 2.0, 2.5, 2.9, 3.0] {
4420            assert_eq!(step(60, held_ms), 60, "moved at {held_ms} ms");
4421        }
4422        assert_ne!(step(60, 1.4), 60, "did not grow at well under half budget");
4423    }
4424
4425    /// Both clamps, and the floor's violation stated as a test rather than only
4426    /// as a comment: a populated table drives this to `CHUNK_FLOOR` and holds it
4427    /// there **over budget**, which is [`CHUNK_FLOOR`]'s documented trade.
4428    #[test]
4429    fn the_floor_and_the_ceiling_both_hold() {
4430        // 118 µs/row + 0.03 ms fixed — the populated arm, where 35 rows is
4431        // ~4.1 ms and no size in range meets the bound.
4432        let trace = converge(chunk_rows::EDGES, 118.0, 0.03, chunk_rows::EDGES, 8);
4433        assert!(
4434            trace.iter().all(|&n| n >= CHUNK_FLOOR),
4435            "fell through the floor: {trace:?}"
4436        );
4437        assert_eq!(*trace.last().unwrap(), CHUNK_FLOOR, "settled off the floor");
4438
4439        // A free machine cannot grow past the path's constant.
4440        let fast = converge(CHUNK_FLOOR, 1.0, 0.01, chunk_rows::EDGES, 40);
4441        assert_eq!(*fast.last().unwrap(), chunk_rows::EDGES);
4442        assert!(fast.iter().all(|&n| n <= chunk_rows::EDGES));
4443    }
4444
4445    /// Zero is the one answer that cannot be recovered from: a loop asked for
4446    /// chunks of no rows makes no progress and never finishes. Degenerate
4447    /// inputs included, since `held` is a measurement and measurements arrive
4448    /// from a machine under load.
4449    #[test]
4450    fn the_law_never_returns_zero() {
4451        let cases = [
4452            (0usize, Duration::ZERO),
4453            (0, Duration::from_secs(60)),
4454            (1, Duration::from_secs(60)),
4455            (90, Duration::from_secs(3600)),
4456            (usize::MAX, Duration::from_nanos(1)),
4457            (1, Duration::ZERO),
4458        ];
4459        for (current, held) in cases {
4460            for (floor, ceiling) in [(35, 90), (1, 1), (0, 0), (90, 35)] {
4461                let n = next_chunk_size(current, held, CHUNK_BUDGET, floor, ceiling);
4462                assert!(
4463                    n > 0,
4464                    "returned 0 for current={current}, held={held:?}, \
4465                     floor={floor}, ceiling={ceiling}"
4466                );
4467            }
4468        }
4469    }
4470
4471    /// A zero budget is not a configuration anyone should reach, but it is one
4472    /// division away from a panic, so it is pinned.
4473    #[test]
4474    fn a_zero_budget_shrinks_to_the_floor_rather_than_dividing_by_it() {
4475        assert_eq!(
4476            next_chunk_size(90, Duration::from_millis(1), Duration::ZERO, 35, 90),
4477            35
4478        );
4479    }
4480
4481    /// A panicked write actor is reported, and the report says so (W7.3, D-177).
4482    ///
4483    /// This is the branch `close()` actually has. Through 0.13.3 it sat beside
4484    /// `Ok(res) => res?` on an actor `Result` that could never be `Err`, and the
4485    /// pair looked like two failure paths under review — so the one that cannot
4486    /// fire was carried through two signatures and the one that can had no test.
4487    ///
4488    /// The `JoinError` is real rather than mocked: `JoinError` has no public
4489    /// constructor, and one built by hand would pin the mapping against a value
4490    /// tokio does not produce.
4491    #[tokio::test]
4492    async fn a_writer_that_panicked_is_reported_by_close() {
4493        // Swallow the panic's own output. The task is *meant* to panic, and a
4494        // backtrace in a green suite trains people to skim it.
4495        let prev = std::panic::take_hook();
4496        std::panic::set_hook(Box::new(|_| {}));
4497        let handle = tokio::spawn(async { panic!("the write connection is gone") });
4498        let joined = handle.await;
4499        std::panic::set_hook(prev);
4500
4501        assert!(
4502            joined.is_err(),
4503            "the task must have panicked for this to test anything"
4504        );
4505
4506        match writer_exit(joined) {
4507            Err(DbError::WriterStopped(reason)) => {
4508                assert!(
4509                    reason.contains("did not exit cleanly"),
4510                    "the message must say what happened: {reason}"
4511                );
4512            }
4513            other => panic!("a panicked actor must be WriterStopped, got {other:?}"),
4514        }
4515    }
4516
4517    /// An actor that ran to completion closes clean.
4518    ///
4519    /// The other half, and the one that must not acquire a failure mode by
4520    /// accident: `run_writer_actor` returns `()`, so the only way this can start
4521    /// reporting an error is if someone gives the actor a `Result` again.
4522    #[tokio::test]
4523    async fn a_writer_that_finished_normally_closes_clean() {
4524        let handle = tokio::spawn(async {});
4525        assert!(writer_exit(handle.await).is_ok());
4526    }
4527
4528    /// A token is a handle to one flag, not a value that is copied (0.13.8,
4529    /// W7.6). The clone the caller keeps and the clone the import holds have to
4530    /// be the same flag, or `cancel()` reaches nothing.
4531    #[test]
4532    fn a_cloned_token_cancels_the_original() {
4533        let token = CancelToken::new();
4534        let held_by_the_import = token.clone();
4535        assert!(!held_by_the_import.is_cancelled());
4536        token.cancel();
4537        assert!(held_by_the_import.is_cancelled());
4538        // And it stays cancelled: there is no un-cancel, deliberately, because
4539        // a token that could be reset would let a second import inherit a
4540        // decision made about the first.
4541        token.cancel();
4542        assert!(held_by_the_import.is_cancelled());
4543    }
4544
4545    /// The default control is the one the plain bulk methods pass, and it must
4546    /// never stop a write.
4547    #[test]
4548    fn the_default_control_neither_cancels_nor_reports() {
4549        let control = BulkControl::new();
4550        assert!(!control.is_cancelled());
4551        // No callback, so this is a no-op rather than a panic on an `unwrap`.
4552        control.report(BulkProgress {
4553            written: 1,
4554            total: 1,
4555            rows: 1,
4556            held: std::time::Duration::ZERO,
4557        });
4558    }
4559
4560    /// The callback receives what it was promised, once per call to `report`.
4561    #[test]
4562    fn progress_reaches_the_callback_unchanged() {
4563        let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
4564        let control = BulkControl::new().on_progress({
4565            let seen = Arc::clone(&seen);
4566            move |p| seen.lock().unwrap().push(p)
4567        });
4568        let sample = BulkProgress {
4569            written: 180,
4570            total: 900,
4571            rows: 90,
4572            held: std::time::Duration::from_millis(12),
4573        };
4574        control.report(sample);
4575        assert_eq!(*seen.lock().unwrap(), vec![sample]);
4576    }
4577}