Skip to content

feat(persistence): export a Store's changed rows as bundles for replicas - #495

Open
rexjz wants to merge 2 commits into
Ephemeral-AI-Lab:mainfrom
rexjz:feat/incremental-store-replication
Open

rexjz wants to merge 2 commits into
Ephemeral-AI-Lab:mainfrom
rexjz:feat/incremental-store-replication

Conversation

@rexjz

@rexjz rexjz commented Oct 28, 2026 •

Copy link
Copy Markdown

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 after since into 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:

  • keep a backup of a Store current while the Store is in use;
  • move a Store to another machine and keep the copy in step;
  • the push side of item R in LayerFS 2.0 roadmap #446: with bundles, a push could send only the rows written since the last push instead of the whole file.

How

  • Change log. Two new tables, change_state and change_log, and AFTER INSERT/UPDATE/DELETE triggers on the 18 replicated tables (plus pack_unit in the group-row layouts). Every trigger is WHEN 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.
  • Chained, verified bundles. Each bundle carries a BLAKE3 digest of its rows and deletions and a state chained from the base: state' = H(catalog, state, seq, seq', digest). Before it installs anything, apply_changes refuses 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.
  • Publishing a bundle. An export writes the bundle under a temporary name beside the bundle path and hard-links it into place, so the path names a complete bundle or nothing, also after a process crash. An existing path is refused with Exists.
  • Checkpoints. mark_base and apply_changes checkpoint after they commit, so that the Store file alone holds the change. When another connection's read blocks that checkpoint, or it fails, they return ChangeError::NotCheckpointed: the change committed, and the file holds it only together with its WAL.
  • Connections. The module opens Stores through the Disposable profile (profile::apply: WAL, synchronous=OFF) with no busy handler. A locked Store is refused at once with SQLITE_BUSY instead of waiting 5 s in rusqlite's default handler; a Store that is not in WAL mode is refused as ChangeError::Profile(PersistenceError::Malformed). ChangeError implements source() for the wrapped SQLite, I/O and profile errors.
  • Migration. add_change_log is an additive open-time migration (REBUILD.md kind 2), added under the same conditions as add_file_digests.
  • Layout. src/replication/ has one file per concern: base.rs, export.rs and apply.rs for the three calls, and tables.rs, digest.rs, connection.rs, lookup.rs, records.rs and error.rs for what they share. The largest file, export.rs, is 227 lines.
  • Second commit. apply_changes installed rows with logging on and then deleted that log, which left free pages in the replica file (22 in the new test). It now removes change_state for the install and restores it with the ending state, in the same transaction.
  • Architecture doc 84 lists the two tables and describes export, apply and the checkpoint report.

Not changed

  • No change to object formats, pack layout, history semantics, the publication lane or any layerfs-api / SDK / CLI surface. Nothing outside the module's tests calls it yet.
  • The Store is still one SQLite database. A bundle is a transfer file that only apply_changes reads; it is not part of the Store.
  • Cargo.lock is unchanged. blake3 =1.8.5 moves from dev-dependencies to dependencies of layerfs-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.

Tree Median Range Store file after close change_log rows
main 582bc78 494.7 ms 447–545 ms 909,312 B –
this branch, Store not marked 472.7 ms 445–562 ms 958,464 B 0
this branch, Store marked 499.0 ms 476–582 ms 1,294,336 B 18,000

An 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

  1. Writer admission. mark_base and apply_changes rely on SQLite's BEGIN IMMEDIATE with no wait. They do not take the Linux directory lock in writer_admission.rs. Both require an offline Store, so I left them outside the lane. Should they go through it, or through Handles?
  2. Cursor check above the base. For a cursor above the base, export_changes checks 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_changes still 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?
  3. Log growth. The log grows until the next mark_base. Nothing prunes entries that were already exported, because export does not write to the live Store. A prune_changes(cursor) writer call would fix that; I left it out to keep this PR small.
  4. Do you want this in layerfs-persistence at 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 at mark_base plus 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 carry pack_unit rows.
  • 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_refused
  • a_base_marked_under_a_reader_commits_and_reports_the_blocked_checkpoint
  • a_locked_replica_is_refused_at_once_and_left_unchanged
  • a_replica_outside_the_disposable_profile_is_refused_and_left_unchanged
  • an_unmarked_store_logs_nothing_and_exports_nothing
  • applying_a_bundle_logs_nothing_and_leaves_no_free_pages_on_the_replica

Updated: lineage_backfill.rs and old_format_open.rs build pre-change Stores by dropping the change log tables and triggers, so the open-time migration runs; sqlite_publication.rs has the new table count.

What the code did before each change, checked on a tree without it:

  • free pages: the free-page test leaves 22 free pages;
  • busy handler: the locked-replica test waits about 5 s in rusqlite's default busy handler;
  • profile: the non-WAL replica does not get the profile refusal;
  • checkpoint: with a reader open, mark_base returned Ok, and a copy of the Store file taken right after held no change_state row;
  • meta parsing: a bundle with a second meta row applied successfully.

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: pass
  • python3 -B tools/check_product_boundary.py, with and without --skip-sdk: pass
  • python3 -B tools/check_fuser_integrity.py: pass
  • python3 -B -m unittest discover -s tools -p 'test_*.py': pass
  • cargo +1.85.1 clippy --locked --workspace --exclude layerfs-node --all-targets -- -D warnings: pass
  • cargo +1.85.1 test --locked --all-targets --no-run: pass
  • CI fast lane (tools/ci_tests.py targets fast / filter fast): 1146 run, 1146 passed, 12 skipped. One earlier run of this branch hit concurrent_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.
  • every layerfs-persistence test target, slow ones included: 107 run, 107 passed, 2 skipped
  • the first commit alone: fmt, clippy -p layerfs-persistence --all-targets and tests/change_export.rs (8 tests) pass

Not 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.

@rexjz
rexjz marked this pull request as ready for review October 28, 2026 12:31
rexjz added 2 commits October 28, 2026 22:07
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
rexjz force-pushed the feat/incremental-store-replication branch from c2dc716 to 8993ff8 Compare October 28, 2026 14:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant