feat(core,platform-objects,spec): ADR-0119 D2 — the migration-journal runner (#4617) - #4668
Merged
Merged
Conversation
… runner (#4617) A migration killed mid-run is now either resumable to completion or compensable to clean, with journal rows proving which. ADR-0119 D1 made `engine.transaction()` contract-reachable, which answers multi-write atomicity that fits in one transaction. Migration-class work does not fit: a million-row backfill cannot hold one write-lock, driver-memory's `beginTransaction` deep-clones the whole database, `transaction()` binds the default driver only, and a KILLED process defeats in-process rollback entirely. So the unit of atomicity is the chunk, and durability across chunks is a journal. - `runMigrationJournal` (@objectstack/core): preflight dry-run across every step before any step writes; chunked writes each inside `engine.transaction()`; LIFO compensation newest-first on failure; re-entrant forward recovery under a per-plan `onCrash` policy; at-least-once with an `attempt` counter, reusing bulk-write.ts's delivery contract rather than re-deriving it. - `sys_migration_journal` (@objectstack/platform-objects): rows keyed (run_id, seq) under a unique index, registered unconditionally beside sys_migration so recovery is discoverable with zero host wiring (ADR-0078). Distinct in grain from sys_migration, which holds one verdict per named migration; this holds many rows per run. - Row contract + object-name constant in @objectstack/spec/system, so core's runner writes the journal without depending on platform-objects. The invariant carrying the design: `chunk_done(i)` is written INSIDE the chunk's transaction so `done ⇔ committed` holds by construction, while `chunk_started(i)` is written autonomously before it. That asymmetry gives `started ∧ ¬done` exactly one meaning — outcome unknown — which is the only state a crash leaves and the only state recovery reasons about. The runner refuses rather than degrades: no rollback capability, a failed preflight, an uncompensable plan declaring onCrash:'compensate', or a resume whose plan hash disagrees with the journal. A compensation failure halts and is journalled, and the run ends `failed` rather than `compensated` — a database in a state no clean story covers must not be reported as a tidy rollback. `engineCanRollBack` is now shared: the two-level probe was the same condition in this runner and in batchData's atomic gate, and two copies drift by one clause and leave one caller believing it has atomicity it does not have. It moves to @objectstack/core as a type predicate; metadata-protocol imports it. Boot reconciliation and `os migrate resume` land separately; the discovery primitive they consume, `findInterruptedRuns`, is exported here. Docs: ADR-0118 (plugin-reachable transactions) is renumbered ADR-0119. It merged a day after an unrelated ADR-0118 (非用户 actor 的平台契约), and the earlier merge holds the number. Its Status line now cites the implementing PR and its tests instead of a dangling "this PR", and records that D2/D3 remain unimplemented. Refs: ADR-0119 D2, #4617, #4612, ADR-0034, ADR-0060, ADR-0078, ADR-0117 D8 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NKcGqCYCCpMkB5UW8jNPXx
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 4 package(s): 115 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
August 2, 2026 16:00
This was referenced Aug 2, 2026
Closed
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.
Implements ADR-0119 D2 (#4617). Closes the gap D1 left open.
Acceptance, as the ADR states it: a migration killed mid-run is either resumable to completion or compensable to clean, with journal rows proving which.
Why a journal, given D1 already gave us transactions
ADR-0119 D1 made
engine.transaction()contract-reachable, which is the right answer for multi-write atomicity that fits in one transaction. Migration-class work does not fit:driver-memory'sbeginTransactiondeep-clones the entire database — O(db) per begin;ObjectQL.transaction()binds the default driver only, so a multi-datasource migration silently commits part of its work outside it;So the unit of atomicity is the chunk, and durability across chunks is the journal.
Four consumers had each converged on the same four moves — preflight, undo journal, LIFO compensation, re-entrant recovery (ADR-0105 D13 promotion, ADR-0117 D8's ownership backfill, the org lifecycle transitions, D10 master-data distribution #4585). One copy is engineering; four is platform debt.
The invariant that carries the design
chunk_done(i)is written inside the chunk's own transaction, sodone ⇔ committedholds by construction.chunk_started(i)is written autonomously, before it.That asymmetry looks untidy and is load-bearing: it gives
started ∧ ¬doneexactly one meaning — the outcome is unknown — which is the only state a crash can leave and the only state recovery reasons about. Symmetrising it would destroy recovery. It is called out in the object doc, the runner header, and the test that pins which writes carry a transaction context.What's here
runMigrationJournal(@objectstack/core) — preflight dry-run across every step before any step writes; chunked writes each insideengine.transaction(); LIFO compensation newest-first on failure, each in its own transaction; re-entrant forward recovery under a per-planonCrash: 'resume' | 'compensate'; at-least-once delivery with anattemptcounter, reusingbulk-write.ts's contract verbatim rather than deriving a second delivery-semantics story.sys_migration_journal(@objectstack/platform-objects) — rows keyed(run_id, seq)under a unique index, so a resumed run that miscomputes its next sequence fails loudly instead of double-recording. Registered unconditionally besidesys_migration, because recovery must be discoverable with zero host wiring (ADR-0078: a journal nobody re-reads is a journal that does not exist). Distinct in grain fromsys_migration— that holds one durable verdict per named migration; this holds many rows per run. Read-only over the API; writes go through the runner in system context.Row contract in
@objectstack/spec/systemso core's runner can write the journal without depending on platform-objects.It refuses rather than degrades
Four places: the runtime cannot roll back; any preflight fails; the plan declares
onCrash: 'compensate'but a step cannot compensate; or a resume's plan hash disagrees with the journal — resuming a changed plan would apply chunk boundaries the journal never described, so "chunk 7 done" would name different rows and the resume would skip work it never did.A compensation failure halts and is journalled — never swallowed — and the run ends
failed, notcompensated. A database in a state no clean story covers must not be reported as a tidy rollback.engineCanRollBackis now sharedThe two-level probe (engine method and default-driver
beginTransaction) was the same condition written twice — here and inbatchData's atomic gate. It moves to@objectstack/coreas a type predicate and@objectstack/metadata-protocolimports it. Two copies of "can this runtime actually roll back?" drift by one clause and leave one caller believing it has atomicity it does not have. The D4 refusal semantics are unchanged — its 10 tests pass untouched in substance.ADR renumber
ADR-0118(plugin-reachable transactions) is renumbered ADR-0119. It merged 2026-08-02 12:11, one day-part after an unrelated ADR-0118 (非用户 actor 的平台契约) at 10:37; the earlier merge holds the number. All 30-odd code citations, both anchors entries and the pending changeset move with it; the non-user-actor record is untouched.Its
Statusline also stops saying "implemented in this PR" — a dangling reference once merged — and now cites the implementing PR plus the key file and test for D1 and D4, and records explicitly that D2/D3 are not implemented.Scope
Deliberately not here, per the split agreed for #4617: boot reconciliation and
os migrate resume. They are the discovery channel and depend on this core landing first.findInterruptedRuns— the primitive they consume — is exported and tested here. #4617 stays open until that second PR lands.Verification
packages/core/src/utils/migration-journal.test.ts— 19 new tests, against a fake engine implementing real rollback, so a failed transaction discards its writes including thechunk_donerow inside it. Without that, every assertion would also pass against a runner that never opened a transaction — the exact bug class D4 existed to kill. Covers all three ADR acceptance cases (crash mid-chunk → resume exactly once with idempotency honoured; crash mid-compensation → halts loudly, no silent partial; plan-hash mismatch → refuses), plus LIFO order, the capability gate, preflight fail-closed, discovery, and sequence continuity across a resume.pnpm typecheck— 122/122.pnpm lintclean.check:adr-anchorsOK.check:generated— 8/8 artifacts current (regeneratedapi-surface/docsfor the new spec exports).packages/spec/src/system/constants/platform-object-names.tsneeded the new object added — the repo's declared-vs-registered conformance test caught it, which is the mechanism working.Refs: ADR-0119 D2, #4617, #4612, ADR-0034, ADR-0060, ADR-0078, ADR-0117 D8
Generated by Claude Code