Repository navigation
Conversation
rexjz
marked this pull request as ready for review
October 28, 2026 12:31
A Store marked as an export base logs the key of every row written to its replicated tables. export_changes writes the rows changed after a cursor into a small SQLite bundle, from one read snapshot of the live Store: it takes no write lock and stops no writer. apply_changes installs bundles in order into an offline copy of the base. Each bundle carries a BLAKE3 digest of its rows and deletions and a chained state, so an out-of-order, repeated, changed or diverged bundle is refused before anything is installed. - change_log / change_state tables and AFTER INSERT/UPDATE/DELETE triggers on the 18 replicated tables (plus pack_unit in the group-row layouts). The triggers log nothing until mark_base, so an unmarked Store only pays one EXISTS check per written row. - Additive open-time migration (add_change_log, REBUILD.md kind 2) for Stores written before, under the same conditions as the other additive migrations. - The module (src/replication/) keeps marking, export and apply in their own files. Store connections run the Disposable profile (profile::apply: WAL, synchronous OFF) with no busy handler; a locked or non-WAL Store is refused at once, as ChangeError::Profile with the profile's own error. - An export writes the bundle under a temporary name and hard-links it into place, so the bundle path names a complete file or nothing, also after a process crash. Bundles use 1 KiB pages and carry only the tables with logged changes. - apply_changes parses the bundle's meta row without trusting the bundle's own constraints (one row, value types and lengths, sequence order). - mark_base and apply_changes checkpoint after they commit. A checkpoint that a reader blocks, or that fails, returns ChangeError::NotCheckpointed: the change committed, and the Store file alone does not hold it yet. - New tests/change_export.rs (8 tests: the replica round trip, refused bundles, locked and non-WAL replicas, a reader blocking the checkpoint, all three pack layouts, an unmarked Store). The old-format fixtures and the table count follow the two new tables. Architecture doc 84 lists them. Production LOC: 102763 -> 103792 (delta +1029) engine 61709 -> 62738 (+1029): layerfs-persistence 8422 -> 9451; core 38395 and api 2659 unchanged. Counted with tools/production_loc.py (blob 3341e4d) --root on git archive extracts of the parent (main 582bc78, tree e7075fc) and the committed tree f9d5591.
apply_changes installed a bundle's rows while the replica's change_state row was present, so every trigger logged them, and the apply then deleted that log again. The deleted log left free pages in the replica file, and every copy or backup of the replica carried them. - install deletes change_state before it installs rows and inserts it again with the bundle's ending state, in the same transaction, so a failed apply leaves the replica as it was. - New test: applying a 1500-object bundle leaves only the sequence marker in change_log and no free pages. Without the change it leaves 22 free pages. Production LOC: 103792 -> 103793 (delta +1) engine 62738 -> 62739 (+1): layerfs-persistence 9451 -> 9452; core 38395 and api 2659 unchanged. Counted with tools/production_loc.py (blob 3341e4d) --root on git archive extracts of the parent tree f9d5591 and the committed tree 58c86fa.
rexjz
force-pushed
the
feat/incremental-store-replication
branch
from
October 28, 2026 14:08
c2dc716 to
8993ff8
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
I would like to know whether this direction fits the roadmap. I am happy to rework or split it, and the open questions below are where I most need your call.
What
A new module,
layerfs_persistence::replication, keeps a second copy of a Store up to date by moving only the rows that changed:mark_base(store, token)makes an offline Store the start of an export chain and returns its cursor. A plain copy of the file taken at that point is the replica.export_changes(store, since, bundle)writes every row changed aftersinceinto a new SQLite bundle file, from one read snapshot of the live Store. It takes no write lock and does not stop writers.apply_changes(replica, bundle)installs bundles in order into the offline replica. The replica then holds exactly the live Store's rows and opens through the normal open path.Nothing here moves a file anywhere. A bundle is a local file; the caller decides whether it goes to another disk, another machine or object storage.
Why
Today the only way to bring a second copy of a Store up to date is to copy the whole file again, or a consistent snapshot of it. That cost follows the size of the Store, not the size of the change: a few new Commits on a multi-GiB Store mean copying multi-GiB again. Cases where I need a second copy:
How
change_stateandchange_log, and AFTER INSERT/UPDATE/DELETE triggers on the 18 replicated tables (pluspack_unitin the group-row layouts). Every trigger isWHEN EXISTS(SELECT 1 FROM change_state), so a Store that was never marked logs nothing. The log holds row keys only. Export reads the current values, so a row written many times is exported once, and a deleted row becomes a deletion.state' = H(catalog, state, seq, seq', digest). Before it installs anything,apply_changesrefuses a meta row that is not exactly one well-formed row (it does not trust the bundle's own constraints), an unknown bundle format, another catalog, a cursor that is not the replica's (out of order or repeated), a replica written since its last apply (Diverged), a bundle that deletes an immutable row, and a changed bundle (digest or ending state). One bundle installs in one transaction, with foreign keys checked at commit.Exists.mark_baseandapply_changescheckpoint after they commit, so that the Store file alone holds the change. When another connection's read blocks that checkpoint, or it fails, they returnChangeError::NotCheckpointed: the change committed, and the file holds it only together with its WAL.profile::apply: WAL,synchronous=OFF) with no busy handler. A locked Store is refused at once withSQLITE_BUSYinstead of waiting 5 s in rusqlite's default handler; a Store that is not in WAL mode is refused asChangeError::Profile(PersistenceError::Malformed).ChangeErrorimplementssource()for the wrapped SQLite, I/O and profile errors.add_change_logis an additive open-time migration (REBUILD.md kind 2), added under the same conditions asadd_file_digests.src/replication/has one file per concern:base.rs,export.rsandapply.rsfor the three calls, andtables.rs,digest.rs,connection.rs,lookup.rs,records.rsanderror.rsfor what they share. The largest file,export.rs, is 227 lines.apply_changesinstalled rows with logging on and then deleted that log, which left free pages in the replica file (22 in the new test). It now removeschange_statefor the install and restores it with the ending state, in the same transaction.Not changed
layerfs-api/ SDK / CLI surface. Nothing outside the module's tests calls it yet.apply_changesreads; it is not part of the Store.Cargo.lockis unchanged.blake3 =1.8.5moves from dev-dependencies to dependencies oflayerfs-persistence; six other workspace crates already depend on it.Cost
Measured with a temporary test that is not part of this PR: save 3000 distinct 2 KiB objects into a fresh Store, 7 rounds per tree, Linux aarch64 container, test profile (
opt-level = 1). I measured the first revision of this branch; later revisions changed export and apply only, not the triggers or the write path.change_logrowsAn unmarked Store shows no difference beyond run-to-run noise; its file is 48 KiB larger, which is the new schema. A marked Store was 0.9% slower than main and 5.6% slower than the unmarked run at the median. It logged 6 rows per saved object, about 19 bytes per row in the file. I can turn this into a proper benchmark if you want one in the tree.
Open questions
mark_baseandapply_changesrely on SQLite'sBEGIN IMMEDIATEwith no wait. They do not take the Linux directory lock inwriter_admission.rs. Both require an offline Store, so I left them outside the lane. Should they go through it, or throughHandles?export_changeschecks the sequence but not the state, because the live Store keeps no record of the states it exported. A cursor from a separately written copy is therefore not detected at export;apply_changesstill refuses the result, because the replica's state does not match. The doc comment says this. Should the Store record the states it exports instead?mark_base. Nothing prunes entries that were already exported, because export does not write to the live Store. Aprune_changes(cursor)writer call would fix that; I left it out to keep this PR small.layerfs-persistenceat all, or behind a feature, before anything uses it?Tests
New
tests/change_export.rs, 9 tests:a_replica_from_the_base_and_its_bundles_holds_the_live_rows: a replica copied atmark_baseplus every bundle holds the same rows as the live Store, opens normally and answers the same history. It also checks that a refused existing path keeps its bytes and that no temporary file is left behind.every_pack_layout_replicates_its_pack_rows: the same round trip for the monolithic and both group-row layouts; the group-row layouts carrypack_unitrows.a_refused_bundle_leaves_the_replica_unchanged: an unmarked replica, and eight bundles changed by one statement each (format, catalog, ending state, a deleted pack, a second meta row, sequences out of order, a negative sequence, a short digest). Each is refused with its own reason, and the replica's rows and state stay the same.a_written_replica_and_a_changed_bundle_are_refuseda_base_marked_under_a_reader_commits_and_reports_the_blocked_checkpointa_locked_replica_is_refused_at_once_and_left_unchangeda_replica_outside_the_disposable_profile_is_refused_and_left_unchangedan_unmarked_store_logs_nothing_and_exports_nothingapplying_a_bundle_logs_nothing_and_leaves_no_free_pages_on_the_replicaUpdated:
lineage_backfill.rsandold_format_open.rsbuild pre-change Stores by dropping the change log tables and triggers, so the open-time migration runs;sqlite_publication.rshas the new table count.What the code did before each change, checked on a tree without it:
mark_basereturnedOk, and a copy of the Store file taken right after held nochange_staterow;Checks run
Linux aarch64 (Debian bookworm container on a macOS host), Rust 1.85.1, cargo-nextest 0.9.146, following engine/AGENTS.md "Checks and completion" and the CI fast lane. Every test command ran under a 120 s timeout.
cargo +1.85.1 fmt --all -- --check: passpython3 -B tools/check_product_boundary.py, with and without--skip-sdk: passpython3 -B tools/check_fuser_integrity.py: passpython3 -B -m unittest discover -s tools -p 'test_*.py': passcargo +1.85.1 clippy --locked --workspace --exclude layerfs-node --all-targets -- -D warnings: passcargo +1.85.1 test --locked --all-targets --no-run: passtools/ci_tests.py targets fast/filter fast): 1146 run, 1146 passed, 12 skipped. One earlier run of this branch hitconcurrent_first_installs::two_writers_saving_the_same_new_content_both_complete(writer a: Err(Integrity("dependency cycle"))); that test also fails on main, in 1 of 40 runs on 582bc78.layerfs-persistencetest target, slow ones included: 107 run, 107 passed, 2 skippedclippy -p layerfs-persistence --all-targetsandtests/change_export.rs(8 tests) passNot run: macOS, the Durable profile, the privileged FUSE daemon tests and
layerfs-node.Production LOC: 102763 → 103793 (delta +1030): engine 61709 → 62739 (+1030), all in layerfs-persistence 8422 → 9452; core 38395 and api 2659 unchanged. Per commit +1029 and +1. Counted with tools/production_loc.py (blob 3341e4d036) --root on git archive extracts of main 582bc78 and of each commit's tree.