fix(storage): canonicalize apply and task identity keys - #1217
Conversation
The stored-check guards join checks to applies on repository/database; with the checks side folded, unfolded applies would stop matching on PostgreSQL and the newer-apply guard would fail open. Fold apply and task identity keys Go-side at the store boundary (writes and predicates) to keep the correlated guards byte-consistent across dialects, with cross-dialect parity subtests.
There was a problem hiding this comment.
Pull request overview
This PR makes storage identity comparisons cross-dialect consistent by canonicalizing (lowercasing) key identity fields for applies and tasks at the SQL store boundary. This prevents PostgreSQL’s byte-wise comparisons from diverging from MySQL’s case-insensitive behavior, keeping check↔apply correlation predicates byte-consistent.
Changes:
- Add
storage.CanonicalKey()and apply it to repository/database/database type/environment at write boundaries (applies + tasks) and in key read filters. - Canonicalize identity-filter inputs for
Applies.GetByDatabase/GetByPR/GetRecent/CountRecentByState/ExistsForDatabaseHead/DeleteByPRandTasks.GetByDatabase/GetByPR/List/FindTableOwners. - Add storagetest parity subtests asserting identity keys are treated case-insensitively while deployment/table casing is preserved.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| pkg/storage/storagetest/tasks.go | Adds a parity subtest asserting task identity keys are case-insensitive across store methods. |
| pkg/storage/storagetest/applies.go | Adds a parity subtest asserting apply identity keys are case-insensitive across store methods. |
| pkg/storage/internal/sqlstore/tasks.go | Canonicalizes task identity keys on insert/upsert and canonicalizes identity-based query filters. |
| pkg/storage/internal/sqlstore/applies.go | Canonicalizes apply identity keys on create paths and canonicalizes identity-based query filters. |
| pkg/storage/canonical.go | Introduces CanonicalKey() helper for consistent identity folding. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
shardTaskInsertValues no longer mutates its argument (the only entry path, UpsertShardProgress, already folds). The in-place canonicalization contract is now pinned by input-struct assertions in storagetest and documented on the ApplyStore/TaskStore godocs. Addresses external review of pull/1217.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
morgo
left a comment
There was a problem hiding this comment.
🤖 Approved on Morgan's behalf by his AI agent.
Pure addition — 0 deletions across all six files, 37/37 green, and the folding is inert on MySQL where utf8mb4_0900_ai_ci already matched case-insensitively. Consistent with #1213/#1215/#1216.
Worth stating plainly: this closes a window main is in right now. #1216 merged at 02:53 and folded the checks side; this PR folds the applies/tasks side. If the premise in your description holds — correlated subqueries joining checks to applies on repository/database, with one side folded and the other not — then the newer-apply guard is fail-open on PostgreSQL on main today, until this lands. That's an argument for merging it promptly rather than a reason to hold it, and it's the kind of intermediate state worth calling out in a split series so nobody parks the second half.
apply_operations.go staying out of scope is right — operations carry no program identity, and #1224 is separately adding external_id there, so keeping the two changes disjoint avoids a needless conflict.
One nit, non-blocking: canonicalizeApplyIdentity(apply *storage.Apply) mutates the caller's struct in place, and Create/createWithRows/Update all call it before doing anything else. Two small consequences worth knowing about:
- The caller's in-memory
*storage.Applyis silently rewritten as a side effect of a store call. That's mostly desirable — their view now matches what's persisted — but it isn't documented on the function or at the call sites, and a caller that comparesapply.Repositoryagainst something case-sensitive afterwards would see different behavior than before. - The mutation happens even when the subsequent write fails, so a failed
Createstill leaves the caller's struct folded.
Neither is a bug in any current call path; a one-line doc comment saying the fold is applied to the passed struct would make it intentional rather than incidental.
On the read-path folding (GetRecent, GetByDatabase, GetByPR, List, FindTableOwners): same pre-existing-row question I raised on #1215 about PostgreSQL rows written before folding becoming unfindable. Not re-litigating it here — #1215 merged, so I'll take it as settled — but flagging that this PR widens the same surface, so if the answer there was "PG storage hasn't carried real traffic yet," that assumption is now load-bearing across four PRs.
|
🤖 Adversarial correctness review, requested by @aparajon and performed by their agent. Reviewed at head Verdict: the folding is right and the call sites are consistent — but the merge-order condition in your PR body has already been crossed, so this is now the fix for a live half-folded state on Findings1. The ordering your PR body warns about has already happened, and the resulting guards fail open. #1216 merged first, so on 2. No backfill, and 3. The Action items
Verified (tried to break, couldn't)The failure I most expected is not there: This review was generated by Claude Code (claude-opus-5). |
aparajon
left a comment
There was a problem hiding this comment.
🤖 Approving on @aparajon's behalf after the adversarial correctness review above. Not a blocking approval with a caveat attached — the review's first finding is that main is already carrying the half-folded state this PR fixes, so the ordering note there is a reason to land it sooner, not a reason to hold it. The rest are yours to pick up as follow-ups.
This stamp was left by Claude Code (claude-opus-5).
|
🤖 Adversarial correctness review (sweep pickup), performed by Cato at head Verdict: correct, additive-only, and safe to land — land it promptly, because Findings1. (doc) The TaskStore godoc promises an in-place rewrite that Action items
Verified (tried to break, couldn't)Lock-name stability (the scariest failure):
Fold coverage and the paths left unfolded:
Correlated guards this PR exists for:
Build, tests, CI:
This review was performed by Cato, an AI review agent operated by Armand. |
aparajon
left a comment
There was a problem hiding this comment.
🤖 Approving on Armand's behalf after the adversarial correctness review above. The one new finding is a doc-level fix — flagging it, not gating on it. Land this promptly: main is half-folded until it merges.
This stamp was left by Cato, an AI review agent operated by Armand.
Update never touched the identity fields; only Create and UpsertShardProgress canonicalize in place.
morgo
left a comment
There was a problem hiding this comment.
🤖 Re-approving on Morgan's behalf at 99834b73 (automated review).
Force-pushed past my earlier approval, so I re-verified rather than assuming a clean rebase.
The substantive delta is fix(storage): keep value builders free of identity mutation, which drops canonicalizeTaskIdentity(task) from shardTaskInsertValues. That's the right hygiene call — a function that builds placeholders and args shouldn't mutate its argument — but it's only safe if the fold still runs on every path that reaches it, so I traced it rather than trusting the commit message.
It does. shardTaskInsertValues has exactly two callers, insertShardTaskGuarded (:253) and insertShardTaskGuardedByApply (:255), and both are inside UpsertShardProgress, which folds at its entry (:192). The other insert path, insertTask, folds at :66. No path reaches the value builder unfolded, so shard task rows still get canonical identity keys on both dialects.
The added require.Equal assertions in storagetest/applies.go and storagetest/tasks.go are a good addition — they pin the in-place folding as contract rather than leaving it implied by the later lookup assertions, which would still pass if the fold moved to the SQL layer.
CI green. One cross-PR note below, filed identically on #1231.
Merge-order collision: this PR and #1231 both rewrite the same CanonicalKey doc block in pkg/storage/canonical.go, with different accounts of lock owners. Here they're "deliberately not folded … compared byte-wise on both sides of every ownership check"; on #1231 the same block says "the lock API folds a caller-supplied owner (cli:user@host) with this function at acquire and release." Those describe different boundaries and can both be true, but they'll conflict textually, and a careless resolution leaves the doctrine reading inconsistently. Whichever lands second should reconcile to text that states both: the store boundary doesn't fold owners, the lock API does for caller-supplied ones.
|
🤖 Review response — created by Kiran's code review agent (Amp, Claude Opus 4.5) — pull/1217, follow-up commit The one new finding (a
|
…ditive-convergence * origin/main: (33 commits) feat(postgres): add ADD COLUMN synthesis to the statement parser seam (#1212) feat(cli): add storage canonicalize-identity-keys admin subcommand (#1231) fix(storage): canonicalize remaining identity keys (#1218) fix(storage): canonicalize apply and task identity keys (#1217) fix(webhook): canonicalize repository identity at ingress (#1213) docs: document the PostgreSQL support envelope (#1144) fix(engine): report why a Vitess schema change failed (#1242) feat(ddl): detect statements whose cost scales with table size (#1237) fix(operator): keep a multi-table apply running while tables are queued behind a cutover (#1241) fix(storage): index the webhook inbox claim ordering (#1196) fix(github): drop the cutover duration promise from progress surfaces (#1240) fix(github): render row-copy progress percentages at their true precision (#1239) fix(observability): do not report a shutdown as a claim failure (#1233) fix(github): tell an operator why a refused apply's database is busy (#1224) fix(engine): do not mark an apply failed when its driver shuts down (#1234) feat(github): render live row-copy progress on sharded table lines (#1191) feat(ui): add approximate row and byte formatters (#1236) fix(planetscale): delete the branch an apply created when it fails before its deploy request (#963) feat(api): app grouping field on database config (#1226) feat(cli): filter pulled tables with --table (#1235) ... # Conflicts: # docs/configuration.md # pkg/ddl/postgres_parser.go # pkg/ddl/postgres_parser_test.go
Fold apply and task identity keys Go-side at the store boundary, keeping the checks↔applies correlated guards byte-consistent.
Why
The stored-check guards run correlated subqueries joining checks to applies on repository/database. With the checks side folded (sibling PR) but applies unfolded, those guards stop matching on PostgreSQL — the newer-apply guard fails open. Folding the applies/tasks side keeps every cross-table predicate byte-consistent on both dialects.
What
applies.go:Create,Update,GetRecent/CountRecentByStatefilters,GetByDatabase,GetByPR,DeleteByPR,ExistsForDatabaseHeadfold identity fields (repository, database, type, environment).tasks.go:Create/insertTask, shard-progress upserts,GetByDatabase,GetByPR,List,FindTableOwnersfold the same fields.apply_operations.gounchanged: operations carry no program identity fields (deployment names, operation keys, targets, lease owners are out of scope).LOWER().Before / after