fix(cli,metadata-protocol): os migrate resume completes an interrupted recorded-by run, and os serve reports interrupted migration runs at boot - #21527
Conversation
…he migrate data boot; owner registers its plan Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-authored-by: Claude <noreply@anthropic.com>
…ugin beside the journal Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-authored-by: Claude <noreply@anthropic.com>
…stration Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-authored-by: Claude <noreply@anthropic.com>
… (no new runtime option); checklist item follows the composition Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-authored-by: Claude <noreply@anthropic.com>
…lans composition Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-authored-by: Claude <noreply@anthropic.com>
…e frozen memory driver Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 35 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 37cce35f749bdedeeed7bc9ecb8c01ce1a8826d8 && git checkout 37cce35f749bdedeeed7bc9ecb8c01ce1a8826d8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ad7c3518983a1bb63fd4601954ac92d055124e42 e5cdb086b0ff9b90965d408e67c9e69d434e8b07 && git checkout -B drift-repro ad7c3518983a1bb63fd4601954ac92d055124e42 && git merge --no-ff e5cdb086b0ff9b90965d408e67c9e69d434e8b07
node scripts/docs-audit/affected-docs.mjs --json ad7c3518983a1bb63fd4601954ac92d055124e42
|
Fixes #21498
Clause-②: no
What changed
MigrationRecoveryPlugin(@objectstack/runtime) owns themigration-plansregistry and the ADR-0119 D2 boot scan. Nothing composed it. This PR composes it once in every stack that can host a plan. It also makes the plan's owner register the plan, because composing the registry alone does not makeresumework.os migratedata boot (packages/cli/src/utils/data-migration-plugins.ts) composesnew MigrationRecoveryPlugin()besidePlatformObjectsPlugin. This is the one composition point forrecorded-by,resume,value-shapes,summary-nulls,files-to-references,meta --stored,audit-metadata-bodiesandos storage orphans.os serveboot (packages/cli/src/commands/serve.ts, step 5c-bis) composes it beside thePlatformObjectsPluginauto-registration.os startandos devspawnserve, so they get it too. The block uses the same presence guard as the block above it, so a config that composes its own instance keeps that one. Measured with a host config that composesnew MigrationRecoveryPlugin():Plugin registered: com.objectstack.migration-recovery×1,Plugin superseded×0,Service 'migration-plans' registered×1.packages/metadata-protocol/src/plugin.ts).assembleMetadataProtocolis the code path that bothObjectQLPluginandMetadataProtocolPluginrun. It now handsmetadata.recorded-by-sentinel-to-nulltomigration-plansatkernel:ready, but only when a registry is composed.kernel:ready. The registry is registered in the recovery plugin'sinit(), and the kernel runs that after the engine'sinit(). Atkernel:ready, everyinit()has run, so a missing registry is really missing.start(), which is Phase 2.dispatchHookPropagatingruns handlers in registration order, so the scan sees the plan.runPlatformMigrations. Registering a plan runs nothing.@objectstack/clipatch and@objectstack/metadata-protocolpatch.packages/runtimegains only a test, so it ships nothing.platform-core.interrupted-migration-boot-report(rev 2) and row D14 indocs/qa/platform-checklist/FOLLOW-UPS.md. Both said that no boot composes the plugin.Why the registry alone was not enough (the ruling's mechanism, measured)
The ruling expected
recorded-by'splans.register()andresume'splans.get()to "meet one registry". They cannot.recorded-byandresumeare separate processes, and an in-memory registry does not survive a process boundary. The third ablation below measures exactly that state: the composition is present and the owner registration is removed.resumestill refuses with the original sentence, and the serve scan calls the run unresumable.The ruling's intent is that a run resumes once its owning package is loaded. That holds only if the owner registers the plan in every process. So the owner,
@objectstack/metadata-protocol, now does. The refusal sentence inresume.tsis unchanged. It is now true exactly when it appears: no loaded package registers the plan.The public door, before and after
Fixture: three
sys_metadata_historyrows holdrecorded_by = 'system'. A real process ran the recorded-by plan underrunMigrationJournaland was SIGKILLed inside chunk 0's transaction. That leavesrun_startedandchunk_started(0)in the journal, with no chunk committed.25797a16e1)os migrate resume --jsonresumable: falseresumable: trueos migrate resume --run RUN_ID --yes --jsonstatus: completed,chunksCommitted 1/1, 0 sentinel rows left, journalrun_started, chunk_started, chunk_started, chunk_done, run_doneos serveover the runInterrupted migration run 'RUN_ID' (plan 'metadata.recorded-by-sentinel-to-null', …) … Resume with: os migrate resume --run RUN_ID, plus1 interrupted migration run(s) found in sys_migration_journal. They are NOT resumed automatically …The boot scan's cost and noise (A4)
Log level and silence on a clean database.
os servewas booted over a journal whose only run had concluded, and over a fresh database. Neither boot printed a scan line. The fresh-database boot listed 3 boot-diagnostic warnings, and none of them came from the scan. The scan logs atwarn, and only when it has an interrupted run to report or when its read fails.Cost. The scan is one
findInterruptedRunscall. I timed it on SQLite, median of 20: 0.26 ms with an empty journal, 0.65 ms with 1 concluded run, 11.2 ms with 51 concluded runs. It re-reads each run's events, so the cost grows linearly with journal history (see the acceptance notes). These are shared-box numbers.In the one-shot
os migrateboot the scan runs too. I measured two effects:os migrate resumetherefore lists the run twice in human mode.os migrate value-shapes --jsonon a fresh project went from 5 to 6 WARN lines. Those commands already exit 1 on that database (see the findings).A registry-only constructor option would remove both effects. I wrote one, measured it, and withdrew it. It would have widened
@objectstack/runtime's public surface (making Clause-②yes) for a cosmetic gain.Pins
packages/cli/src/commands/migrate/resume.recorded-by.integration.test.ts(integration tier). Every boot runs in a hook.os migrate resumecommand lists the runresumable: true, and--run … --yescompletes it. The test then reads the rows and the journal on its own connection.os servereports the run withResume with: os migrate resume --run RUN_ID.os serveover a fresh database prints no scan line.packages/runtime/src/migration-recovery-plugin.plan-owner.test.ts. A realObjectKernelrunsObjectQLPlugin,PlatformObjectsPluginand the recovery plugin over in-memory SQLite, with the journal rows a chunk-0 crash leaves. The scan reports the run as resumable, and the registry returns the plan after boot.Reverse verification
Each mutation went through
scripts/ablation-replace.mjs, which proves the anchor hit and that the restore matches the HEAD blob. Every pin turned red as expected.kernel.usedeleted (fe23beb97e,srcvia tsx, no build)expected [] to have a length of 1plugins.push(new MigrationRecoveryPlugin())deleted (fe23beb97e)expected false to be true; act exit 1 with the original refusale5cdb086b0,metadata-protocolrebuilt,ablation-dist-preflightmarker present in 2 dist files)… to contain 'Resume with: …')For the third ablation, the restore leg rebuilt the package.⚠️ My first attempt at this ablation was a no-op. It used a
ablation-dist-preflight --absentpassed, the tree was clean against HEAD, and the registration call was back indist/.void 'marker'statement, which esbuild drops, so the marker never reacheddist/. The preflight refused before any test ran. I re-anchored the marker on an assignment and re-ran.Local verification
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 69 commands ate5cdb086b0, and all 69 exit 0 ate5cdb086b0.--ranreconciliation: "69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero)".check:driver-memory-censuswent red on the first version of the runtime pin, which bound the frozen@objectstack/driver-memory. The pin now uses SQLite.check:dual-build-cjs-loadsandcheck:i18n-coveragefirst answered PREREQUISITE NOT MET (nodist). I re-ran them after a fullturbo build(72/72).pnpm lint(the whole repo,eslint . --no-inline-config) exits 0 ate5cdb086b0.e5cdb086b0.preview-read-only,meta.stored-flow-resolution,platform-migrations-arming,schema-migrate.one-shot-family,schema-migrate.teardown): 98 pass, 1 skipped (the live-PG cell).fe23beb97e. The only change since then is the runtime test's driver.@objectstack/cliunit tier: 251 files, 3611 pass, 29 skipped. Two files first failed on a missingpackages/cli/distand passed afterpnpm --filter @objectstack/cli build.@objectstack/metadata-protocolfull suite: 205 files pass, 3 skipped.typecheckpasses for cli and metadata-protocol. runtime'stypecheckwas re-run ate5cdb086b0and passes.Acceptance notes
Out of scope, reported for filing.
resumenow reaches the runner, which refuses two kinds ofrecorded-byrun withPLAN_CHANGED:load()only selects rows that still hold the sentinel, so the chunk plan recomputed on resume hashes differently);--chunk-size.Measured at the public door: 203 rows killed in chunk 1, and 3 rows at chunk size 2. In both cases the list says
resumable: trueand--run … --yesexits 1Refused (PLAN_CHANGED). For these runs, re-running--applyremains the recovery. The pin above interrupts in chunk 0 and deliberately does not pin around this.Out of scope, reported for filing. On a project whose database does not exist yet,
os migrate resume --json,recorded-by --jsonandvalue-shapes --jsonalready exit 1 with an opaque "The database refused to run this query for object …" at25797a16e1. The scan now adds one warning to those runs.The boot scan's cost grows linearly with journal history: one query per run ever recorded. 11 ms at 51 runs. This is an observation and has no carrier.
recorded-by's in-processplans.register(plan)now re-registers a plan the owner already registered. The last registration wins, and it carries the flag's chunk size. I left it in place because the ruling names it.Generated by Claude Code