Skip to main content

DEPLOY.PREFLIGHT.FREEZE_HORIZON — A table near its freeze horizon, where an anti-wraparound vacuum will not yield

  • Category: safety
  • Severity: derived per finding — high when a worker is already running or the table is at or past its threshold, medium while it is merely close
  • Level: 0
  • Downtime class: blocking on the finding; the rule catalog names none for this check, as it does for every deploy check. The statement this deploy runs will wait, holding its own lock request in the queue, and every reader arriving behind it waits too.
  • Stability: stable
  • Suites: deploy
  • Applies to: PostgreSQL. MySQL has no visible freeze horizon — InnoDB's counterpart is purge lag, which is a different measurement against a different failure, and whether that earns a check of its own is its own question.

What it reports

For each table the pending run will take an ACCESS EXCLUSIVE lock on, both freeze clocks and the cluster thresholds they are held against. A finding appears when the nearer clock is at 90 % of its threshold or beyond, or when an anti-wraparound autovacuum is already running on that table.

Tables the run does not lock are not examined. This is not a health report about your database.

Why it is a deploy question

An ordinary autovacuum yields: ask for a conflicting lock and it cancels itself, and the ALTER TABLE behind it proceeds within a moment. An autovacuum running to prevent wraparound does not. It holds its lock until it is finished, because the alternative is a cluster that stops accepting writes.

On a large table that is hours. And from outside it does not look like a lock at all: pg_locks shows your statement waiting, the holder is a background worker, and the deploy simply does not move. The question can this DDL run right now is the one the preflight exists to answer, and the freeze horizon is one of the few answers an instance can give in advance.

Two clocks, and the second is the one nobody watches

ClockConsumed byAgeThreshold
Transaction idswritingage(relfrozenxid)autovacuum_freeze_max_age
Multixactslockingmxid_age(relminmxid)autovacuum_multixact_freeze_max_age

A multixact is created as soon as a second transaction locks a row somebody else already holds — the ordinary state of a foreign-key check and of every explicit row lock taken in a SELECT. So a lock-heavy table can be spotless on the first clock and be the table that stops the cluster.

Both thresholds are read from the server, not hard-coded. autovacuum_freeze_max_age is tunable and frequently tuned; a check comparing against a fixed 200 million would be wrong in both directions on such an instance, quietly.

What to do about it

  • A worker already running: wait for it, or move the deploy. The finding reports its phase and block progress from pg_stat_progress_vacuum so you can tell how long that is.
  • At or past the threshold, nothing running yet: the launcher will start one, possibly during your window. Either vacuum the table deliberately at a time you choose — VACUUM (FREEZE) on a quiet evening advances the horizon without a surprise — or run the deploy now and accept that it may queue.
  • Merely close: a short deploy will very likely get through. A long one may not.

Never cancel the worker

pg_cancel_backend() on an anti-wraparound worker buys nothing. The launcher starts it again within autovacuum_naptime, all the work it had done is thrown away, and the horizon is closer than it was before. The finding says so explicitly, because canceling is the intuitive move and the expensive one.

A rewrite makes the window longer, not shorter

If the run also rewrites a table — an ALTER COLUMN … TYPE that changes the storage — the finding says so. The lock is held for the whole rewrite, a vacuum that arrives during it waits, and everything behind that vacuum waits too.

When it cannot answer

pg_class, pg_stat_progress_vacuum and current_setting() are all readable on a self-managed instance and are commonly withheld on a managed one. When any of them refuses, the check reports undetermined with freeze_horizon_unreadable and says what could not be read — never silence, which would be indistinguishable from a clean answer.

What it does not claim

  • That a worker WILL start during your window. It says one can. The launcher's timing depends on autovacuum_naptime and on how many workers are free.
  • That the table carries the cluster's threshold. A per-table reloptions override is not read, so a table with its own autovacuum_freeze_max_age is judged against the wider setting. That over-reports rather than going quiet, and the finding says which numbers it used.
  • Anything about tables the run does not lock. A table elsewhere in the schema may be far closer to its horizon; that is a monitoring question, and this check is not a monitor.