Scale Analysis (experimental)
A query that is fine on 1,000 rows can fall over at 1,000,000.
scale_analysis loads your schema at a ladder of sizes, runs a function
at each one, and fits how its work grows. The answer is an exponent: about
1 is linear, about 2 is quadratic. You assert on it like any other
property:
Why an exponent, and why buffers
Section titled “Why an exponent, and why buffers”A complexity class belongs to the function, not the machine. O(n²) is
true on a laptop, in CI and in production; “times out at 380K rows”
changes the day someone upgrades the runner.
The exponent is fitted on buffer counts (pages read or hit, from
EXPLAIN (ANALYZE, BUFFERS)), not wall-clock time. On an idle machine,
wall-clock time varied 1.96× between identical runs; buffer counts varied
1.0007×. That is what makes assert result.exponent < 1.5 stable on a
noisy CI runner.
How a sweep runs
Section titled “How a sweep runs”- Validate everything before touching a table: sizes, function name, that the bulk loader can load the schema.
- Calibrate the fixed per-call cost on a one-row load, so it is not mistaken for growth.
- Climb a geometric ladder (×1, ×2, ×4, …): truncate, bulk load with
COPY,ANALYZE, resolve the arguments against the new data, probe. - Stop when at least
min_pointspoints fit well enough to claim an exponent (its standard error at most 0.15), atmax_factor, or when a probe exceedsprobe_timeout_s. Postgres cancels such a probe at the timeout, and the result is marked truncated. - Split the fit wherever the plan’s shape changes, and report each regime.
Options passed through to the sweep: max_factor (default 32),
min_points (default 5), probe_timeout_s (default 30), seed
(default 0), columns (per-column overrides, as for bulk generation) and
distribution. With distribution="zipf", a few parents own most of
the children, as real tenants do; uniform data (the default) spreads
them evenly. Pair it with heaviest to measure the heavy tenant.
Choosing arguments
Section titled “Choosing arguments”Functions that take a key need one that exists at every scale. Argument resolvers are re-run against each freshly loaded dataset:
| Resolver | Picks |
|---|---|
heaviest("t.col") |
The most-referenced key: the most rows pointing at it, over every foreign key into the column. The largest key when nothing references it. Heaviest by row count, not a worst-case guarantee. |
median_key("t.col") |
The middle key in key order: a typical value. |
random_key("t.col", seed=0) |
An arbitrary key, deterministic for a seed. |
Plain values are passed through unchanged.
To measure one particular row as everything else grows, sweep a
scenario and pass RowRef("name"):
the named row’s key, the same at every factor.
Reading the result
Section titled “Reading the result”| Member | Meaning |
|---|---|
exponent |
Exponent of the final plan regime. Raises SqlProofScaleError, with the reason, when the fit is inconclusive or execution time grows clearly faster than the work (see below). |
r_squared |
Fit quality of the final regime, for reading. Acceptance uses the exponent’s standard error (regimes[-1].slope_stderr): R² of a flat series measures only its noise. |
regimes, plan_flips |
Each plan regime’s fit, and where the plan changed. |
spills_below(rows) |
Whether a sort or hash spilled to disk at or below rows. Raises beyond the measured range rather than guess. |
rows_before_timeout(ms) |
Projected row band where the function crosses a timeout. Machine-dependent, unlike exponent. |
points |
Every probe: factor, rows, work and temp blocks, peak memory, plan hash, time, arguments. |
Each run also writes a JSON artifact to .sqlproof/scale-runs/ (pass
artifact_dir=None to skip). It uses the same envelope as
mutation runs, so you can keep a history per
function.
To read the history, render a report over those runs:
Per function, it shows the exponent over time, with each run’s standard
error, the rows measured, any spill, plan flips and the data
distribution. A run whose exponent is more than 0.3 above the
function’s previous one is flagged REGRESSED. A refused exponent shows
as refused, with the reason, by the same rules exponent applies in a
test.
Index suggestions
Section titled “Index suggestions”Pass advisors=True to ask Supabase’s
index_advisor which
indexes would help. It is called once, after the sweep, on the largest
dataset it measured, and plans each statement with hypothetical indexes
(through hypopg). Nothing is created and nothing extra runs.
result.advisor_hits lists the suggestions, highest estimated saving
first. Each hit has rank, relation, index_statement, the
statement it came from, and the planner’s estimated
startup_cost_before/_after and total_cost_before/_after for that
statement. Only indexes on tables the sweep scaled are kept. The same
list goes into the run artifact as advisor_hits, and
sqlproof scale report shows the latest run’s suggestions.
Which statements are advised:
- With
nested_plans=True, every statement the function ran that reads a table, asauto_explainreported it. Parameter names in those statements become typed generic parameters (customer_id = cbecomescustomer_id = ($1::bigint)). - Always, the outer
SELECT fn($1, ...)call. Withoutnested_plans, that is the only statement, andindex_advisorsees inside the function only if Postgres inlines it. A PL/pgSQL function, or a SQL function with aFROMclause, is never inlined in a select list. Passnested_plans=Truefor those.
SqlProof never installs the extension. If it is missing,
result.advisor_notes says so
(index_advisor not installed (CREATE EXTENSION index_advisor CASCADE))
and the measurement still stands. The notes also list statements
index_advisor could not plan, such as PL/pgSQL expressions that use
local variables. Install it once in your test database:
With in_clone=True, the throwaway copy inherits the extension from the
database it copies. Supabase’s other advisor, splinter, is not wrapped
yet.
What it cannot see
Section titled “What it cannot see”- Growth in CPU work alone. Buffers count pages touched, not CPU. A
join on
a.v < b.vover a materialized inner side is quadratic in CPU but its work fits about 1. When execution time grows more than 0.5 faster than the work (and the last call took at least 20 ms),exponentrefuses instead of reporting the smaller number. Below that, CPU growth passes unseen. - Plans inside a function, by default.
EXPLAINofSELECT fn()shows only the outer statement. For PL/pgSQL and non-inlined SQL functions, work is still measured, but plan changes and peak memory inside them are not, unless you passnested_plans=True. Thenauto_explainreports the plan of every statement the function runs, so an inner plan change splits the fit and inner sorts report memory. It needs a superuser, or a server that preloadsauto_explainand lets your role set it (the Supabase images do), and it slows functions that run many statements, so it is off by default. - O(n log n) versus O(n). An in-memory sort touches no buffers.
- What
work_blockscounts. Only buffers of the modelled tables, their indexes and TOAST storage. System catalog reads are excluded, so neither per-connection cache state nor other sessions’ activity on a shared server moves the measurement.