From 7da4f97118d5d228469b416368ea1503ad16eddd Mon Sep 17 00:00:00 2001
From: Andrey Borodin <amborodin@acm.org>
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)

