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
120 changes: 60 additions & 60 deletions TEMPLATES.md

Large diffs are not rendered by default.

9 changes: 4 additions & 5 deletions pkg/cmd/commands/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -827,7 +827,7 @@ func (e *logEmitter) emitTableStateChange(tbl *apitypes.TableProgressResponse, t
kvs := tableKVs(recoveringLogMessage(tbl), tbl, ts)
if tbl.RowsTotal > 0 && tbl.PercentComplete < 100 {
kvs = append(kvs,
"progress", fmt.Sprintf("%d%%", min(int(tbl.PercentComplete), 100)),
"progress", ui.FormatRowCopyPercent(int(tbl.PercentComplete), tbl.RowsCopied, tbl.RowsTotal),
"rows", fmt.Sprintf("%s/%s", ui.FormatNumber(ui.ClampRows(tbl.RowsCopied, tbl.RowsTotal)), ui.FormatNumber(tbl.RowsTotal)),
)
if tbl.ETASeconds > 0 {
Expand All @@ -842,8 +842,8 @@ func (e *logEmitter) emitTableStateChange(tbl *apitypes.TableProgressResponse, t
e.emit(kvs...)
case state.Apply.Stopped:
kvs := tableKVs("Table stopped", tbl, ts)
if tbl.PercentComplete > 0 {
kvs = append(kvs, "progress", fmt.Sprintf("%d%%", min(int(tbl.PercentComplete), 100)))
if tbl.PercentComplete > 0 || tbl.RowsCopied > 0 {
kvs = append(kvs, "progress", ui.FormatRowCopyPercent(int(tbl.PercentComplete), tbl.RowsCopied, tbl.RowsTotal))
}
e.emit(kvs...)
default:
Expand Down Expand Up @@ -874,9 +874,8 @@ func (e *logEmitter) emitProgressHeartbeat(tbl *apitypes.TableProgressResponse,
"rows_copied", fmt.Sprintf("%s so far", ui.FormatNumber(tbl.RowsCopied)),
)
} else {
pct := ui.ClampPercent(int(tbl.PercentComplete))
kvs = append(kvs,
"progress", fmt.Sprintf("%d%%", pct),
"progress", ui.FormatRowCopyPercent(int(tbl.PercentComplete), tbl.RowsCopied, tbl.RowsTotal),
"rows", fmt.Sprintf("%s/%s", ui.FormatNumber(ui.ClampRows(tbl.RowsCopied, tbl.RowsTotal)), ui.FormatNumber(tbl.RowsTotal)),
)
if tbl.ETASeconds > 0 {
Expand Down
10 changes: 5 additions & 5 deletions pkg/cmd/commands/apply_log_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ func TestLogEmitter_EmitTableStateChange(t *testing.T) {
status: state.Apply.Recovering,
pct: 42,
wantMsg: "Row copy in progress during restart recovery",
wantFields: []string{"table=users", "progress=42%", "rows=420/1,000", "eta=\"2m 0s\""},
wantFields: []string{"table=users", "progress=42.00%", "rows=420/1,000", "eta=\"2m 0s\""},
},
{
name: "cutting over",
Expand Down Expand Up @@ -234,7 +234,7 @@ func TestLogEmitter_EmitProgressHeartbeat(t *testing.T) {

assert.Contains(t, plain, "Copying rows")
assert.Contains(t, plain, "table=orders")
assert.Contains(t, plain, "progress=45%")
assert.Contains(t, plain, "progress=45.00%")
assert.Contains(t, plain, "rows=99,450/221,000")
assert.Contains(t, plain, "5m 30s")
})
Expand All @@ -257,12 +257,12 @@ func TestLogEmitter_EmitProgressHeartbeat(t *testing.T) {
plain := stripANSI(output)

assert.Contains(t, plain, "table=products")
assert.Contains(t, plain, "progress=20%")
assert.Contains(t, plain, "progress=20.00%")
assert.Contains(t, plain, "rows=10,000/50,000")
assert.Contains(t, plain, "eta=")
})

t.Run("clamps percent to 100", func(t *testing.T) {
t.Run("rows override an overshooting whole-number percent", func(t *testing.T) {
e := &logEmitter{}
ts := &tableLogState{}
tbl := &apitypes.TableProgressResponse{
Expand All @@ -277,7 +277,7 @@ func TestLogEmitter_EmitProgressHeartbeat(t *testing.T) {
})
plain := stripANSI(output)

assert.Contains(t, plain, "progress=100%")
assert.Contains(t, plain, "progress=96.09%")
})

t.Run("estimate exceeded shows finalizing copy progress", func(t *testing.T) {
Expand Down
6 changes: 3 additions & 3 deletions pkg/cmd/commands/watch_tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ func TestWatchModel_ChecksummingShowsVerifyProgress(t *testing.T) {
model := updated.(WatchModel)

view := model.View()
assert.Contains(t, view, "Checksumming to verify data (9%)")
assert.Contains(t, view, "Checksumming to verify data (8.78%)")
assert.Contains(t, view, "Rows verified: 73,075,845 / 832,771,089")
assert.Contains(t, view, "(throttled)")
}
Expand All @@ -270,9 +270,9 @@ func TestWatchModel_RecoveringShowsCopyingRows(t *testing.T) {
model := updated.(WatchModel)

view := model.View()
assert.Contains(t, view, "Row copy in progress (42%)")
assert.Contains(t, view, "Row copy in progress (42.00%)")
assert.Contains(t, view, "Rows: 420 / 1,000")
assert.Contains(t, view, "Row copy is in progress (42%)")
assert.Contains(t, view, "Row copy is in progress (42.00%)")
assert.Contains(t, view, "progress returns to the normal row-copy view")
assert.Contains(t, view, "SchemaBot is recovering after restart")
assert.NotContains(t, view, "Cutover will be available once recovery completes")
Expand Down
17 changes: 12 additions & 5 deletions pkg/cmd/commands/watch_tui_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ func (m WatchModel) progressView() string {
case state.IsState(m.state, state.Apply.Recovering):
b.WriteString("\n\n")
if pct, ok := recoveringCopyPercent(m.tables); ok {
fmt.Fprintf(&b, "SchemaBot is recovering after restart.\nRow copy is in progress (%d%%); once recovery completes, progress returns to the normal row-copy view. (ESC to detach)\n", pct)
fmt.Fprintf(&b, "SchemaBot is recovering after restart.\nRow copy is in progress (%s); once recovery completes, progress returns to the normal row-copy view. (ESC to detach)\n", pct)
} else {
b.WriteString("SchemaBot is recovering after restart.\n")
b.WriteString("Cutover will be available once recovery completes. (ESC to detach)\n")
Expand Down Expand Up @@ -277,17 +277,24 @@ func (m WatchModel) fetchErrorLine() string {
return errStyle.Render(label+": "+m.errorMsg) + "\n"
}

func recoveringCopyPercent(tables []templates.TableProgress) (int, bool) {
percent := 100
// recoveringCopyPercent returns the least-progressed recovering table's copy
// percent as display text, so the recovery footer never overstates how far
// the slowest table has come.
func recoveringCopyPercent(tables []templates.TableProgress) (string, bool) {
fraction := 0.0
text := ""
found := false
for _, table := range tables {
if state.NormalizeTaskStatus(table.Status) != state.Task.Recovering || table.RowsTotal <= 0 || table.PercentComplete >= 100 {
continue
}
percent = min(percent, ui.ClampPercent(table.PercentComplete))
if frac := ui.RowCopyFraction(table.PercentComplete, table.RowsCopied, table.RowsTotal); !found || frac < fraction {
fraction = frac
text = ui.FormatRowCopyPercent(table.PercentComplete, table.RowsCopied, table.RowsTotal)
}
found = true
}
return percent, found
return text, found
}

// renderTables renders tables with the shared FormatNamespacedTables /
Expand Down
21 changes: 14 additions & 7 deletions pkg/cmd/internal/templates/progress.go
Original file line number Diff line number Diff line change
Expand Up @@ -492,8 +492,10 @@ func FormatTableProgressWithActivity(t TableProgress, activityBar, activityLabel
// source. On a large table this can run for hours, so show how far the
// verify has progressed once Spirit has reported a total.
if t.ChecksumRowsTotal > 0 {
pct := ui.RowCopyDisplayPercent(int(math.Round(float64(t.ChecksumRowsChecked)*100/float64(t.ChecksumRowsTotal))), t.ChecksumRowsChecked)
fmt.Fprintf(&b, indentTable+progressSymbol(t.ChangeType)+"%s: %s 🔍 Checksumming to verify data (%d%%)%s\n", t.TableName, ui.ProgressBarRowCopy(pct), pct, throttledSuffix(t))
checksumPct := int(math.Round(float64(t.ChecksumRowsChecked) * 100 / float64(t.ChecksumRowsTotal)))
pct := ui.RowCopyDisplayPercent(checksumPct, t.ChecksumRowsChecked)
fmt.Fprintf(&b, indentTable+progressSymbol(t.ChangeType)+"%s: %s 🔍 Checksumming to verify data (%s)%s\n", t.TableName, ui.ProgressBarRowCopy(pct),
ui.FormatRowCopyPercent(checksumPct, t.ChecksumRowsChecked, t.ChecksumRowsTotal), throttledSuffix(t))
if t.DDL != "" {
b.WriteString(formatProgressDDLForDialect(t.Dialect, t.DDL))
}
Expand Down Expand Up @@ -536,7 +538,8 @@ func FormatTableProgressWithActivity(t TableProgress, activityBar, activityLabel
if recoveringIsCopyingRows(t) {
pct := ui.RowCopyDisplayPercent(t.PercentComplete, t.RowsCopied)
bar := ui.ProgressBarRowCopy(pct)
fmt.Fprintf(&b, indentTable+progressSymbol(t.ChangeType)+"%s: %s Row copy in progress (%d%%)\n", t.TableName, bar, pct)
fmt.Fprintf(&b, indentTable+progressSymbol(t.ChangeType)+"%s: %s Row copy in progress (%s)\n", t.TableName, bar,
ui.FormatRowCopyPercent(t.PercentComplete, t.RowsCopied, t.RowsTotal))
if t.DDL != "" {
b.WriteString(formatProgressDDLForDialect(t.Dialect, t.DDL))
}
Expand Down Expand Up @@ -633,7 +636,8 @@ func FormatTableProgressWithActivity(t TableProgress, activityBar, activityLabel
if t.PercentComplete > 0 || t.RowsCopied > 0 {
cancelledPercent := ui.RowCopyDisplayPercent(t.PercentComplete, t.RowsCopied)
bar := ui.ProgressBar(cancelledPercent, ui.ColorOrange)
fmt.Fprintf(&b, indentTable+progressSymbol(t.ChangeType)+"%s: %s 🚫 Cancelled at %d%%\n", t.TableName, bar, cancelledPercent)
fmt.Fprintf(&b, indentTable+progressSymbol(t.ChangeType)+"%s: %s 🚫 Cancelled at %s\n", t.TableName, bar,
ui.FormatRowCopyPercent(t.PercentComplete, t.RowsCopied, t.RowsTotal))
} else {
fmt.Fprintf(&b, indentTable+progressSymbol(t.ChangeType)+"%s: 🚫 Cancelled (not started)\n", t.TableName)
}
Expand All @@ -652,7 +656,8 @@ func FormatTableProgressWithActivity(t TableProgress, activityBar, activityLabel
// At 100% = was waiting for cutover when stopped
fmt.Fprintf(&b, indentTable+progressSymbol(t.ChangeType)+"%s: %s ⏹️ Stopped (was waiting for cutover)\n", t.TableName, bar)
case t.PercentComplete > 0 || t.RowsCopied > 0:
fmt.Fprintf(&b, indentTable+progressSymbol(t.ChangeType)+"%s: %s ⏹️ Stopped at %d%%\n", t.TableName, bar, stoppedPercent)
fmt.Fprintf(&b, indentTable+progressSymbol(t.ChangeType)+"%s: %s ⏹️ Stopped at %s\n", t.TableName, bar,
ui.FormatRowCopyPercent(t.PercentComplete, t.RowsCopied, t.RowsTotal))
default:
fmt.Fprintf(&b, indentTable+progressSymbol(t.ChangeType)+"%s: ⏹️ Stopped (not started)\n", t.TableName)
}
Expand All @@ -679,7 +684,8 @@ func FormatTableProgressWithActivity(t TableProgress, activityBar, activityLabel
// Parsed successfully - show emoji progress bar with structured data
displayPercent := ui.RowCopyDisplayPercent(info.Percent, info.RowsCopied)
bar := ui.ProgressBarRowCopy(displayPercent)
fmt.Fprintf(&b, indentTable+progressSymbol(t.ChangeType)+"%s: %s %d%%%s\n", t.TableName, bar, displayPercent, throttledSuffix(t))
fmt.Fprintf(&b, indentTable+progressSymbol(t.ChangeType)+"%s: %s %s%s\n", t.TableName, bar,
ui.FormatRowCopyPercent(info.Percent, info.RowsCopied, info.RowsTotal), throttledSuffix(t))
if t.DDL != "" {
b.WriteString(formatProgressDDLForDialect(t.Dialect, t.DDL))
}
Expand Down Expand Up @@ -716,7 +722,8 @@ func FormatTableProgressWithActivity(t TableProgress, activityBar, activityLabel
// Row copy in progress — show progress bar with structured fields
displayPercent := ui.RowCopyDisplayPercent(t.PercentComplete, t.RowsCopied)
bar := ui.ProgressBarRowCopy(displayPercent)
fmt.Fprintf(&b, indentTable+progressSymbol(t.ChangeType)+"%s: %s %d%%%s\n", t.TableName, bar, displayPercent, throttledSuffix(t))
fmt.Fprintf(&b, indentTable+progressSymbol(t.ChangeType)+"%s: %s %s%s\n", t.TableName, bar,
ui.FormatRowCopyPercent(t.PercentComplete, t.RowsCopied, t.RowsTotal), throttledSuffix(t))

if t.DDL != "" {
b.WriteString(formatProgressDDLForDialect(t.Dialect, t.DDL))
Expand Down
8 changes: 2 additions & 6 deletions pkg/cmd/internal/templates/progress_shard.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,12 +121,8 @@ func formatShardLine(s ShardProgress) string {
case state.Task.Completed:
return fmt.Sprintf(indentShardLine+"%s✓ %s%s: %s rows\n", ANSIGreen, s.Shard, ANSIReset, ui.FormatNumber(s.RowsTotal))
case state.Task.Running:
pct := s.PercentComplete
if pct == 0 && s.RowsTotal > 0 {
pct = int(s.RowsCopied * 100 / s.RowsTotal)
}
pct = ui.RowCopyDisplayPercent(pct, s.RowsCopied)
detail := fmt.Sprintf("%d%% (%s/%s rows)", pct, ui.FormatNumber(ui.ClampRows(s.RowsCopied, s.RowsTotal)), ui.FormatNumber(s.RowsTotal))
detail := fmt.Sprintf("%s (%s/%s rows)", ui.FormatRowCopyPercent(s.PercentComplete, s.RowsCopied, s.RowsTotal),
ui.FormatNumber(ui.ClampRows(s.RowsCopied, s.RowsTotal)), ui.FormatNumber(s.RowsTotal))
if s.ETASeconds > 0 {
detail += fmt.Sprintf(" ETA %s", FormatDurationSeconds(s.ETASeconds))
}
Expand Down
9 changes: 6 additions & 3 deletions pkg/cmd/internal/templates/progress_shard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,10 @@ func TestFormatDurationSeconds(t *testing.T) {
}
}

func TestFormatShardLineDisplaysOnePercentAfterCopyStarts(t *testing.T) {
// A copying shard that hasn't reached 1% shows the true fraction computed
// from its row counts, so a shard's early progress on a huge table reads as
// the small fraction it is instead of a rounded-up 1%.
func TestFormatShardLineShowsSubPercentFraction(t *testing.T) {
line := formatShardLine(ShardProgress{
Shard: "-80",
Status: state.Task.Running,
Expand All @@ -127,8 +130,8 @@ func TestFormatShardLineDisplaysOnePercentAfterCopyStarts(t *testing.T) {
PercentComplete: 0,
})

assert.Contains(t, line, "1% (3,000/1,604,159 rows)")
assert.NotContains(t, line, "0%")
assert.Contains(t, line, "0.19% (3,000/1,604,159 rows)")
assert.NotContains(t, line, " 0%")
}

func TestIsPlanetScaleEngine(t *testing.T) {
Expand Down
33 changes: 20 additions & 13 deletions pkg/cmd/internal/templates/progress_states_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -635,17 +635,18 @@ func TestFormatTableProgress_Checksumming(t *testing.T) {
TableName: "orders", ChangeType: "alter", Status: state.Task.Checksumming,
ChecksumRowsChecked: 321450, ChecksumRowsTotal: 1466232,
})
assert.Contains(t, withProgress, "🔍 Checksumming to verify data (22%)")
assert.Contains(t, withProgress, "🔍 Checksumming to verify data (21.92%)")
assert.Contains(t, withProgress, "Rows verified: 321,450 / 1,466,232")

// A verify that has only just begun still renders as visibly started: a
// non-zero checked count floors the display at 1% instead of showing an
// empty "not started" bar while verification is actively running.
// A verify that has only just begun still renders as visibly started: the
// bar shows a first segment, and the percent shows the true sub-1% fraction
// (floored away from 0.00%) instead of an empty "not started" display while
// verification is actively running.
justStarted := FormatTableProgress(TableProgress{
TableName: "orders", ChangeType: "alter", Status: state.Task.Checksumming,
ChecksumRowsChecked: 1, ChecksumRowsTotal: 1000000,
})
assert.Contains(t, justStarted, "🔍 Checksumming to verify data (1%)")
assert.Contains(t, justStarted, "🔍 Checksumming to verify data (0.01%)")
assert.Contains(t, justStarted, "🟦")
}

Expand All @@ -661,7 +662,7 @@ func TestFormatTableProgress_Throttled(t *testing.T) {
RowsCopied: 45000, RowsTotal: 100000, PercentComplete: 45,
Throttled: true, ThrottleReason: "redo-aware 4 > 3",
})
assert.Contains(t, copying, "45% (throttled)",
assert.Contains(t, copying, "45.00% (throttled)",
"the annotation lands on the header line next to the percent")
assert.Contains(t, copying, "ℹ️ Throttled: redo-aware 4 > 3 · backing off while the database's active threads exceed its budget")

Expand All @@ -670,7 +671,7 @@ func TestFormatTableProgress_Throttled(t *testing.T) {
RowsCopied: 45000, RowsTotal: 100000, PercentComplete: 45,
Throttled: true,
})
assert.Contains(t, noReason, "45% (throttled)")
assert.Contains(t, noReason, "45.00% (throttled)")
assert.NotContains(t, noReason, "ℹ️ Throttled", "no tooltip without a reason")

unknownSignal := FormatTableProgress(TableProgress{
Expand All @@ -687,7 +688,7 @@ func TestFormatTableProgress_Throttled(t *testing.T) {
ChecksumRowsChecked: 321450, ChecksumRowsTotal: 1466232,
Throttled: true, ThrottleReason: "threads-running 21 > 18",
})
assert.Contains(t, checksumming, "🔍 Checksumming to verify data (22%) (throttled)")
assert.Contains(t, checksumming, "🔍 Checksumming to verify data (21.92%) (throttled)")
assert.Contains(t, checksumming, "ℹ️ Throttled: threads-running 21 > 18 · backing off while the database's active threads exceed its budget")

notThrottled := FormatTableProgress(TableProgress{
Expand Down Expand Up @@ -789,7 +790,7 @@ func TestFormatTableProgress_InstantAlterRendering(t *testing.T) {
PercentComplete: 25,
}
output := FormatTableProgress(copying)
assert.Contains(t, output, "25%", "a copying instant-flagged ALTER shows its real percent")
assert.Contains(t, output, "25.00%", "a copying instant-flagged ALTER shows its real percent")
assert.NotContains(t, output, "Applying instantly", "the instant label must not mask copy progress")

instant := TableProgress{
Expand Down Expand Up @@ -834,16 +835,22 @@ func TestFormatTableProgress_CreateDropLabels(t *testing.T) {
assert.Contains(t, output, ui.ProgressBarRowCopy(45))
assert.NotContains(t, output, ui.ProgressBarRowCopy(100))

// Once row counts arrive, the displayed percent is computed from them
// rather than the engine's stale whole-number percent.
tp.RowsCopied = 420
tp.RowsTotal = 1000
tp.ETASeconds = 120
output = FormatTableProgress(tp)
assert.Contains(t, output, "Row copy in progress (45%)")
assert.Contains(t, output, "Row copy in progress (42.00%)")
assert.Contains(t, output, "Rows: 420 / 1,000 · ETA: 2m")
assert.NotContains(t, output, "Recovering state...")
}

func TestFormatTableProgress_RowCopyDisplaysOnePercentAfterCopyStarts(t *testing.T) {
// A copy that has begun but not yet reached 1% shows the true fraction
// computed from the row counts, with the bar's first segment lit so the
// operator sees both that copying started and how little of a huge table has
// actually copied.
func TestFormatTableProgress_SubPercentRowCopyShowsFraction(t *testing.T) {
tp := TableProgress{
TableName: "orders",
ChangeType: "alter",
Expand All @@ -855,7 +862,7 @@ func TestFormatTableProgress_RowCopyDisplaysOnePercentAfterCopyStarts(t *testing

output := FormatTableProgress(tp)

assert.Contains(t, output, "orders: "+ui.ProgressBarRowCopy(1)+" 1%")
assert.Contains(t, output, "orders: "+ui.ProgressBarRowCopy(1)+" 0.19%")
assert.Contains(t, output, "Rows: 3,000 / 1,604,159")
assert.NotContains(t, output, " 0%")
}
Expand Down Expand Up @@ -1053,7 +1060,7 @@ func TestFormatTableProgressOperatorHaltedBars(t *testing.T) {
RowsCopied: 300,
RowsTotal: 1000,
})
assert.Contains(t, cancelled, "🚫 Cancelled at 30%")
assert.Contains(t, cancelled, "🚫 Cancelled at 30.00%")
assert.Contains(t, cancelled, ui.ColorOrange)
assert.NotContains(t, cancelled, ui.ColorRed)

Expand Down
Loading
Loading