Skip to main content

Database

Struct Database 

Source
pub struct Database { /* private fields */ }
Expand description

Primary database handle for Macrame bitemporal ledger.

§Why this is not Clone, and what a multi-consumer caller uses instead

Share it as Arc<Database>. Every method here but one takes &self, so an Arc is a complete handle and not a workaround: reads run concurrently off read_conn, writes queue behind the actor’s channel exactly as they do through a &Database, and nothing becomes serialised that was not serialised already. The exception is Database::close, which takes self, so the last owner closes with Arc::into_inner(db).expect("last handle").close().await.

That exception is the whole reason Clone is absent. Cloning would have to duplicate the right to shut down, and each field carrying that right breaks differently when duplicated:

  • writer is a tokio::task::JoinHandle, which is not Clone at all — so a hand-written impl would have to give the copy a None, and close() on that copy returns Ok(()) without ever checking the actor’s exit status. That status is one of the two reasons Drop tells callers to prefer close().
  • cadence_stop is a tokio::sync::watch::Sender, which is Clone, and that is the worse case. Its contract is that dropping it stops the snapshot task; a watch channel closes when the last sender goes, so one surviving copy keeps that task running against a database that is going away. Nothing returns an error, which is why this is the argument rather than the JoinHandle.
  • closed is per-handle, so two copies disagree about whether the ledger was closed: Drop warns about a database that was closed, or stays silent about one that was not.

And the ordering close() documents — cadence stopped, actor joined, then the final snapshot, so that no write can land between the fold and the file — is only enforceable while one handle can perform it. A second close() writes a “final” snapshot with the actor still alive.

So the missing impl is the type saying shutdown has exactly one owner. The Python binding reached the same shape from the other side and for the same reason: PyDatabase holds a RwLock<Option<Database>> rather than a copy per caller (0.13.30, W11.1, D-203).

Implementations§

Source§

impl Database

Source

pub async fn open(path: impl AsRef<Path>) -> Result<Self>

Open a database file at path, configuring pragmas, running migrations, and spawning the Write Actor.

The snapshot cadence runs with SnapshotCadence::default. Use Database::open_with_cadence to tune or disable it.

Source

pub async fn open_with_cadence( path: impl AsRef<Path>, cadence: Option<SnapshotCadence>, ) -> Result<Self>

Open with an explicit snapshot cadence, or None to run without one (§5.5, D-053).

None restores the pre-0.5.5 behaviour, where close() is the only thing that ever writes an anchor. That is the right setting for a short-lived process that will not accumulate a delta worth bounding, and for tests that assert on the contents of the snapshot directory.

Source

pub async fn open_with_clock( path: impl AsRef<Path>, cadence: Option<SnapshotCadence>, clock: Arc<dyn Clock>, ) -> Result<Self>

Open with an injected clock (§5.1.2, defect K, D-062).

The reason this exists is testing: recorded_at is the transaction-time axis, and until now every test that wanted to assert on one had to either avoid it or drive a raw connection, because open() hardcoded SystemClock. FakeClock has been public and constructed in the test harness since 0.5.2 with nothing to inject it into — the compiler warned about the dead field on every build for three releases.

The clock is floored against the database before the actor starts. Clock::raise_floor is called with the newest recorded_at in the ledger, so an injected clock cannot issue a stamp below what is already stored — which would abort the next concept write on trg_concepts_monotonic_ra rather than merely being odd. This is the step whose absence kept the defect open: the obvious implementation (take an Arc<dyn Clock>, use it) produces a Database that fails on its first write against any non-empty file.

On a fresh database there is no floor, so an injected FakeClock issues exactly the stamps it was given.

Source

pub async fn open_tuned(path: impl AsRef<Path>, tuning: Tuning) -> Result<Self>

Open with an explicit Tuning (0.12.12, W5.1, D-155).

The consolidated form of the three constructors above, and the one that grows: every knob 0.13.0 adds arrives as a field here rather than as a fourth open_*. See Tuning for why the struct is #[non_exhaustive] and why that makes the growth additive.

That sentence was written at 0.12.12 and was false until 0.15.13: the struct was not #[non_exhaustive], and Tuning’s own docs carried a section arguing at length that it should not be. Two documents in one file contradicting each other for eleven releases, which is the shape C-16 is about; W15.3 resolved it by making this one true.

Source

pub fn read_conn(&self) -> &Connection

Read connection handle for queries, traversals, and folds.

Source

pub fn path(&self) -> &Path

The file this handle opened.

Source

pub async fn diagnostic_conn(&self) -> Result<Connection>

The OS-level read-only connection to this database, for diagnostics (§4.7, T5.1, D-091).

§Why this exists when read_conn() already does

Two different things, and the difference is the point:

  • read_conn() returns a shared &Connection carrying PRAGMA query_only = ON. That pragma is per-connection and reversible by its holder in one statement, so it is a guardrail against accident, not a capability boundary. And because it is the connection the crate’s own traversals and folds run on, a caller who runs a long reporting query there is competing with all of them.
  • This returns a connection opened with SQLITE_OPEN_READ_ONLY, which is enforced by the engine below the pragma layer, and which nothing inside the crate runs on.
§One connection per Database, shared between callers (0.15.14, W15.4)

Through 0.15.13 this minted a connection per call and the sentence above read “a new, independently owned … connection”. Review item C-9 asked for the file to be opened once per handle instead, and the measurement behind it (examples/diagnostic_conn_probe.rs) was sharper than the ask: connect() is 51.5 µs of an 82.7 µs call, and Builder::…build() — which every document in this crate called the open — is 0.10 µs and opens nothing, succeeding against a path that does not exist. Caching the handle would have removed 0.10 µs. What ships caches the connection: 82.7 µs → 19.9 µs, and what remains is the stat below, not the connection.

So per-connection state is shared between diagnostic callers. An ATTACH, a PRAGMA, a temp table one caller creates is visible to the next — which matters here and nowhere else in this API, because diagnostic_query is the one arbitrary-SQL surface the crate exposes. Asserted in diagnostic_callers_share_one_connection_and_its_state rather than left to this paragraph.

What that costs is isolation between diagnostic callers. What it does not cost is the thing D-091 was for: this is still not read_conn(), a reporting query here still does not compete with the crate’s traversals and folds, and the read-only boundary below is untouched.

§What is scrubbed before you get it, and what is not (0.15.15, W15.5)

0.15.14 shipped the sharing and documented it. [D-257] measured what it actually admits, and one of the four is not an isolation nuisance but a correctness hole that leaves this surface entirely: a leaked BEGIN pins a WAL read snapshot. Measured — 200 writes through the typed surface while a diagnostic caller held an unclosed read transaction — later diagnostic reads answered 1 row instead of 201, silently, and Database::checkpoint became a no-op with the WAL stuck at 8.5 MB until the transaction was rolled back. Stale answers on the surface a caller reaches for when they already distrust the typed one, and an unbounded WAL on a method that has nothing to do with diagnostics.

So this method scrubs on entry rather than trusting the caller, and the prices are from examples/diagnostic_hygiene_probe.rs:

left behindhow it is foundwhat happens
an open transactionis_autocommit(), 0.04 µsROLLBACK, 2.4 µs
a temp table or viewPRAGMA temp.schema_versionconnection dropped, re-minted at 56.6 µs
an ATTACHPRAGMA database_listconnection dropped, re-minted
busy_timeout, cache_sizenot detected — restated, 1.0 µsreset to the crate’s values
any other pragmanot detectedinherited by the next caller

The two dirt questions are asked as pragmas because the same two asked over temp.sqlite_master and pragma_database_list cost 7.8 µs against 2.4 µs, on a call whose entire warm cost is the stat.

The last row is the honest residue. SQLite has no cheap enumeration of connection-scoped pragma state, so the crate restates the two pragmas it set (D-159’s busy_timeout above all — a caller who sets it to 0 would otherwise remove the 5 s margin from every later diagnostic call) and leaves the rest. A caller who sets case_sensitive_like or recursive_triggers changes what later diagnostic queries on this handle see, and nothing else: the crate’s own readers and writer are different connections, so no typed answer can move. That is a smaller blast radius than 0.15.14 had and a larger one than zero, and it is written down rather than rounded off.

