Skip to main content

CHUNK_BUDGET

Constant CHUNK_BUDGET 

Source
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:

PathBoundWhy it cannot be chunked
Database::write_bulk_atomicnone — caller-sized VecD-014: the batch is one act under one stamp. Splitting it is the thing the method exists not to do
Database::archivemeasured 26.8 ms for 2,000 archivable edges; see Database::archive_windowedD-012: copy-then-delete must be atomic, or a crash between the phases duplicates or loses rows
rebuild_currentmeasured 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::rehydrateunmeasured; a function of how many rows the caller namedD-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_branchunmeasured; a function of how much one lineage wroteD-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_swapmeasured 46.8 ms at the largest fixture (D-082), and it grows with the tableIndex 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::checkpointa function of the WAL’s size, which is a function of how long since the last checkpoint — not of anything the caller passesIt 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 EXISTSOne 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 corpusOne 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 directionSame 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.