Skip to main content

Database

Struct Database 

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

Primary database handle for Macrame bitemporal ledger.

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.

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>

A new, independently owned, 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 the reference is shared, a caller who runs a long reporting query on it is competing with every traversal and fold in the process.
  • This returns a connection opened with SQLITE_OPEN_READ_ONLY, which is enforced by the engine below the pragma layer, and it is the caller’s own.

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 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 is R15’s shape

This is the one method on Database that opens the file. Everything else runs on connections established once, at open. Each call here is a fresh libsql::Builder::…build(), so N threads calling it at once are N concurrent opens — which is exactly the pattern behind R15, the upstream libSQL access violation (0xC0000005) that examples/r15_soak.rs reproduces and RUST_TEST_THREADS=1 exists to avoid in the suite.

This is measured, not inferred. 48 threads sharing one handle and calling only this method: 7 bad runs in 18 — two access violations and five returned SQLite errors (database is locked, bad parameter or other API misuse). With the calls serialised, 0 in 18 (tests_py/probes/r15_diagnostic_path.py). The returned-error mode is the one to watch for: it looks like a fact about the database, on the method a caller reaches for when they already doubt the typed answer.

Bound this yourself if you call it from more than one thread. One outstanding open at a time is enough; a mutex around the call costs nothing on a diagnostic path. This method does not do it for you on purpose: serialising behind a lock the caller cannot see would contradict the thing above it — that the connection is the caller’s own — and it would put a hidden queue in front of the one surface whose job is to answer questions when the typed path is already suspect. The Python binding does bound it, because it wraps this in a method a caller cannot see into (PyDatabase::diagnostic_rows); a Rust caller can.

§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.

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.

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 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 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>) -> Result<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).

Source

pub async fn write_concepts( &self, concepts: Vec<ConceptUpsert>, ) -> Result<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).

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 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>)>, ) -> Result<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.

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.

§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.

Source

pub async fn write_analytics_annotations( &self, annotations: Vec<Annotation>, ) -> Result<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.

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 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.

attribute_mode is ignored: hydration here is always the live concept row, which is what a Subgraph has always carried.

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 = Infallible

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

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

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