That paragraph is about pragmas that belong to a connection, which is what “residue” means and what the scrub is for. Not every pragma reachable here is one, and the section below is the exception — measured after D-257 claimed this one “cannot change any typed answer” without checking (0.15.16, D-258).

Scrubbed on entry, not on exit, because there is no exit: this returns a Connection clone the caller keeps for as long as it likes, and the crate is never told they are done. Entry is the one place that covers both this method and diagnostic_query. The consequence is that a caller who leaks a transaction and never calls again holds the pin until the handle drops — so the Python binding, which does know when a query is over, scrubs on exit as well.

Measured on libSQL 0.9.30 rather than assumed (examples/readonly_open_probe.rs), against a live WAL database with the write actor running:

read_conn()diagnostic_conn()
SELECT, EXPLAIN QUERY PLANallowedallowed
INSERTrefusedrefused
PRAGMA query_only = OFFallowedallowed
INSERT after thatallowedrefused
ATTACH an existing fileallowedallowed
INSERT into the attachmentrefused¹refused
ATTACH a path that does not existrefused (SQLITE_CANTOPEN)

The third and fourth rows are the whole difference: turning the pragma off restores writes on read_conn() and does not here. That is what “boundary rather than guardrail” means, and it is now a number rather than a claim.

¹ On read_conn() that refusal is query_only — the same reversible thing as row 2. On diagnostic_conn() it is the open flags, and the probe runs it after query_only = OFF so that the pragma cannot be what is doing the work.

§ATTACH is permitted, and does not widen the write boundary

Checked because diagnostic_query (Python) is the only arbitrary-SQL surface this crate exposes, and an attachment is a second open whose flags it does not obviously inherit. It does inherit them: the attachment is read-only, and a nonexistent path is SQLITE_CANTOPEN rather than a new file, because SQLITE_OPEN_CREATE is dropped for the attachment as it is for main. So SQLITE_OPEN_READ_ONLY bounds the connection, not just the one file it names (0.10.0, W4.3).

What it does widen is reading: an ATTACH can name any file the process can open, so this connection is a read surface over the filesystem, not over this database. That is a property of arbitrary SQL rather than of the flags, and it is unchanged by them.

§One pragma here can end the process, and sharing is not why

PRAGMA hard_heap_limit = 1 through this connection leaves the whole process unable to use SQLite. Not this connection, not this handle: measured (tests_py/probes/diagnostic_global_pragmas.py), the next ordinary write, the next read, checkpoint(), close(), and opening a different database file all fail with out of memory, permanently.

SQLITE_OPEN_READ_ONLY does not stand in the way because setting it is not a write to the database file, and the scrub above does not help because there is nothing left on the connection to scrub: the limit lives in the SQLite library, one per process. Re-measured with a connection minted per call — the 0.15.13 shape, before any of the sharing this method now does — the outcome is identical. So this is not a cost of [D-256]’s shared connection and no amount of hygiene addresses it.

Six other candidates were measured and are harmless: soft_heap_limit (a hint, not a wall), locking_mode = EXCLUSIVE (accepted; the writer kept working), temp_store_directory, max_page_count (clamped, and per-connection), case_sensitive_like (the control), and wal_checkpoint, which is refused outright because this connection is read-only.

It belongs with the ATTACH note above rather than with the scrub: both are properties of handing a caller arbitrary SQL, not of the flags the connection was opened with. The practical form is one sentence — diagnostic_query is not a safe place to put a string that came from somewhere else — which ATTACH already made true and this makes sharper. Not blocked by refusing statements that look like this one, because matching SQL text is guesswork wearing the costume of a guarantee, and it would do nothing for a Rust caller holding the connection directly (D-258).

§One way this is more permissive, which is worth knowing

CREATE TEMP TABLE succeeds here and is refused by read_conn(). Temp tables live in a separate temporary database that is writable regardless of how the main one was opened, whereas query_only refuses them outright — which is the mechanism [D-050] measured when it removed TwoPhaseTempTable for returning SQLITE_READONLY (8) on the read connection. So the stronger boundary is not uniformly stronger, and a strategy that needs a temp table has a connection it could run on. That is recorded, not acted on: D-050 removed the strategy for two reasons and this addresses one of them.

§Calling this concurrently was R15’s shape, and 0.15.14 is why it is not

Through 0.15.13 this section said “this is the one method on Database that opens the file … each call is a fresh libsql::Builder::…build(), so N threads calling it at once are N concurrent opens”. The first half was true and the second was wrong about which call does it: build() opens nothing, and connect() is the open. The conclusion happened to be right for the wrong reason, which is why it took a measurement to move.

Measured through the unlocked Python binding, 48 threads on a barrier, 30 runs per arm (tests_py/probes/r15_diagnostic_path.py):

armbad runs
a connection per call, as before 0.15.143 / 30
the libsql::Database handle cached, a connection per call2 / 30
the connect() serialised behind a mutex1 / 18
one connection, as shipped0 / 30

Rows two and three are why the shape that preserved the old contract was not taken: caching the handle leaves the crash because it leaves the connect(), and serialising the connect() alone does not reach zero — the race is between minting a connection and the use of the others, not between two mintings.

There is nothing left on this path to bound, because after the first call it no longer opens anything. Database::open from many threads is still R15’s shape and examples/r15_soak.rs still reproduces it; this method is no longer a way to reach it. The Python binding keeps its mutex as margin rather than as a measured necessity — see PyDatabase::diagnostic_rows.

§Errors

The file must already exist. SQLITE_OPEN_READ_ONLY drops SQLITE_OPEN_CREATE with it, so a missing file is SQLITE_CANTOPEN rather than a fresh empty database — which is the right failure, and is surfaced as a typed error rather than as libSQL’s error 14.

The check is a stat on every call, not only the first, and it is still most of what a warm call costs (18.6 µs of the 22 µs a clean one takes since 0.15.15). It is kept at that price because the alternative is the worst failure a diagnostic surface can have: a cached connection whose file has been deleted and replaced answers from the old inode, silently, on the one method a caller reaches for when they already doubt the typed answer.

Source

pub async fn scrub_diagnostic_conn(&self)

Roll back and discard whatever the last diagnostic caller left behind, without handing a connection out (0.15.15, W15.5, D-257).

Database::diagnostic_conn does this on the way in, which is the only place the crate can do it: the method returns a Connection clone and is never told the caller is finished with it. That covers every caller and leaves one gap — somebody who leaks a transaction and then never calls again holds the WAL read snapshot until the handle drops, which makes Database::checkpoint a no-op for that whole time.

This is for the callers that do know when they are done. It costs the scrub and not the stat — around 3.5 µs on a clean connection — because it hands nothing back and so has nothing to promise about the file still being there.

The Python binding does not call it, though the first draft did. A mutation deleting that call left the whole suite green, and the reason is that the gap is not reachable from there: diagnostic_query runs one statement, a bare BEGIN pins nothing — the snapshot is taken by the first read inside the transaction — and any statement that would take it arrives through the same method, whose entry scrub has already rolled the transaction back. The gap is real for a Rust caller holding a clone across both, which is what this method and scrubbing_releases_the_pin_without_handing_out_a_connection are for.

Infallible by construction: everything it might have reported is something it responds to by discarding the connection, and the next Database::diagnostic_conn opens a fresh one.

Source

pub async fn verify_snapshot_chain(&self, ts: &str) -> Result<ChainCheck>

Cross-check the snapshot chain against a fold from genesis (§5.5, T5.3, D-092).

write_final composes onto the previous snapshot, so snapshot n is derived from snapshot n−1 and nothing in the chain ever folds the whole log. An error at any link propagates forward forever and every read agrees with it, because every read descends from it. This is the check that would notice.

§When to run it

