feat(tern): one deployment correlates to exactly one remote apply - #1060
Conversation
…ion echo Operation-scoped remote dispatches now share one idempotency key per deployment and generation instead of minting one key per operation, so a deployment's sibling operations land on a single data-plane apply — the first dispatch creates it and each sibling attaches its own operation. Because the shared apply answers many operations under one key, an accepted response is only trusted when it echoes the operation key the request's shape derives to; a response without the right echo (most often a data plane that predates sibling-operation attach and would alias every sibling to the first operation) is refused, the dispatch fails closed, and a counter fires for the operator. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All operations of a deployment attach into the deployment's single data-plane apply, so they all record the same remote apply id. persistRemoteApplyID now fails closed when a dispatch result would give a deployment a second remote apply, DeploymentRemoteApplyID resolves the shared id for read paths, and the refusal is countable via schemabot.remote_apply_deployment_id_conflict_total. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…red-external-id # Conflicts: # pkg/metrics/README.md # pkg/metrics/metrics.go # pkg/tern/grpc_client.go # pkg/tern/grpc_client_test.go
There was a problem hiding this comment.
Pull request overview
This PR strengthens SchemaBot’s remote (gRPC) apply correlation model by enforcing the invariant that a single deployment must map to exactly one remote data-plane apply ID, failing closed when a dispatch response would split a deployment across multiple remote applies. This improves operator triage by ensuring there’s a single remote apply identifier to correlate across control-plane and data-plane logs/storage.
Changes:
- Add a write-path guard (
guardDeploymentRemoteApplyID) that rejects persisting a remote apply ID when sibling operations for the same deployment already recorded a different ID (or already disagree). - Introduce shared storage helpers (
ApplyOperation.RemoteApplyID()+storage.DeploymentRemoteApplyID) to consistently resolve the remote apply ID (including legacyengine_resume_contextfallback) and fail closed on disagreement. - Add a new metric (
schemabot.remote_apply_deployment_id_conflict_total) and document the operator action in the metrics README.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| pkg/tern/grpc_client.go | Enforces deployment-level single-remote-apply-ID persistence with a fail-closed guard and emits a new conflict metric. |
| pkg/tern/grpc_client_deployment_id_test.go | Adds focused tests for the new deployment-shared remote apply ID invariant on the gRPC client write path. |
| pkg/storage/deployment_remote_apply.go | Adds helpers to resolve per-operation and per-deployment remote apply IDs (with legacy fallback) and fail closed on disagreement. |
| pkg/storage/deployment_remote_apply_test.go | Adds unit tests covering RemoteApplyID() and DeploymentRemoteApplyID() behavior, including disagreement cases. |
| pkg/metrics/README.md | Documents the new conflict counter and the expected operator response. |
| pkg/metrics/metrics.go | Adds RecordRemoteApplyDeploymentIDConflict for emitting the new counter with deployment dimension. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The refusal that protects an operation's already-recorded remote apply id is the same fail-closed divergence the deployment guard counts, so it now emits the conflict counter and an error log carrying the recorded and refused ids for operator correlation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🤖 Review findings - created by Kiran's code review agent - for schemabot/pull/1060, 34b5418. Verdict: 4 findings — 1 blocking (TOCTOU race in the new deployment guard), 2 non-blocking (test-coverage gap, legacy-carrier log fetch gap), 1 general suggestion (duplicate id-precedence logic). Blocking
Non-blocking
General suggestions
The one thing that could have broken, verifiedThe concurrency question: does Verified correct
This review was generated by Claude Code (claude-sonnet-5). |
…rite time Sibling operations of one deployment persist their dispatch results concurrently across the driver pool, so a divergence check outside the writing transaction cannot stop two of them from each reading "no id recorded yet" and committing different remote apply ids for the same deployment. SaveExternalID now locks the apply's operation rows and re-verifies the deployment invariant inside the writing transaction, refusing divergence with ErrRemoteApplyDeploymentIDConflict; the unlocked guard read remains for precise triage logging. The drive scope's remote apply id resolution now goes through the canonical ApplyOperation.RemoteApplyID() accessor instead of duplicating its precedence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…an-out An operation dispatched to a remote data plane before the external-id columns existed records its remote apply id only in the legacy engine resume context carrier. The deployment log fan-out read external_id directly and silently skipped such operations. It now resolves ids through ApplyOperation.RemoteApplyID() after proving the deployment routes to a remote client, so the legacy carrier is never mistaken for a local drive's engine resume state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🤖 Addressed in two commits:
This reply was written by Claude Code (Fable 5). |
…ew-drift-rollup * origin/main: (357 commits) fix(github): render each lint violation as its own bullet in unsafe-change comments (#1105) feat(engine): disclose at plan time whether an apply continues or discards a copy (#1087) fix(operator): choose the drive mode from the generation manifest, not the attached row count (#1101) feat(tern): one deployment correlates to exactly one remote apply (#1060) fix(github): record the passing check when an apply plan finds no changes (#1099) feat(spirit): detect an unfinished row copy and log what the apply will do to it (#1048) docs: reserve metrics for signals worth alerting on (#1089) feat(cli): browse stored plan history with the list-plans command (#1083) feat(cli): render status sources as OSC 8 hyperlinks on interactive terminals (#1097) feat(github): show VSchema changes in sharded apply comments (#1096) test(webhook): PostgreSQL failure-matrix row — declined stop is terminal, apply completes (#1098) feat(observability): log the delivery GUID when a goroutine panics (#1092) test(webhook): pin apply-confirm lock-path dispositions (#1091) fix(api): type terminal rollback validation errors (#1090) build(deps): pin pg-sprite to released v0.1.0 (#1093) feat(cli): show apply provenance as a clickable source in status output (#1086) fix(github): give sharded applies a real terminal summary comment (#1085) fix(vitess): gate stored-plan applies on recorded VSchema deletions and mutations (#1084) webhook: PostgreSQL failure-matrix rows — restart survival and permanent privilege refusal (#1079) fix(tern): complete a deployment-keyed apply only when its generation manifest is satisfied (#1076) ... # Conflicts: # pkg/webhook/plan.go # pkg/webhook/templates/plan.go
Why this matters
Deployment-keyed dispatch attaches every sibling operation into the deployment's one data-plane apply, so all of a deployment's operation rows must record the same remote apply id — that shared id is what an operator greps in the data plane's storage and logs during an incident. Nothing enforced this: a dispatch result carrying a different id (an in-flight apply spanning a dispatch-key rollout, or a data plane that lost its keyed apply and minted a fresh one) would be persisted silently, splitting one deployment across two remote applies and breaking every correlation built on top.
What it does
persistRemoteApplyIDfails closed when storing a dispatch's remote apply id would correlate the operation's deployment to a second remote apply — either because the deployment's siblings already recorded a different id, or because the siblings themselves already disagree. Sibling deployments of the same apply are exempt: they own their own remote applies.SaveExternalIDlocks the apply's operation rows and re-verifies the deployment's id inside the writing transaction, so sibling dispatches persisting concurrently across the driver pool cannot each pass an unlocked check and commit divergent ids. The unlocked guard read stays for precise triage logging.storage.DeploymentRemoteApplyIDresolves the single remote apply id shared by a deployment's operations (with the legacy engine-resume-context carrier honored), erroring on disagreement instead of picking one. The write-path guard consumes it now; the status/progress read model consumes it next.ApplyOperation.RemoteApplyIDnames the external-id-else-legacy-carrier lookup both sites used. The drive scope and the data-plane log fan-out now resolve ids through it too, so an id recorded only in the legacy carrier still contributes its logs.schemabot.remote_apply_deployment_id_conflict_total(database, environment, deployment) counts the refusals, documented in the metrics README with the operator action.How it moves us toward the northstar
One data-plane apply per deployment; operations dispatch into it.
Opened by Claude (Fable 5).