Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions TEMPLATES.md
Original file line number Diff line number Diff line change
Expand Up @@ -7501,6 +7501,40 @@ ALTER TABLE `mutes`
📋 **Plan**: **1** table to alter


---

▶️ **To apply** all schema changes from this PR, comment:
```
schemabot apply -e production
```

</details>

<details>
<summary><a name="plan-many-shards-32"></a><strong>Plan: Many Shards (32)</strong></summary>


## Schema Change Plan — Production

**Database**: `cdb_resolute` | **Type**: `Strata`

*Requested by @jackjackbits at 2026-01-01 00:00:00 UTC · planned from [`abcdef1`](https://github.com/block/schemabot/commit/abcdef1234567890abcdef1234567890abcdef12)*

#### Keyspace: `cdb_resolute_sharded`
<details>
<summary><b>all 32 shards</b></summary>

`-08`, `08-10`, `10-18`, `18-20`, `20-28`, `28-30`, `30-38`, `38-40`, `40-48`, `48-50`, `50-58`, `58-60`, `60-68`, `68-70`, `70-78`, `78-80`, `80-88`, `88-90`, `90-98`, `98-a0`, `a0-a8`, `a8-b0`, `b0-b8`, `b8-c0`, `c0-c8`, `c8-d0`, `d0-d8`, `d8-e0`, `e0-e8`, `e8-f0`, `f0-f8`, `f8-`

</details>

```sql
ALTER TABLE `mutes` ADD INDEX `created_at`(`created_at`);
```

📋 **Plan**: **1** table to alter


---

▶️ **To apply** all schema changes from this PR, comment:
Expand Down
1 change: 1 addition & 0 deletions pkg/cmd/internal/templates/preview_comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,7 @@ func previewCommentShardedAllOutput() {
fn func()
}{
{"PLAN: DIVERGENT SHARDS", func() { fmt.Print(webhooktemplates.PreviewCommentShardedPlanDivergent()) }},
{"PLAN: MANY SHARDS (32)", func() { fmt.Print(webhooktemplates.PreviewCommentShardedPlanManyShards()) }},
{"PLAN: PARTIALLY APPLIED SHARDS", func() { fmt.Print(webhooktemplates.PreviewCommentShardedPlanPartiallyApplied()) }},
{"PLAN: UNSAFE CHANGE ON ONE SHARD", func() { fmt.Print(webhooktemplates.PreviewCommentShardedPlanUnsafe()) }},
{"APPLY IN PROGRESS", func() { fmt.Print(webhooktemplates.PreviewCommentShardedApplyInProgress()) }},
Expand Down
22 changes: 19 additions & 3 deletions pkg/webhook/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,7 @@ func shardedUnsafeChanges(shards []*apitypes.ShardPlanResponse) []templates.Unsa
if len(shards) == 0 {
return nil
}
total := plannedShardCount(shards)
type key struct{ table, reason string }
var order []key
byKey := make(map[key]*templates.UnsafeChangeData)
Expand All @@ -665,7 +666,7 @@ func shardedUnsafeChanges(shards []*apitypes.ShardPlanResponse) []templates.Unsa
k := key{table: unsafeChange.Table, reason: unsafeChange.Reason}
uc := byKey[k]
if uc == nil {
uc = &templates.UnsafeChangeData{Table: unsafeChange.Table, Reason: unsafeChange.Reason}
uc = &templates.UnsafeChangeData{Table: unsafeChange.Table, Reason: unsafeChange.Reason, TotalShards: total}
byKey[k] = uc
order = append(order, k)
}
Expand All @@ -679,6 +680,19 @@ func shardedUnsafeChanges(shards []*apitypes.ShardPlanResponse) []templates.Unsa
return out
}

// plannedShardCount counts the shards the plan actually covers, so a shard
// list rendered against it states coverage over what was planned rather than
// over slots that carried no plan.
func plannedShardCount(shards []*apitypes.ShardPlanResponse) int {
total := 0
for _, sp := range shards {
if sp != nil {
total++
}
}
return total
}

// msgDeferCutoverAllDirect rejects --defer-cutover on a plan whose every
// change the policy routes to direct execution: a direct statement has no
// cutover to defer, so the flag is refused instead of silently ignored.
Expand Down Expand Up @@ -708,6 +722,7 @@ func shardedDirectChanges(shards []*apitypes.ShardPlanResponse) []templates.Dire
if len(shards) == 0 {
return nil
}
total := plannedShardCount(shards)
type key struct{ table, reason string }
var order []key
byKey := make(map[key]*templates.DirectChangeData)
Expand All @@ -722,7 +737,7 @@ func shardedDirectChanges(shards []*apitypes.ShardPlanResponse) []templates.Dire
k := key{table: t.TableName, reason: t.ModeReason}
dc := byKey[k]
if dc == nil {
dc = &templates.DirectChangeData{Table: t.TableName, Reason: t.ModeReason}
dc = &templates.DirectChangeData{Table: t.TableName, Reason: t.ModeReason, TotalShards: total}
byKey[k] = dc
order = append(order, k)
}
Expand All @@ -744,6 +759,7 @@ func shardedBlockedChanges(shards []*apitypes.ShardPlanResponse) []templates.Blo
if len(shards) == 0 {
return nil
}
total := plannedShardCount(shards)
type key struct{ table, reason string }
var order []key
byKey := make(map[key]*templates.BlockedChangeData)
Expand All @@ -758,7 +774,7 @@ func shardedBlockedChanges(shards []*apitypes.ShardPlanResponse) []templates.Blo
k := key{table: t.TableName, reason: t.ModeReason}
bc := byKey[k]
if bc == nil {
bc = &templates.BlockedChangeData{Table: t.TableName, Reason: t.ModeReason}
bc = &templates.BlockedChangeData{Table: t.TableName, Reason: t.ModeReason, TotalShards: total}
byKey[k] = bc
order = append(order, k)
}
Expand Down
1 change: 1 addition & 0 deletions pkg/webhook/plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ func TestBuildPlanCommentData_PerShardUnsafe(t *testing.T) {
require.Len(t, data.UnsafeChanges, 1)
assert.Equal(t, "mutes", data.UnsafeChanges[0].Table)
assert.Equal(t, []string{"40-80"}, data.UnsafeChanges[0].Shards, "the unsafe change is scoped to the drifted shard")
assert.Equal(t, 2, data.UnsafeChanges[0].TotalShards, "coverage is stated against every planned shard")
}

// A shard that already matches the desired schema while siblings change is
Expand Down
3 changes: 1 addition & 2 deletions pkg/webhook/templates/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -880,8 +880,7 @@ func renderShardSummary(sb *strings.Builder, table TableProgressData) {
return // completed/pending/cancelled/failed: no breakdown, stay quiet
}

const inlineLimit = 8
if len(table.Shards) <= inlineLimit {
if len(table.Shards) <= shardNamesInlineLimit {
parts := make([]string, 0, len(table.Shards))
for _, sh := range table.Shards {
if isCopyingShardStatus(sh.Status) && sh.PercentComplete > 0 {
Expand Down
2 changes: 1 addition & 1 deletion pkg/webhook/templates/apply_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ func RenderBlockedChangesApplyRejected(data PlanCommentData) string {
for _, c := range data.BlockedChanges {
table := "`" + c.Table + "`"
if len(c.Shards) > 0 {
table = fmt.Sprintf("%s (%s)", table, planShardList(c.Shards))
table = fmt.Sprintf("%s (%s)", table, planShardList(c.Shards, c.TotalShards))
}
if reason := SanitizeInlineError(c.Reason); reason != "" {
fmt.Fprintf(&sb, "- %s: %s\n", table, html.EscapeString(reason))
Expand Down
21 changes: 12 additions & 9 deletions pkg/webhook/templates/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,7 @@ func (d SchemaErrorData) EnvironmentHeader() string {
case 1:
return "**Environment**: " + markdownInlineCode(d.Environments[0])
default:
quoted := make([]string, len(d.Environments))
for i, name := range d.Environments {
quoted[i] = markdownInlineCode(name)
}
return "**Environments**: " + strings.Join(quoted, ", ")
return "**Environments**: " + strings.Join(markdownInlineCodeList(d.Environments), ", ")
}
}

Expand Down Expand Up @@ -298,10 +294,7 @@ func RenderInvalidCommand() string {
// handles. The configured environment names are normalized for markdown
// display so an unexpected character cannot break the comment.
func RenderInvalidEnv(action string, available []string) string {
quoted := make([]string, len(available))
for i, name := range available {
quoted[i] = markdownInlineCode(name)
}
quoted := markdownInlineCodeList(available)
availableLine := ""
if len(quoted) > 0 {
availableLine = "\n**Available environments**: " + strings.Join(quoted, ", ") + "\n"
Expand All @@ -321,6 +314,16 @@ func markdownInlineCode(s string) string {
return "`" + strings.Join(strings.Fields(s), " ") + "`"
}

// markdownInlineCodeList renders each value as a normalized markdown inline
// code span, ready to join into a comma-separated list.
func markdownInlineCodeList(values []string) []string {
quoted := make([]string, len(values))
for i, v := range values {
quoted[i] = markdownInlineCode(v)
}
return quoted
}

// RenderMissingEnv generates an error message when -e flag is missing.
func RenderMissingEnv(action string) string {
return offerSupportChannel(fmt.Sprintf(`## ❌ Missing Argument
Expand Down
72 changes: 62 additions & 10 deletions pkg/webhook/templates/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ type UnsafeChangeData struct {
// where only some shards carry it. Empty for a non-sharded change (applies to
// the whole table).
Shards []string
// TotalShards is how many shards the plan covers in the keyspace, so a
// rendering too wide to name every shard can state coverage ("12 of 32
// shards") instead of a bare count. Zero when unknown.
TotalShards int
}

// BlockedChangeData is a planned change the engine deterministically refuses:
Expand All @@ -38,6 +42,10 @@ type BlockedChangeData struct {
// Shards names the shards this blocked change applies to, for a sharded
// plan where only some shards carry it. Empty for a non-sharded change.
Shards []string
// TotalShards is how many shards the plan covers in the keyspace, so a
// rendering too wide to name every shard can state coverage ("12 of 32
// shards") instead of a bare count. Zero when unknown.
TotalShards int
}

// DirectChangeData is a planned change the database's direct execution policy
Expand All @@ -50,6 +58,10 @@ type DirectChangeData struct {
// Shards names the shards this direct change applies to, for a sharded
// plan where only some shards carry it. Empty for a non-sharded change.
Shards []string
// TotalShards is how many shards the plan covers in the keyspace, so a
// rendering too wide to name every shard can state coverage ("12 of 32
// shards") instead of a bare count. Zero when unknown.
TotalShards int
}

// AttributedChangeData is a table carrying a planned destructive change that
Expand Down Expand Up @@ -774,14 +786,14 @@ func writeShardedPlanDDL(sb *strings.Builder, shards []KeyspaceShardChange) {
// satisfied shards means nothing is changing, so render nothing rather than
// an empty code block.
if len(groups) == 1 && !groups[0].Satisfied {
fmt.Fprintf(sb, "**%s**\n\n", planShardList(groups[0].Shards))
writeShardGroupHeading(sb, groups[0].Shards, len(shards))
writePlanDDLBlock(sb, groups[0].Statements)
}
return
}
sb.WriteString("Shards diverge — what applies where:\n\n")
for _, g := range groups {
fmt.Fprintf(sb, "**%s**\n\n", planShardList(g.Shards))
writeShardGroupHeading(sb, g.Shards, len(shards))
// A satisfied group already matches the desired schema; say so instead
// of rendering an empty code block.
if g.Satisfied {
Expand Down Expand Up @@ -834,18 +846,58 @@ func shardGroupSignature(s KeyspaceShardChange) string {
return status + "\x02" + strings.Join(s.Statements, "\x01")
}

// planShardList renders a group's shards as "shard `x`" or "shards `x`, `y`".
func planShardList(shards []string) string {
quoted := make([]string, len(shards))
for i, s := range shards {
quoted[i] = fmt.Sprintf("`%s`", s)
// shardNamesInlineLimit caps how many shard names render inline in a PR
// comment. Beyond it, listing every range reads as a wall — a wide keyspace
// collapses to a count, with the names behind a collapsed block where the
// rendering has room for one.
const shardNamesInlineLimit = 8

// planShardList renders a group's shards as "shard `x`" or "shards `x`, `y`"
// when few enough to read inline, stating coverage beyond that — "12 of 32
// shards", or "all 32 shards" when the group spans the keyspace. Used where
// the list rides inside a line item and has no room for a collapsed name
// list; the full names stay reachable in the DDL section's collapsed
// shard-group blocks.
func planShardList(shards []string, totalShards int) string {
if len(shards) > shardNamesInlineLimit {
return shardCoveragePhrase(len(shards), totalShards)
}
quoted := markdownInlineCodeList(shards)
if len(quoted) == 1 {
return "shard " + quoted[0]
}
return "shards " + strings.Join(quoted, ", ")
}

// shardCoveragePhrase states how much of a keyspace a shard group covers:
// "all 32 shards" when it covers every planned shard, "12 of 32 shards" for
// a subset, or a bare count when the keyspace total is unknown — a subset
// must never read like whole-keyspace coverage.
func shardCoveragePhrase(count, totalShards int) string {
if count == totalShards {
return fmt.Sprintf("all %d shards", count)
}
if totalShards > 0 {
return fmt.Sprintf("%d of %d shards", count, totalShards)
}
return fmt.Sprintf("%d shards", count)
}

// writeShardGroupHeading writes a shard group's bold heading above its DDL
// block. Few shards read inline by name; a wide group leads with how much of
// the keyspace it covers — "all 32 shards" when it covers every planned
// shard, "19 of 32 shards" for a subset — as a single collapsed line that
// expands into the full name list, so the names stay reachable without
// walling the comment.
func writeShardGroupHeading(sb *strings.Builder, shards []string, totalShards int) {
if len(shards) <= shardNamesInlineLimit {
fmt.Fprintf(sb, "**%s**\n\n", planShardList(shards, totalShards))
return
}
fmt.Fprintf(sb, "<details>\n<summary><b>%s</b></summary>\n\n%s\n\n</details>\n\n",
shardCoveragePhrase(len(shards), totalShards), strings.Join(markdownInlineCodeList(shards), ", "))
}

// writeDeploymentDrift renders the review-time drift rollup: a single uniform
// line when every deployment matches the reviewed plan, or a per-deployment
// breakdown naming which deployments diverged or could not be verified. It is a
Expand Down Expand Up @@ -912,7 +964,7 @@ func writeBlockedChanges(sb *strings.Builder, changes []BlockedChangeData) {
for _, c := range changes {
table := "`" + c.Table + "`"
if len(c.Shards) > 0 {
table = fmt.Sprintf("%s (%s)", table, planShardList(c.Shards))
table = fmt.Sprintf("%s (%s)", table, planShardList(c.Shards, c.TotalShards))
}
if c.Reason != "" {
fmt.Fprintf(sb, "- %s: %s\n", table, c.Reason)
Expand Down Expand Up @@ -955,7 +1007,7 @@ func writeDirectChanges(sb *strings.Builder, changes []DirectChangeData, databas
for _, c := range changes {
table := "`" + c.Table + "`"
if len(c.Shards) > 0 {
table = fmt.Sprintf("%s (%s)", table, planShardList(c.Shards))
table = fmt.Sprintf("%s (%s)", table, planShardList(c.Shards, c.TotalShards))
}
if c.Reason != "" {
fmt.Fprintf(sb, "- %s: %s\n", table, c.Reason)
Expand All @@ -972,7 +1024,7 @@ func writeUnsafeWarning(sb *strings.Builder, changes []UnsafeChangeData, isMySQL
for _, c := range changes {
table := "`" + c.Table + "`"
if len(c.Shards) > 0 {
table = fmt.Sprintf("%s (%s)", table, planShardList(c.Shards))
table = fmt.Sprintf("%s (%s)", table, planShardList(c.Shards, c.TotalShards))
}
writeUnsafeChangeItem(sb, table, c.Reason)
}
Expand Down
39 changes: 38 additions & 1 deletion pkg/webhook/templates/preview_sharded.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package templates

import (
"fmt"
"time"

"github.com/block/schemabot/pkg/apitypes"
Expand Down Expand Up @@ -158,6 +159,42 @@ func PreviewCommentShardedPlanDivergent() string {
})
}

// PreviewCommentShardedPlanManyShards renders a uniform sharded plan across a
// wide keyspace: the DDL shows once under an "all N shards" heading, with the
// shard names behind a collapsed block instead of walling the comment.
func PreviewCommentShardedPlanManyShards() string {
idx := "ALTER TABLE `mutes` ADD INDEX `created_at`(`created_at`)"
shards := make([]KeyspaceShardChange, 0, 32)
for i := range 32 {
shards = append(shards, KeyspaceShardChange{Shard: previewShardRange(i, 32), Statements: []string{idx}})
}
return RenderPlanComment(PlanCommentData{
Database: "cdb_resolute", Environment: "production", DatabaseType: "strata",
HeadSHA: previewHeadSHA, Repository: previewRepository, RequestedBy: previewRequestedBy,
Changes: []KeyspaceChangeData{{
Keyspace: "cdb_resolute_sharded",
Statements: []string{idx},
Shards: shards,
}},
})
}

// previewShardRange returns shard i's keyrange name in an evenly-split
// keyspace of n shards, in Vitess notation: "-08", "08-10", …, "f8-".
func previewShardRange(i, n int) string {
width := 256 / n
lower := fmt.Sprintf("%02x", i*width)
upper := fmt.Sprintf("%02x", (i+1)*width)
switch i {
case 0:
return "-" + upper
case n - 1:
return lower + "-"
default:
return lower + "-" + upper
}
}

// PreviewCommentShardedPlanPartiallyApplied renders a sharded plan where one
// shard already has the change (e.g. an interrupted earlier rollout) and the
// rest still need it. The satisfied shard renders as an "already applied" group
Expand Down Expand Up @@ -188,7 +225,7 @@ func PreviewCommentShardedPlanUnsafe() string {
Database: "cdb_resolute", Environment: "production", DatabaseType: "strata",
HeadSHA: previewHeadSHA, Repository: previewRepository, RequestedBy: previewRequestedBy,
HasUnsafeChanges: true,
UnsafeChanges: []UnsafeChangeData{{Table: "mutes", Reason: "DROP COLUMN removes data and is irreversible", Shards: []string{"40-80"}}},
UnsafeChanges: []UnsafeChangeData{{Table: "mutes", Reason: "DROP COLUMN removes data and is irreversible", Shards: []string{"40-80"}, TotalShards: 4}},
Changes: []KeyspaceChangeData{{
Keyspace: "cdb_resolute_sharded",
Shards: []KeyspaceShardChange{
Expand Down
Loading
Loading