Not on a schedule this crate chooses. A genesis fold is precisely the cost snapshots exist to avoid, so running it periodically by default would give every application the bill snapshots were bought to remove — on a database whose log is large enough for snapshots to matter, which is the only kind where this is worth doing. The plan calls it a scheduling problem and it is the caller’s schedule: an idle period, a nightly job, or once per N anchors, chosen against a log size this crate cannot see.

The cadence is deliberately left alone for the same reason — it runs on a connection shared with nothing and a fold there would compete with interactive reads at a moment nobody chose.

§It reports; it does not repair

A divergence means the snapshots are a wrong cache, not that the ledger is corrupt: Doctrine VI makes them disposable, so deleting Self::snapshots_dir restores correctness and costs only speed. Rewriting the file here would destroy the evidence that composition has a defect, which is the only thing this can tell you that you did not already know.

Pair it with the actor counters (Self::metrics, D-079) so a divergence found by a scheduled run is visible beside the write latency of the period that produced it.

Check the newest link of the snapshot chain (0.15.19, review C-18).

The affordable half of Self::verify_snapshot_chain: re-derive the newest snapshot from the one before it and compare, which is one anchored delta rather than a fold from genesis. Ok(None) when there are not two snapshots yet.

The snapshot cadence already runs this after every anchor it writes and logs a divergence at warn, so a caller reaching for it directly is usually one that wants the crate::temporal::ChainCheck itself — the disagreeing ids — rather than a yes or no.

It reports; it does not repair. A snapshot is derivative (Doctrine VI), so the repair is to delete the snapshot directory, which is the caller’s call and one line. What this cannot tell you is whether the chain went wrong further back than one link; that is what Self::verify_snapshot_chain is for, and why it stays.

Source

pub fn clock(&self) -> &Arc<dyn Clock>

The clock every write is stamped with (§5.1.1).

Source

pub fn schema_version(&self) -> u32

Schema version this handle opened against.

Source

pub fn archive_path(&self) -> &Path

Cold database path, derived by convention from the main file.

Source

pub fn snapshots_dir(&self) -> &Path

Snapshot directory, derived by convention from the main file.

Source

pub fn metrics(&self) -> MetricsSnapshot

What the write actor has done since this handle was opened (T1.4, D-079).

Requires the metrics feature. The counters are per-handle and start at zero on open() — they are not read from the database, because the thing being measured is this process’s actor and merging two processes’ histograms would produce a number about neither.

The intended first question is crate::metrics::MetricsSnapshot::budget_violations:

for k in db.metrics().budget_violations() {
    eprintln!("{} broke the 3 ms bound {} times", k.kind, k.over_budget);
}

Reading this does not stop the actor — see crate::metrics::ActorMetrics::snapshot for what that costs in consistency, and why the trade goes that way.

Source

pub async fn assert_edge(&self, edge: EdgeAssertion) -> Result<()>

Assert an edge (Doctrine III: a new row, never an update).

§One row costs a transaction, so N rows cost N transactions

This is the correct method for a caller who genuinely has one edge, and it is the wrong one in a loop. Each call is its own transaction and pays the ~0.8 ms per-transaction floor (D-090) whole, so a thousand edges asserted one at a time spend roughly 0.8 s in transaction overhead alone — before any of the work — and mint a thousand distinct recorded_at stamps for what the caller probably means as one act.

There are two bulk forms and the difference between them is the one to get right:

  • Self::bulk_import is chunked against CHUNK_BUDGET and atomic per chunk. It amortises the transaction floor across the batch while still yielding to interactive work at every chunk boundary. This is the one a loop should almost always become.
  • Self::write_bulk_atomic is one transaction under one stamp and is the one write with no latency bound — the hold is a function of edges.len(), tabulated in its own docs, and is time every other writer spends waiting. Reach for it when the batch is genuinely one act that must not be observable half-applied, not for speed.

The choice is the caller’s and neither form is deprecated. Doctrine III makes “one act, one stamp” a semantic claim rather than a performance one, and only the caller knows whether their thousand edges are one act.

Source

pub async fn retire_edge( &self, source: impl Into<String>, target: impl Into<String>, edge_type: impl Into<String>, valid_from: &str, valid_to: &str, ) -> Result<()>

Close an open interval by asserting its replacement (Doctrine III).

Source

pub async fn retire_edge_on( &self, source: impl Into<String>, target: impl Into<String>, edge_type: impl Into<String>, valid_from: &str, valid_to: &str, branch: BranchId, ) -> Result<()>

Retire an edge on a lineage, which is a different write (0.14.8).

The _on suffix is the crate’s established spelling for the branch-taking variant of a call whose trunk form predates branching — query_as_of_edges_on is the other one. A sixth positional Option<BranchId> on Self::retire_edge would have made every existing call site read as though it had made a lineage decision it never made.

§This closes a row; it does not close the row

Retiring an edge the branch inherited writes the branch’s own row at the ancestor’s key, carrying the closed interval and this lineage’s id. The ancestor’s row is untouched, and the read prefers the nearer one, so the edge is gone from this lineage’s view and unchanged in its parent’s. That is shadow retirement, and it is the only retirement across lineages that does not commit the parent corruption Doctrine III forbids — which is not a rule this method obeys but a shape the ledger cannot express: links is append-only and no statement in this crate closes a row in place.

weight and properties are carried over from the visible row rather than restated, which is what makes this a retirement rather than a new assertion that happens to be closed.

§Errors
  • DbError::UnknownBranch when branch is not registered.
  • DbError::NotFound when this lineage can see no open row at that valid_from. On a branch that includes never inherited it and inherited it and already shadowed it, which are one answer here because they are one answer to the question asked: there is nothing at that key to retire.
Source

pub async fn upsert_concept(&self, concept: ConceptUpsert) -> Result<()>

Insert or update a concept.

§One row costs a transaction

The same trade Self::assert_edge describes, for the same reason and with the same ~0.8 ms floor (D-090): correct for one concept, wrong in a loop. Self::write_concepts takes a Vec and commits it as one transaction under one stamp.

There is no atomic-across-chunks concept path and none is needed to make the choice: write_concepts is chunked against CHUNK_BUDGET and atomic per chunk, so a large Vec is cooperative rather than a stall. The responsiveness argument for writing one row at a time therefore does not apply — the bulk form already yields at every chunk boundary.

Source

pub fn view(self: &Arc<Self>, branch: BranchId) -> BranchView

A handle on one lineage (§15.4, 0.14.9, D-226).

Takes &Arc<Self> rather than &self because the view holds the handle and must not be able to end it: close takes self by value and an Arc cannot surrender that while a clone survives, so the restriction is structural rather than documented. Sharing the handle is already Arc<Database> (§5.1.11), so this asks for nothing a caller did not have.

Does no I/O and cannot fail. Whether the lineage is registered is asked by every operation on the view, which is where DbError::UnknownBranch names it.

Source

pub async fn fork(&self, name: BranchId, from: BranchId) -> Result<Branch>

Cut a new lineage from an existing one (§15.2, §15.4).

§A fork is O(1) in rows written

One row in branches, and nothing else. No ledger table is read, copied or touched: a branch inherits its parent’s history by resolution at read rather than by owning a copy of it, which is what TraversalBuilder::on_branch resolves and 0.14.6 bounds by the fork point. The cost of that choice is on the read side and is measured — D-220 for the resolution, D-223 for the cutoff — and the cost of the alternative would be here, as an O(rows) fork and storage multiplied by branch count (§15.3, option 3).

§The fork point is now, and that is a bound on this release rather

than on the design

forked_at is stamped from the same clock as every other write, so the new lineage sees its parent’s history up to this instant. Forking from a past instant is a coherent thing to want and the schema has always allowed it — branches carries forked_at and created_at as separate columns under CHECK (forked_at <= created_at) — but it is not in this release and is additive when it is.

§What this lineage can do

