diff --git a/src/backend/utils/time/snapmgr.c b/src/backend/utils/time/snapmgr.c index 10fe18df2e7..73894eeecec 100644 --- a/src/backend/utils/time/snapmgr.c +++ b/src/backend/utils/time/snapmgr.c @@ -1117,8 +1117,10 @@ ExportSnapshot(Snapshot snapshot) TransactionId topXid; TransactionId *children; ExportedSnapshot *esnap; + int nsubxids; int nchildren; int addTopXid; + bool suboverflowed; StringInfoData buf; FILE *f; int i; @@ -1162,6 +1164,22 @@ ExportSnapshot(Snapshot snapshot) */ nchildren = xactGetCommittedChildren(&children); + /* + * We export a recovery snapshot's subxip whole (see below), so refuse an + * export that no importer will accept. This rare edge case only happens + * when a snapshot taken during recovery is imported after its standby is + * promoted, the importing transaction subcommits many subtransactions, + * and then attempts to export the same snapshot a second time. + */ + if (snapshot->takenDuringRecovery && + snapshot->subxcnt + nchildren > GetMaxSnapshotSubxidCount()) + ereport(ERROR, + (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), + errmsg("cannot export snapshot with %d running transaction IDs", + snapshot->subxcnt + nchildren), + errdetail("A snapshot taken during recovery is exported with every transaction ID that it treats as running, and at most %d can be stored.", + GetMaxSnapshotSubxidCount()))); + /* * Generate file path for the snapshot. We start numbering of snapshots * inside the transaction from 1. @@ -1224,16 +1242,29 @@ ExportSnapshot(Snapshot snapshot) appendStringInfo(&buf, "xip:%u\n", topXid); /* - * Similarly, we add our subcommitted child XIDs to the subxid data. Here, - * we have to cope with possible overflow. + * Similarly, we add our subcommitted child XIDs to the subxid data. + * + * Report overflow when the snapshot overflowed, and also when our subxids + * won't fit in what a snapshot can hold. For a snapshot taken outside + * recovery, claiming overflow is always safe, since it just makes + * importers fall back on pg_subtrans. */ - if (snapshot->suboverflowed || - snapshot->subxcnt + nchildren > GetMaxSnapshotSubxidCount()) - appendStringInfoString(&buf, "sof:1\n"); - else + nsubxids = snapshot->subxcnt + nchildren; + suboverflowed = snapshot->suboverflowed || + nsubxids > GetMaxSnapshotSubxidCount(); + + /* + * Ignore the subxid array if it has overflowed, unless the snapshot was + * taken during recovery - in that case, top-level XIDs are in subxip as + * well, and we mustn't lose them. + */ + if (suboverflowed && !snapshot->takenDuringRecovery) + nsubxids = 0; + + appendStringInfo(&buf, "sof:%u\n", suboverflowed); + appendStringInfo(&buf, "sxcnt:%d\n", nsubxids); + if (nsubxids > 0) { - appendStringInfoString(&buf, "sof:0\n"); - appendStringInfo(&buf, "sxcnt:%d\n", snapshot->subxcnt + nchildren); for (i = 0; i < snapshot->subxcnt; i++) appendStringInfo(&buf, "sxp:%u\n", snapshot->subxip[i]); for (i = 0; i < nchildren; i++) @@ -1494,11 +1525,11 @@ ImportSnapshot(const char *idstr) snapshot.xip[i] = parseXidFromText("xip:", &filebuf, path); snapshot.suboverflowed = parseIntFromText("sof:", &filebuf, path); + snapshot.subxcnt = xcnt = parseIntFromText("sxcnt:", &filebuf, path); + snapshot.subxip = NULL; - if (!snapshot.suboverflowed) + if (snapshot.subxcnt) { - snapshot.subxcnt = xcnt = parseIntFromText("sxcnt:", &filebuf, path); - /* sanity-check the xid count before palloc */ if (xcnt < 0 || xcnt > GetMaxSnapshotSubxidCount()) ereport(ERROR, @@ -1509,11 +1540,6 @@ ImportSnapshot(const char *idstr) for (i = 0; i < xcnt; i++) snapshot.subxip[i] = parseXidFromText("sxp:", &filebuf, path); } - else - { - snapshot.subxcnt = 0; - snapshot.subxip = NULL; - } snapshot.takenDuringRecovery = parseIntFromText("rec:", &filebuf, path); diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build index 39ec8c4946d..72113c5ac6e 100644 --- a/src/test/recovery/meson.build +++ b/src/test/recovery/meson.build @@ -64,6 +64,7 @@ tests += { 't/053_standby_login_event_trigger.pl', 't/054_unlogged_sequence_promotion.pl', 't/055_cascade_reconnect.pl', + 't/056_standby_snapshot_export.pl', ], }, } diff --git a/src/test/recovery/t/056_standby_snapshot_export.pl b/src/test/recovery/t/056_standby_snapshot_export.pl new file mode 100644 index 00000000000..73abb9c49b8 --- /dev/null +++ b/src/test/recovery/t/056_standby_snapshot_export.pl @@ -0,0 +1,104 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Test snapshot export and import on a standby. +# +# A snapshot taken during recovery holds its whole in-progress set in subxip, +# so export must write subxip out even when the snapshot is suboverflowed. +# Otherwise, a session that imports the snapshot treats running transactions +# as aborted, incorrectly setting hint bits. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +# We must use at least enough subxacts to overflow the primary's subxid cache +my $nsubxacts = 80; + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf('postgresql.conf', 'autovacuum = off'); +$primary->start; + +$primary->backup('backup'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'backup', has_streaming => 1); +$standby->start; + +$primary->safe_psql( + 'postgres', q[ +CREATE TABLE vistest AS SELECT g AS k FROM generate_series(1, 10) g; +CREATE TABLE xid_burner(i int); +]); + +# This transaction deletes a row and stays open, so every snapshot taken from +# here on must report its XID as running +my $deleter = $primary->background_psql('postgres'); +$deleter->query_safe('BEGIN'); +$deleter->query_safe('DELETE FROM vistest WHERE k = 7'); +my $deleter_xid = $deleter->query_safe('SELECT pg_current_xact_id()'); + +# This one deletes another row in an early subtransaction, then overflows its +# subxid cache and stays open. Recovery removes the deleting subtransaction's +# XID from KnownAssignedXids, so reaching that tuple's xmax has to map the +# child XID back to its parent through pg_subtrans. +my $subxact_deleter = $primary->background_psql('postgres'); +$subxact_deleter->query_safe('BEGIN'); +$subxact_deleter->query_safe('SAVEPOINT early'); +$subxact_deleter->query_safe('DELETE FROM vistest WHERE k = 8'); +$subxact_deleter->query_safe('RELEASE early'); + +# Burn $nsubxacts-many subxact XIDs to make exported snapshot suboverflowed +$subxact_deleter->query_safe( + qq[DO \$\$ BEGIN + FOR i IN 1..$nsubxacts LOOP + BEGIN INSERT INTO xid_burner VALUES (i); + EXCEPTION WHEN OTHERS THEN NULL; END; + END LOOP; END \$\$]); + +# Commit a transaction that writes WAL of its own. That advances +# latestCompletedXid on the standby past the deleting XID, and flushes the +# xid-assignment WAL that those subtransactions wrote. +$primary->safe_psql('postgres', 'INSERT INTO xid_burner VALUES (0)'); +$primary->wait_for_replay_catchup($standby); + +my $exporter = $standby->background_psql('postgres'); +$exporter->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); +my $snap = $exporter->query_safe('SELECT pg_export_snapshot()'); + +my $snapfile = slurp_file($standby->data_dir . "/pg_snapshots/$snap"); +note("exported snapshot $snap:\n$snapfile"); + +like($snapfile, qr/^rec:1$/m, 'snapshot was taken during recovery'); +like($snapfile, qr/^sof:1$/m, 'snapshot is suboverflowed'); + +my ($xmin) = $snapfile =~ /^xmin:(\d+)$/m; +my ($xmax) = $snapfile =~ /^xmax:(\d+)$/m; +ok( $xmin <= $deleter_xid && $deleter_xid < $xmax, + 'running XID falls inside the exported xmin/xmax range'); + +like($snapfile, qr/^sxp:$deleter_xid$/m, + 'running XID appears in exported subxip array'); + +# Let both deleters commit, and let the standby replay that +$deleter->query_safe('COMMIT'); +$subxact_deleter->query_safe('COMMIT'); +$primary->wait_for_replay_catchup($standby); + +is( $standby->safe_psql( + 'postgres', qq[BEGIN ISOLATION LEVEL REPEATABLE READ; + SET TRANSACTION SNAPSHOT '$snap'; + SELECT count(*) FROM vistest]), + 10, + 'imported recovery snapshot still sees the deleted rows'); + +$exporter->query_safe('COMMIT'); + +$subxact_deleter->quit; +$deleter->quit; +$exporter->quit; +$standby->stop; +$primary->stop; + +done_testing();