v1-0001-Add-a-hot-standby-index-only-scan-TID-recycling-r.patch
application/octet-stream
Filename: v1-0001-Add-a-hot-standby-index-only-scan-TID-recycling-r.patch
Type: application/octet-stream
Part: 0
Patch
Format: format-patch
Series: patch v1-0001
Subject: Add a hot-standby index-only-scan TID-recycling reproducer
| File | + | − |
|---|---|---|
| src/test/recovery/meson.build | 1 | 0 |
| src/test/recovery/t/054_standby_ios_tid_recycle.pl | 423 | 0 |
From 4127f00e0cd9c2bacf42e280a17f42a17c41a8a6 Mon Sep 17 00:00:00 2001
From: Peter Geoghegan <pg@bowt.ie>
Date: Wed, 17 Jun 2026 16:59:52 -0400
Subject: [PATCH v1 1/2] Add a hot-standby index-only-scan TID-recycling
reproducer
src/test/recovery/t/054_standby_ios_tid_recycle.pl demonstrates that an
index-only scan (IOS) on a hot standby can return phantom rows: index keys for
heap TIDs that were recycled out from under the scan, which the IOS trusts
because the visibility map says all-visible.
On the primary an IOS is interlocked against concurrent TID recycling by its
leaf-page buffer pin -- VACUUM (btbulkdelete) takes a cleanup lock on every leaf
page before any heap TID is recycled. During recovery the standby only takes a
cleanup lock when replaying an XLOG_BTREE_VACUUM record, i.e. for leaf pages the
primary actually deleted index tuples from. So whenever the dead index entries
an IOS has buffered leave its pinned page without an XLOG_BTREE_VACUUM landing on
that page, nothing on the standby interlocks against the recycling, and the scan
returns its stale buffered keys as live rows.
The hazard is physical, not MVCC: by the time VACUUM recycles a tombstone the
tuple is dead to every snapshot, so neither recovery conflicts nor
hot_standby_feedback can prevent it.
Each scenario buffers the same picture on the cursor's pinned leaf -- a live
anchor plus a large group of dead-to-all duplicate keys -- and they differ only
in how those dead entries leave the page:
- split: a page split moves the whole dead group to a right sibling, so
VACUUM's record lands on the sibling, never on the pinned page
(the README's "items moved right" case);
- simpledel: the entries are removed in place by simple index deletion
(XLOG_BTREE_DELETE, replayed under an exclusive lock, not a
cleanup lock);
- onpage: the CONTROL -- the entries stay on the pinned page, so VACUUM
emits XLOG_BTREE_VACUUM for it, replay cleanup-locks it, and a
buffer-pin conflict safely cancels the cursor.
The test asserts the scan returns no phantom rows, so the two BUG routes fail
(printing the wrongly-returned rows) until the underlying defect is addressed,
while the CONTROL passes -- proving the failures are a real defect, not a harness
artifact. A duplicate group makes the split land deterministically: the split
code refuses to divide a run of equal keys (SPLIT_MANY_DUPLICATES), so the lone
anchor stays on the pinned page and the whole dead group moves right regardless
of the exact split arithmetic. The simple-deletion route runs with both
aborted-xact and committed-DELETE phantoms, showing the aborted xact is a
convenience, not the cause.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
src/test/recovery/meson.build | 1 +
.../recovery/t/054_standby_ios_tid_recycle.pl | 423 ++++++++++++++++++
2 files changed, 424 insertions(+)
create mode 100644 src/test/recovery/t/054_standby_ios_tid_recycle.pl
diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build
index 9eb8ed114..f5c8a173f 100644
--- a/src/test/recovery/meson.build
+++ b/src/test/recovery/meson.build
@@ -62,6 +62,7 @@ tests += {
't/051_effective_wal_level.pl',
't/052_checkpoint_segment_missing.pl',
't/053_standby_login_event_trigger.pl',
+ 't/054_standby_ios_tid_recycle.pl',
],
},
}
diff --git a/src/test/recovery/t/054_standby_ios_tid_recycle.pl b/src/test/recovery/t/054_standby_ios_tid_recycle.pl
new file mode 100644
index 000000000..ed1f0a845
--- /dev/null
+++ b/src/test/recovery/t/054_standby_ios_tid_recycle.pl
@@ -0,0 +1,423 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+#
+# Reproducer for a hot-standby index-only-scan (IOS) correctness bug.
+#
+# Concurrent heap TID recycling is normally interlocked by the btree leaf-page
+# buffer pin: VACUUM must take a cleanup lock on *every* leaf page before it can
+# recycle any heap TID (src/backend/access/nbtree/README, "Making concurrent TID
+# recycling safe"). An IOS holds the pin on the leaf page whose index tuples it
+# has buffered, so VACUUM cannot recycle those TIDs until the scan is done -- the
+# heap LP_DEAD tombstones stay put, and the IOS never trusts an all-visible VM
+# bit for a TID whose heap slot was recycled out from under it.
+#
+# During recovery the standby does NOT take a cleanup lock on every leaf page; it
+# only takes one when replaying an XLOG_BTREE_VACUUM record, which the primary
+# emits only for leaf pages it actually deletes index tuples from (nbtree.c,
+# _bt_delitems_vacuum). So whenever the dead index entries an IOS has buffered
+# leave its pinned page WITHOUT an XLOG_BTREE_VACUUM on that page, the standby
+# never cleanup-locks the pinned page, nothing blocks replay of the heap
+# recycling, and the IOS returns its locally-buffered stale index keys as live
+# rows: phantoms.
+#
+# The hazard is physical, not MVCC. By the time VACUUM recycles the tombstones
+# the tuples are dead to every snapshot, so neither recovery conflicts nor
+# hot_standby_feedback can prevent it -- both are MVCC-shaped, and this is about
+# a buffered *physical TID reference*, not visibility.
+#
+# Every scenario buffers the same picture on the cursor's pinned leaf P: a live
+# anchor a=1 plus a large group of dead-to-all duplicate entries a=PHANTOM. They
+# differ only in how those dead entries leave P:
+#
+# 'split' BUG: inserting more duplicates splits P, moving the whole dead
+# group to a right sibling. VACUUM's XLOG_BTREE_VACUUM lands on
+# the sibling, never on P (README's "items moved right" case).
+# 'simpledel' BUG: a plain index scan marks the dead entries LP_DEAD and an
+# insert reclaims them in place by simple deletion, replayed as
+# XLOG_BTREE_DELETE under a plain exclusive lock, not a cleanup
+# lock (nbtxlog.c btree_xlog_delete).
+# 'onpage' CONTROL: the dead entries stay on P; VACUUM removes them there and
+# emits XLOG_BTREE_VACUUM for P, so replay cleanup-locks P and a
+# buffer-pin conflict cancels the cursor (the interlock working).
+#
+# The two BUG routes check that the standby index-only scan returns no phantom
+# rows, and on failure print the rows it wrongly returned (index entries for
+# recycled heap TIDs, visible only because of the all-visible bit). The 'onpage'
+# CONTROL shows the safe outcome -- the cursor is cancelled -- so a BUG-route
+# failure is a real defect, not a setup artifact. Each BUG route runs with
+# aborted phantoms; 'simpledel' also runs with a committed DELETE, to show the
+# aborted xact is a convenience, not the cause.
+#
+# A duplicate group is what makes the 'split' route deterministic: the split code
+# refuses to divide a group of equal keys, so with a lone distinct anchor below a
+# big duplicate group the split point can only fall between them -- the anchor
+# stays alone on P (with a high key, which points to no heap tuple and is never
+# recycled) and the entire dead group moves right, regardless of the exact split
+# arithmetic (nbtsplitloc.c, SPLIT_MANY_DUPLICATES).
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# ---------------------------------------------------------------------------
+# Cluster: primary + streaming hot standby.
+#
+# We steer the primary's OldestXmin with hot_standby_feedback -- a *standby*-side
+# xmin. (A snapshot held open on the primary would instead stop VACUUM's recycle
+# pass from ever marking the emptied phantom blocks all-visible, so feedback is
+# the route that works.) wal_receiver_status_interval is tiny so the feedback
+# xmin tracks the standby closely; the default ~10s is stale enough that the
+# anchor row would never get marked all-visible in time.
+# ---------------------------------------------------------------------------
+my $node_primary = PostgreSQL::Test::Cluster->new('primary');
+$node_primary->init(allows_streaming => 1);
+$node_primary->append_conf(
+ 'postgresql.conf', qq[
+autovacuum = off
+]);
+$node_primary->start;
+$node_primary->safe_psql('postgres',
+ 'CREATE EXTENSION pageinspect; CREATE EXTENSION pg_walinspect;');
+$node_primary->backup('b');
+
+my $node_standby = PostgreSQL::Test::Cluster->new('standby');
+$node_standby->init_from_backup($node_primary, 'b', has_streaming => 1);
+$node_standby->append_conf(
+ 'postgresql.conf', qq[
+hot_standby = on
+hot_standby_feedback = on
+wal_receiver_status_interval = 1
+max_standby_streaming_delay = 100ms
+]);
+$node_standby->start;
+
+# The phantom value, the size of its duplicate group buffered on P, and the extra
+# duplicates inserted to trigger the 'split' route. ANCHOR + NDUP entries fit on
+# one leaf (so FETCH 1 buffers the whole group); + NTRIG overflows it, forcing a
+# split. fillfactor=10 + a wide pad gives one heap row per block, so each
+# recycled row leaves an *empty* block that VACUUM marks all-visible+all-frozen.
+my $PHANTOM = 1000;
+my $NDUP = 350;
+my $NTRIG = 120;
+my $PRESS = 100; # distinct low keys that pressure P in the 'simpledel' route
+
+# The leftmost leaf has no left sibling (btpo_prev = 0); a forward scan from the
+# smallest key lands there, so it is the page the IOS cursor pins. Use the true
+# on-disk page count, not the (stale between vacuums) pg_class.relpages.
+sub leftmost_leaf
+{
+ my ($idx) = @_;
+ return $node_primary->safe_psql(
+ 'postgres', qq[
+ SELECT s.blkno
+ FROM generate_series(1, pg_relation_size('$idx')/current_setting('block_size')::int - 1) AS g,
+ LATERAL bt_page_stats('$idx', g::int) AS s
+ WHERE s.type = 'l' AND s.btpo_prev = 0
+ ORDER BY s.blkno LIMIT 1]);
+}
+
+# Create the dead-to-all duplicate group a=PHANTOM on the (single) leaf P.
+# 'abort' -> a ROLLED-BACK insert. Aborted heap tuples are dead to every
+# snapshot unconditionally, so VACUUM recycles them whatever
+# OldestXmin is, and deleting/recycling them carries an Invalid
+# conflict horizon (no snapshot conflict).
+# 'delete' -> ordinary rows removed by a committed DELETE (returns its xid):
+# the proof that the aborted xact is just a convenience. Recycling
+# the tombstones needs OldestXmin past the deleting xid, achieved by
+# making the cursor's (feedback-reported) xmin greater than it.
+sub make_dead_dups
+{
+ my ($tbl, $n, $mode) = @_;
+ if ($mode eq 'abort')
+ {
+ $node_primary->safe_psql(
+ 'postgres', qq[
+ BEGIN;
+ INSERT INTO $tbl SELECT $PHANTOM FROM generate_series(1, $n) g;
+ ROLLBACK;
+ ]);
+ return 0;
+ }
+ $node_primary->safe_psql('postgres',
+ "INSERT INTO $tbl SELECT $PHANTOM FROM generate_series(1, $n) g;");
+ my $d = $node_primary->safe_psql('postgres',
+ "BEGIN; DELETE FROM $tbl WHERE a = $PHANTOM; SELECT txid_current(); COMMIT;"
+ );
+ chomp $d;
+ return $d;
+}
+
+# Run one scenario on its own table. $route is how the buffered dead entries
+# leave P ('split'/'simpledel' = BUG, 'onpage' = CONTROL); $mode is how they were
+# made dead ('abort'/'delete'). Returns a hashref describing the outcome.
+sub run_scenario
+{
+ my ($tbl, $route, $mode) = @_;
+ my $idx = "${tbl}_a";
+
+ # a=1 is a committed live anchor for the cursor to rest on. Crucially we
+ # VACUUM it all-visible *before* the phantom rows exist, so the cursor can
+ # return a=1 from the VM at FETCH 1 without a heap fetch. An anchor heap fetch
+ # would pin the anchor's heap page, which the recycle VACUUM later cleanup-locks
+ # (to set it all-visible), cancelling the cursor with an unrelated buffer-pin
+ # conflict.
+ #
+ # Marking the anchor all-visible needs OldestXmin past a=1's xmin. OldestXmin
+ # here is the feedback xmin, so wait for feedback to advance past the anchor
+ # first -- without this the VACUUM leaves the anchor not-all-visible and the bug
+ # does not reproduce (the cursor heap-fetches a=1 and is cancelled).
+ $node_primary->safe_psql(
+ 'postgres', qq[
+ CREATE TABLE $tbl (a int NOT NULL, pad char(1024) DEFAULT '')
+ WITH (autovacuum_enabled = false, fillfactor = 10);
+ INSERT INTO $tbl VALUES (1);
+ -- Disable deduplication so each duplicate is its own index tuple (no
+ -- posting lists): the split code sees a real group to keep together, and
+ -- the insert-time reclaim path can only pick simple deletion.
+ CREATE INDEX $idx ON $tbl (a) WITH (deduplicate_items = off);
+ ]);
+ my $a1xmin = $node_primary->safe_psql('postgres',
+ "SELECT xmin::text::bigint FROM $tbl WHERE a = 1");
+ chomp $a1xmin;
+ $node_primary->poll_query_until(
+ 'postgres', qq[
+ SELECT backend_xmin IS NOT NULL AND backend_xmin::text::bigint > $a1xmin
+ FROM pg_stat_replication]
+ ) or die "$tbl: feedback never advanced past anchor xmin";
+ $node_primary->safe_psql('postgres', "VACUUM (TRUNCATE false) $tbl;");
+ $node_primary->wait_for_replay_catchup($node_standby);
+
+ # Build the dead duplicate group on P.
+ my $D = make_dead_dups($tbl, $NDUP, $mode);
+ $node_primary->wait_for_replay_catchup($node_standby);
+
+ # Precondition: anchor + the whole dead group still sit on ONE leaf, so FETCH 1
+ # buffers the entire group. (A premature split here would silently leave most
+ # of the group off P and unbuffered.)
+ my $nblocks = $node_primary->safe_psql('postgres',
+ "SELECT pg_relation_size('$idx')/current_setting('block_size')::int;"
+ );
+ is($nblocks, '2',
+ "$tbl: anchor + dead group buffered on a single leaf P");
+
+ my $P = leftmost_leaf($idx);
+ note "$tbl: leftmost leaf (cursor pins this) = block $P"
+ . ($mode eq 'delete' ? "; deleting xid D=$D" : "");
+
+ # Confirm an Index-ONLY Scan on the standby (a plain Index Scan would visit the
+ # heap and not exhibit the bug).
+ my $plan = $node_standby->safe_psql(
+ 'postgres', qq[
+ SET enable_seqscan=off; SET enable_bitmapscan=off;
+ EXPLAIN (COSTS OFF) SELECT a FROM $tbl WHERE a >= 1;]);
+ like(
+ $plan,
+ qr/Index Only Scan/,
+ "$tbl: cursor query uses an index-only scan");
+
+ # Open the IOS cursor and FETCH 1: reads the whole leaf P into the scan's local
+ # buffer (so->currTuples) -- including the dead a=PHANTOM entries, still
+ # physically present -- returns the anchor a=1 from the VM, and holds the buffer
+ # pin on P.
+ my $sb = $node_standby->background_psql('postgres', on_error_stop => 0);
+ my $f1 = $sb->query_safe(
+ qq[
+ BEGIN ISOLATION LEVEL REPEATABLE READ;
+ SET enable_seqscan=off; SET enable_bitmapscan=off;
+ DECLARE c NO SCROLL CURSOR FOR SELECT a FROM $tbl WHERE a >= 1;
+ FETCH 1 FROM c;]);
+ like($f1, qr/^1$/m,
+ "$tbl: FETCH 1 returned anchor a=1 and pinned leaf P");
+
+ # The cursor's xmin will, via feedback, hold the primary's OldestXmin at it.
+ # For 'delete' that must be past D so the recycle actually removes the
+ # tombstones (and so the recycle record's conflict horizon = D < cursor xmin,
+ # no snapshot conflict). Wait for feedback to fully catch up to the cursor's
+ # xmin before the recycle VACUUM.
+ my $cur_xmin = $sb->query_safe(
+ 'SELECT backend_xmin::text::bigint FROM pg_stat_activity WHERE pid = pg_backend_pid();'
+ );
+ chomp $cur_xmin;
+ cmp_ok($cur_xmin, '>', $D,
+ "$tbl: cursor snapshot xmin ($cur_xmin) is past the deleting xid D ($D)"
+ ) if $mode eq 'delete';
+ $node_primary->poll_query_until(
+ 'postgres', qq[
+ SELECT backend_xmin IS NOT NULL AND backend_xmin::text::bigint >= $cur_xmin
+ FROM pg_stat_replication]
+ ) or die "$tbl: feedback never caught up to cursor xmin";
+
+ my $logpos = -s $node_standby->logfile;
+ my $wal_start =
+ $node_primary->safe_psql('postgres', 'SELECT pg_current_wal_lsn();');
+
+ # Take the buffered dead entries off P (or, for the CONTROL, leave them on P).
+ if ($route eq 'split')
+ {
+ # Insert more dead duplicates. The page overflows and splits; because the
+ # split code will not divide the equal-key group, the lone anchor stays on
+ # P (with a high key only) and the entire dead group -- the buffered entries
+ # included -- moves to a right sibling. No index entry is removed from P.
+ make_dead_dups($tbl, $NTRIG, $mode);
+ }
+ elsif ($route eq 'simpledel')
+ {
+ # Remove the dead entries from P *in place*, without a VACUUM btree record:
+ # (1) a plain (non-IOS) index scan visits the heap, finds the tuples dead,
+ # and marks the matching index entries LP_DEAD on P;
+ # (2) inserting distinct low keys pressures P; with LP_DEAD items present
+ # and no room, the insert runs simple deletion -> XLOG_BTREE_DELETE on
+ # P (exclusive lock on replay, no cleanup lock).
+ # The pressure keys (2..) differ from the phantom value, so they never
+ # collide with it in the ground-truth check.
+ $node_primary->safe_psql(
+ 'postgres', qq[
+ SET enable_indexonlyscan=off; SET enable_seqscan=off; SET enable_bitmapscan=off;
+ SELECT count(*) FROM $tbl WHERE a = $PHANTOM;
+ ]);
+ $node_primary->safe_psql('postgres',
+ "INSERT INTO $tbl SELECT g FROM generate_series(2, @{[ 1 + $PRESS ]}) g;"
+ );
+ }
+
+ # Recycle the dead heap tuples. INDEX_CLEANUP on forces the index-vacuuming
+ # pass and hence the heap second pass that turns LP_DEAD into LP_UNUSED and
+ # marks the emptied blocks all-visible. Plain VACUUM, and no CHECKPOINT
+ # anywhere in the scenario: a FREEZE, or an FPI emitted within VACUUM after a
+ # checkpoint, makes the recycle freeze opportunistically and emit records whose
+ # conflict horizon cancels the cursor.
+ $node_primary->safe_psql('postgres',
+ "VACUUM (TRUNCATE false, INDEX_CLEANUP on) $tbl;");
+ $node_primary->wait_for_replay_catchup($node_standby);
+
+ # Did any XLOG_BTREE_VACUUM (cleanup-lock-on-replay) record land on the pinned
+ # page P? (pg_walinspect on the primary.) Both BUG routes must emit none;
+ # the CONTROL must emit at least one.
+ my $vacuum_on_P = $node_primary->safe_psql(
+ 'postgres', qq[
+ SELECT count(*) FROM (
+ SELECT (regexp_match(block_ref, 'blk (\\d+)'))[1]::int AS blk
+ FROM pg_get_wal_records_info('$wal_start', pg_current_wal_lsn())
+ WHERE resource_manager = 'Btree' AND record_type = 'VACUUM'
+ ) s WHERE blk = $P]);
+
+ # Record whether the cursor was cancelled by any recovery conflict, and
+ # specifically by the buffer-pin conflict (the safe interlock). For the BUG
+ # routes these are only diagnostics -- the pass/fail check is purely the
+ # absence of phantoms -- but the CONTROL asserts on them.
+ my $conflict_any =
+ $node_standby->log_contains(qr/conflict with recovery/, $logpos);
+ my $conflict_pin = $node_standby->log_contains(
+ qr/User was holding shared buffer pin for too long/, $logpos);
+ note "$tbl: XLOG_BTREE_VACUUM records on pinned page P=$P: $vacuum_on_P; "
+ . "cursor hit a recovery conflict: "
+ . ($conflict_any ? "yes" : "no");
+
+ my $fall = '';
+ unless ($conflict_any)
+ {
+ ($fall, my $ferr) = $sb->query('FETCH ALL FROM c;');
+ $sb->query('ROLLBACK;') unless $ferr;
+ }
+ $sb->quit;
+
+ # Ground truth: which a>=1 values legitimately exist on the primary now. The
+ # phantom value is gone (aborted or deleted-then-recycled), so every a=PHANTOM
+ # the standby cursor returns is a phantom.
+ my $truth = $node_primary->safe_psql('postgres',
+ "SELECT a FROM $tbl WHERE a >= 1 ORDER BY a;");
+ my %truth = map { $_ => 1 } ($truth =~ /^(-?\d+)$/mg);
+ my @phantoms =
+ grep { $_ == $PHANTOM && !$truth{$_} } ($fall =~ /^(-?\d+)$/mg);
+
+ # Summarise the wrongly-visible rows for an on-failure diagnostic: each
+ # distinct value the scan returned that does not exist on the primary, with
+ # how many times it came back.
+ my %byval;
+ $byval{$_}++ for @phantoms;
+ my $phantom_detail = join(
+ "\n",
+ map {
+ " a=$_ -- returned $byval{$_} time(s), but no such row exists on the primary"
+ } sort { $a <=> $b } keys %byval);
+
+ return {
+ vacuum_on_P => $vacuum_on_P,
+ conflict_any => $conflict_any,
+ conflict_pin => $conflict_pin,
+ phantoms => scalar @phantoms,
+ phantom_detail => $phantom_detail,
+ };
+}
+
+# Run a BUG scenario in isolation and check the correctness property: the
+# standby index-only scan must return NO phantom rows. The whole scenario runs
+# under eval, so a setup failure fails just this scenario and leaves the others
+# to run and report their own results. On failure, print the rows the scan
+# wrongly returned.
+sub bug_scenario
+{
+ my ($tbl, $route, $mode, $label) = @_;
+ my $r = eval { run_scenario($tbl, $route, $mode) };
+ unless (defined $r)
+ {
+ fail("$label: scenario did not run to completion");
+ diag($@) if $@;
+ return;
+ }
+ my $ok = is($r->{phantoms}, 0,
+ "$label: standby index-only scan returned no phantom rows");
+ diag(
+ "$label: the standby IOS returned these rows -- visible only because "
+ . "of the all-visible bit, but absent on the primary (index entries "
+ . "for recycled heap TIDs):\n"
+ . $r->{phantom_detail})
+ unless $ok;
+ return;
+}
+
+# ===========================================================================
+# BUG via page split: the dead group moves to a right sibling (README's "items
+# moved right"); the buffered entries leave P without an XLOG_BTREE_VACUUM on P.
+# ===========================================================================
+note "===== BUG: page split moves the dead group off the pinned page =====";
+bug_scenario('t_split', 'split', 'abort', 'split/abort');
+
+# ===========================================================================
+# BUG via simple deletion: the dead entries are removed from P as XLOG_BTREE_DELETE
+# (exclusive lock on replay), not XLOG_BTREE_VACUUM (cleanup lock). Shown with
+# aborted phantoms and, again, with a committed DELETE -- the aborted xact only
+# makes recycling trivial, it is not the cause.
+# ===========================================================================
+note "===== BUG: simple deletion removes the dead entries in place =====";
+bug_scenario('t_simpledel', 'simpledel', 'abort', 'simpledel/abort');
+bug_scenario('t_simpledel_d', 'simpledel', 'delete', 'simpledel/delete');
+
+# ===========================================================================
+# CONTROL: the dead entries stay on P; VACUUM removes them there, emits
+# XLOG_BTREE_VACUUM for P, and replay cleanup-locks P -> the cursor is cancelled.
+# This always passes (bug present or fixed): it confirms the interlock works for
+# the on-page case, so a BUG-route failure is a real defect, not a setup artifact.
+# ===========================================================================
+note
+ "===== CONTROL: dead entries stay on the pinned page (interlock holds) =====";
+my $ctl = eval { run_scenario('t_ctl', 'onpage', 'abort') };
+if (defined $ctl)
+{
+ cmp_ok($ctl->{vacuum_on_P}, '>', '0',
+ 'CONTROL: VACUUM emitted XLOG_BTREE_VACUUM for the pinned page');
+ ok($ctl->{conflict_pin},
+ 'CONTROL: cursor cancelled by a buffer-pin recovery conflict (interlock held)'
+ );
+ is($ctl->{phantoms}, 0, 'CONTROL: no phantom rows (the safe outcome)');
+}
+else
+{
+ fail("CONTROL: scenario did not run to completion");
+ diag($@) if $@;
+}
+
+$node_standby->stop;
+$node_primary->stop;
+done_testing();
--
2.53.0