It can be read: every traversal entry point takes a branch, and on a forked ledger the read resolves along the ancestry and stops at the fork point. Since 0.14.8 it can also be writtenEdgeAssertion and ConceptUpsert carry a lineage, and Self::retire_edge_on shadows an inherited edge (D-225). Through 0.14.7 they did not, and a caller who forked and then called assert_edge got a successful write on the trunk; that is fixed rather than documented now.

What a branch still may not do is restate an inherited concept. concepts is keyed by identity, so that is refused as DbError::CrossLineage — see ConceptUpsert::branch. Edges are the thing a lineage may hold its own belief about, and superseding one is a row written beside the ancestor’s rather than over it.

§Errors
  • DbError::UnknownBranch when from is not registered. Named rather than left to the foreign key, because the caller asked about a branch.
  • DbError::BranchExists when name is taken — including "main", which every database has from its first migration.
  • DbError::ForkPrecedesParent when the clock would place this fork point before the parent’s own — not before the parent’s created_at, which is what the schema comment promised until 0.14.7 and is not checkable: the trunk’s created_at is stamped during migration from the wall clock, before an injected clock exists, so that rule refuses every fork on every FakeClock database (D-224). Reachable with FakeClock, and the one refusal here that no CHECK could have made — it is cross-row, and a CHECK sees one row.
§Example
let alt = db.fork(BranchId::new("turn/17/alt/1")?, BranchId::main()).await?;
let seen = TraversalBuilder::new("socrates")
    .on_branch(alt.id.clone())
    .execute_ids(db.read_conn(), "2026-08-29T00:00:00.000000Z")
    .await?;
Source

pub async fn branches(&self) -> Result<Vec<Branch>>

Every lineage the ledger knows about, trunk first (§15.4).

Read through Self::read_conn rather than the write actor, which is the difference between this and Self::fork and is deliberate: branches is append-only, so the only way this listing can be stale is by missing a branch created after it was taken, and a caller who wanted to know about that branch would have had to create it. Queueing a read behind the write actor would make listing branches wait on a bulk import for no answer it could change.

A database that has never forked returns exactly one row: the trunk, with no parent and no fork point.

Source

pub async fn diff(&self, a: &BranchId, b: &BranchId) -> Result<Vec<Divergence>>

The beliefs a holds that b does not (§15.4, 0.14.11, D-228).

One Divergence per edge key the two lineages disagree about, in key order: b holds no belief about it, or holds one with a different interval or weight. Not symmetric — diff(b, a) is the other half, and composing the two is two snapshots even though each is one.

Read through the read connection rather than the actor, like Self::branches, and taken at one snapshot rather than two: see graph::lineage::diff_sql for why that decides the shape of the query.

There is no instant parameter. A diff filtered to a valid-time instant cannot report the one divergence that is about an instant having passed — a branch that retired an edge its parent still holds open — so this compares the whole of both views.

§Errors

DbError::UnknownBranch, naming whichever of the two is not registered, and a first when neither is.

Source

pub async fn write_bulk_atomic( &self, edges: Vec<EdgeAssertion>, ) -> Result<usize>

Assert many edges in one transaction under one stamp (D-014).

§This is the one write with no latency bound, and here is what it costs

The batch is one act under one recorded_at, so it cannot be chunked — splitting it is the thing this method exists not to do. That makes the actor’s hold a function of edges.len(), and until now the only statement of that anywhere was the prose “uncapped” in CHUNK_BUDGET’s table. A caller who stalls every other writer for eight seconds should have been able to predict it from the signature.

Measured on libSQL 0.9.30 (T1.3, D-081), holding the actor for:

rowshold
500~34 ms
2,000~155 ms
10,000~1.0 s
20,000~2.6 s

estimated_bulk_hold is that curve as a function, and this method emits a tracing::warn! when it predicts more than BULK_ATOMIC_WARN_HOLD. The estimate is a shape, not a promise — see estimated_bulk_hold for what it is calibrated against and where it will be wrong.

A caller who needs the latency bound and not the atomicity wants Self::bulk_import, which is the same write chunked and explicitly not atomic overall (D-011).

Source

pub async fn checkpoint(&self) -> Result<CheckpointReport>

Move the WAL back into the main database file (§4.5, F-30, 0.12.13, W5.2, D-156).

Runs PRAGMA wal_checkpoint(FULL) and then (TRUNCATE) on the write connection, as one actor turn, and returns what SQLite reported. Read CheckpointReport::busy — a checkpoint that could not run is an Ok whose WAL is still there.

Two passes rather than one because a truncating checkpoint cannot report its own work: the counts describe the WAL after the operation, and after a truncation there is nothing left to describe, so TRUNCATE alone answers busy=0, log=0, checkpointed=0 on success — indistinguishable from having done nothing. FULL supplies the frame count and TRUNCATE resets the file; busy is the union of the two.

§When a caller needs this

Three cases, and only three:

  • Before copying the database file elsewhere. In WAL mode the .db file alone is not the database; recent commits live in the -wal. A complete checkpoint is what makes the main file self-contained.
  • At the end of a bulk load that turned the automatic checkpointer off. That is the pairing this method exists for — see Tuning::wal_autocheckpoint. Disabling autocheckpoint without calling this leaves a WAL that grows for the life of the process.
  • Before a long idle period, to give back the disk.

Nobody else should call it on a timer. SQLite checkpoints automatically every 1,000 pages and that default is not changed by this method existing; a periodic explicit checkpoint on top of it buys nothing and takes the write lock to do so.

§It takes the write lock, and it is budget-exempt

The hold is a function of how many frames have accumulated, which is a function of how long since the last checkpoint — not of anything passed in. It is on CHUNK_BUDGET’s exemption table for that reason, and it is the one entry there that is not a transaction: there is no smaller unit to chunk into, because the operation is the copy.

Source

pub async fn rebuild_current(&self) -> Result<RebuildReport>

Rebuild links_current from links and verify zero drift (§5.8).

One transaction holding the write lock for its whole duration, because D-023 will not let the DELETE and the INSERT be split: a reader landing between them would see a graph with no edges and no error. Self::rebuild_current_chunked is the same result with a different latency profile, and is what a populated database wants.

The report’s drift_after is the audit run inside the same transaction, so a repair that did not converge is reported by the call that made it rather than by the next one to look.

Source

pub async fn rebuild_current_chunked(&self) -> Result<RebuildReport>

Rebuild links_current beside itself, in chunks (§5.8, T1.2, D-082).

Same result as Self::rebuild_current, different latency profile. rebuild_current is one transaction holding the write lock for its whole duration, because D-023 will not let the DELETE and the INSERT be split: a reader landing between them sees a graph with no edges and no error. This builds the replacement in a shadow table instead — the live table stays live and trigger-maintained throughout — and swaps it in at the end.

Each step is its own actor turn, so an interactive assertion can jump the queue between chunks. That is the whole of the improvement, and it is why the loop is here rather than inside the actor’s arm (the same reasoning as Self::archive_windowed and Self::bulk_import).

§What the swap still costs

Not microseconds. Index names are global and SQLite has no ALTER INDEX … RENAME, so the shadow cannot be built carrying links_current’s index names while links_current still holds them — and building it under other names would leave the table permanently indexed under names absent from CREATE_INDICES, so the next migration would create a second copy of each. DROP TABLE frees the names, so the swap transaction is where the three indexes get built. What the chunking moves off the lock is the projection — the window function over all of links — which is the O(E log E) term.

§When this returns an error rather than a repair

DbError::RebuildInterrupted means an archive committed while the shadow was being built. Its deletions are invisible to a catch-up pass keyed on recorded_at — a deleted row has no recorded_at left to find it by — so the work is discarded rather than swapped in. links_current is untouched and the call can simply be retried.

Use Self::rebuild_current when the repair must be one atomic act, or when nothing else is contending for the actor and the extra turns are pure overhead.

Source

pub async fn shadow_step(&self, step: ShadowStep) -> Result<ShadowOutcome>

Run one step of a chunked rebuild, for a caller doing its own scheduling.

