Skip to main content

The deploy readers: what they need to see, and what a partial answer means

Two readers run immediately before a deploy. The statistics reader answers how big the objects a finding names are; the activity reader answers whether anything is blocking right now. Both read catalog and state views only: no user table, no EXPLAIN, no lock of their own.

This page is about what happens when they cannot see everything — because on a managed database that is the normal case, and because one of the ways a state view degrades is genuinely dangerous.

The short version

A reading that could not see something says so. It never comes back empty and lets you read silence as calm. If you take one thing from this page, take this: a partial snapshot is an honest result, not a defect, and the fix is usually a privilege rather than a configuration change.

Why "empty" and "calm" are the same shape

An activity snapshot with no lock waits is what a healthy server produces. It is also what a reading whose views were all withheld produces. Identical contents, opposite meanings.

They are told apart by the skips, never by the contents — every source that could not be read leaves a named entry, and the snapshot reports itself partial. A consumer that wants to conclude something from silence has to pass through that check first.

PostgreSQL

ViewUsed forWithout the privilegeYou will see
pg_class, pg_namespacerow estimates, sizesreadable to any role
pg_relation_size and friendssizesreadable to any role
pg_stat_all_tableswhen statistics were last refreshedreadable to any role
pg_lockslock waitsreadable to any role
pg_stat_activitylong-running sessionsrows present, columns emptyinsufficient_privilege, and the reading is partial
pg_stat_replicationreplica lagnot measured here — see the note belowinsufficient_privilege, or not_readable on a standby

Every row above except one was measured against a real PostgreSQL 18 with an account holding nothing but CONNECT. The exception is pg_stat_replication: the server it was measured on has no replica, so an empty result there says nothing about the privilege, and this page will not claim otherwise. What SQLens does with it is settled — it establishes whether the connection reached a primary or a standby rather than assuming, and a standby is reported as the wrong instance rather than as replicas that are caught up.

The one that does not refuse

pg_stat_activity is the view worth understanding before you grant anything.

Without pg_monitor membership it does not refuse. It answers with every session listed and the useful columns — state, xact_start, query_start, wait_event_type — all null for sessions that are not yours. The query text reads <insufficient privilege>.

A tool that filtered on the duration would therefore return nothing on a server with hours-old transactions and report that nobody is running long. No error, nothing empty enough to be suspicious of. SQLens counts the rows and the visible states in the same statement and reports the difference as a named skip instead.

pg_locks, by contrast, is fully readable to an ordinary role. The lock graph survives where the session attributes do not, so lock waits are reported honestly even from an unprivileged account.

Granting the minimum

GRANT pg_monitor TO sqlens_preflight;

pg_monitor is a predefined role: it makes the statistics views fully readable and grants no access to table data. It is what turns the masked view above into a complete one.

Do not use the migration role for this. The role that runs your migrations holds DDL rights on your schema; a preflight reads and must never be able to write. Give the preflight its own account, and give that account no more than CONNECT, USAGE on the schemas you audit, and pg_monitor.

MySQL

ViewUsed forWithout PROCESSYou will see
information_schema.TABLESrow estimates, sizesreadable
mysql.innodb_table_statswhen statistics were last refreshedrefusesinsufficient_privilege; the numbers survive, their age is unknown
mysql.innodb_index_statsper-index sizerefusesinsufficient_privilege
information_schema.INNODB_TRXlong-running transactionsrefuses (1227)insufficient_privilege
performance_schema.metadata_locksDDL blocked by DMLrefuses (1142)insufficient_privilege
performance_schema.data_lock_waitsrow contentionrefuses (1142)insufficient_privilege
performance_schema.threadsthread → connection idrefuses (1142) without SELECT on performance_schemainsufficient_privilege
information_schema.USER_PRIVILEGESwhether this account holds PROCESSreadable to any account, for itself
information_schema.PROCESSLISTsessionsnarrowed to your own, no errorinsufficient_privilege, detected via USER_PRIVILEGES

The one that does not refuse

MySQL's dangerous view is information_schema.PROCESSLIST. Without PROCESS it returns your own session and nothing else — no error, and one row is what a quiet server looks like too.

It cannot be detected from its own answer — one row is one row. So SQLens asks the server which privileges the connecting account holds, using information_schema.USER_PRIVILEGES, which every account may read for itself. A process list read without PROCESS is then reported as a named skip rather than as a quiet server.

An earlier version inferred it from performance_schema.threads refusing, and that was wrong. The correction is worth stating because the belief is a common one: threads is gated by SELECT on performance_schema, not by PROCESS. Measured on 8.4.10:

Grantsperformance_schema.threadsinformation_schema.PROCESSLIST
nonerefused1 row — narrowed
PROCESS onlyrefused3 rows — whole server
SELECT on performance_schema onlyreadable1 row — narrowed
bothreadable3 rows

The third row is why it mattered: the view answered, so the old check concluded there was no problem, while the process list showed only the reader's own session. That combination is not exotic — it is what a carefully set-up monitoring account gets.

