pub const CHUNK_BUDGET: Duration;Expand description
The latency bound chunk_rows is derived from (§5.1.5, D-058).
This is the golden rule’s actual content. §9 has carried it as a row count with a duration attached — “chunk commit, 500 rows ≤ 3 ms” — which reads as two requirements and is one: the duration is the requirement, and the row count is whatever satisfies it on a given path and machine.
3 ms is §9’s number, kept rather than renegotiated. What it buys, end to end: an interactive assertion arriving at the worst possible moment waits for the chunk in flight (≤ 3 ms — the SQLite write lock is not preemptible, so priority buys the next turn and not this one) and then runs its own write (≤ 5 ms, §9), so ≤ 8 ms worst case. That fits inside a 60 Hz frame with room, which is the standard this bound is ultimately answerable to.
§Some operations are exempt, and the exemption is a contract, not an oversight
This was recorded in three separate rustdoc notes and nowhere near the bound itself, which is where a reader looks for its scope (§8.6). Stated here, with Wave 3’s measurements:
| Path | Bound | Why it cannot be chunked |
|---|---|---|
Database::write_bulk_atomic | none — caller-sized Vec | D-014: the batch is one act under one stamp. Splitting it is the thing the method exists not to do |
Database::archive | measured 26.8 ms for 2,000 archivable edges; see Database::archive_windowed | D-012: copy-then-delete must be atomic, or a crash between the phases duplicates or loses rows |
rebuild_current | measured 24.6 / 104 / 318 ms at 4K / 16K / 40K rows in links (was “~50 s per 10M edges”, which nothing had measured) | D-023: the window between DELETE and INSERT is the whole of current belief; a reader landing in it sees a graph with no edges and no error |
Database::rehydrate | unmeasured; a function of how many rows the caller named | D-012 backwards: the same copy-then-delete atomicity, in the other direction. A row here since 0.12.9 only because it was previously invisible — rehydration reported as archive and inherited its exemption without anyone deciding on it (W4.3, D-152) |
Database::archive_branch | unmeasured; a function of how much one lineage wrote | D-012 again, and D-230’s chain: the links, the log entries and the branches row leave together or the ledger disagrees with itself about what is currently believed. There is no smaller unit — half a forgotten lineage is a lineage whose reads are answered by its parent |
the swap turn of Database::rebuild_current_chunked, counted as shadow_swap | measured 46.8 ms at the largest fixture (D-082), and it grows with the table | Index names are global and SQLite has no ALTER INDEX … RENAME, so the shadow cannot carry idx_lc_traversal_cover while the live table still holds it — all three indexes are built here, under the lock. This is the residual T1.2 could not remove, and there is no smaller unit: half a swapped projection is not a projection. Exempt since 0.14.16 (W12.16, D-233). The fill half keeps its own kind and is deliberately absent from this table, which is what makes a violation there a regression rather than a constant |
Database::checkpoint | a function of the WAL’s size, which is a function of how long since the last checkpoint — not of anything the caller passes | It is not a transaction at all. PRAGMA wal_checkpoint copies frames back into the main file and there is no unit smaller than the frame it is already working in; the caller asked for exactly this, and the alternative to a long checkpoint is a WAL that keeps growing (0.12.13, W5.2, D-156) |
the drop turn of Database::bulk_embeddings, counted as drop_embedding_index (0.16.2, D-276) | µs-scale; one DROP INDEX IF EXISTS | One statement, no smaller unit — the same shape as Database::checkpoint by nature and Database::write_bulk_atomic by atomicity. Its kind exists for attribution beside rebuild_embedding_index, not for cost |
the rebuild turn of Database::bulk_embeddings, counted as rebuild_embedding_index (0.16.2, D-276) | measured 2.61 / 19.7 / 39.0 s for 2,000 vectors at dim 64 / 256 / 512, ~10 ms/vector at dim 256, growing with the corpus | One CREATE INDEX over the whole table — the one-pass DiskANN build is indivisible, exactly the criterion shadow_swap and rebuild_current meet. The difference is schedule: this hold is caller-scheduled and opt-in, so the docstring states the number instead of arguing it. Counted would add a permanent N(bulk loads) to every database that ever bulk-embedded — shadow_swap’s own argument, unchanged |
the toggle turn of Database::bulk_import_deferred, counted as links_current_mirror (0.16.3, D-277) | µs-scale; one DDL statement either direction | Same shape as drop_embedding_index: one statement, no smaller unit, and its kind exists for attribution beside the load it wraps. The window’s cost is the chunked rebuild that follows, which keeps its own counted kinds rather than hiding behind the toggle’s exemption — the toggle is not where the time goes |
The archive figure is end-to-end through this method, so it includes
the re-derivation archive() runs inside its transaction — but it does not
attribute it, and until D-077 more than half of that re-derivation was an
audit comparing links_current against the query that had just filled it.
Note also which variable that cost scales with: rebuild_within reprojects
all of links, so the archive’s repair term grows with the surviving
table and not with the batch being archived. A budget stated per “100K closed
intervals” (§9) is
therefore parameterised on the wrong quantity.
The first four are atomic by contract, which is why “cap the batch” and “add a third tier” were both considered and neither was taken: capping breaks the guarantee the operation exists to provide, and a third tier changes which caller waits without changing how long the lock is held. What was wrong was never the exemption — it was that the bound was stated as though it had none.
A caller who needs the latency bound and not the atomicity has
Database::bulk_import, which is the same write chunked at
chunk_rows::EDGES and explicitly not atomic overall (D-011).
§One of them is no longer unbounded (T1.1, D-080)
archive was the worst of them, because its hold is a function of how long
since the last archive rather than of anything the caller chose.
Database::archive_windowed runs the same work as N sessions, each
atomic, each its own actor turn. Measured on an 8,000-key fixture with four
generations of superseded history: the longest single hold falls from
3.3 s to 0.77 s at one-hour windows, for total wall time that is flat
within this cycle’s noise.
The same measurement at 2,000 keys goes the other way — the hold falls
260 ms → 117 ms while total time rises 260 ms → 671 ms — so windowing is a
trade and not a free improvement. It pays when the backlog is large, which
is when the unwindowed hold is a problem in the first place. archive is
kept, not deprecated, for exactly that reason.