Self::rebuild_current_chunked is this in a loop and is what almost everyone wants. This exists because that loop offers no seam: it drives Begin, then Fill to exhaustion, then Swap, and a caller who needs to do something between steps — pace them against a frame budget, abandon a rebuild that has run long enough, or provoke the archive interlock in a test — cannot get in.

The obligation that comes with it: epoch from ShadowOutcome::Started must be handed back to ShadowStep::Swap, or the archive interlock is defeated and a stale projection can be swapped in. The looping version cannot get that wrong; this one can.

Source

pub async fn bulk_import(&self, edges: Vec<EdgeAssertion>) -> BulkResult<usize>

Import edges on the background channel, chunked (D-011).

Atomic per chunk, not overall: a failure partway leaves earlier chunks committed. That is the tradeoff chunk_rows documents — use Database::write_bulk_atomic when the batch must be all-or-nothing.

Chunked adaptively, at most chunk_rows::EDGES rows at a time: that constant is where the loop starts and the largest chunk it will send, and each chunk’s measured hold sizes the next against CHUNK_BUDGET. It is also faster in total than the larger chunks this used through 0.5.5 (D-058).

A consequence worth planning for: the chunk boundaries — and so the recorded_at stamps this import writes — depend on how fast the machine was, not only on how many edges were passed (§5.1.6).

§The WAL during a bulk (measured, 0.16.1 — see D-274’s diagnostics)

Every chunk’s commit runs WAL pages back into the database file when SQLite’s own autocheckpoint threshold (1,000 pages) is reached, and on a bulk that fires every few chunks. Measured on the 16,000-edge random-pair ladder (medians of 3, benchmarks/diagnostics/): 5.0 s with the default, 3.7 s with the threshold at 10,000 pages (wal_autocheckpoint = 10_000 at open), 3.6 s with it disabled outright — and the checkpoint the bulk was paying is 240 ms once at the end instead. The WAL is the price of the third: ~554 MB for that fixture against 43 MB at the 10,000-page threshold and ~6 MB at the default, so the 10,000-page setting is the recipe unless the disk is known to be large. On an ordered (chain-shaped) import the sweep is a wash — the recipe is for the random-order shape a real importer produces. The knob is per-handle and set at Database::open; call Self::checkpoint once after a disabled-checkpoint bulk.

Returns BulkInterrupted rather than DbError on failure, because a path that is not all-or-nothing owes its caller the count of what landed (0.13.8, W7.6). ? into a Result<_, DbError> still compiles and drops the count, which is the caller’s decision to take.

Self::bulk_import_with adds cancellation and per-chunk progress.

Source

pub async fn bulk_import_with( &self, edges: Vec<EdgeAssertion>, control: BulkControl, ) -> BulkResult<usize>

Self::bulk_import with cancellation and progress (0.13.8, W7.6, D-181).

The chunk boundaries this path already has are what make both possible: the loop is between transactions several times a second, which is where a token can be read and a callback run without holding anything.

Source

pub async fn bulk_import_deferred( &self, edges: Vec<EdgeAssertion>, ) -> BulkResult<usize>

Load edges without the maintained projection in the way, then re-derive it in one chunked rebuild (D-277, plan §8.1 / F1).

Three phases through the actor: drop trg_links_current_sync (the per-row upsert into links_current), load every edge through the same chunked path Self::bulk_import uses, restore the trigger, then Self::rebuild_current_chunked — the re-derivation D-082 made chunked, whose fill costs ~104 ms per 16k rows and whose swap is the exempt 46.8 ms turn. Measured on the reference box, 16,000 random-pair edges (medians of three):

armbulkrebuild + restoretotal
shipped (bulk_import)5.08 s5.08 s
this2.29 s0.19 s2.48 s (2.05×)

The mirror is what the ladder showed growing with the graph on random pairs (plan §9.2): the near-chain shape pays it too, just flatter, so this is the lever for both — and the rebuild’s cost is a function of the table, not the shape, which is why the skip arm’s total is flat where the shipped arm’s is not.

§What the window actually touches, stated precisely

The ledger is complete at every instant of the window. The log mirror and the single-open guard stay up: links and transaction_log gain every row this call loads, versioned and guarded exactly as the shipped path does. What lags is links_current — Doctrine VI’s derivative state, which the crate has always maintained as the projection you could fold from the ledger. Current-time reads during the window (traverse, query_as_of_edges without an instant) see a partial projection and are not wrong about the past — they are stale about the present, the one state Doctrine VI calls disposable.

The window ends when this method returns, success, failure, or cancellation: the restore runs before the rebuild, so any write that lands after it mirrors again, and the rebuild then sweeps the backlog. If the future is dropped or the process unwinds mid-load, the window stays open — the projection stays stale but the ledger stays complete, audit_current reports the drift as DbError::CurrentDrift, and Self::rebuild_current_chunked closes it. That is the honest doc entry plan §8.1 asked for, and why this is a signature rather than a default: the shipped path never has a window at all.

§What a failure does

The load’s failure — BulkInterrupted with its written count — propagates only after the trigger is restored and the projection rebuilt from what did commit, so a failed deferred bulk is in exactly the state a caller expects: the prefix is committed, the projection is true, the mirror is on. An empty edges is a no-op: no DDL, no rebuild, nothing attributed.

Source

pub async fn bulk_import_deferred_with( &self, edges: Vec<EdgeAssertion>, control: BulkControl, ) -> BulkResult<usize>

Self::bulk_import_deferred with cancellation and progress, on the load half — the same chunked loop, the same token, the same callbacks Self::bulk_import_with takes. The toggle and the rebuild are single turns around it.

Source

pub async fn write_concepts( &self, concepts: Vec<ConceptUpsert>, ) -> BulkResult<usize>

Upsert many concepts on the background channel, chunked (D-011).

This is the bulk concept path, and every row it writes is a ledger write: it versions the concept and lands in transaction_log. Derived analytics output does not belong here — see Database::write_analytics_annotations and D-041.

Called write_annotations through 0.5.6, from when the two writes were one call. D-041 split them and the name stayed on the wrong one for three releases, so the crate had a write_annotations that wrote concepts sitting beside a write_analytics_annotations that wrote annotations (D-075).

Chunked, so it returns BulkInterrupted and its written count on failure (0.13.8, W7.6); Self::write_concepts_with adds cancellation and progress.

Source

pub async fn write_concepts_with( &self, concepts: Vec<ConceptUpsert>, control: BulkControl, ) -> BulkResult<usize>

Self::write_concepts with cancellation and progress (0.13.8, W7.6).

Source

pub async fn reconstruct(&self, ts: &str) -> Result<MaterializedState>

State as believed at ts (§5.5, D-026, D-049).

A read: it runs on read_conn and never touches the Write Actor, so a reconstruction and a full-speed write-back do not slow each other.

Prefer this to calling crate::temporal::reconstruct directly. The free function takes the archive path and the snapshot directory as arguments, and a caller who passes None for the second gets a correct answer that folds the whole log every time — the composition is opt-in at that layer and easy to leave off by accident. Here both come from the handle, so the fast path is the default one.

Source

pub async fn reconstruct_on( &self, ts: &str, branch: &str, ) -> Result<MaterializedState>

State at ts as branch saw it (0.15.17, D-259, review C-10).

Self::reconstruct with the ancestry resolved: each ancestor bounded at its fork point, one belief per edge key from the nearest lineage holding it. See crate::temporal::reconstruct_on for how it is assembled, what it costs, and the two things it does not do — concepts are not resolved by lineage, and the result must not be saved as a snapshot.

A read, on read_conn, like Self::reconstruct. The archive path and the snapshot directory come from the handle, so snapshot composition is on by default for each of the folds this runs.

Source

pub async fn ancestry(&self, branch: &str) -> Result<Vec<Ancestor>>

branch’s ancestry, nearest first, each with its fork-point cutoff.

The input crate::temporal::resolve_beliefs takes. Resolved from branches in Rust since 0.15.17 (D-259) — the walk is a few microseconds and the table is tiny and append-only, so this is a read like any other rather than something to cache.

