From 7da4f97118d5d228469b416368ea1503ad16eddd Mon Sep 17 00:00:00 2001 From: Andrey Borodin Date: Tue, 14 Jul 2026 13:45:55 +0500 Subject: [PATCH] Add injection-point test for TOAST/heap vacuum horizon corruption VACUUM processes a relation and its TOAST table with separately computed removal horizons. A transaction in the same database can hold the main relation's horizon back so a deleted tuple stays RECENTLY_DEAD, then commit before the TOAST table is vacuumed; the TOAST vacuum then advances past that horizon and removes the external chunks. A concurrent transaction in another database keeps the cluster-wide snapshot xmin low, so a later CREATE INDEX still treats the tuple as RECENTLY_DEAD, tries to detoast it, and fails with "missing chunk number 0 for toast value" (SQLSTATE XX001). Add a "vacuum-before-toast" injection point between the main and TOAST vacuums and a TAP test that reproduces this race. Discussion: https://postgr.es/m/1779078055.630771314%40fmail2.qdit --- src/backend/commands/vacuum.c | 8 ++ src/test/modules/injection_points/Makefile | 2 + src/test/modules/injection_points/meson.build | 5 + .../t/001_toast_vacuum_horizon.pl | 95 +++++++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 src/test/modules/injection_points/t/001_toast_vacuum_horizon.pl diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c index a4abb29cf64..0946dfbf9ca 100644 --- a/src/backend/commands/vacuum.c +++ b/src/backend/commands/vacuum.c @@ -2344,6 +2344,14 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params, toast_vacuum_params.options |= VACOPT_PROCESS_MAIN; toast_vacuum_params.toast_parent = relid; + /* + * Allow tests to pause here, after the main relation has been + * vacuumed but before its TOAST table is, to exercise the horizon + * race that can leave the TOAST table more aggressively cleaned than + * the main relation. + */ + INJECTION_POINT("vacuum-before-toast", NULL); + vacuum_rel(toast_relid, NULL, toast_vacuum_params, bstrategy, isTopLevel); } diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile index c01d2fb095c..3aca8c389de 100644 --- a/src/test/modules/injection_points/Makefile +++ b/src/test/modules/injection_points/Makefile @@ -24,6 +24,8 @@ ISOLATION = basic \ # some isolation tests require wal_level=replica ISOLATION_OPTS = --temp-config $(top_srcdir)/src/test/modules/injection_points/extra.conf +TAP_TESTS = 1 + # The injection points are cluster-wide, so disable installcheck NO_INSTALLCHECK = 1 diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build index 59dba1cb023..e2bf524a0c0 100644 --- a/src/test/modules/injection_points/meson.build +++ b/src/test/modules/injection_points/meson.build @@ -60,4 +60,9 @@ tests += { '--temp-config', files('extra.conf'), ], }, + 'tap': { + 'tests': [ + 't/001_toast_vacuum_horizon.pl', + ], + }, } diff --git a/src/test/modules/injection_points/t/001_toast_vacuum_horizon.pl b/src/test/modules/injection_points/t/001_toast_vacuum_horizon.pl new file mode 100644 index 00000000000..8ee12234529 --- /dev/null +++ b/src/test/modules/injection_points/t/001_toast_vacuum_horizon.pl @@ -0,0 +1,95 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# A relation and its TOAST table are vacuumed with separately computed +# removal horizons. A same-database transaction can keep a deleted tuple +# RECENTLY_DEAD during the main relation's vacuum, then commit before the +# TOAST table is vacuumed, so the TOAST vacuum removes chunks the main +# relation still references. A transaction in another database keeps the +# cluster-wide snapshot xmin low, so a later CREATE INDEX still reads the +# tuple, detoasts it, and fails with "missing chunk number 0 for toast +# value". A "vacuum-before-toast" injection point splits the two vacuums. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if ($ENV{enable_injection_points} ne 'yes') +{ + plan skip_all => 'Injection points not supported by this build'; +} + +my $node = PostgreSQL::Test::Cluster->new('main'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; + +if (!$node->check_extension('injection_points')) +{ + plan skip_all => 'Extension injection_points not installed'; +} + +$node->safe_psql('postgres', 'CREATE EXTENSION injection_points;'); + +# A second database; its open transaction (below) keeps the cluster-wide +# snapshot xmin low without affecting the per-database data horizon. +$node->safe_psql('postgres', 'CREATE DATABASE other;'); + +# The TOAST value must be stored externally (uncompressed) to be detoasted. +$node->safe_psql( + 'postgres', qq{ + CREATE TABLE tbl (i int, t text); + ALTER TABLE tbl ALTER COLUMN t SET STORAGE EXTERNAL; + ALTER TABLE tbl SET (autovacuum_enabled = false); + INSERT INTO tbl (i, t) VALUES (1, repeat('1234567890', 250)); +}); + +# Same-database holder: keeps the main relation's vacuum from removing the +# deleted tuple, so it stays RECENTLY_DEAD. +my $same_db = $node->background_psql('postgres'); +$same_db->query_safe('BEGIN; SELECT txid_current();'); + +# Other-database holder: keeps the cluster-wide snapshot xmin low. +my $other_db = $node->background_psql('other'); +$other_db->query_safe('BEGIN; SELECT txid_current();'); + +$node->safe_psql('postgres', 'DELETE FROM tbl WHERE i = 1;'); + +# Park VACUUM after the main relation, before its TOAST table. +$node->safe_psql('postgres', + "SELECT injection_points_attach('vacuum-before-toast', 'wait');"); +my $vacuum = $node->background_psql('postgres'); +$vacuum->query_until(qr/start/, "\\echo start\nVACUUM (VERBOSE) tbl;\n"); +$node->wait_for_event('client backend', 'vacuum-before-toast'); + +# Drop the same-database holder, then let the TOAST vacuum advance its +# horizon and remove the external chunks. +$same_db->query_safe('COMMIT;'); +$node->safe_psql('postgres', + "SELECT injection_points_wakeup('vacuum-before-toast');"); +$node->safe_psql('postgres', + "SELECT injection_points_detach('vacuum-before-toast');"); +$vacuum->query_until(qr/done/, "\\echo done\n"); + +# CREATE INDEX must succeed. On unpatched code it instead fails with +# SQLSTATE XX001 ("missing chunk number 0 for toast value"), so this test +# currently fails and documents the unfixed bug. +my ($stdout, $stderr) = ('', ''); +my $ret = $node->psql( + 'postgres', 'CREATE INDEX tbl_t_idx ON tbl (t);', + stdout => \$stdout, + stderr => \$stderr); + +is($ret, 0, 'CREATE INDEX succeeds despite the heap/TOAST horizon race'); +unlike( + $stderr, + qr/missing chunk number 0 for toast value/, + 'CREATE INDEX does not raise XX001 data corruption'); + +$same_db->quit; +$other_db->quit; +$vacuum->quit; +$node->stop; + +done_testing(); -- 2.50.1 (Apple Git-155)