BUG #19620: pg_class index corruption caused by statement_timeout during VACUUM FULL
The following bug has been logged on the website:
Bug reference: 19620
Logged by: Zheng Hacker
Email address: hackerzheng666@gmail.com
PostgreSQL version: 19beta3
Operating system: Linux x86_64
Description:
SUMMARY
On PostgreSQL 18devel (git master cc1ea71), a single backend that
repeatedly runs catalog-churning DDL/maintenance under a short
statement_timeout drives the system-catalog index
pg_class_relname_nsp_index into a corrupt state: one btree leaf page
acquires two index tuples pointing to the same pg_class heap TID.
A subsequent CREATE TABLE touches that leaf page, a btree
simple-delete/dedup pass collects the two identical TIDs, and the
cassert assertion in index_delete_sort_cmp (heapam.c:8668) fires.
On a production (non-cassert) build, the corrupt catalog index is
silent and can produce wrong catalog lookups (we separately observe
the Assert(relid == targetRelId) in relcache.c:1138 from the same
corruption when a pg_class OID-index scan returns the wrong row).
KEY DISTINCTION from known reports (#18490, #17386, #17255): this
reproducer is purely single-session, single-backend -- all parallelism
disabled (max_worker_processes=0), no concurrent connections. The
trigger is statement cancellation (CHECK_FOR_INTERRUPTS) during
catalog-index maintenance, not a concurrency race.
VERSION / BUILD
PostgreSQL master (ahead of 19beta3), git commit cc1ea71
("Remove unnecessary list_free() calls in OpenTableList()")
Build: clang -O1 -g --enable-cassert (no sanitizers, no sancov),
x86_64 Linux.
First observed on an ASan+sancov build; confirmed on this clean
build to rule out instrumentation artifacts.
REPRODUCTION
1) Server setup:
initdb -D $PGDATA --no-locale -A trust
cat >> $PGDATA/postgresql.conf << 'EOF'
fsync = off
debug_parallel_query = off
max_parallel_workers = 0
max_parallel_maintenance_workers = 0
max_worker_processes = 0
EOF
pg_ctl -D $PGDATA -l $PGDATA/server.log start
createdb testdb
2) Save as repro.sql:
CREATE TABLE t1 (i int, t text);
INSERT INTO t1 SELECT g, md5(g::text) FROM generate_series(1,100) g;
CREATE INDEX t1_i ON t1(i);
CREATE INDEX t1_t ON t1(t);
CREATE TABLE t2 (i int PRIMARY KEY, a int[], p point);
INSERT INTO t2 SELECT g, array[g,g+1], point(g,g) FROM
generate_series(1,100) g;
CREATE INDEX t2_hash ON t2 USING hash(i);
CREATE INDEX t2_gin ON t2 USING gin(a);
CREATE INDEX t2_gist ON t2 USING gist(p);
CREATE TABLE t3 (k int, v text);
INSERT INTO t3 SELECT g, repeat('x',200) FROM generate_series(1,100) g;
CREATE INDEX t3_k ON t3(k);
CREATE TABLE t4 (a int) PARTITION BY LIST(a);
CREATE TABLE t4p1 PARTITION OF t4 FOR VALUES IN (1);
CREATE TABLE t4p2 PARTITION OF t4 FOR VALUES IN (2);
CREATE TABLE t4p3 PARTITION OF t4 FOR VALUES IN (3);
INSERT INTO t4 SELECT (g%3)+1 FROM generate_series(1,60) g;
CREATE TABLE t5 (i int PRIMARY KEY, t text);
ALTER TABLE t5 ALTER COLUMN t SET STORAGE EXTERNAL;
INSERT INTO t5(i,t) VALUES (generate_series(1,20),
repeat('1234567890',269));
VACUUM FULL t1;
VACUUM FULL t2;
VACUUM FULL t3;
VACUUM FULL t5;
DELETE FROM t1 WHERE i < 50;
DELETE FROM t3 WHERE k < 50;
VACUUM t1;
VACUUM t3;
DROP TABLE t1;
DROP TABLE t2;
DROP TABLE t3;
DROP TABLE t4 CASCADE;
DROP TABLE t5;
VACUUM FULL pg_class;
VACUUM FULL pg_am;
VACUUM FULL pg_database;
3) Save as repro.sh and run:
#!/bin/bash
Q="psql -d testdb -q"
for round in $(seq 1 200); do
to=$(( (round % 12) + 1 ))
{ echo "SET statement_timeout='${to}ms';"
cat repro.sql
echo "SET statement_timeout=0;"
echo "CREATE TABLE probe_${round}(a int, t text);"
echo "DROP TABLE IF EXISTS probe_${round};"
} | $Q 2>/dev/null
if grep -q "TRAP:" "$PGDATA/server.log" 2>/dev/null; then
echo "CRASH at round $round"
grep -m1 "TRAP:" "$PGDATA/server.log"
break
fi
done
RESULT: crashes 5/5 runs within 37 rounds (rounds 13, 25, 37, 37, 37).
Using the first 187 lines of src/test/regress/sql/vacuum.sql instead
(heavier pg_class churn) accelerates the crash to 3/3 within round 24.
Without statement_timeout (timeout = 0), the same workload runs for
hundreds of rounds without corruption -- query cancellation is the
essential trigger.
ASSERTION + BACKTRACE
TRAP: failed Assert("false"), File: "heapam.c", Line: 8668
#5 ExceptionalCondition assert.c:65
#6 index_delete_sort_cmp heapam.c:8668
#7 index_delete_sort heapam.c:8708
#8 heap_index_delete_tuples heapam.c:8366
#10 _bt_delitems_delete_check nbtpage.c:1536
#11 _bt_simpledel_pass nbtinsert.c:2959
#12 _bt_delete_or_dedup_one_page nbtinsert.c:2770
#14 _bt_doinsert nbtinsert.c:261
#15 btinsert nbtree.c:219
#16 index_insert indexam.c:231
#17 CatalogIndexInsert indexing.c:170
#18 CatalogTupleInsert indexing.c:243
#19 InsertPgClassTuple heap.c:989
#20 AddNewRelationTuple heap.c:1049
#21 heap_create_with_catalog heap.c:1447
#22 create_toast_table toasting.c:254
#25 ProcessUtilitySlow utility.c:1197
#31 exec_simple_query
"CREATE TABLE covering_index_heap (f1 int, f2 int, f3 text);"
DIRECT CORRUPTION EVIDENCE (core dump)
frame _bt_delitems_delete_check:
rel (index) = OID 2663 "pg_class_relname_nsp_index"
heapRel (heap) = OID 1259 "pg_class"
delstate->ndeltids = 119
delstate->deltids:
entry[3] = { tid=(block 0, offset 5), id=45 }
entry[6] = { tid=(block 0, offset 5), id=45 } <-- DUPLICATE
Two entries carry the identical heap TID (0,5). This can only happen
if pg_class_relname_nsp_index contains two live index pointers to the
same pg_class heap tuple -- the catalog index is corrupt.
SERIAL CONFIRMATION
Re-run with all parallelism disabled (debug_parallel_query=off,
max_parallel_workers=0, max_parallel_maintenance_workers=0,
max_worker_processes=0): still crashes at rounds 13-24 across
multiple runs. The crashing backend has no parallel/bgworker frames
anywhere in the stack. This rules out any parallel-index-build or
concurrent-reindex race condition.
A second core dump from a serial run shows a different crashing
statement (REINDEX TABLE CONCURRENTLY testcomment) hitting the same
corrupt pg_class_relname_nsp_index leaf via CatalogTupleUpdate ->
_bt_simpledel_pass. The corruption is latent in the index; any
subsequent catalog write that hits the corrupt page triggers the
assert.
ROOT CAUSE ANALYSIS
The corruption is a pg_class_relname_nsp_index btree leaf page
holding two pointers to one heap TID. Bisection of the workload
identifies three necessary ingredients:
1. VACUUM FULL pg_class -- rewrites the entire pg_class heap and
rebuilds all pg_class btree indexes from scratch. This is the
essential catalog operation; removing it eliminates the crash.
2. Heavy DDL churn (CREATE TABLE/INDEX, DROP TABLE, VACUUM FULL on
user tables) -- generates many pg_class inserts, updates, and
deletes, filling btree pages and creating conditions for
dedup/simple-delete passes during subsequent inserts.
3. statement_timeout cancellation -- interrupts (1) or (2) mid-flight
via CHECK_FOR_INTERRUPTS(). Without cancellation, no corruption
occurs even after hundreds of rounds.
The most likely mechanism: VACUUM FULL pg_class calls cluster_rel()
which rewrites the heap and rebuilds all indexes via index_build().
If statement_timeout fires during this rebuild at a
CHECK_FOR_INTERRUPTS() site, the transaction is aborted, but a
catalog-index btree page may be left in an inconsistent state with a
duplicate pointer to the same heap TID. The abort/rollback path
does not fully undo the partial btree page modification.
PRIOR ART
The "duplicate heap TID in a pg_class index" corruption class is
known:
- BUG #18490 (2024) -- same symptom, index
pg_class_tblspc_relfilenode_index, trigger = concurrent REINDEX
during table creation; Peter Geoghegan: "a known issue", no fix
committed.
- BUG #17386 (2022) -- btree corruption after REINDEX CONCURRENTLY.
- BUG #17255 (2021) -- index_delete_sort_cmp via parallel-vacuum
race; fixed 2022.
What is new: all known reports require concurrency (parallel workers,
concurrent connections, concurrent REINDEX). This reproducer triggers
the same corruption in a single session with all parallelism disabled,
via statement cancellation alone. This is an interrupt-safety bug in
catalog index maintenance, distinct from the concurrency races in the
known reports.
DISCOVERY
Credit: Zheng Wang, Yanjie Zhao, Yiyang Liu.
On 15 Aug 2026, at 05:37, PG Bug reporting form <noreply@postgresql.org> wrote:
What is new: all known reports require concurrency (parallel workers,
concurrent connections, concurrent REINDEX). This reproducer triggers
the same corruption in a single session with all parallelism disabled,
via statement cancellation alone. This is an interrupt-safety bug in
catalog index maintenance, distinct from the concurrency races in the
known reports.
Hi Zheng,
Thanks for the report. This is worth fixing for sure, thanks for raising
attention to this.
I believe this is a known bug, unfixed since July 2020 [0]/messages/by-id/20200728151002.GE20393@telsasoft.com. Justin
Pryzby hit the same duplicate-TID corruption in a pg_class index, and
Peter Geoghegan and Tom Lane diagnosed it there; Peter also pointed at
that thread when BUG #18490 arrived in 2024 [1]/messages/by-id/CAH2-WzkaOCUV_0JYmbv=ZKH5cs6X27eKZLouWq0d6_meGyNJtg@mail.gmail.com. The mechanism is in
heapam_index_build_range_scan(): for a HEAPTUPLE_INSERT_IN_PROGRESS
tuple inserted by another transaction, the build waits only when it is
checking uniqueness. So for a non-unique index our opinion of which HOT
chain member is live can change mid-scan, and the build emits two
entries for one chain, both carrying the chain root's TID.
Worth adding, since that thread only ever discussed the assertion: this
is not confined to assert builds. I reproduced on REL_18_STABLE with
two sessions, one looping REINDEX INDEX
pg_class_tblspc_relfilenode_index and one looping CREATE TABLE /
CREATE INDEX / DROP TABLE, with no statement_timeout anywhere. A
cassert build trips comparetup_index_btree_tiebreak() in 0.4 seconds. A
build without assertions completes the rebuild and leaves the catalog
index corrupt on disk, where amcheck finds it after 23 iterations:
ERROR: posting list contains misplaced TID in index
"pg_class_tblspc_relfilenode_index"
DETAIL: Index tid=(1,210) posting list offset=1 page lsn=0/1C2A950.
So bt_index_check() on catalog indexes seems worth running in
production....
Now the thing to check. I suspect your reproducer is concurrent after
all, which would make this the same bug rather than a new one.
max_worker_processes = 0 does not turn autovacuum off. With that
setting autovacuum and autoanalyze are still running, I see 6
on pg_class itself within half a minute. Some of those updates
a pg_class row without touching an indexed column, which is a HOT
update on pg_class, and that is precisely the concurrent writer the
2020 mechanism needs. The code also seems to require one: both branches
that can emit a second entry for a chain sit inside
"if (!TransactionIdIsCurrentTransactionId(xwait))", so a backend acting
alone should not be able to reach them.
Could you set autovacuum = off in postgresql.conf, rather than relying
on max_worker_processes, and re-run your loop? If it still corrupts,
then you do have something new and I would very much like to see it. It
would also help to know the xmin of the two heap tuples your duplicate
pointers reference, and whether that XID belongs to your session.
I could not reproduce a single-session failure myself, but that is weak
evidence either way, so I would rather have your answer than my guess.
Thank you!
Best regards, Andrey Borodin.
[0]: /messages/by-id/20200728151002.GE20393@telsasoft.com
[1]: /messages/by-id/CAH2-WzkaOCUV_0JYmbv=ZKH5cs6X27eKZLouWq0d6_meGyNJtg@mail.gmail.com
Hi Andrey,
Thanks for pointing that out.
We re-ran the reproducer for 2,000 rounds with autovacuum=off,
together with max_worker_processes=0,
max_parallel_workers_per_gather=0, and
max_parallel_maintenance_workers=0. We saw no corruption or crashes,
and autovacuum_count for pg_class stayed at 0 throughout the test.
So it looks like the issue only reproduces when autovacuum can run
concurrently with the index build, which matches the behavior
described in #18490 and #17386.
Thanks again for the clarification.
Best,
Zheng Wang
Andrey Borodin <x4mmm@yandex-team.ru> 于2026年8月17日周一 00:01写道:
Show quoted text
On 15 Aug 2026, at 05:37, PG Bug reporting form <noreply@postgresql.org> wrote:
What is new: all known reports require concurrency (parallel workers,
concurrent connections, concurrent REINDEX). This reproducer triggers
the same corruption in a single session with all parallelism disabled,
via statement cancellation alone. This is an interrupt-safety bug in
catalog index maintenance, distinct from the concurrency races in the
known reports.Hi Zheng,
Thanks for the report. This is worth fixing for sure, thanks for raising
attention to this.I believe this is a known bug, unfixed since July 2020 [0]. Justin
Pryzby hit the same duplicate-TID corruption in a pg_class index, and
Peter Geoghegan and Tom Lane diagnosed it there; Peter also pointed at
that thread when BUG #18490 arrived in 2024 [1]. The mechanism is in
heapam_index_build_range_scan(): for a HEAPTUPLE_INSERT_IN_PROGRESS
tuple inserted by another transaction, the build waits only when it is
checking uniqueness. So for a non-unique index our opinion of which HOT
chain member is live can change mid-scan, and the build emits two
entries for one chain, both carrying the chain root's TID.Worth adding, since that thread only ever discussed the assertion: this
is not confined to assert builds. I reproduced on REL_18_STABLE with
two sessions, one looping REINDEX INDEX
pg_class_tblspc_relfilenode_index and one looping CREATE TABLE /
CREATE INDEX / DROP TABLE, with no statement_timeout anywhere. A
cassert build trips comparetup_index_btree_tiebreak() in 0.4 seconds. A
build without assertions completes the rebuild and leaves the catalog
index corrupt on disk, where amcheck finds it after 23 iterations:ERROR: posting list contains misplaced TID in index
"pg_class_tblspc_relfilenode_index"
DETAIL: Index tid=(1,210) posting list offset=1 page lsn=0/1C2A950.So bt_index_check() on catalog indexes seems worth running in
production....Now the thing to check. I suspect your reproducer is concurrent after
all, which would make this the same bug rather than a new one.
max_worker_processes = 0 does not turn autovacuum off. With that
setting autovacuum and autoanalyze are still running, I see 6
on pg_class itself within half a minute. Some of those updates
a pg_class row without touching an indexed column, which is a HOT
update on pg_class, and that is precisely the concurrent writer the
2020 mechanism needs. The code also seems to require one: both branches
that can emit a second entry for a chain sit inside
"if (!TransactionIdIsCurrentTransactionId(xwait))", so a backend acting
alone should not be able to reach them.Could you set autovacuum = off in postgresql.conf, rather than relying
on max_worker_processes, and re-run your loop? If it still corrupts,
then you do have something new and I would very much like to see it. It
would also help to know the xmin of the two heap tuples your duplicate
pointers reference, and whether that XID belongs to your session.I could not reproduce a single-session failure myself, but that is weak
evidence either way, so I would rather have your answer than my guess.Thank you!
Best regards, Andrey Borodin.
[0] /messages/by-id/20200728151002.GE20393@telsasoft.com
[1] /messages/by-id/CAH2-WzkaOCUV_0JYmbv=ZKH5cs6X27eKZLouWq0d6_meGyNJtg@mail.gmail.com