The trunk of an unforked database answers with one row and no cutoff, which is its true ancestry. A lineage that is not registered is refused by name with DbError::UnknownBranch.

Source

pub async fn edges(&self, plan: ReadPlan) -> Result<Vec<EdgeBelief>>

Every edge one ReadPlan names (0.15.9, W13.4, D-251).

The whole projection filtered to the plan’s instants and lineage — topology only, no start node, and no budget on the answer. On a large ledger that is a large Vec; Self::load_subgraph is the bounded neighbourhood read and crate::graph::TraversalBuilder is the anchored one.

§What this can express that nothing else could

crate::temporal::query_as_of_edges_on is the same read at a valid-time instant, and it takes no transaction-time one: before this release, “which edges did we believe existed, as of March, as they stood in January” had exactly two answers available — walk it from a start node, or fold the entire log with Self::reconstruct and filter the result. The first needs an anchor the question does not have and the second is a different order of work. The fold this uses is the traversal’s own, so the bitemporal cell is now readable whole at the cost of reading it.

The two functions share one statement, which is why neither can drift from the other; query_as_of_edges_on is this with recorded unset and the lineage dropped from each row.

§Errors

DbError::UnknownBranch naming a lineage that was never registered — refused rather than answered for the trunk, for graph::lineage::Lineages::shape’s reason. DbError::RecordedInstantUnreachable when ReadPlan::recorded is below what the hot log still covers (D-247). DbError::InvalidTimestamp for a stamp that is not canonical, from the same normaliser every other read uses — a plan is inert and validates nothing, so this is where a malformed instant is noticed.

Source

pub async fn register_model(&self, model: &ModelName, dim: usize) -> Result<()>

Create a model’s embedding table and DiskANN index (§5.9, D-048).

Idempotent: registering a model that already exists at the same dimension succeeds, and at a different dimension fails with DbError::DimMismatch naming both, rather than no-opping through IF NOT EXISTS and leaving the caller believing the dimension they asked for is the one in force.

This issues DDL, which everywhere else in the crate is the migration runner’s exclusive business (D-032). The exception is bounded and deliberate: a model’s table is created once, by an explicit call, and the alternative — a caller-supplied write connection — is the very thing the Write Actor exists to make impossible.

§Latency

One small transaction, but it queues like any other write: see §5.1.8.

Source

pub async fn upsert_embeddings( &self, model: &ModelName, rows: Vec<(String, Vec<f32>)>, ) -> BulkResult<usize>

Store or replace vectors for model, chunked (§5.9, D-011, D-048).

The write path for embeddings. Before 0.5.4 there was none: [crate::vector::upsert_embedding] takes a raw connection, read_conn is query_only, and the write connection lives inside the actor — so an application could search vectors it had no way to store.

Low priority and chunked at chunk_rows::EMBEDDINGS, because embedding is bulk derived work: a 50,000-vector backfill must yield to an interactive assertion at every chunk boundary. That constant is the smallest of the four by a wide margin — DiskANN index maintenance makes an embedding the most expensive row in the system (D-058). Atomic per chunk, not overall, which is the same trade Database::bulk_import makes and is safer here than there — an embedding is derived (Doctrine VII), so a partially written batch is recoverable by re-embedding.

Fails with DbError::ModelNotRegistered if model has no table, and DbError::DimMismatch if a vector’s length is not the declared dimension. The dimension is read from the schema once per chunk (D-037): the crate keeps no registry of its own to fall out of date.

Chunked, so it returns BulkInterrupted and its written count on failure (0.13.8, W7.6). A 50,000-vector backfill is the longest-running write the crate has, which makes it the one most likely to be cancelled — Self::upsert_embeddings_with is how.

Source

pub async fn upsert_embeddings_with( &self, model: &ModelName, rows: Vec<(String, Vec<f32>)>, control: BulkControl, ) -> BulkResult<usize>

Self::upsert_embeddings with cancellation and progress (0.13.8, W7.6).

Source

pub async fn bulk_embeddings( &self, model: &ModelName, rows: Vec<(String, Vec<f32>)>, ) -> BulkResult<usize>

Load embeddings without the DiskANN index in the way, then rebuild it in one pass (D-276, plan §9.1).

Three actor turns in sequence: drop the index (DROP INDEX IF EXISTS, µs-scale), load every row through the same chunked path Self::upsert_embeddings uses, rebuild the index in one statement. Measured on the reference box, medians of three, 2,000 vectors:

dimindexed (upsert_embeddings)bulk_embeddingsone-pass build alone
643.78 s2.62 s2.61 s
25631.0 s19.7 s19.7 s
51256.0 s39.0 s39.0 s

At 5,000 × 256: 89.2 s indexed, 48.6 s here — 1.8×. The build, not the blob writes, is the cost: inserts without the index are flat at ~5 µs/row at every dimension measured. The rest of the gap against an HNSW writer is DiskANN-vs-HNSW build economics inside libSQL, engine- side, and out of scope for a crate that does not fork its engine.

§The trade, stated

Between the drop and the rebuild, the model’s vectors are not searchable (vector_top_k reports the missing index) and not dimension-checked at the storage layer — the index is that check (D-037, ddl::create_embeddings_index). The crate-side check (crate::vector::EmbeddingCodec::encode) still applies to everything this method loads, and the rebuild restores the storage check at the end; what is given up is the check on rows a different client inserts during the window. That is the measured decision, opt-in by signature: the plain path keeps the storage backstop at every instant.

A failed or cancelled load still rebuilds. The load’s own failure — BulkInterrupted with its written count — propagates only after the rebuild has run, so a bulk load can never leave the file in the disarmed state. An empty rows is a no-op: the index is not touched for a load of nothing.

The rebuild turn is budget-exempt (atomic by necessity, same criterion as shadow_swapcrate::CHUNK_BUDGET’s table), and its hold grows with the corpus: ~10 ms/vector at dim 256, ~20 ms at 512. For a very large backfill that hold is the price of the recipe; the alternative — keep the index and pay the same total spread across rows — is what upsert_embeddings already is.

Source

pub async fn bulk_embeddings_with( &self, model: &ModelName, rows: Vec<(String, Vec<f32>)>, control: BulkControl, ) -> BulkResult<usize>

Self::bulk_embeddings with cancellation and progress, on the load half. The drop and the rebuild are single statements; progress and cancel bound the chunked load between them, exactly as they do for Self::upsert_embeddings_with.

Source

pub async fn rebuild_fts(&self) -> Result<()>

Reconstruct the concept-text search index from the ledger (§5.9, D-036).

The FTS index is derivative: D-036 promises every derivative table can be rebuilt from the ledger tables, and this is that promise made callable for concepts_fts. Needed after a restore that skipped the shadow tables, or if the index is ever suspected of drifting from the text — and, as a matter of policy, cheaper to run than to reason about.

The work is INSERT INTO concepts_fts(concepts_fts) VALUES('rebuild'), which is FTS5’s own operation over the content table, so this is not a second implementation of the sync triggers that could disagree with them.

Source

pub async fn analyze(&self) -> Result<()>

Refresh the query planner’s statistics (0.12.4, D-149).

Runs ANALYZE, which writes sqlite_stat1. Before 0.12.4 nothing in this crate ever did, so the planner costed every query against SQLite’s built-in defaults — assume ~1M rows, assume each bound equality column divides by ten. That estimate is structural: it depends on how many columns a query binds, not on what the table contains.

Which is this schema’s own worst defect restated. D-042, D-059 and D-064 are three occasions where a covering index captured a query because it contained the columns, not because it discriminated, and two of the four declared indices lead on the same column. Statistics are what let the planner tell them apart by measurement instead of by shape.

§Cost, and why it is bounded

This is a write and it takes the write lock. PRAGMA analysis_limit (set per connection, see ddl::ANALYSIS_LIMIT) caps the rows examined per index. It is scheduled as low-priority work and will not preempt an interactive assertion.

