Repository navigation
Description
Affected versions: v0.9.0 (ee21bfb, on-disk B+tree indexes) through v2.0.5. v3 (format v8, copy-on-write checkpoints) is not affected: the reproducer below passes there.
Bug
On a file-backed database, a query that runs on another thread while a checkpoint is in progress can return wrong results. It can miss facts that were committed before it started, and it can see one fact of a transaction without the others.
PersistentFactStorage::save (src/storage/persistent_facts.rs on main) writes the new fact pages at 1 + old_fact_page_count, the same pages the previous checkpoint's B+tree index occupies. Queries still read through the old index reader during that time: they hold FactStorage's read lock, and save does not take it. The new state is then published in three separately locked steps (set_committed_reader, set_committed_index_reader, post_checkpoint_clear), so a query can also combine a new fact reader with the old index, or the new committed facts with the pending facts that are about to be cleared.
Stored data is not affected: the file written by the checkpoint is correct (single-threaded queries after the checkpoint are right). Only queries that run during a checkpoint are affected. Every checkpoint counts, including the automatic one at wal_checkpoint_threshold (default 1,000 WAL entries) and the one at close.
Reproducer
A writer thread commits [:pN :pair/x N] [:pN :pair/y N] with execute, then calls checkpoint(). A reader runs [:find ?e :where [?e :pair/y ?v] (not [?e :pair/x ?v])], which must be empty, and [:find ?e :where [?e :pair/x ?v]], which must not return fewer entities than were committed before the query began. On main, with the #554 and #555 fixes applied (PR #559), it fails within 30 ms:
43 entities with y and no x
1 x facts, 43 committed before the query
It passes on v3 for the whole run. Without checkpoints (wal_checkpoint_threshold(usize::MAX)) it passes on main.
Fix (v2.x)
save takes FactStorage's write lock before it overwrites any page that the published readers use, and holds it until the new readers are published and the pending facts cleared. Queries wait for a checkpoint to finish instead of reading one that is half-written. Lock order must stay data lock → backend mutex, as on the query path.
Workaround
Do not query from another thread while a checkpoint can run: call checkpoint() yourself while no query runs, and set wal_checkpoint_threshold(usize::MAX) to prevent automatic checkpoints. Note that usize::MAX also skips the checkpoint at close (#476), so call checkpoint() before closing.
Activity
Merge pull request #560 from project-minigraf/fix/558-checkpoint-query
release: v2.0.6
adityamukho commentedon Oct 14, 2026
Fixed in v2.0.6 (released 2026-10-14, on crates.io). Moved to "Fixed in v2.0.6" in #421.
fix(storage): queries wait for a checkpoint instead of reading it hal…