fix(tern): complete a deployment-keyed apply only when its generation manifest is satisfied - #1076
Conversation
bb60ce5 to
a08e3b5
Compare
a08e3b5 to
6ae96c6
Compare
There was a problem hiding this comment.
Pull request overview
Adds deployment generation manifests so Tern data planes wait for every declared shard and finalizer before completing an apply.
Changes:
- Sends and persists per-deployment operation manifests.
- Gates apply creation, attachment, and completion against the manifest.
- Adds schema, telemetry, and integration coverage.
Reviewed changes
Copilot reviewed 16 out of 17 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
pkg/tern/sharded_apply_dispatch_test.go |
Tests manifest-aware finalizer scope. |
pkg/tern/local_dispatch_finalizer_integration_test.go |
Tests VSchema-only keyed dispatches. |
pkg/tern/local_dispatch_attach_integration_test.go |
Tests manifest persistence and attach gates. |
pkg/tern/local_client.go |
Validates, stores, and enforces manifests. |
pkg/tern/grpc_client.go |
Sends deployment operation manifests. |
pkg/tern/grpc_client_test.go |
Tests manifest construction. |
pkg/storage/types.go |
Adds manifest storage helpers. |
pkg/storage/types_test.go |
Tests manifest helper behavior. |
pkg/storage/internal/sqlstore/applies.go |
Persists and loads manifests. |
pkg/schema/postgres/applies.sql |
Adds the PostgreSQL manifest column. |
pkg/schema/mysql/applies.sql |
Adds the MySQL manifest column. |
pkg/proto/ternv1/tern.pb.go |
Updates generated protocol bindings. |
pkg/proto/tern.proto |
Adds the manifest request field. |
pkg/metrics/metrics.go |
Adds refusal and completion-hold metrics. |
pkg/api/operator.go |
Holds completion for missing operations. |
pkg/api/operator_test.go |
Tests manifest-gated state projection. |
pkg/api/ensure_schema_postgres_test.go |
Updates PostgreSQL schema expectations. |
Files not reviewed (1)
- pkg/proto/ternv1/tern.pb.go: Generated file
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
… manifest is satisfied A deployment-keyed dispatch now carries its generation manifest — the full operation-key set its deployment sends under the shared idempotency key — and the data plane stores it on the keyed apply at creation. The manifest is the completion authority: the state projection holds the apply's success verdict at running until every declared operation has attached and finished, an attach outside the stored manifest is refused fail-closed, and a dispatch whose manifest omits its own key is refused at creation. Failure verdicts pass through unheld, and an apply without a manifest keeps the attached-rows-only semantics, so mixed-version planes stay safe in either deploy order. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6ae96c6 to
0f086de
Compare
…ed tests Progress derives its state from task rows, which all settle before the drive persists the apply row's terminal state. A wait that returns on the task-derived signal alone cancels the test context while the drive is still finalizing, leaving a running applies row that blocks later tests for the same database behind the active-apply gate. waitForApplyComplete now also requires the stored applies row to be terminal, so the drive finishes inside the test lifetime. The two tests that sit behind the active-apply gate adopt the file's cleanupTasks isolation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🤖 Review findings - created by Kiran's code review agent - for schemabot/pull/1076, e27aa06. Verdict: 3 findings — 1 blocking (Reverted bypasses the manifest hold), 2 non-blocking (log field gap, retry co-rotation test coverage). Blocking
Non-blocking
The one thing that could have broken, verifiedThe retry key/manifest co-rotation mechanism in Verified: both guards are textually identical today — Verified correct
This review was generated by Claude Code (claude-sonnet-5). |
…key/manifest co-rotation The generation-manifest hold gated only the completed verdict, but a reverted verdict makes the same whole-generation claim: a single reverted shard could terminalize a keyed apply while manifest-declared siblings had not yet attached, and their later dispatches would refuse the terminal apply. The hold now gates both verdicts via manifestGatedVerdict; failure verdicts still pass through, since a failed generation must not wait for siblings that may never dispatch. Also pin the retry co-rotation invariant with a joint test — a deliberate retry must rotate the idempotency key and narrow the manifest on the same scope in the same step — and build the creation-gate refusal log with apply.LogAttrs() like the sibling gates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🤖 All three findings are addressed in 21aac54:
This reply was posted by Claude Code (Claude Fable 5) on Armand's behalf. |
Why this matters
When a schema change fans out across shards, the control plane sends the work to the data plane one operation at a time: one dispatch per shard, plus a finalizer that applies the VSchema at the end. All of those dispatches land on one shared apply on the data plane.
The bug: the data plane decided that shared apply was "complete" by looking only at the operations that had arrived so far. If the first shard finished before its siblings were dispatched, the apply was marked completed — and because a completed apply refuses new work, every late-arriving sibling was then turned away. The apply reported success while most of the work never ran.
The rule this PR enforces: an apply completes only when all of its shards and finalizers are done.
What it does
Every dispatch now declares up front the full list of operations its deployment will send — its generation manifest. The data plane stores that list on the apply at creation and holds the apply open until the list is satisfied:
The manifest is enforced fail-closed at three points:
schemabot.apply_manifest_hold_totalcounts sustained holds so a dispatcher that died mid-generation is visible instead of silent.Failures are never held: if any operation fails, the apply reports the failure immediately instead of waiting for siblings that may never arrive. Terminal stays terminal — nothing reopens a completed apply. An apply with no stored manifest keeps today's behavior, so old and new planes can be deployed in either order.
Retries follow the same honesty rule: a deliberate retry redispatches only its own operation (successful siblings never dispatch again), so a retry gets its own operation-scoped remote apply declaring just that one operation — it can never wait on siblings that will not come.
The manifest also settles a long-standing ambiguity: a dispatch carrying a single namespace's VSchema could be either that namespace's finalizer (one of several in a sharded change) or the whole deployment's finalizer (a VSchema-only change). The two are indistinguishable on arrival, but the manifest names the dispatcher's actual keys, so the data plane now adopts whichever shape was declared.
Each deployment declares only its own operations, and each data plane gates only on what was declared to it:
How it moves us toward the northstar
In the target architecture the control plane composes the full operation set for a schema change, and each data plane reconciles against that declared set. This PR carries the declared set across the plane boundary for the first time: the data plane now completes against what was declared, not what happened to arrive. The natural next step is to materialize every declared operation row when the keyed apply is created, so a later dispatch simply claims its row instead of attaching a new one.
Opened by Claude (Fable 5).