Repository navigation
Conversation
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). Inode values and binding hints are logged as the schema version 2 blocks they are stored in (inode_value_block by first_ordinal, binding_hint_block by grp and seq, Ephemeral-AI-Lab#91). 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: a Store gains the tables at its first writable open. - The Doctor's storage-class check (schema_diagnostic::type_checks) skips a STRICT table's ANY columns, which declare no storage class. The change log's keys are ANY; without this every Store fails store.database.types. - 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, which also runs the Doctor on the live Store and the replica, 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. docs/architecture/engine/store/sqlite.md and architecture doc 84 describe them. Production LOC: 159967 -> 160997 (delta +1030) engine 74419 -> 75449 (+1030): layerfs-persistence 17267 -> 18297; core 39787, control 39785 and api 5976 unchanged. Counted with tools/production_loc.py --json (blob 35e4a6d) on git archive extracts of the parent (layerfs_v2_0_0 03e4c54, tree 3f78c16) and the committed tree 124b0ed.
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: 160997 -> 160998 (delta +1) engine 75449 -> 75450 (+1): layerfs-persistence 18297 -> 18298; core 39787, control 39785 and api 5976 unchanged. Counted with tools/production_loc.py --json (blob 35e4a6d) on git archive extracts of the parent tree 124b0ed and the committed tree c4752bc.
|
Review of the 2.0 port, read against Findings, most serious first1. A marked Store's change log grows without bound. After 2. No measurement of the cost on Commit. Every Store now carries 57 triggers, and each written row checks whether the Store is marked. When it is marked, the writer's transaction also inserts log rows. AGENTS §3 rule 9 asks for both speed and storage numbers. The PR times export and apply, but not Commit or Save with logging off and on. 3. Every Store's schema changes for an API nothing calls yet. The first writable open by this binary adds the two tables and the triggers, after which an older binary (for example one pinned in a VM image or a worker) may no longer open that Store. Nothing outside the module's tests calls the API, and the review on #495 asked to agree the order with #506 and #533 in the 2.0 roadmap first. Whether this lands in 2.0 now is a call for the maintainers. 4. Nothing stops 5. A bundle does not check that both sides have the same schema. 6. The Project-records gap (already disclosed). 2.0 keeps Project records in the home, outside the Store file, so after a bundle that creates a Project the replica's CLI answers Checked and correct
|
This is #495 ported to
layerfs_v2_0_0(03e4c541a), as an alternative to #630. #630 restores #495's original two commits. Those were written againstmainbefore schema version 2, so they conflict with 2.0 in four files, and their triggers nameinode_valueandbinding_hint, which 2.0 no longer has. This PR has the same two commits and the same API, with the changes 2.0 needs (listed below). If you would rather keep #630, its branch can take these two commits as they are.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. A copy of the closed file is then a replica at the returned cursor.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 stops no writer.apply_changes(replica, bundle)installs bundles in order into the offline replica, one transaction each. The replica then holds exactly the live Store's rows and opens through the normal path.A bundle is a local file. Nothing here moves it anywhere: the caller decides whether it goes to another disk, another machine or object storage. The design is the same as in #495 (chained states, BLAKE3 digest, refusal before any install, publish by hard link, checkpoint report). See that PR for the details. Its open questions still apply.
What changed for 2.0
inode_value_block(keyfirst_ordinal) andbinding_hint_block(keysgrp,seq) instead ofinode_valueandbinding_hint. There are still 18 logged tables, pluspack_unitin the group-row layouts.schema_diagnostic::type_checkscompares each column's stored values with the column's declared type.change_log's key columns are declaredANY, which aSTRICTtable allows.ANYnames no storage class, so every Store failedstore.database.types, and about 20 diagnostic tests failed. The check now skipsANYcolumns (3 lines). The round-trip test also runs the Doctor on the live Store and on the replica, and expects no failure.schema.rs. 2.0 madescripts()visible to the diagnostic module. This PR keeps that, and adds the change log script for each layout as before. The open-time migrationadd_change_logis unchanged. A Store written by 2.0 before this PR gains the two tables at its first writable open, as with the lineage, merge and digest tables.docs/architecture/engine/store/sqlite.mddescribes the two tables and has a short "Change export" section. Doc 84 keeps its paragraph.SqliteHandlesand the block tables.The second commit (apply no longer logs the rows it installs) is unchanged. On 2.0, without that commit, the free-page test still finds 22 free pages.
Not changed
layerfs-api, SDK or CLI surface. Nothing outside the module's tests calls it yet.Cargo.lockis unchanged:blake3 =1.8.5moves from the dev-dependencies to the dependencies oflayerfs-persistence.Tests
tests/change_export.rshas 9 tests:lineage_backfill.rs,old_format_open.rsandsqlite_publication.rsare updated for the two new tables.Checked on trees without each 2.0 change: without the
ANYskip, about 20 tests fail indiagnostic_inventory,diagnostic_schema,diagnostic_snapshotandstore_diagnostic. Without the second commit, the free-page test finds 22 free pages.The loop again, on 2.0
I reran the two-machine loop from #495 with the 2.0 CLI built from this branch. Machine A writes through
layerfsand exports one bundle per round. The bundle moves through a shared folder, and machine B applies it. B was restored from A's MinIO backup at r0. After each round, all 23 Store tables on A and B are compared row by row, and the same CLI reads run on both machines.file apply: write, mkdir, mv, cp, rm, editshop/web: init + writeproject remove shop/web0b8ae8af5: r1 went from 137 to 31 rows, r3 from 3,735 to 1,446, and r5 from 3,260 to 290. As far as I can tell, this is because inode values and hints now move as blocks.Cursor). A replica with its own Commit is refused (Diverged). In all three cases, the replica's 23 table digests did not change.NoSuchProject. 2.0 keeps Project records in the home, andapply_changeschanges only the Store file.Loop setup
mark_base,export_changes,apply_changesandread_state.LAYERFS_HOMEon ext4. At r0, A ranbackup createand B ranbackup restore, through MinIO (the pinned image fromminio_service.sh). The snapshot carries the change state, so B starts at A's cursor.git archiveof0b8ae8af5: 5,631 files, 132 MB.file read/stat/list/glob/grep,branch list/get,commit list), compared byte by byte; and B'sread_state, checked against theaftercursor of A's export.Checks run
CI is off for pull requests into
layerfs_v2_0_0, so I ran these locally. Setup: Linux aarch64 (Debian bookworm container on a macOS host), Rust 1.85.1, cargo-nextest 0.9.146. I used the commands from engine/AGENTS.md "Checks and completion",ci.ymlandnightly.yml, with every test command under a timeout. I ran the same commands on this branch and on03e4c541a.03e4c541acargo fmt --all -- --checkcheck_product_boundary.py, with and without--skip-sdkcheck_fuser_integrity.py,toolsunit testsclippy --locked --workspace --exclude layerfs-node --all-targets -D warningsclippy -p layerfs-persistence --features postgresql --all-targets -D warningscargo test --locked --all-targets --no-runci_tests.py targets/filter fast)ci_tests.py targets/filter nightly)nightly.yml: each binary in its own container with/dev/fuse)layerfs-persistencetargetcontrol/crates/layerfs-store/src/lease.rsandapi/crates/layerfs-cli/tests/concurrent_reads.rs, from14f62997b.TMPDIRon ext4, as on the runner. As root, withTMPDIRon the container's overlay, both trees fail the same 59 tests: the kernel guard refuses overlayfs, and the daemon refuses uid 0.Not run: x86_64, macOS, the Durable profile and
layerfs-node.Production LOC: 159967 -> 160998 (delta +1031): engine 74419 -> 75450, all in layerfs-persistence 17267 -> 18298; core, control and api unchanged. Per commit: +1030 and +1. Counted with
tools/production_loc.py --json(blob 35e4a6d601) ongit archiveextracts of03e4c541aand of each commit's tree.