Conversation
A rollout of many targets against many tables renders past GitHub's comment cap, and a comment over the cap is rejected outright — which would leave the pull request with no plan at all rather than a long one. An oversized comment now gives up DDL bodies until it fits, largest plan first, and stops as soon as it does. What it never gives up is the summary lines: every target is still named, every plan is still counted, and each withheld plan is replaced by the command that prints it in full. A group's plan identifier comes from the member plan rows its members were stored with, so the pointer resolves to exactly the plan that was withheld. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
🤖 Review findings - created by Kiran's code review agent - for schemabot/pull/1427, 865e7d4. Verdict: 4 findings — 3 non-blocking (withholding order, no terminal guarantee, multi-env gap), 1 suggestion. Non-blocking
General suggestions
The one thing that could have broken, verifiedClamping mutates the group slice the caller owns. It does not: 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 865e7d47. Approving — the withholding loop is correct, terminates, does not mutate the caller's data, and points at a command that really exists with the argument shape it uses. This also closes the budget multiplication I raised on #1425, which I confirmed rather than assumed. One finding: the guard is scoped to the grouped path, and the same fleet size overflows the comment on the path beside it.
1. A blocked rollout still renders past GitHub's cap, unguarded
RenderPlanComment returns before clamping when len(data.planGroups()) == 0. Grouping is gated on rollup.Clean (#1424), so a blocked rollup never has groups — and its per-member list is the one list #1426 deliberately left unclamped, for good reasons.
Measured, one entry per member with a routine timeout detail:
500 blocked members -> 56,033 chars (under)
600 blocked members -> 67,133 chars over GitHub's 65,536 cap
800 blocked members -> 89,333 chars over
1200 blocked members -> 133,933 chars over
That is roughly 112 characters per member, so the cliff sits near 585. #1426's own motivation says "a targets: list can address hundreds of targets" and its example uses 144, so this is above the documented scale but the same order of magnitude — and it is the case where losing the comment costs the most, because a blocked rollup is the only place the comment says which member failed and why. The failure mode is exactly the one this PR's first sentence describes: the comment is rejected and the PR ends up with no plan at all.
RenderPlanComment's new doc comment opens "A rollout of N targets against M tables can render past what GitHub will accept, so an oversized comment gives up DDL bodies until it fits", which reads as a property of the function. It is a property of one branch of it.
I do not think the fix is to clamp the blocked list — #1426 argues convincingly against that, and I agree. The options that preserve both properties are a collapsed block holding the tail of the member list, or a final backstop that truncates with a visible marker when nothing else can be given up. The second is worth having regardless: today, if every group is clamped and the comment still does not fit, the loop breaks and returns the oversized string unchanged, so there is no path on which the function guarantees its own budget.
Notes
renderedDDLSizeunder-weights plans with many short statements. It sums raw statement bytes and ignores what rendering adds — fences, keyspace and shard headings, blank lines. A group of 2,000 short ALTERs and a group of 20 long ones can sum alike while rendering very differently, so "largest first" can pick the wrong one and need an extra iteration. Only costs re-renders, since the loop keeps going until it fits — the comment already says it is for ordering only, which is the right disclaimer. Mentioning it because the re-render is a fullrenderPlanCommenteach time.g.Empty()inlargestUnclampedGroupis redundant. An empty group hasrenderedDDLSize == 0, andsize > bestSizestarts atbestSize = 0, so it can never be selected. Removing the check leaves the suite green — which is fine, because there is nothing behind it to test. Harmless as defense in depth; noting it so it is not mistaken for a load-bearing guard later.- "they are identical, so any one of them answers the question" is true of the work, not of the rows. Group members share a fingerprint, so each has its own stored plan row with its own identifier and the same canonical change set.
schemabot list-plans <first member's id>therefore prints a row that is equivalent to, not the same as, the other members'. Right call; the comment could say "equivalent" to avoid a reader wondering why the members' IDs differ. - The empty-
PlanIDfallback prints prose instead of a broken command, which is the right shape — andPlanIdentifier's own doc says empty is a legitimate state ("Empty means the member runs the plan the apply itself was created [from]"), so this is a reachable branch and not just defense.
What I checked rather than took on trust
- Fault injection — five of six. Disabling the clamp entirely → three tests; withholding smallest-first instead of largest →
OversizedRolloutWithholdsTheLargestPlan+WithheldPlanWithoutAnIdentifierSaysSo; printing the command with an emptyPlanID→WithheldPlanWithoutAnIdentifierSaysSo; clamping every group instead of stopping once it fits →WithholdingStopsWhenTheCommentFits; giving the primary's group its member plan ID instead of the reviewed plan's →GroupsCarryThePlanToPrintThem. The only green one is the redundantg.Empty()guard above. schemabot list-plans <id>is a real invocation.pkg/cmd/main.go:53registersPlansCmdundername:"list-plans", andplans_test.go:34parseslist-plans plan-1784327902264169990— a bare positional plan ID, exactly the shape rendered. Theschemabotprefix matches the convention inapply.goandapply_commands.go.- The loop terminates and cannot spin.
for range groupsbounds the iterations,largestUnclampedGroupreturns −1 once every non-empty group is clamped, and each iteration sets exactly oneclampedflag, so the worst case is one render per group plus the first. - The caller's data is genuinely not mutated.
slices.Clonecopies the group slice,drift := *data.DeploymentDriftcopies the struct, anddatais a value parameter — so theclampedwrites land only in this call's copy. The comment's claim holds through all three levels, which I traced rather than trusted becauseDeploymentDriftis a pointer field. - This does close #1425's budget multiplication. With
maxCommentDDLLen = 32768per call towriteKeyspaceChanges, two large groups render ~65.5k, aboveplanCommentBudget = 64512, so the loop engages and withholds one. The overflow I measured on #1425 (four large groups → 132,313 chars) cannot survive to the top of the stack. clampedis unexported on an exported struct, so a caller cannot preset it. Matches the field comment's claim that it is set while rendering and never by the caller.- Merge-base against
pr1426is an ordinary commit; +233/−16, and the sixteen deletions are the twowriteKeyspaceChangescall sites, thedeploymentDriftPreview/deploymentPlanGroupssignatures, and their doc lines. No test deletions. CI: 41 checks, no genuine failures.
A rollout of many targets against many tables renders past GitHub's 65,536
character comment cap. A comment over the cap is rejected outright, so the pull
request ends up with no plan at all rather than a long one.
An oversized comment now gives up DDL bodies until it fits, largest plan first,
and stops as soon as it does. What it never gives up is the summary lines: every
target is still named, every plan is still counted, and each withheld plan is
replaced by the command that prints it in full.
A group's plan identifier comes from the member plan rows its members were
stored with, so the pointer resolves to exactly the plan that was withheld. The
primary runs the reviewed plan itself and has no member plan row of its own, so
its group carries the reviewed plan's identifier.
Three targets planning independently: one needs a single statement, one needs
2,000, and one needs 2,500.
This upholds RV-3: what an operator consents to stays specific. A plan the
comment has no room for is named, counted, and reachable rather than silently
absent, and no summary of withheld DDL is invented in its place.
The rendered comment, with one plan withheld
Schema Change Plan — Production
Database:
testapp| Type:MySQLStarted at 2026-09-17 18:42:05 UTC
✅ Planned separately for all 3 targets (
primary/my_db_1,primary/my_db_2,primary/my_db_3) — 3 distinct plans. Each target applies its own.`primary/my_db_1` (primary) — 1 DDL statement
`primary/my_db_2` — 2000 DDL statements
`primary/my_db_3` — 2500 DDL statements
This plan is too large to render here. To read it in full:
📋 Plan: 3 distinct plans on 3 targets
Opened by Claude (Claude Opus 5).