Conversation
An environment whose members are distinct targets plans each one against its own live schema, so the members are free to run different work. The plan comment said only that: "each target holds its own schema, so their plans are not expected to match". True of the contract, and silent about the round in front of the reviewer. Targets free to differ usually do not, and a fleet converging over several PRs — some targets changed, the rest already there — was invisible. The members are now grouped by the plan each would run, and the comment states the result: every target needs the same change, or how many of them are already at this schema, or how many distinct plans the apply would run. Members group on the plan fingerprint, so they share a group exactly when their plans are the same work. Each group carries the plan its own members would run, in the shape the comment already renders the reviewed plan in, so a later change can show it. Grouping is confined to a clean rollup of independent members. A blocked rollup still lists every member on its own, because the operator's next step is the target that could not be planned. Mirrored members stay ungrouped: a clean mirrored rollup has already proved they are one group, and re-reporting that in the vocabulary of a fleet free to diverge would read as an outcome rather than the requirement that let the check pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2ea4fc3 to
bf36a87
Compare
|
🤖 Review findings - created by Kiran's code review agent - for schemabot/pull/1424, bf36a87. Verdict: 3 findings — 1 blocking (drift line contradicts "No changes detected"), 2 non-blocking. BlockingA converged primary makes the drift line claim N targets need a change the same comment says does not exist. Non-blockingSame contradiction in the 2-target shape: "1 needs this change" immediately followed by "No schema changes detected". With Two different vschema rewrites of the same namespace collide into one group and are described as "the same change". Grouping keys on the plan fingerprint at General suggestions
The one thing that could have broken, verifiedAn errored member silently sharing the empty-fingerprint bucket would have merged unplanned targets into the "already at this schema" group. It cannot happen: grouping is gated on Verified correct
This review was generated by Claude Code (claude-opus-5). |
morgo
left a comment
There was a problem hiding this comment.
🤖 Reviewed on Morgan's behalf, at bf36a875. Approving — every rule this PR adds is pinned by a test that fails when I break it, and all six sentences in the description's table are what the code actually produces. One thing worth deciding before the stack lands, plus two precision notes.
1. memberPlanChanges is a second implementation of the reviewed-plan renderer, and the two already disagree
The doc comment says it renders a member's plan "in the shape the comment renders the reviewed plan in, so a group's changes are described by the same code that describes the plan a reviewer has already read." The second half is not so: the reviewed plan is built at pkg/webhook/plan.go:925-973, and this is a parallel build of the same []KeyspaceChangeData. (The counting side genuinely is shared — Empty() goes through countChanges/keyspaceStatementCount, which is why a group whose only DDL is per-shard is correctly not "already at this schema".)
Where they differ, on the same input:
- A namespace that appears only on shard rows.
memberPlanChangeskeeps it, via theshardedNamespacesfallback, with the comment "dropping it would silently remove work from a plan the comment claims to describe in full."plan.gobuildsksDataonly by loopingplanResp.Changes, so it drops exactly that namespace. One of two things is true and it matters which: either the shape is producible, and the reviewed-plan block has been quietly omitting work all along — in which case the fix belongs inplan.go, not only in the new renderer, or the two surfaces will keep disagreeing; or it is not producible, and the fallback plusTestMemberPlanChanges_KeepsShardOnlyNamespacecover a shape that cannot occur. I could not settle it from the planner side, and you are better placed to say. - A shard that reports changes but whose DDL is all empty.
plan.gotreats this as"shard %q in keyspace %q reported %d change(s) with no DDL — plan is incomplete for this shard"and refuses to render the shard.memberPlanChangestakeslen(shard.Statements) == 0and setsSatisfied = true, which renders it as already at this schema — the inverse. This one is unreachable through the grouped path today, and I checked rather than assumed:canonicalDDLForDriftreturnsempty DDLfor a blank statement (pkg/tern/local_plan_drift.go:204), so such a member fails its self-comparison, classifiesDeploymentErrored, clearsClean, and never reachesdeploymentPlanGroups. It is safe because of a gate two packages away, not because of anything local, so it is worth a word in the comment.
Neither is a defect in what ships. I am raising it because the comment invites a reader to believe the two renderers cannot drift, and they already have.
Notes
DeploymentPlanGroup.Changesis the first member's rendering, not "the plan every member of the group would run". Members share a group on canonicalized work, andTestChangeSetFingerprint_CanonicallyIdenticalSetsShareAKeypins that backticked and bare spellings of the same ALTER share a key — so two members can legitimately group while their raw DDL text differs, and the group renders whichever one came first in rollout order. Same work, so the grouping is right; but the rendered statement is a representative's spelling rather than every member's, and the field comment currently promises the stronger thing.deploymentPlanGroupskeys amap[string]onPlanFingerprint, where""means "do not group". It is safe here only because therollup.Cleangate excludes every errored member, which is the one thing that produces an empty fingerprint — andTestDeploymentDriftPreview_BlockedRollupIsNotGroupedpins that gate, so it cannot be removed silently. Flagging it because the gate and the sentinel are in different packages: the next caller ofPlanFingerprintthat is not behindCleanwill group every blocked member into one plan.case converged == 0: "%d distinct plans. Each target applies its own."is the only arm that does not name a member count, while the arm below it says "across the %d targets that change". Matches the description's table, so intentional — noting it only in case the asymmetry was not.
What I checked rather than took on trust
- Fault injection — eight for eight. Dropping the
rollup.Cleangate →BlockedRollupIsNotGrouped; dropping theindependentgate →MirroredMembersAreNotGrouped; inverting the primary-first comparator →PrimaryGroupComesFirst+DifferentWorkSplits+ConvergedTargetsAreTheirOwnGroup; forcingPrimaryfalse →SameWorkGroupsTogether+PrimaryGroupComesFirst; forcingSatisfiedfalse →MarksSatisfiedShards; removing the shard-only-namespace fallback →KeepsShardOnlyNamespace; disabling theplans == 1 && converged == 0arm →PlanGroupsDescribeThisRound+DriftCleanNamesMultiTargetMembers; forcingcountedVerbplural →PlanGroupsDescribeThisRound+VSchemaOnlyPlanIsNotAlreadyApplied; disabling the ungrouped fallback →UngroupedIndependentRollupStatesTheContract. Nothing I could break stayed green. - All six rows of the wording table are literally what the code emits. Traced each arm by hand:
plans==0→ "every target is already at this schema.";plans==1 && converged==0→ "every target needs the same change.";plans==1→countedVerb(2,"needs","need")+countedVerb(1,"is","are")giving "2 need this change, 1 is already at this schema.";converged==0→ "2 distinct plans. Each target applies its own."; default → "2 distinct plans across the 3 targets that change; 2 are already at this schema.";len(groups)==0→ the prior contract sentence verbatim. Subject-verb agreement holds at 1 on both halves. Empty()cannot call a shard-only plan "already at this schema".keyspaceStatementCountfalls back to the distinct statements acrossks.Shardswhen the collapsedStatementsis empty, so a group whose DDL lives only on shard rows counts above zero. This was the failure mode I most expected and it is closed by reuse rather than by a new count.- A vschema-only plan is not folded in with the converged targets.
countChangesreturnskeyspacesWithVSchemaseparately andEmpty()sums both, so a namespace whose only work is a vschema rewrite keeps its own group. Pinned byVSchemaOnlyPlanIsNotAlreadyApplied. - Group order is deterministic and does not depend on map iteration.
byPlanis only an index; groups are appended in rollout order of first appearance, andshardedNamespacesis a slice built alongside the map for the same reason. A re-rendered comment on a later push cannot reshuffle. - Exactly one group can carry
Primary. It is set fromi == 0at group creation, so only the group the first member opens can have it, andSortStableFuncleaves the rest in insertion order. memberPlanChangesis nil-safe where the reviewed-plan builder is not — it goes throughGetDdl()/GetChanges()getters, whileplan.go:965dereferencest.DDLwithout a nil check on the namespace-level loop. Not introduced here, and not reachable through this path, but the new code is the better-behaved of the two.- Merge-base against
pr1423is an ordinary commit; +668/−5, and the five deletions are the onefmt.Fprintfcall this replaces. No test deletions. CI: 41 checks, no genuine failures.
An environment whose members are distinct targets plans each one against its own live schema, so the members are free to run different work. The plan comment said exactly that and nothing else:
That is true of the contract and silent about the round in front of the reviewer. Targets that are free to differ usually do not, and the shape a fleet rolling out over several PRs actually has — some targets changed, the rest already there — was invisible.
This groups the members by the plan each would run and says what came out.
What changes for the reader
A production fleet of three targets, where two still need the reviewed change and one already has it:
The wording, by case:
every target needs the same change.2 need this change, 1 is already at this schema.2 distinct plans. Each target applies its own.2 distinct plans across the 3 targets that change; 2 are already at this schema.every target is already at this schema.Divergence renders on the success glyph. Under a targets list, targets holding different schemas is the contract rather than a problem, so calling it out with the attention glyph would send an operator to reconcile a fleet that is behaving correctly.
Where grouping applies
Members group on the plan fingerprint, so they share a group exactly when their plans are the same work. Two cases deliberately stay ungrouped:
A group with no statements and no vschema rewrite is "already at this schema" — a plan in its own right, not a missing one. A plan that only rewrites the vschema runs no DDL and is still work, so it is not folded in with the targets that have nothing to do.
Each group carries its plan
A group holds the changes its own members would run, converted into the same shape the comment already renders the reviewed plan in, rather than a summary of it. A sharded namespace keeps both views of its changes and a satisfied shard stays visible, so the next change can render a group's plan through the code that renders the plan a reviewer has already read, instead of a second renderer that agrees until it does not.
Not in this change
The per-group DDL blocks. The comment states how many distinct plans there are but still renders the reviewed plan's DDL once, unattributed to a group. That change carries the preview fixtures for these renderings, so
TEMPLATES.mdshows the finished output rather than a half-rendered intermediate.Opened by Claude (Claude Opus 5).
🤖 Generated with Claude Code