From 2c559fa2e2116480d3a1042a2140773a707331bf Mon Sep 17 00:00:00 2001 From: shihao zhong Date: Tue, 20 Oct 2026 07:44:09 -0400 Subject: [PATCH v2 2/2] Add a test for the WAL receipt time used by recovery conflicts Check that a standby keeping up with a busy primary waits for max_standby_streaming_delay before canceling a conflicting query, and that a standby that is really behind cancels it at once. Discussion: https://postgr.es/m/ymyivl5bw7qwd3bhduxmv5kytsl6q4nstjtledw5l6nr7sdwcw@nl7egmss5l2m --- src/test/recovery/meson.build | 1 + .../recovery/t/062_standby_receipt_time.pl | 118 ++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 src/test/recovery/t/062_standby_receipt_time.pl diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build index b6ac9dd07a7..fedad654e64 100644 --- a/src/test/recovery/meson.build +++ b/src/test/recovery/meson.build @@ -69,6 +69,7 @@ tests += { 't/058_shutdown_crash_restart.pl', 't/059_remote_apply_status_interval.pl', 't/060_multixact_crashed_updater.pl', + 't/062_standby_receipt_time.pl', ], }, } diff --git a/src/test/recovery/t/062_standby_receipt_time.pl b/src/test/recovery/t/062_standby_receipt_time.pl new file mode 100644 index 00000000000..85dc68eb671 --- /dev/null +++ b/src/test/recovery/t/062_standby_receipt_time.pl @@ -0,0 +1,118 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Check that max_standby_streaming_delay is measured from a sane WAL receipt +# time. A standby that keeps up with a busy primary must give conflicting +# queries their grace period, while a standby that is really behind must +# cancel them without waiting. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node_primary = PostgreSQL::Test::Cluster->new('primary'); +$node_primary->init(allows_streaming => 1); +$node_primary->append_conf('postgresql.conf', 'autovacuum = off'); +$node_primary->start; + +$node_primary->safe_psql('postgres', 'CREATE TABLE conflict_tab (a int)'); +# The load runs in its own database so that it does not hold back the removal +# horizon of conflict_tab. +$node_primary->safe_psql('postgres', 'CREATE DATABASE loaddb'); +$node_primary->safe_psql( + 'loaddb', q[ +CREATE TABLE load_tab (a int); +CREATE PROCEDURE gen_load(n int) LANGUAGE plpgsql AS $$ +BEGIN + FOR i IN 1..n LOOP + INSERT INTO load_tab VALUES (i); + COMMIT; + PERFORM pg_sleep(0.05); + END LOOP; +END $$; +]); + +$node_primary->backup('backup'); +my $node_standby = PostgreSQL::Test::Cluster->new('standby'); +$node_standby->init_from_backup($node_primary, 'backup', + has_streaming => 1); +# The apply delay makes walreceiver flush several chunks while the startup +# process is replaying, which is what used to leave the receipt time stale. +$node_standby->append_conf( + 'postgresql.conf', q[ +recovery_min_apply_delay = 1s +max_standby_streaming_delay = 5s +hot_standby_feedback = off +log_recovery_conflict_waits = on +deadlock_timeout = 1s +]); +$node_standby->start; + +my $load = $node_primary->background_psql('loaddb'); +my $standby_session = $node_standby->background_psql('postgres', + on_error_stop => 0); + +# Generate a snapshot conflict while a standby transaction holds a snapshot, +# and return the standby log written until the session was canceled. +sub run_conflict +{ + my ($before_conflict, $after_conflict) = @_; + + $node_primary->safe_psql('postgres', + 'INSERT INTO conflict_tab SELECT generate_series(1, 100)'); + $node_primary->wait_for_replay_catchup($node_standby); + + $standby_session->query_safe( + q[BEGIN ISOLATION LEVEL REPEATABLE READ; + SELECT count(*) FROM conflict_tab;]); + + my $log_offset = -s $node_standby->logfile; + $before_conflict->(); + $node_primary->safe_psql('postgres', 'DELETE FROM conflict_tab'); + $node_primary->safe_psql('postgres', 'VACUUM conflict_tab'); + $after_conflict->(); + + $node_standby->wait_for_log(qr/conflict with recovery/, $log_offset); + $standby_session->reconnect_and_clear(); + $node_primary->wait_for_replay_catchup($node_standby); + return slurp_file($node_standby->logfile, $log_offset); +} + +# The primary commits 20 times per second for longer than the grace period +# before the conflict, while the standby stays about a second behind. +$load->query_until(qr/load started/, "\\echo load started\nCALL gen_load(200);\n"); +my $log = run_conflict(sub { sleep(7); }, sub { }); +like( + $log, + qr/recovery still waiting after .*recovery conflict on snapshot.*conflict with recovery/s, + 'standby keeping up with a busy primary waits before canceling'); + +# Now the standby falls behind by more than the grace period, so the +# conflicting query must be canceled without waiting. +$load->query_safe('SELECT 1'); +$node_primary->wait_for_replay_catchup($node_standby); +$load->query_until(qr/load started/, "\\echo load started\nCALL gen_load(200);\n"); +$log = run_conflict( + sub { + $node_standby->safe_psql('postgres', 'SELECT pg_wal_replay_pause()'); + $node_standby->poll_query_until('postgres', + "SELECT pg_get_wal_replay_pause_state() = 'paused'") + or die "timed out waiting for replay to pause"; + }, + sub { + sleep(7); + # This session holds a snapshot from before the conflict too, so it + # may be canceled as soon as replay resumes. The resume itself has + # taken effect by then, so ignore the error. + $node_standby->psql('postgres', 'SELECT pg_wal_replay_resume()'); + }); +like($log, qr/conflict with recovery/, 'lagging standby cancels query'); +unlike($log, qr/recovery still waiting/, + 'lagging standby cancels query without waiting'); + +$load->quit; +$standby_session->quit; + +done_testing(); -- 2.37.1 (Apple Git-137.1)