pub struct Database { /* private fields */ }Expand description
Primary database handle for Macrame bitemporal ledger.
Implementations§
Source§impl Database
impl Database
Sourcepub async fn open(path: impl AsRef<Path>) -> Result<Self>
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.
Sourcepub async fn open_with_cadence(
path: impl AsRef<Path>,
cadence: Option<SnapshotCadence>,
) -> Result<Self>
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.
Sourcepub async fn open_with_clock(
path: impl AsRef<Path>,
cadence: Option<SnapshotCadence>,
clock: Arc<dyn Clock>,
) -> Result<Self>
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.
Sourcepub async fn open_tuned(path: impl AsRef<Path>, tuning: Tuning) -> Result<Self>
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.
Sourcepub fn read_conn(&self) -> &Connection
pub fn read_conn(&self) -> &Connection
Read connection handle for queries, traversals, and folds.
Sourcepub async fn diagnostic_conn(&self) -> Result<Connection>
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&ConnectioncarryingPRAGMA 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 PLAN | allowed | allowed |
INSERT | refused | refused |
PRAGMA query_only = OFF | allowed | allowed |
INSERT after that | allowed | refused |
ATTACH an existing file | allowed | allowed |
INSERT into the attachment | refused¹ | refused |
ATTACH a path that does not exist | — | refused (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.
Sourcepub async fn verify_snapshot_chain(&self, ts: &str) -> Result<ChainCheck>
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.
Sourcepub fn schema_version(&self) -> u32
pub fn schema_version(&self) -> u32
Schema version this handle opened against.
Sourcepub fn archive_path(&self) -> &Path
pub fn archive_path(&self) -> &Path
Cold database path, derived by convention from the main file.
Sourcepub fn snapshots_dir(&self) -> &Path
pub fn snapshots_dir(&self) -> &Path
Snapshot directory, derived by convention from the main file.
Sourcepub fn metrics(&self) -> MetricsSnapshot
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.
Sourcepub async fn assert_edge(&self, edge: EdgeAssertion) -> Result<()>
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_importis chunked againstCHUNK_BUDGETand 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_atomicis one transaction under one stamp and is the one write with no latency bound — the hold is a function ofedges.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.
Sourcepub 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<()>
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).
Sourcepub async fn upsert_concept(&self, concept: ConceptUpsert) -> Result<()>
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.
Sourcepub async fn write_bulk_atomic(
&self,
edges: Vec<EdgeAssertion>,
) -> Result<usize>
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:
| rows | hold |
|---|---|
| 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).
Sourcepub async fn checkpoint(&self) -> Result<CheckpointReport>
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
.dbfile 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.
Sourcepub async fn rebuild_current(&self) -> Result<RebuildReport>
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.
Sourcepub async fn rebuild_current_chunked(&self) -> Result<RebuildReport>
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.
Sourcepub async fn shadow_step(&self, step: ShadowStep) -> Result<ShadowOutcome>
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.
Sourcepub async fn bulk_import(&self, edges: Vec<EdgeAssertion>) -> Result<usize>
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).
Sourcepub async fn write_concepts(
&self,
concepts: Vec<ConceptUpsert>,
) -> Result<usize>
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).
Sourcepub async fn reconstruct(&self, ts: &str) -> Result<MaterializedState>
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.
Sourcepub async fn register_model(&self, model: &ModelName, dim: usize) -> Result<()>
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.
Sourcepub async fn upsert_embeddings(
&self,
model: &ModelName,
rows: Vec<(String, Vec<f32>)>,
) -> Result<usize>
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.
Sourcepub async fn rebuild_fts(&self) -> Result<()>
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.
Sourcepub async fn analyze(&self) -> Result<()>
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.
Sourcepub async fn optimize(&self) -> Result<()>
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.
Sourcepub async fn write_analytics_annotations(
&self,
annotations: Vec<Annotation>,
) -> Result<usize>
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.
Sourcepub async fn archive(&self, cutoff: &str) -> Result<ArchiveReport>
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).
Sourcepub async fn rehydrate(&self, ids: &[&str]) -> Result<RehydrateReport>
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.
Sourcepub async fn archive_windowed(
&self,
cutoff: &str,
window: Duration,
) -> Result<Vec<ArchiveReport>>
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).
Sourcepub async fn close(self) -> Result<()>
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
impl Database
Sourcepub async fn load_subgraph(
&self,
start_node: &str,
max_hops: u32,
now_ts: &str,
byte_budget: usize,
) -> Result<Subgraph>
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.
Sourcepub async fn load_subgraph_with(
&self,
traversal: &TraversalBuilder,
now_ts: &str,
byte_budget: usize,
) -> Result<Subgraph>
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.
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.
Auto Trait Implementations§
impl !Freeze for Database
impl !RefUnwindSafe for Database
impl !UnwindSafe for Database
impl Send for Database
impl Sync for Database
impl Unpin for Database
impl UnsafeUnpin for Database
Blanket Implementations§
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
impl<ST, DT> CastableFrom<ST, Initialized, Initialized> for DT
impl<ST, DT> CastableFrom<ST, Uninit, Uninit> for DT
Source§impl<T> Instrument for T
impl<T> Instrument for T
Source§fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
Source§fn in_current_span(self) -> Instrumented<Self> ⓘ
fn in_current_span(self) -> Instrumented<Self> ⓘ
Source§impl<T> IntoRequest<T> for T
impl<T> IntoRequest<T> for T
Source§fn into_request(self) -> Request<T>
fn into_request(self) -> Request<T>
T in a tonic::Request