Escalation thresholds: why a finding gets louder on a big table
The same migration is not the same risk on every table. ALTER TABLE … TYPE on a lookup table with
four hundred rows finishes before anyone notices; on a table of half a billion it is an outage. The
statement is identical, so a linter reading only the statement has to report both the same way.
The deploy suite reads real statistics, so it can do better — and this page is about the numbers it does that with, because a threshold is a claim somebody has to be able to argue with.
The short version
A threshold raises the severity of a finding that already exists. It never creates one and never removes one. A small table does not make an unsafe migration safe; it just does not make it worse.
Why the numbers live in a file
They sit in resources/data/escalation-thresholds.json, shipped with the package, with a
schema_version and a source link per operation.
That is not filing for its own sake. These numbers reach other people's pipelines: raising one turns somebody's passing deploy into a blocked one. As data, a change is one reviewable line in a diff and one line in a changelog. As constants scattered through classes, it changes silently and nobody can say when, or why, or who decided.
The four operations, and what each is measured by
| Operation | Measured by | Raises to high at | Raises to critical at |
|---|---|---|---|
rewrite | bytes | 1 GiB | 100 GiB |
index_build | rows | 10 million | 500 million |
constraint_validation | rows | 50 million | — |
backfill | rows | 100 thousand | 10 million |
The unit is part of the claim, not a detail. A rewrite copies every row to new storage, so its
cost tracks the bytes on disk — a table of wide jsonb columns and one of narrow integers with the
same row count are not the same operation. An index build does work per row, so rows are the
better predictor. Mixing the two would give one of the four a number that means nothing.
constraint_validation has no critical step, and that is deliberate rather than unfinished. It
scans without copying, so it is the same shape of risk one rung down; a second step would imply a
cliff this operation does not have.
backfill escalates earliest, at a hundred thousand rows. It is the one the guidance is
sharpest about, because a backfill that belongs in a queued job looks exactly like one that belongs
in a migration until somebody knows the row count.
These are heuristics, and the file says so
No number here separates "fine" from "outage" for every schema, every disk, and every maintenance window. A 1 GiB rewrite on provisioned NVMe during a quiet Sunday is nothing; the same rewrite on a throttled burst volume at 09:00 on a Monday is an incident.
What the artifact buys is not precision. It is that the number is reviewable — a line somebody can point at and disagree with — instead of a constant somebody once typed into a class.
Every escalation is reported as resting on an estimate, and the estimate carries its own accuracy. See The deploy readers for what a statistic is worth when the planner's numbers are stale, and what happens when there are none.
When statistics are missing, nothing escalates
A null row count or a null size matches no threshold. It does not fall through to a threshold of zero.
That asymmetry is the whole reason statistics are allowed near a deploy gate at all. A number that
could silence a finding would make the report depend on when somebody last ran ANALYZE — the
report would go quiet exactly when the database knows least about itself. A number that can only
raise a finding cannot do that.
The highest matching step wins
Steps are evaluated in declared order and the last match is kept, so a table over both the high and
the critical line gets critical. Answering with the first match would report the gentler of two
truths, which is the wrong direction for a gate.
Overriding them in a project
The shipped numbers are defaults. A project whose tables are all large, or whose maintenance window is genuinely generous, may replace the steps for an operation.
Two rules apply, and both exist for the same reason:
- An unknown operation name is an error, not a no-op. An override naming an operation this build does not have is almost always a typo or a rename, and ignoring it is silent: the value is dropped, the default stays in force, and the project believes it configured something. The refusal names every operation the build does know.
- An override replaces an operation's steps; it does not merge into them. Merging would let a half-written override inherit a step nobody intended to keep.
// config/sqlens.php
'preflight' => [
'thresholds' => [
'rewrite' => [
['min_bytes' => 10_000_000_000, 'raise_to' => 'critical'],
],
],
],
Every operation left out keeps the shipped steps. The override above changes when a rewrite is called critical and leaves index builds, constraint validations and backfills exactly as they are.
What a threshold is not
It is not a rule source. It cannot make a migration report a finding, and it cannot make one stop reporting. The rules decide what is true; the thresholds decide only how loudly a true thing is said.