feat(engine): capture per-table size estimates at plan time - #1238
feat(engine): capture per-table size estimates at plan time#1238aparajon wants to merge 9 commits into
Conversation
265fbf8 to
7a2d2f8
Compare
72f24bc to
71e2c80
Compare
cf13543 to
062f0ae
Compare
71e2c80 to
f594eb9
Compare
Engines report best-effort table sizes with each planned change: Spirit and Vitess run one information_schema query per shard on the differ's own connection; PlanetScale takes shard counts from the API and probes shards over vtgate when a DSN is available. The estimates ride TableChange (approximate rows, on-disk bytes, shard count, largest single shard) through proto, storage, and apitypes, with cross-shard aggregation at both namespace-collapse seams. Aggregation is all-or-nothing: if any shard's estimate is missing the summed totals are omitted and only the shard count survives, so a partial sum can never understate a table. A failed or slow probe logs a warning and the plan proceeds without sizes; nothing gates on an estimate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
f594eb9 to
1a34552
Compare
There was a problem hiding this comment.
Pull request overview
This PR adds best-effort, plan-time per-table size estimates (rows + bytes) to engine.TableChange, aggregates them correctly across shards for sharded plans, and plumbs the new fields through proto/storage/API layers so they persist with the plan and are available for later rendering/UX work.
Changes:
- Capture plan-time size estimates in engines (Spirit: information_schema; PlanetScale: API shard counts + vtgate shard probes when DSN is available).
- Aggregate per-shard estimates into namespace-level totals (sum + largest shard) with “all-or-nothing” semantics when any shard is missing an estimate.
- Extend proto/storage/apitypes models and conversion helpers, and add tests covering aggregation and engine-side estimate capture.
Reviewed changes
Copilot reviewed 14 out of 15 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| pkg/engine/engine.go | Adds new optional size-estimate fields to the engine-level TableChange contract. |
| pkg/engine/spirit/spirit.go | Queries information_schema for per-table rows/bytes at plan time and attaches estimates to planned changes (best-effort, non-fatal). |
| pkg/engine/spirit/spirit_integration_test.go | Integration test validating Spirit attaches estimates for existing tables and omits them for newly created tables. |
| pkg/engine/planetscale/plan.go | Fetches shard counts (best-effort) and attaches keyspace table-size data onto planned changes. |
| pkg/engine/planetscale/table_sizes.go | Implements PlanetScale shard-count lookup and vtgate shard probes for per-table rows/bytes, aggregated across shards. |
| pkg/engine/planetscale/table_sizes_test.go | Unit tests for shard-count lookup and DSN-absent behavior (counts still attached, estimates omitted). |
| pkg/tern/local_client.go | Computes shard-level aggregates and overwrites the namespace view’s kept TableChange with cross-shard totals. |
| pkg/tern/table_sizes.go | New accumulator implementing shard aggregation with “all-or-nothing” totals when any shard is missing. |
| pkg/tern/tablechange_convert.go | Plumbs new fields through engine→storage, engine→proto, and proto→storage conversions. |
| pkg/tern/local_client_shardplan_test.go | Tests namespace aggregation behavior, missing-shard behavior, and pass-through behavior for already-aggregated changes. |
| pkg/proto/tern.proto | Adds new TableChange fields (estimated_rows, estimated_bytes, shard_count, largest_shard_rows). |
| pkg/proto/ternv1/tern.pb.go | Regenerates Go proto bindings for the new fields and accessors. |
| pkg/storage/types.go | Persists new estimate fields on stored storage.TableChange. |
| pkg/apitypes/apitypes.go | Exposes new estimate fields in API TableChangeResponse. |
| pkg/api/proto_helpers.go | Maps proto TableChange fields to API response struct (including new estimate fields). |
Files not reviewed (1)
- pkg/proto/ternv1/tern.pb.go: Generated file
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Size estimates are display-only, so a probe that cannot answer inside its own budget is abandoned and the plan proceeds without sizes — a slow or wedged statistics read must never extend plan latency. One budget covers Spirit's single statistics query; on PlanetScale it covers the keyspace's whole sequential shard walk. Also rewords the largest-shard field docs in plain terms: the biggest chunk a shard-at-a-time apply works through at once. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…trics API Replace the per-shard vtgate probes with one branch table metrics API call per plan. The endpoint reports storage bytes per table for the whole branch — no row counts and no per-shard breakdown — so PlanetScale changes carry ShardCount and EstimatedBytes, while row estimates remain probe-derived on the engines that connect to every shard. The metrics call needs no vtgate DSN, is budget-bound like every size probe, and a failure logs and leaves the plan sizeless rather than failing it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
morgo
left a comment
There was a problem hiding this comment.
🤖 Approving on Morgan's behalf (automated review).
The parts that could have gone wrong here mostly didn't. The plan-time probe is genuinely best-effort — its own TableSizeProbeTimeout (5s), failure sets sizeEstimates = nil and the plan proceeds, and a NULL statistics row is skipped rather than recorded as zero. Proto fields 10–13 are cleanly additive past the previous max of 9, no reuse and nothing needing reserved. And the "never a gate input" instruction is stated in both the proto and the Go doc, which is the right guardrail for a stale-by-nature number.
tableSizeAccumulator handles the trap I went looking for: a partial sum across shards would understate a table, and the missing flag makes row totals all-or-nothing while letting shardCount survive. That's the correct call and it's explained in place.
CI green. Two findings, one worth acting on.
1. The probe reads every base table in the schema to use a handful.
SELECT table_name, table_rows, data_length + index_length
FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'Only planned tables are ever read back out of the map — sizeEstimates[pc.TableName], and pc comes from plan.Changes, which is already in hand before the probe runs.
On MySQL 8.0, TABLE_ROWS / DATA_LENGTH / INDEX_LENGTH are dynamic columns: when the cached statistics have aged past information_schema_stats_expiry (default 24h), the server retrieves them from the storage engine, opening each table to do it. On a schema with thousands of tables that is a genuinely expensive query, and it now runs on every Plan — i.e. every PR push that triggers a plan — against the production database.
The 5s bound means it can't stall a plan, which is why this isn't a hold. But the degenerate case is quiet: a large schema pays 5s on every plan, gets nothing, and leaves a warning in the log. Narrowing to the planned tables would cut the work proportionally and make the timeout far less likely to be reached:
... WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'
AND table_name IN (?, ?, ...)Same cost model as today when a plan touches every table, dramatically cheaper in the normal case of a plan touching two or three.
Minor, same area: binding the schema explicitly instead of DATABASE() would fail loudly rather than return zero rows if a DSN ever arrives without a default database selected. Existing code (fetchCurrentSchema) leans on the DSN the same way, so this is consistency rather than a defect.
2. shardCount and sum count TableChange entries, not distinct shards.
In aggregateShardTableSizes, a.shardCount++ and a.sum += *tc.EstimatedRows fire once per tc in sc.TableChanges. If a single sharded SchemaChange can ever carry two TableChanges naming the same table — two statements against one table in the same namespace — that shard is counted twice and its rows are added twice, inflating both the shard count and the total.
I could not establish from this diff whether the planner guarantees one TableChange per table per SchemaChange, so I'm raising it as a question rather than asserting a bug. If that uniqueness is guaranteed upstream, it's worth a line in the accumulator's doc comment saying so, since the fold silently depends on it. If it isn't, keying the accumulator on the shard name rather than incrementing per entry would make it robust either way.
Cosmetic, ignore freely: two doc comments have mangled line wrapping mid-sentence — LargestShardRows in engine.go (// Nil alone on a line) and the aggregateShardTableSizes header (// the number of / // planned shards). Presumably a formatter artifact.
|
🤖 Review findings - created by Kiran's code review agent - for schemabot/pull/1238, 2585671. Verdict: 8 findings — 3 blocking (shard overcount, keyspace-unscoped byte map, dropped API hop), 5 non-blocking. Every finding is latent today (nothing renders these fields yet), which is exactly why they should be fixed now rather than debugged through #1230. Blocking1. for _, tc := range sc.TableChanges {
a := byTable[tc.Table]
...
a.shardCount++ // once per TableChange, not once per shard
a.sum += *tc.EstimatedRows
a.bytesSum += *tc.EstimatedBytesOne shard's Proved by execution via an injected
Latent today — no in-tree engine sets 2. The PlanetScale byte map is fetched branch-wide but applied per keyspace, dropping an invariant the PR itself deleted. attachTableSizes(shardCounts[ks], tableBytes, tableChanges)
The repo defends It fails either way, and which way is unverifiable from the repo: if the API keys are bare names, two keyspaces sharing a 3. func tableChangeResponseFromStorage(change storage.TableChange) *apitypes.TableChangeResponse {
return &apitypes.TableChangeResponse{
TableName: change.Table,
Namespace: change.Namespace,
DDL: change.DDL,
ChangeType: change.Operation,
IsUnsafe: change.IsUnsafe,
UnsafeReason: change.UnsafeReason,
ExecutionMode: change.ExecutionMode,
ModeReason: change.ModeReason,
}
}The four sibling advisory fields are carried in the same literal; the four new ones are absent. The sibling converter Consequence once #1230 lands: the same plan shows sizes in a fresh PR comment and blank in the stored-plan view. Note that the design comment in Non-blocking4. 5. 6. 7. The new Spirit integration test leaks tables into the shared container. 8. Every localscale-backed plan now logs an ERROR for a display-only feature. General suggestions
The one thing that could have broken, verifiedThe all-or-nothing nil contract in Verified correct
This review was generated by Claude Code (claude-opus-5). |
# Conflicts: # pkg/tern/local_client.go
The namespace-level size view folded every table-change entry, so a shard that plans several statements against one table (a partition-type change needs its own REMOVE PARTITIONING statement) counted that shard more than once, inflating the shard count and double-counting its rows and bytes. The fold now records which shards it has seen per table. Rows and bytes are also tracked independently: an engine that reports bytes without row counts now still yields a byte total instead of having it suppressed by the absent row estimates.
The size estimates persist with the plan, but the storage-to-response hop dropped them, so fetching a stored plan reported no sizes while the freshly planned response reported them. Both views now carry the same four values.
The branch table metrics endpoint keys its bytes by bare table name with no keyspace dimension, so those bytes are only attributable when the branch has a single keyspace. A multi-keyspace branch now skips the read entirely rather than stamping one keyspace's bytes onto another's identically named table, and a keyspace the API reports as unsharded maps to no shard count instead of one. Both size probes are budget-bound the same way: the shard-count lookup now runs under the shared probe timeout its sibling already used, so neither can extend plan latency.
The MySQL size probe read every base table in the schema, so a database with thousands of tables paid for all of them to display sizes for the handful a plan touches. The read is now scoped to the planned tables, deduplicated so a table with several planned statements is named once.
The PlanetScale path reads bytes from the branch table metrics API rather than from a database connection, and attributing them correctly needs its own reasoning about which keyspace a bare table name belongs to. That is a separable concern from capturing statistics on a connection, so it moves out and this change covers the MySQL-family engines.
morgo
left a comment
There was a problem hiding this comment.
🤖 Re-approving on Morgan's behalf (automated review) — my stamp had gone stale at 2585671a, and the PR has grown from +73/-0 to +788/-40 since, so this is a fresh look rather than a rubber stamp.
Both findings from my earlier review are fixed, and correctly.
The probe no longer reads the whole schema (f16a8bd3). The query is now scoped to the planned tables:
... WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'
AND table_name IN (?, ?, ...)with plannedTableNames deduplicating first — and the dedup carries the right reason, that one table can legitimately carry several statements (a partition-type change needs its own REMOVE PARTITIONING). The len(tables) == 0 early return is load-bearing and present: without it strings.Repeat(", ?", len(tables)-1) would panic on a negative count. Cost now scales with the plan instead of the schema, which was the point.
The shard fold counts shards, not entries (0b32b685). seenShards gates the whole per-table accumulation, so a shard planning two statements against one table contributes one to shardCount and one set of values to the totals. That's the shard-keyed fix rather than a comment promising uniqueness, which is the more robust of the two options I offered.
The author also went past what I raised: missing is now split into missingRows and missingBytes, tracked independently. That's a real improvement — a source carrying bytes but no row estimates now still yields a byte total instead of having both suppressed by one absent kind.
On the new material (roughly 700 lines I hadn't seen):
tablechange_convert.goandproto_helpers.goshow deletions, but every one is gofmt realignment for the widened struct literals — no existing field's behavior changes. Worth confirming explicitly since a-24on a converter is where I'd expect a regression to hide.- The size aggregate is applied at both seams —
planResultToProtoChangesfor the live response andnamespacesFromEngineChangesfor the stored plan — using the sameaggregateShardTableSizesfold. That's the consistency property that matters here: the numbers in a PR comment and the numbers in the stored-plan view are computed the same way and can't drift.tableChangeResponseFromStoragethen reads the persisted values, so the stored-plan view doesn't re-derive anything. - Attaching the cross-shard totals to the first-seen (kept) entry composes correctly with the now-merged #1243 dedup, which only collapses when
sc.Sharded()— the same predicateaggregateShardTableSizesskips on. Unsharded changes keep the engine's own values untouched, as documented. - Deferring the PlanetScale byte capture to its own change (
0234f7d8) leaves nothing half-wired here — there are nopkg/engine/planetscalefiles left in the diff.
Full suite green (38 checks). Read-only, display-only, still bounded by TableSizeProbeTimeout with failure degrading to "no sizes" rather than a failed plan, so nothing here can hurt the live MySQL path.
Two residuals, both nits, neither worth a change on its own:
The IN match is exact, so a planned table name that differs from information_schema.tables.table_name in case (under a case-sensitive lower_case_table_names) or in qualification simply doesn't match and yields no estimate. That degrades in the right direction — a missing size line, not a wrong one — which is why it's a nit rather than a finding.
int32(shardCount) narrows an int at two call sites. Not reachable at any plausible shard count; noting it only because it's the kind of conversion that gets copied into a context where it does matter.
The staleness caveat on TABLE_ROWS is unchanged and correctly documented in place as display-only.
|
🤖 Addressing morgo's review and Kiran's review. Both landed on the same defect from different directions, so they are answered together.
The fold counted entries, not shards. Morgan asked whether one sharded Rows and bytes are now independent. A single PlanetScale bytes are attributed, not guessed. Kiran is right that the branch metrics endpoint has no keyspace dimension, so its bare table names are only attributable on a single-keyspace branch. Rather than restore a "one keyspace only" guard around the attach, the read itself is now gated: a multi-keyspace branch skips the call entirely and logs why. That costs byte estimates on multi-keyspace PlanetScale databases, which I think is the right trade — a confidently wrong size is worse than an absent one — and it also removes one of the two round trips on exactly the branches where the answer would have been unusable. Separately, an unsharded keyspace now maps to no shard count instead of one, and The probe is scoped to the planned tables. The PlanetScale path now lives in #1249. Kiran's finding 2 is what prompted it: attributing a branch-scoped, keyspace-less byte map is its own piece of reasoning, and it does not belong in the same change as reading statistics off a connection. #1249 carries the PlanetScale capture with all three of its findings fixed, and folds in the LocalScale route (finding 8) so the endpoint the emulator was missing arrives with the code that calls it. This PR is now the MySQL-family engines only. The fixes above are unchanged, just relocated — the split is line-for-line lossless.
This reply was written by Claude (Opus 5). |
Why this matters
A plan shows the DDL but not the scale of what it touches:
ADD INDEXon a 12-row table and on a 120-million-row table read identically, so the operator judging "how long will this index build run" has nothing to go on. The same missing data hurts apply progress from the other side (#1183, #1191): shards the driver cap has not dispatched yet report no row totals, so the table-level copy bar can only disclose partial coverage instead of estimating the whole table.What is captured
Four new optional fields on
TableChange, one set per planned table change:EstimatedRowsTABLE_ROWS, summed across shards (Spirit, Strata)13_100_000EstimatedBytesDATA_LENGTH + INDEX_LENGTH, summed (Spirit, Strata)6_200_000_000ShardCount4LargestShardRows3_400_000All values come from engine statistics: approximate, possibly stale, display-only. Nothing reads them yet — the plan-comment rendering is #1230.
ShardCountzero means there is no shard count to render — the target is not sharded, or its topology could not be read. The two are indistinguishable, so a renderer omits the count rather than asserting "not sharded".When and how
Captured once, at plan time, while the engine computes the diff — never re-probed at apply. Spirit and Strata share one statistics query, scoped to the tables the plan touches:
TableChange. A shard contributes each of its tables once even when it plans several statements against that table, so the shard count counts shards.Guardrails
TableChange{ShardCount: 4}, because a partial sum would silently understate the table.TABLE_ROWS/DATA_LENGTHare cached dictionary statistics; the server answers from metadata it already has, at any table size.TableSizeProbeTimeout(5s) bounds Spirit's single query and the PlanetScale metrics call; on expiry the probe is abandoned and the plan proceeds without sizes, so a wedged statistics read can never extend plan latency past the budget.How it moves us toward the northstar
Third slice of the plan-time table-size estimates stack (#1236 formatters → #1237 cost-scaling detection → this PR → #1249 PlanetScale capture → #1230 plan-comment rendering). Persisted with the plan, these estimates can later seed the progress denominator for shards whose waves have not started, turning #1191's "across N of M shards" disclosure into a whole-table bar.
Opened by Claude (Fable 5).