Reported symptoms
INSERT latency on the events table rose from 0.9 ms to 1.2 ms
overnight, six weeks ago, and has stayed there.
The rise began during a maintenance window in which a CREATE INDEX CONCURRENTLY was started and then cancelled. The engineer reported that
the index had been rolled back and no index was created.
Query plans on the table are unchanged β nothing uses a new index, which appeared to confirm the rollback. The intended query is still slow, because it still has no usable index.
Table size grew by 22 GB during the window and has not come back.
psql \d on the table shows an index nobody recognises, with the word
INVALID after it.
Evidence provided
$ psql -c "SELECT c.relname, i.indisvalid, i.indisready, i.indislive, pg_size_pretty(pg_relation_size(c.oid)) AS size FROM pg_class c JOIN pg_index i ON i.indexrelid=c.oid WHERE i.indrelid='t'::regclass;" relname | indisvalid | indisready | indislive | size
-----------+------------+------------+-----------+-------
t_pad_idx | f | t | t | 21 MB
t_pkey | t | t | t | 49 MBIt is not a catalog stub β that is 21 MB of real index in the reproduction, and 22 GB in the incident.
$ psql -c "EXPLAIN (COSTS OFF) SELECT * FROM t WHERE pad = repeat('y',200);" QUERY PLAN
------------
Seq Scan on t
Filter: (pad = 'yyyyyyyy...yyyy'::text)
(2 rows)$ psql -c "SELECT pg_size_pretty(pg_relation_size('t_pad_idx'));" -c "INSERT INTO t ... 200000 rows" -c "SELECT pg_size_pretty(pg_relation_size('t_pad_idx'));" before_insert
---------------
21 MB
INSERT 0 200000
after_insert
--------------
22 MBAnd pg_stat_user_indexes confirms nothing has ever read it:
indexrelname | idx_scan | idx_tup_read | size
--------------+----------+--------------+-------
t_pad_idx | 0 | 0 | 22 MB
The server log for the window contains ERROR: canceling statement due to user request from the CREATE INDEX CONCURRENTLY session.
Work the evidence before reading on
indisvalid = fandindisready = t. What does each flag control?- No plan changed and no query got faster. Why is that consistent with the index still existing?
- The index grew during an insert. What does that cost?
- Where would you have to be looking to have caught this on day one?
Root cause
A cancelled concurrent build leaves something that is not inert
Everything observed was consistent with a successful rollback
No plan changed, because the planner ignores it. No query got faster, because the planner ignores it. The 30 percent write regression is the maintenance work and the 22 GB is the index β and neither was attributed to a build everybody believed had been undone.
The only visible traces are the word INVALID in \d and the
indisvalid column. Neither is anywhere a person routinely looks.
Resolution
Find every invalid index, in every database:
SELECT n.nspname AS schema, t.relname AS table, c.relname AS index,
i.indisvalid, i.indisready,
pg_size_pretty(pg_relation_size(c.oid)) AS size
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_class t ON t.oid = i.indrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE NOT i.indisvalid
ORDER BY pg_relation_size(c.oid) DESC;
Read indisready and the size before choosing:
indisready | Size | Meaning | Action |
|---|---|---|---|
false | 0 bytes | Inert | Drop when convenient |
true | non-zero | Costing writes now | Act |
For an index you still want, finish the job in place:
REINDEX INDEX CONCURRENTLY events_pad_idx;
This is usually right: you wanted the index, the build was interrupted,
and this completes it without a second decision about naming or
definition. On the reproduction it returned the index to indisvalid = t.
For one you no longer want:
DROP INDEX CONCURRENTLY events_pad_idx;
While you are there, look for the neighbouring fault β indexes nothing reads:
SELECT relname AS table, indexrelname AS index, idx_scan,
pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;
Treat idx_scan = 0 as a question, not an answer. Statistics reset when
the cluster does, and an index supporting a quarterly job reads as unused
for most of the quarter. Check stats_reset in pg_stat_database first.
Verification
The invalid-index query returns no rows, or only rows you deliberately kept.
If you rebuilt it, both halves must hold β valid and used:
SELECT c.relname, i.indisvalid, i.indisready FROM pg_class c
JOIN pg_index i ON i.indexrelid = c.oid WHERE c.relname = 'events_pad_idx';
EXPLAIN (COSTS OFF) SELECT * FROM events WHERE pad = 'value';
A valid index the planner still ignores is a different problem, and worth knowing about before you close the ticket.
If you dropped it, measure INSERT latency returning to 0.9 ms and
the 22 GB coming back. That measurement is the only direct confirmation
that the index caused the regression.
idx_scan climbs for a rebuilt index. Zero scans on a valid index means
the build was wasted work even though it succeeded.
Prevention
Check for invalid indexes after every maintenance window, and on a schedule. The query is cheap and the failure is silent by construction.
Alert on indisvalid = false. Most monitoring stacks have no metric
for it, and the cost of one that is also indisready is continuous.
Never assume a cancelled CREATE INDEX CONCURRENTLY cleaned up. The
runbook step for cancelling one must end with βthen check pg_index and
drop or rebuild what is leftβ.
Read \d output after DDL. The INVALID suffix was there for six
weeks.
Use DROP INDEX CONCURRENTLY on a live table.
Watch write latency after every index change, in both directions. An index makes reads faster and writes slower, always. That trade is the reason to add one, and it is a cost to measure rather than assume away.
Audit unused indexes periodically, with stats_reset in hand. An
unused index costs the same on write as an invalid one, and is far more
common.
Prefer REINDEX INDEX CONCURRENTLY to drop-and-recreate. It keeps
the definition, the name and the dependencies, and never leaves a window
with no index at all.