Repository navigation
feat(storage): operator command to inspect and apply offline store upgrades - #56
Merged
Merged
Conversation
…grades The storage kernel's offline, data-preserving owner-store upgrades had no entry point: nothing but tests called them, and the refusal of an upgradable predecessor told the operator to move the file aside. - eg-storage: one registry of offline upgrades (declared predecessor, inspect function, apply function) and a read-only classification of an owner file's format. The named refusal of a registered predecessor now names the operator command; a predecessor with no upgrade keeps the move-aside step. - server binary: `store-upgrade inspect <data-dir>` (read-only) and `store-upgrade apply <data-dir> --confirm`. Apply holds the engine's own directory lock, runs each applicable upgrade through the kernel's inspected single-commit transition, stops at the first failure, never writes a store it does not upgrade, and is a no-op on a second run. The last output line is one JSON object; the exit status distinguishes nothing to do, upgrade available, blocked, undetermined and failed. - startup: the engine refuses a data directory holding a store a registered upgrade applies to, with that store's named error and the exact command. It never upgrades a store itself. - the generated owner-store format document carries the operator procedure. The startup health check for the SPARQL federation endpoint moves from main.rs to server_startup.rs unchanged, so the subcommand hook does not grow a file that is already over the size cap. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
EG-DURABLE-KERNEL-R070 is implemented on this branch and not yet merged, so its delivery state is BUILDING with the implementation commit as evidence. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2 tasks done
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.
What
The storage kernel has offline, data-preserving owner-store upgrades, but nothing except tests could run them, and the refusal of an upgradable store told the operator to move the file aside. This adds the entry point:
store-upgrade inspect <data-dir>andstore-upgrade apply <data-dir> --confirm;No existing upgrade's semantics, digests, lineage declarations or on-disk formats change, and nothing is upgraded at an ordinary open.
Wire contract: unchanged. The refusal stays what it already was, a named
{LAYOUT}_FORMAT_UPGRADE_REQUIREDerror declared by the lineage registry and reported in the startup log with exit status 1; no generated contract artifact moves.Inventory (what was on main)
sql-catalog/*.redb)inspect_sql_source_checkpoint_upgrade(and its two aliases) →upgrade_sql_source_checkpointsagent_library.redb)inspect_agent_library_mcp_catalog_upgrade→upgrade_agent_library_mcp_cataloggraph-<n>.redb)inspect_graph_shard_upgrade(…, source)→upgrade_graph_shardDeclared predecessors with no upgrade (still refused with the move-aside step): Agent Library before connector packs, blob store before holder-scoped references, graph shard before the storage scrub, graph shard before enrichment policy revisions.
Token and confirmation model. Every inspection takes the file path, the physical identity the caller expects, an optional private-payload authenticator, and a private local staging root with a byte budget. It pins the source file, copies it to an anonymous scratch file, lets native recovery run on the copy, and checks the pinned predecessor digest, the exact table census and the typed row evidence. It returns a token that cannot be cloned or constructed elsewhere, bound to the file descriptor and a SHA-256 of the bytes. Applying the token takes the file exclusively (and proves the filesystem enforces the lock), re-checks all of that, and performs one immediate-durability commit that creates the missing empty tables and replaces the owner manifest (authority epoch + 1). Existing rows are not rewritten.
What they refuse: any other generation (the current one included), a different physical identity, a stray or multimap table, bytes that changed after inspection, a staging root under
/tmpor.cacheor one that is not private, and non-Linux hosts.No backup. The upgrade functions keep no copy of the source: the "quarantine" in that code retires the scratch file, not the store. The safety net is the single atomic commit (main already has a crash-stage test for it).
How the server reported such a store. Every manifest read refuses a declared predecessor with
{LAYOUT}_FORMAT_UPGRADE_REQUIREDand tells the operator to move the file aside, upgradable or not; any other digest isOWNER_STORE_FORMAT_UNKNOWN. Graph shards fail at boot. The Agent Library and the SQL catalogs open lazily, and the SQL catalog path replaces the reason with "could not be opened".Operator CLI surface. The server binary had flags only. The offline tools are separate binaries shipped beside it (
migrate-shards,restore): one clap parser each, the persistence directory from--persist-dirorGRAPH_SERVICE_PERSIST_DIR, the engine stopped, one result line, exit status 1 on failure. Each of them links the whole engine, so a third would add another engine-sized binary to the published package, and the package payload has its own owner. The command is therefore a subcommand of the server binary, following the same conventions: no packaging change, and the tool is by construction the same build as the engine that will open the stores afterwards.Design
Registry —
crates/eg-storage/src/owner/offline_upgrade.rs.OFFLINE_STORE_UPGRADESis a table of rows: declared predecessor (which names the store kind, the generation and the file), inspect function, apply function. From a row follow the read-only classification (classify_owner_store_format: current / upgrade available / declared predecessor with no upgrade / unknown digest), the refusal text, and the operator document.Command —
src/server/persistence/store_upgrade.rs, wired as a clap subcommand insrc/operator_command.rs.inspect <data-dir>reads each store's manifest read-only and writes nothing (not even the lock file).apply <data-dir> --confirmtakes the engine's own single-writer lock (engine.lock, the lock the engine holds for its lifetime) for the whole run, so it refuses while an engine is running and no engine can start meanwhile. For each store with a registered upgrade it runs the kernel's inspect and then its single commit. It stops at the first failure; it never writes a store it does not upgrade; a second run reports nothing to do. No interactive prompt.inspectreports it as not inspectable;applylets the registered upgrades of that store kind inspect it (they recover a private copy, never the file) and upgrades it if one admits it.inspect) an upgrade is available--confirm, bad directory) or an upgrade failed and the run stoppedThe last output line is one JSON object (
command,verb,outcome,exit_code,pre_upgrade_copy,not_processed,error,stores[]).Startup refusal. Before the first store is opened, the server classifies the data directory with the same function and refuses, with exit status 1, if a registered upgrade applies to any store. This also covers the lazily opened stores. The message is the store's named error followed by
Run: epistemic-graph-server store-upgrade apply <the directory> --confirm. The kernel's own refusal of an upgradable predecessor now names the command too, instead of advising to move the file aside.What a new upgrade adds
One row in
OFFLINE_STORE_UPGRADES, plus a predecessor fixture for the command test. The graph-shard upgrade that merged while this branch was open (before the audit-append idempotency index) is registered here exactly that way:The identity operation family (#46) is a different case, and one row is not enough for it yet. It keeps its new state inside the existing access-control policy image, so the store's table set and layout digest do not change, and nothing can tell an earlier file from a current one. The lineage identifies a predecessor by its owner-table set. To get a fail-closed path through this command, #46 must add:
The command already knows the physical identity of the access-control store (
every_registered_upgrade_is_one_the_command_can_runfails if a registered store kind has none), so nothing in the command itself needs to change.Checks
Rust was built and tested on the build host from a local-disk copy of the branch, because the offline-upgrade tests cannot run from a network mount (the scratch-file reservation is refused there).
cargo fmt --all -- --checkcargo clippy -p eg-storage --all-targets -- -D warningscargo clippy -p epistemic-graph --no-default-features --features full,ast-extended --all-targets -- -D warningscargo clippy -p epistemic-graph --no-default-features --features server --all-targets -- -D warningscargo test -p eg-storagetest result: ok. 120 passed; 0 failed(plus 5 doc tests)cargo test -p epistemic-graph --no-default-features --features full,ast-extended --lib -- store_upgrade persist_lock durable_stores sql_tables redb_layout agent_library::test result: ok. 36 passed; 0 failedcargo test -p epistemic-graph --no-default-features --features full,ast-extended --bin epistemic-graph-servertest result: ok. 6 passed; 0 failedcargo run -p eg-storage --example gen_owner_store_formatsstore-upgrade inspect <empty dir>,applywithout and with--confirm, a missing directory,--helpHooks, run over
origin/main..HEAD: the commit-stage set (33 passed, the rest have no files in scope), and the manual-stagecomplexity-staged,kiss-changed-rust,dupehound-changed-functions,jscpd-differential,kiss-census,cccc-census,rust-arch-lint,orphan-modules,durable-table-registrationandregistry-test-ownership: all pass.scripts/check_public_specs.pyandscripts/security/check_secret_history.py --base origin/mainpass.Tests added (disposable stores only):
owner::offline_upgrade::tests): every registry row is a declared predecessor and is registered once; the refusal names the command only where an upgrade is registered and writes nothing; classification names current / upgradable / no-upgrade / unknown without writing; every row upgrades a genuine predecessor to the current layout exactly once.server::persistence::store_upgrade::tests): inspect on current stores reports nothing to do and creates nothing; inspect on a predecessor reports the upgrade and leaves the bytes alone; apply upgrades two graph-shard generations, the Agent Library and a SQL catalog with the exact row bytes kept, and a second apply changes nothing; both verbs refuse while the engine lock is held; apply without--confirmopens nothing; an unknown format and a predecessor with no upgrade are reported (exit 20) and stay byte-identical; a refused upgrade stops the run and the later store is untouched; a predecessor left unclean is not inspectable read-only and is still upgraded by apply; the startup check, the Agent Library open and the graph store open refuse a predecessor with the named error and the command.persist_lock::tests) and command-line parsing (operator_command::tests).Not run here: the whole
epistemic-graphtest suite (left to CI), and the contract generator check, because no contract input is touched.--features server,securitywithoutquerydoes not pass clippy on main today (two unused imports in files this change does not touch), so that combination was not usable as a third profile.Limits
"pre_upgrade_copy":"none"and the procedure tells the operator to take a filesystem snapshot first if one is wanted.EG-DURABLE-KERNEL-R006): only a store that a registered upgrade applies to refuses startup this way.applyupgrades it, and the lazy open still hides the reason. That reporting path is unchanged here.applyneeds free space of about one store's size for the scratch copy, in a private (mode 0700) local directory. The default is created inside the data directory and left empty.inspectthen exits 30, andapplycopies each such store of a kind that has registered upgrades to the scratch directory to find out whether an upgrade admits it. Stores it cannot place are left untouched and reported with exit 30; the engine recovers them at its next start. The deployment job decides whether 30 lets the engine start.sql-catalog/are not scanned.EG-DURABLE-KERNEL-R070is recorded as BUILDING with this branch as evidence.🤖 Generated with Claude Code