The bound is a constant factor, not an independence (0.12.23, D-166). This rustdoc said the hold “scales with the number of indices — four — and not with the size of links_current”, which is measurably wrong: the pragma is worth 3–4× and what remains still grows with the table. Measured, examples/analyze_hold.rs: 5.26 ms at 10,000 edges, 19.1 ms at 40,000, against a 3 ms crate::CHUNK_BUDGET.

So this call misses the budget by ~6× on a moderately sized ledger, and crate::metrics::CommandKind::Analyze is deliberately not among the budget-exempt kinds — metrics().budget_violations() names it. That is the honest position: the work is low priority and preemptible between commands, but it is one indivisible statement and cannot be chunked, so the hold is what it is. Prefer optimize, which does nothing when nothing has moved.

Since 0.13.24 the counter is this call and not also optimize (W10.5, [D-197]). The two shared CommandKind::Analyze until then, which is why an analyze row in budget_violations() used to be unreadable: it could have been an explicit call or a handle close.

§When to call it

After a bulk import, and after anything that changes a table’s shape by an order of magnitude. Prefer optimize for routine upkeep: it does nothing when nothing has moved, and this does the work unconditionally.

Statistics are derived state in the sense Doctrine VI means it — deleting sqlite_stat1 costs plan quality and no information, and this call rebuilds it.

Source

pub async fn optimize(&self) -> Result<()>

Re-analyse only what has gone stale (0.12.4, D-149).

PRAGMA optimize. SQLite tracks how far each table has drifted since its last analysis and re-analyses only where it believes the statistics no longer hold — so this is a no-op on an idle database and the full cost of analyze on one that has changed completely.

That property is the whole point: it is safe to call on a schedule, where analyze is not. close() runs it, so a process that opens, works and closes keeps its statistics current without anybody arranging it.

§What it costs, measured, and the threshold it applies rather than takes

(0.13.24, W10.5, D-197)

examples/optimize_hold.rs, on a 40,000-edge ledger: 10.7 ms the first time on a database that has never been analysed — there is nothing incremental about the first call — and 90–220 µs every time after, well inside crate::CHUNK_BUDGET.

The staleness test is SQLite’s and it is a ratio, not a row count. Measured by reading sqlite_stat1 across the call rather than by timing it: growth of 2× and 5× both left the statistics untouched, and only at 25× did it re-analyse — for a 460 ms hold. So this is not a cheaper analyze() and calling it after a bulk load is not a way to refresh statistics the load invalidated: below the ratio it declines, and above it it costs what analyze costs. It reports as crate::metrics::CommandKind::Optimize since 0.13.24, which is what makes those two outcomes distinguishable in the metrics at all.

Source

pub async fn write_analytics_annotations( &self, annotations: Vec<Annotation>, ) -> BulkResult<usize>

Write derived analytics results on the background channel, chunked (§5.4, D-041).

Rows go to analytics_annotations, which has no log trigger, so nothing written here reaches transaction_log and nothing here versions a concept. Rerunning an algorithm replaces the previous pass rather than recording that the world changed.

Low priority and chunked at up to chunk_rows::ANNOTATIONS — the largest ceiling of the four, because this is the only bulk table carrying no triggers at all and its rows are correspondingly cheap (D-058) — so a 50,000-label Louvain save yields to interactive writes at every chunk boundary and carries the per-chunk fidelity boundary of §5.1.6 — a partially written pass is recoverable by rerunning, which is the property that makes derived state safe to write this way and assertions not.

Chunked, so it returns BulkInterrupted and its written count on failure (0.13.8, W7.6); Self::write_analytics_annotations_with adds cancellation and progress.

Source

pub async fn write_analytics_annotations_with( &self, annotations: Vec<Annotation>, control: BulkControl, ) -> BulkResult<usize>

Self::write_analytics_annotations with cancellation and progress (0.13.8, W7.6).

Source

pub async fn archive(&self, cutoff: &str) -> Result<ArchiveReport>

Move closed intervals and superseded log rows older than cutoff to the cold database (§5.7, D-012).

Source

pub async fn archive_branch(&self, branch: BranchId) -> Result<ArchiveReport>

Forget one lineage: move its whole ledger to the cold database and remove the lineage record (0.14.13, §15.4, D-230).

The abandonment arm. A conversation tree discards most of what it grows, and Self::archive cannot reclaim it: that arm is indexed by time, so archiving an abandoned branch’s recent history means archiving the trunk’s recent history with it.

Everything the lineage holds moves in one transaction — its links, its concepts, its transaction_log entries and its branches row — and afterwards the name is unknown: every read and write naming it raises DbError::UnknownBranch. That is the design’s whole shape, and temporal::archive::archive_branch records why it has no smaller version.

§It refuses more than it accepts, on purpose
  • The trunk, and a name that is not registered (DbError::UnknownBranch).
  • A branch with descendants: they read through it, so archiving it would delete rows they still believe.
  • A branch whose concepts another lineage’s hot link names. The road map assumed an abandoned branch’s rows were “a contiguous archivable set by construction”; a concept is keyed by identity across the whole ledger (D-214), so they are not, and this refusal is what makes them contiguous in the cases it accepts.

All but the first return DbError::BranchNotArchivable with a reason.

The lineage record lands in cold.branches with an archived_at, so a cold row’s branch_id still resolves to something — in the cold file, which is now the only place it does.

Source

pub async fn rehydrate(&self, ids: &[&str]) -> Result<RehydrateReport>

Move the named concepts back from the cold database into the hot tables (§2.3, C3).

Rehydration is a physical move back, not a write: it mints no transaction-time facts and is invisible to both clocks. An id that is not in the cold file is skipped rather than being an error — the caller generally has a list from a cold-side query, and a partially-stale list is the normal case rather than a mistake. The report says how many actually moved.

See RehydrateReport::rowids_reassigned for the one way a rehydrated row can differ from the row that was archived.

Source

pub async fn archive_windowed( &self, cutoff: &str, window: Duration, ) -> Result<Vec<ArchiveReport>>

Archive up to cutoff as a sequence of sessions, each covering at most window of transaction time (T1.1, D-080).

archive(cutoff) is one transaction whose size is set by how long it has been since the last one, which makes it the least bounded of the three operations exempt from CHUNK_BUDGET — its hold is a function of operational history rather than of anything a caller chose. This runs the same work as N complete sessions, each with its own marker, horizon row and rebuild, and returns one ArchiveReport per session in order.

§D-012 is satisfied per session, and that is what it requires

The atomicity D-012 demands is that copy-then-delete never be split — a crash between the phases duplicates or loses rows. N small sessions satisfy that exactly as one large one does. The obligation windowing adds is that a partial run leave a coherent intermediate state, which it does: each session commits a valid horizon, so a failure at window k leaves a database archived up to boundary k−1 and nothing in between. The sequence is not atomic and does not claim to be — on error, the reports for the sessions that did commit are lost with it, but their effect is not, and re-running with the same cutoff completes the job.

§Each session is its own actor turn, and that is the entire point

This loop lives here, on the handle, rather than inside the actor’s Archive arm. Putting it there would have produced N small transactions inside one hold, which shrinks the transaction and changes the latency not at all: the actor is single-threaded, so nothing else writes until its turn returns regardless of how many COMMITs the turn contains. Sending N commands returns the actor to its select! between sessions, which is where an interactive assertion gets to jump the queue — and it is high-priority, so it does.

The same reasoning is why Self::bulk_import chunks here and not there, and it is the trap T1.2 names for CREATE TABLE … AS SELECT.

§Choosing a window

The bound is on transaction time, so the session count is set by how far back the hot file goes, not by how much it holds. A window is rejected rather than clamped if it would need more than MAX_ARCHIVE_SESSIONS sessions — see DbError::ArchiveWindow.

Windows containing nothing archivable are cheap but not free: each still opens a transaction and writes a horizon row. What they no longer do is re-project links_current, which archive_session now skips when its DELETE removed no rows — without that, windowing costs more in total than not windowing, because the repair term scales with the surviving table and not with the batch (D-077).

