From a285b3f5e37a57dea72483863a2446bee653daf8 Mon Sep 17 00:00:00 2001 From: Alexander Korotkov Date: Thu, 17 Sep 2026 01:54:09 +0300 Subject: [PATCH v6] Say what WAIT's snapshot restriction actually is The reference page said WAIT "cannot be used in contexts where such a snapshot must remain active, including transactions running at isolation levels higher than READ COMMITTED", which reads as a blanket ban at those levels. It is not one. The rule the code enforces is that the current transaction must hold no snapshot, and a transaction-snapshot-mode transaction has no snapshot until its first query: WaitStmt is exempt in PlannedStmtRequiresSnapshot(), so the command itself does not take one. WAIT therefore works before that first query, which is the order an application wants anyway, and it is what 447aae13b03 set out to allow by keeping the wait snapshot-free. State the rule in terms of the snapshot rather than the isolation level, name the two ways to hold one, and say why the rule exists at all: a snapshot held here is what recovery would conflict with while replaying the records being waited for. Note that the write and flush modes are no exception, since WAL is recycled only at restartpoints, which cannot pass a checkpoint record replay has not reached. The error for a held snapshot named the internal snapshot states instead of saying what the user did, and attached the isolation-level explanation as DETAIL unconditionally, so a READ COMMITTED session with a cursor was told its isolation level was too high. Report what is held rather than how it is held, and emit the DETAIL only when the isolation level is in fact the reason. Reported-by: Sami Imseih Reported-by: Xuneng Zhou Reviewed-by: Sami Imseih Reviewed-by: Xuneng Zhou Discussion: https://postgr.es/m/CAN12%2BYJddgAZzA36JZFX%2BYrnNR7Uubp9qWFz6ASs3v0kL30AMA%40mail.gmail.com Backpatch-through: 19 --- doc/src/sgml/ref/wait.sgml | 30 +++++++++++++++++++++---- src/backend/commands/wait.c | 6 +++-- src/test/recovery/t/049_wait_for_lsn.pl | 23 ++++++++++++++++++- 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/doc/src/sgml/ref/wait.sgml b/doc/src/sgml/ref/wait.sgml index cddcbf09695..8ec358f4fd4 100644 --- a/doc/src/sgml/ref/wait.sgml +++ b/doc/src/sgml/ref/wait.sgml @@ -260,10 +260,32 @@ WAIT FOR LSN 'lsn' WAIT must be executed as a top-level command. It cannot be executed from a function, procedure, or - DO block. It also requires that no active or - registered snapshot be held, and therefore cannot be used in contexts - where such a snapshot must remain active, including transactions running - at isolation levels higher than READ COMMITTED. + DO block. It also cannot be executed while the + current transaction holds a snapshot. A transaction running at an + isolation level higher than READ COMMITTED holds one + from its first query until it ends, so WAIT is + accepted before that query and rejected after it. An open cursor or an + exported snapshot has the same effect at any isolation level. + + + + The restriction above exists because such a snapshot could stand in + the way of the very replay being waited for: replaying a record that + removes rows the snapshot can still see raises a recovery conflict. + This conflict could be resolved by delaying the replay or by cancelling + this session. Having to run without a snapshot is also the reason why + WAIT is a command rather than a function or a procedure, + which execute with one held. + + + + The other standby modes are not independent of replay either, even though + they do not wait for it directly. Their positions advance as WAL arrives, + but WAL is recycled only at restartpoints, and a restartpoint cannot be + created past a checkpoint record that replay has not reached. While + replay is held back, pg_wal therefore keeps growing + past , and reception stops once the file + system fills. diff --git a/src/backend/commands/wait.c b/src/backend/commands/wait.c index 2223e91a3e3..86cb27ea2e1 100644 --- a/src/backend/commands/wait.c +++ b/src/backend/commands/wait.c @@ -13,6 +13,7 @@ */ #include "postgres.h" +#include "access/xact.h" #include "access/xlog.h" #include "access/xlogrecovery.h" #include "access/xlogwait.h" @@ -155,8 +156,9 @@ ExecWaitStmt(ParseState *pstate, WaitStmt *stmt, bool isTopLevel, if (HaveRegisteredOrActiveSnapshot()) ereport(ERROR, errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE), - errmsg("WAIT must be called without an active or registered snapshot"), - errdetail("WAIT cannot be executed within a transaction with an isolation level higher than READ COMMITTED.")); + errmsg("WAIT cannot be executed while the current transaction holds a snapshot"), + IsolationUsesXactSnapshot() ? + errdetail("This transaction runs at an isolation level higher than READ COMMITTED, so it holds a snapshot from its first query until it ends.") : 0); /* * As the result we should hold no snapshot, and correspondingly our xmin diff --git a/src/test/recovery/t/049_wait_for_lsn.pl b/src/test/recovery/t/049_wait_for_lsn.pl index 3bc936e0f91..e9b1dcfc810 100644 --- a/src/test/recovery/t/049_wait_for_lsn.pl +++ b/src/test/recovery/t/049_wait_for_lsn.pl @@ -282,13 +282,34 @@ $node_standby->psql( ok($stderr =~ /recovery is in progress/, "get an error when running primary_flush on the standby"); +# A transaction-snapshot-mode transaction has no snapshot until its first +# query, so WAIT FOR is allowed before that point. The target is already +# replayed here, so the command returns immediately. +$output = $node_standby->safe_psql( + 'postgres', + "BEGIN ISOLATION LEVEL REPEATABLE READ; WAIT FOR LSN '${lsn2}'; COMMIT;"); +ok($output eq "success", + "WAIT FOR is allowed before the transaction snapshot is taken"); + $node_standby->psql( 'postgres', "BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT 1; WAIT FOR LSN '${lsn3}';", stderr => \$stderr); -ok( $stderr =~ /WAIT must be called without an active or registered snapshot/, +ok( $stderr =~ /WAIT cannot be executed while the current transaction holds a snapshot/, "get an error when running in a transaction with an isolation level higher than REPEATABLE READ" ); +ok( $stderr =~ /isolation level higher than READ COMMITTED, so it holds a snapshot/, + "the isolation level is given as the reason when it is the reason"); + +# The same error at READ COMMITTED must not blame the isolation level. +$node_standby->psql( + 'postgres', + "BEGIN; DECLARE c CURSOR FOR SELECT 1; WAIT FOR LSN '${lsn3}';", + stderr => \$stderr); +ok( $stderr =~ /WAIT cannot be executed while the current transaction holds a snapshot/, + "get an error when a cursor holds a snapshot"); +unlike($stderr, qr/isolation level/, + "the isolation level is not blamed at READ COMMITTED"); # Test wrapping WAIT FOR into function, procedure, and anonymous DO block -- # should error -- 2.55.0