One honest remainder in the current approach: PROCESS held through an activated role does not appear in USER_PRIVILEGES. A negative answer is therefore reported as no PROCESS privilege is recorded rather than as the list is narrowed, so the error stays on the cautious side.

The grant below is the fix.

Granting the minimum

CREATE USER 'sqlens_preflight'@'%' IDENTIFIED BY 'a-password-you-generate';
GRANT PROCESS, REPLICATION CLIENT ON *.* TO 'sqlens_preflight'@'%';
GRANT SELECT ON `your_database`.* TO 'sqlens_preflight'@'%';

PROCESS is what makes the state views answer about the whole server; REPLICATION CLIENT is only needed if you want replication state. Neither grants access to table data.

Do not reuse the migration account. It holds ALTER and DROP on your schema, and a preflight that runs as it can do more than read.

MySQL: the activity reading can be a tenth of a second old

information_schema.INNODB_TRX is not a live view. InnoDB serves it from a cache it refuses to refresh more than once per 100 ms, and that cache is shared by the whole server — measured, not inferred: a connection that has never read the view still gets the snapshot somebody else's read left behind.

Two consequences, and only one of them is yours to think about.

  • Your reading may be up to 100 ms old, because another tool read the view just before it. It cannot be older than that.
  • Reading it costs every other observer the same window. SQLens reads this view once per activity snapshot. If you run a preflight in a loop, you are also nudging any monitoring that reads the same view.

It cannot turn a busy server into a quiet answer. The cache holds a snapshot, not an empty set, so a transaction that was running when the snapshot was taken is in it. The only transaction the window can hide is one that started within the last 100 ms — and a transaction that young is never what "has something been running too long" is asking about.

SQLens therefore does not wait the window off. Doing so would slow every preflight to remove a staleness that cannot reach the verdict.

On a managed database, partial is normal

RDS, Aurora, Cloud SQL and Neon all withhold something by design. You may not be able to grant pg_monitor at all, and PROCESS may not be available to any account you control.

That is not a defect and SQLens does not treat it as one. What it does is refuse to pretend: the reading comes back partial, each missing source is named, and nothing silently becomes a clean result.

Resist the urge to configure the partial away by inflating privileges. A preflight that can see less is still worth running — the sizes usually read fine, and on PostgreSQL the lock graph does too. What matters is that you know which half you are looking at.

How to read an estimate

Every number the statistics reader produces says what it is worth.

  • A row count is always an estimate. No catalog read on any engine states one exactly, because counting rows means counting rows — a full table scan, which SQLens will never ask a production database to perform. PostgreSQL's reltuples and MySQL's TABLE_ROWS are both maintained by the statistics collector, and MySQL documents its InnoDB row counts as varying from the truth by as much as 40–50%.
  • A size may be exact, depending on the engine. PostgreSQL computes it while answering, so it is a measurement. MySQL derives it from pages allocated times page size, which approximates allocation rather than measuring content — a table you just emptied still reports the pages it holds. The number carries which of the two it is.
  • Freshness is four-valued. Measured at a moment the server named; never collected; unknown; or not applicable because the number had no statistics behind it. "Never collected" and "unknown" are kept apart because they have different fixes — the first is an ANALYZE, the second a privilege.

Estimates escalate severity; they never create a finding

This is the rule the whole type exists to enforce. A rule that read a row count would answer differently on the same schema depending on when statistics were last refreshed, and it would have no way to tell you that. So statistics never reach a rule at all. They attach to a finding that already exists, as context, marked for what they are.

If you see a finding whose severity looks high for a small table, check whether the statistics behind it are stale — a table that has never been analyzed reports no row count at all on PostgreSQL, and the snapshot says so.

Capacity: what is readable and what is not

Both engines will tell you how much room a database occupies. Neither will tell you how much is left on the volume underneath it — there is no SQL for it.

So the headroom reading is degraded by nature, and it carries a named reason rather than a zero. A capacity question is answered null rather than "no": a gate that turned "nobody could look" into a refusal would block deploys on a fact it invented.

MySQL's DATA_FREE is deliberately not offered as free space. It is reclaimable room inside a tablespace file — fragmentation, not capacity — and a gate told "there is room" on the strength of it would wave through the rewrite that fills the disk.

Telling the check how much disk there is

deploy.predeploy.available_disk_bytes is how much free disk the operator says this instance has, in bytes. It ships as null.

// config/sqlens.php
'deploy' => ['predeploy' => ['available_disk_bytes' => 500 * 1024 * 1024 * 1024]],

The disk-headroom check estimates how much space a pending rewrite needs and holds it against what the instance reports as free. On most instances that second half is unanswerable: free filesystem space is not visible from inside the database at all, and on a managed database it never will be, whatever privileges are granted.

So null means ask the instance, not assume plenty — where the answer does not come back, the check reports undetermined rather than passing. Setting the value is how an operator who knows the number supplies it.