Source

pub async fn close(self) -> Result<()>

Clean shutdown: stop the Write Actor, then write the final snapshot (§5.1.7).

Order matters. The snapshot is taken after the actor has stopped and been joined, so no write can land between the fold and the file — the anchor it records is the last thing that happened, not the last thing that happened to be visible.

A failed snapshot is reported rather than swallowed. It is not a durability loss — the ledger is in the WAL and the log replays without it — but it means the next open starts from an older anchor, and a caller that never hears about it cannot know why startup got slower.

The cadence stops first (§5.5, D-053). Both it and write_final end by running retention over the snapshot directory, and retention deletes files. Letting them overlap would mean one pass enumerating the directory while the other removes from it — not a correctness problem for the ledger, which is why the ordering is stated rather than locked, but a source of spurious warnings and of a final anchor that could be deleted by a cleanup that started before it existed. Stopping the cadence, then the actor, then taking the snapshot leaves exactly one writer at each step.

Source§

impl Database

Source

pub async fn load_subgraph( &self, start_node: &str, max_hops: u32, now_ts: &str, byte_budget: usize, ) -> Result<Subgraph>

Load the topology reachable from start_node within max_hops (§5.4).

Runs on the read connection, so it cannot contend with the write actor. byte_budget bounds the result: a hub node in a dense graph can reach most of the database in three hops, and the budget is what turns that into DbError::SubgraphTooLarge rather than into an allocation failure.

Unfiltered: every edge type, every weight. See Self::load_subgraph_with for the filtered form, which this delegates to.

min_weight is NEG_INFINITY rather than TraversalBuilder’s default of 0.0, and the difference is load-bearing. A floor of 0.0 silently drops negative-weight edges — which is precisely the input DbError::NegativeEdgeWeight exists to report, since Dijkstra and A* are unsound over them and D-039 chose to refuse at the boundary rather than return a shortest path that is merely a path. Delegating with the builder default turned that typed refusal into a graph quietly missing edges; a_negative_edge_weight_is_refused_at_load caught it.

So the two mechanisms are made to agree instead of overlapping: an edge a caller has not filtered out reaches the weight guard, and an edge they have is theirs to exclude. See Self::load_subgraph_with for what that means when a caller passes a default builder.

Source

pub async fn load_subgraph_with( &self, traversal: &TraversalBuilder, now_ts: &str, byte_budget: usize, ) -> Result<Subgraph>

Load the topology a TraversalBuilder describes, as a Subgraph (§5.4, D-073).

load_subgraph took neither edge_types nor min_weight while TraversalBuilder took both — the same walk over the same table with two fewer knobs. That was a reachability limit rather than a convenience one: the byte budget bounds the unfiltered neighbourhood, so a caller wanting one edge type out of a hub got DbError::SubgraphTooLarge for a graph whose filtered form would have fitted easily, and filtering the returned Subgraph afterwards cannot help because the refusal happens during the walk.

§The filters apply to the walk and to the returned edges

This is the decision the change turned on, and the two are separable. TraversalBuilder applies its filters to the recursive step — which edges are followed — while this loader’s final projection returns every edge of every node it reached. Wiring the two together naively gives a caller who asked for CITES a graph reached via CITES and populated with KNOWS edges as well, which is surprising enough to be read as a bug.

So both halves filter. If a caller names edge types or a minimum weight, they are asking for a subgraph of those edges: the walk uses them to bound which nodes are reached, and the projection uses them to decide which adjacency lands in the result. load_subgraph passes a default builder — no types, weight ≥ 0 — so its behaviour is unchanged.

§min_weight and the negative-weight guard

TraversalBuilder defaults min_weight to 0.0, so a default builder passed here filters negative-weight edges out rather than letting them reach DbError::NegativeEdgeWeight. That is a real difference from Self::load_subgraph, which passes NEG_INFINITY.

It is deliberate and it is the coherent reading: a caller who states a weight floor has asked to exclude what falls below it, and excluding it is not an error. A caller who states none should be told, because Dijkstra and A* are unsound over negative weights. Pass .min_weight(f64::NEG_INFINITY) to get the guard with a filtered builder.

§The traversal’s instants are honoured (0.13.2, W7.1, F-35)

They were not. This loader bound now_ts where the builder bound the traversal’s own instant, so a historical TraversalBuilder passed here silently returned the present — the walk and the projection both read live topology while the caller had asked for Tuesday’s, with nothing said. Found while splitting as_of and fixed in the same change, because the fix is the same one: TraversalBuilder::bind_params is now the single producer of the parameter list and both call sites take it, so the two cannot bind different instants at ?3 again.

attribute_mode is still ignored: hydration here is always the live concept row, which is what a Subgraph has always carried. That is a narrower gap than the one above and a deliberate one — a Subgraph is the input to the six algorithms, none of which reads a title.

§A limited traversal bounds this walk too (0.15.10, W13.5)

TraversalBuilder::limit is spliced into the same CTE, so a builder carrying one produces a subgraph of the nodes nearest start_node rather than the neighbourhood. A Subgraph has nowhere to record that, which is why no Python keyword offers it here and why this section exists: the bound that belongs to this surface is byte_budget, which refuses with DbError::SubgraphTooLarge rather than truncating. A caller who wants a bounded walk and wants to know whether the bound bit asks TraversalBuilder::execute_ids_explained first.

Trait Implementations§

Source§

impl Drop for Database

Notes a missed close() at warn!, and deliberately does not assert.

§7.3 offered option B — document close() as mandatory and debug_assert in Drop — and Wave 4.2 implemented it, measured the consequence, and reduced it to a warning. The assert fired on roughly thirty tests on its first run. That is the signal it was built to produce, and the right reading of it was not “thirty tests are wrong”.

What dropping actually costs is one final snapshot. Nothing else: every public write method awaits its responder, so by the time a caller can drop the handle, every write it issued has already committed; and the cadence stops on its own, because cadence_stop is a watch::Sender whose drop signals the task. A snapshot is derivative state under Doctrine VI — disposable, reconstructible, and never the only copy of anything. Losing one makes the next reconstruct fold from an older anchor, which is slower, not wrong.

A debug_assert aborts a test run. Spending that on a performance loss, in a project whose own notes say a suite that fails for reasons unrelated to the code under test trains people to ignore red, is the wrong trade — and paying it in thirty places would have made close() look mandatory by ceremony rather than by consequence. close() remains the right thing to call, and the two reasons to call it are now stated where they can be acted on: the snapshot, and the writer’s Result, which only close() can return.

Option A (“abort the actor and log”) stays rejected, for the reason it was rejected twice before: Drop cannot await, so it cannot drain, and cleanup that cannot clean up is worse than none — it looks like cleanup.

Source§

fn drop(&mut self)

Executes the destructor for this type. Read more
Source§

fn pin_drop(self: Pin<&mut Self>)

🔬This is a nightly-only experimental API. (pin_ergonomics)
Execute the destructor for this type, but different to Drop::drop, it requires self to be pinned. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<ST, DT> CastableFrom<ST, Initialized, Initialized> for DT
where ST: ?Sized, DT: ?Sized,

Source§

impl<ST, DT> CastableFrom<ST, Uninit, Uninit> for DT
where ST: ?Sized, DT: ?Sized,

Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T> Instrument for T

Source§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided Span, returning an Instrumented wrapper. Read more
Source§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> IntoRequest<T> for T

Source§

fn into_request(self) -> Request<T>

Wrap the input message T in a tonic::Request
Source§

impl<T> Read<Exclusive, BecauseExclusive> for T
where T: ?Sized,

Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = !

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, !>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
Source§

impl<V, T> VZip<V> for T
where V: MultiLane<T>,

Source§

fn vzip(self) -> V

Source§

impl<T> WithSubscriber for T

Source§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a WithDispatch wrapper. Read more
Source§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a WithDispatch wrapper. Read more