Received: from malur.postgresql.org ([217.196.149.56]) by arkaria.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (Exim 4.96) (envelope-from ) id 1wqp9j-002Qd1-0w for pgsql-hackers@arkaria.postgresql.org; Mon, 03 Aug 2026 09:40:23 +0000 Received: from localhost ([127.0.0.1] helo=malur.postgresql.org) by malur.postgresql.org with esmtp (Exim 4.96) (envelope-from ) id 1wqp9i-005Cda-0j for pgsql-hackers@arkaria.postgresql.org; Mon, 03 Aug 2026 09:40:22 +0000 Received: from makus.postgresql.org ([2001:4800:3e1:1::229]) by malur.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (Exim 4.96) (envelope-from ) id 1wqp9h-005CdR-2C for pgsql-hackers@lists.postgresql.org; Mon, 03 Aug 2026 09:40:21 +0000 Received: from mail-pf1-x436.google.com ([2607:f8b0:4864:20::436]) by makus.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 (Exim 4.98.2) (envelope-from ) id 1wqp9f-00000001gBq-25EG for pgsql-hackers@postgresql.org; Mon, 03 Aug 2026 09:40:20 +0000 Received: by mail-pf1-x436.google.com with SMTP id d2e1a72fcca58-84867f07d63so3709055b3a.2 for ; Mon, 03 Aug 2026 02:40:19 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20251104; t=1785750018; x=1786354818; darn=postgresql.org; h=content-transfer-encoding:content-type:mime-version:references :in-reply-to:message-id:date:subject:cc:to:from:from:to:cc:subject :date:message-id:reply-to:content-type; bh=cgIASSVYsQGwQyqppahfKPQATLmpLiWUsFhaFtdHTLo=; b=YO4TlgBSBg2ePjQTiP4mvX8Pab9JsEp5I5auSMXWz314REu4xuiFsfhTApzbCSXFIO 83mv3wpiLk9bteMvpU5V8/v4skFjgSgMi6MF7F3umGjoGYFkj37ElCqEV+w2DJjtQTGG HkGyJsNN9/qj3vtC15HCyFfaCPLdJctWR/U5KaNPkVT7BczTmfq/EhLQk5p38vdi0pZK g31uYku6CYuHZxSAYOIofEdFMHu4MaCNv936h8fFbKQeNvxSt4gRhGB2IVpHRcqlSVy8 h1MKQwdNlAefty0v3e4FOFJK2v9xQbEKXXY0Pzs5vTWQLJq1q3pFoBZy6mMjApsoCKoQ lnVA== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20251104; t=1785750018; x=1786354818; h=content-transfer-encoding:content-type:mime-version:references :in-reply-to:message-id:date:subject:cc:to:from:x-gm-gg :x-gm-message-state:from:to:cc:subject:date:message-id:reply-to :content-type; bh=cgIASSVYsQGwQyqppahfKPQATLmpLiWUsFhaFtdHTLo=; b=sde05w16hhJvniszgHwHxZL9hCoz9NWS7VeW3lLkDGBNGu0Ikmk3MRtfajEzuuS+ri jZdzFkoUOcSUny1cT+fzIsvjrF2KiBgGzrv2VzS/Rd1Air9rzuU9G3Uwv6W2OKPUy9Ax vDAbOzV5N/nw7ftdbcXs+sc/3CuBV/4Y9446+54hJXd4OvLq/tpky09ApkAMj7ExYGCC ZGoWyYTzPa3YfE0HFle++HAmVgAa3eWdXabaaoJOUAHWvzsWwAAGOnmXGn4el7xhw1OI +Y7Yamo59fPYJG8NJCoaMopQOglu3G6Nn6H+mCXuTzdxoQcHBLh+oCKgtJ7TK8s1R6ag qp3A== X-Gm-Message-State: AOJu0YwnDDrOpgvCcAZQnQ6UOY4T5WBGKG9xZcafkWjD8wSdE4XKUBY9 lDvMn0AOl2pmWoQgObRpRSJ5NiAQGd3wGy0KQbwBITw7iCPXtlvHzecf/NJE4QJqRKQ= X-Gm-Gg: AR+sD13RZRQMP1n7VIYnJM+PBQVKoRiGwKpqVe3hg3QDV3naiIo8vzn4gi511G9h7Iw eb66Be4y00H5eLE/1ngG9Riv2UUY0Z2VFjei6STae8+tsTlkbhzEaAPxKjn3rPs591sRfwv4C/Y hvswJavi2r7txuYkq8oqRFGCiMqqQa3QQFg/0LrCgTXV8ecWyI5/9s7ijWsNsJuzlPk05Q21vue YVktvdQT53V9y+LEHIyctD8didYvaEln4O5VvuWY77nw/8MnzBGnZPlk5wtRW9FY+8wGpRqemft vRHGWfmwCi2vFNmBcCiNB1O7B56mibH/N5AuSbV0HN0tVFx1g85FbWNZrgFaRcs9YDUY6tD849u g++3FqlKqOPpio4SlU3r4sFuOXTNe181BL8uI1oTe83ohN0NQWPf5XfNifQZrcjBw/r/hBwFCBX v4H5q6YYc77BLCJciQUQcS+XkVvSVd0/T1AdBwtjjVkLYLsbGV9b2gXas= X-Received: by 2002:a05:6a00:3d48:b0:847:926c:582c with SMTP id d2e1a72fcca58-84ee484515cmr8614438b3a.29.1785750018274; Mon, 03 Aug 2026 02:40:18 -0700 (PDT) Received: from redmi.lan ([1.85.10.142]) by smtp.gmail.com with ESMTPSA id 41be03b00d2f7-cbe3963e2aasm3352076a12.2.2026.08.03.02.40.16 (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256); Mon, 03 Aug 2026 02:40:17 -0700 (PDT) From: "chee.wooson" To: pgsql-hackers@postgresql.org Cc: "chee.wooson" Subject: [PATCH v4] Fix exported snapshot xmin handoff race Date: Mon, 3 Aug 2026 17:40:08 +0800 Message-ID: <20260803094009.4021947-1-chee.wooson@gmail.com> X-Mailer: git-send-email 2.43.0 In-Reply-To: <20260729101045.422679-1-chee.wooson@gmail.com> References: <20260729101045.422679-1-chee.wooson@gmail.com> MIME-Version: 1.0 Content-Type: multipart/mixed; boundary="----=_export_snapshot_xmin_race_v4" Content-Transfer-Encoding: 8bit List-Id: List-Help: List-Subscribe: List-Post: List-Owner: List-Archive: Archived-At: Precedence: bulk ------=_export_snapshot_xmin_race_v4 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hi, The v2 patch had TAP test failures on some CommitFest platforms. I prepared v3 to fix those test issues, but accidentally sent it inline instead of as a patch attachment. Attached is v4. It has the same code changes as v3, but is sent as an attachment so that the CommitFest app and cfbot can process it properly. Thanks, Chee ------=_export_snapshot_xmin_race_v4 Content-Type: text/x-patch; charset=UTF-8; name="v4-0001-Fix-exported-snapshot-xmin-handoff-race.patch" Content-Disposition: attachment; filename="v4-0001-Fix-exported-snapshot-xmin-handoff-race.patch" Content-Transfer-Encoding: 8bit From 9429a028eceb522ff8d2e558d47ca296286d6ae3 Mon Sep 17 00:00:00 2001 From: "chee.wooson" Date: Thu, 30 Jul 2026 12:00:40 +0800 Subject: [PATCH v4] Fix exported snapshot xmin handoff race ProcArrayInstallImportedXmin() verifies that the source transaction is still running and then installs the imported xmin. Both steps have to be serialized with concurrent proc-array horizon computations; otherwise VACUUM can scan the importer before the xmin is installed while the source transaction is still allowed to clear its xmin concurrently. Take ProcArrayLock in exclusive mode while importing an exported snapshot. That makes the xmin handoff atomic with respect to ComputeXidHorizons(), while keeping the no-importer transaction end path unchanged. Add an injection-point TAP test that pauses VACUUM inside ComputeXidHorizons() while it holds ProcArrayLock shared, starts SET TRANSACTION SNAPSHOT concurrently, and verifies that the importer waits for ProcArrayLock before the imported snapshot is allowed to protect the deleted tuple. --- src/backend/commands/vacuum.c | 1 + src/backend/storage/ipc/procarray.c | 12 +- src/test/modules/test_misc/meson.build | 1 + .../test_misc/t/015_export_snapshot.pl | 230 ++++++++++++++++++ 4 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 src/test/modules/test_misc/t/015_export_snapshot.pl diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c index 38539a6fd3d..d41dc5c679a 100644 --- a/src/backend/commands/vacuum.c +++ b/src/backend/commands/vacuum.c @@ -1139,6 +1139,7 @@ vacuum_get_cutoffs(Relation rel, const VacuumParams *params, * that only one vacuum process can be working on a particular table at * any time, and that each vacuum is always an independent transaction. */ + INJECTION_POINT("vacuum-get-cutoffs-before-oldest-xmin", NULL); cutoffs->OldestXmin = GetOldestNonRemovableTransactionId(rel); Assert(TransactionIdIsNormal(cutoffs->OldestXmin)); diff --git a/src/backend/storage/ipc/procarray.c b/src/backend/storage/ipc/procarray.c index 60336b31803..9ce5dcfcb68 100644 --- a/src/backend/storage/ipc/procarray.c +++ b/src/backend/storage/ipc/procarray.c @@ -1740,6 +1740,16 @@ ComputeXidHorizons(ComputeXidHorizonsResult *h) xid = UINT32_ACCESS_ONCE(other_xids[index]); xmin = UINT32_ACCESS_ONCE(proc->xmin); +#ifdef USE_INJECTION_POINTS + { + char ip_name[64]; + + snprintf(ip_name, sizeof(ip_name), + "compute-xid-horizons-after-reading-pid-%d", proc->pid); + InjectionPointRun(ip_name, NULL); + } +#endif + /* * Consider both the transaction's Xmin, and its Xid. * @@ -2488,7 +2498,7 @@ ProcArrayInstallImportedXmin(TransactionId xmin, return false; /* Get lock so source xact can't end while we're doing this */ - LWLockAcquire(ProcArrayLock, LW_SHARED); + LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE); /* * Find the PGPROC entry of the source transaction. (This could use diff --git a/src/test/modules/test_misc/meson.build b/src/test/modules/test_misc/meson.build index ee290698b31..eb48ee35d1d 100644 --- a/src/test/modules/test_misc/meson.build +++ b/src/test/modules/test_misc/meson.build @@ -23,6 +23,7 @@ tests += { 't/012_ddlutils.pl', 't/013_temp_obj_multisession.pl', 't/014_log_statement_max_length.pl', + 't/015_export_snapshot.pl', ], # The injection points are cluster-wide, so disable installcheck 'runningcheck': false, diff --git a/src/test/modules/test_misc/t/015_export_snapshot.pl b/src/test/modules/test_misc/t/015_export_snapshot.pl new file mode 100644 index 00000000000..d7a1eebc469 --- /dev/null +++ b/src/test/modules/test_misc/t/015_export_snapshot.pl @@ -0,0 +1,230 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group +# +# Test: reproduce the exported-snapshot xmin handoff race. +# +# Strategy: +# 1. Source exports a snapshot that can still see a tuple deleted later. +# 2. Importer starts a transaction but does not yet import the snapshot, so +# its xmin is Invalid. +# 3. VACUUM reaches vacuum_get_cutoffs(), then waits after +# ComputeXidHorizons() has read the importer's still-Invalid xmin. +# 4. Without the fix, SET TRANSACTION SNAPSHOT can complete while VACUUM is +# paused in the proc-array scan. The test then commits the source +# transaction and wakes VACUUM, allowing VACUUM to miss both the importer +# and the source xmin and remove the deleted tuple. +# 5. With the fix, SET TRANSACTION SNAPSHOT waits for ProcArrayLock +# exclusive. The test wakes VACUUM while the source is still open, so +# VACUUM sees the source xmin before the importer installs the snapshot. +# +# The final query must see the deleted tuple through the imported snapshot. +# On an unfixed server it instead sees zero rows. +# +# Depends only on: injection_points (built-in test module) + +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; +use Time::HiRes qw(usleep); + +if ($ENV{enable_injection_points} ne 'yes') +{ + plan skip_all => 'Injection points not supported by this build'; +} + +my $node = PostgreSQL::Test::Cluster->new('export_race_node'); +$node->init; +$node->append_conf('postgresql.conf', + "shared_preload_libraries = 'injection_points'"); +$node->start; + +$node->safe_psql('postgres', 'CREATE EXTENSION injection_points'); + +$node->safe_psql('postgres', qq{ + CREATE TABLE race_test (id int, data text); + INSERT INTO race_test VALUES (1, 'should_be_visible'); +}); + +# Create the importer before the source. The test does not depend on a fixed +# proc-array index, but the wrong-horizon interleaving requires VACUUM to read +# the importer's Invalid xmin before it reads the source's xmin. +my $imp = $node->background_psql('postgres'); +my $src = $node->background_psql('postgres'); +my $vac = $node->background_psql('postgres'); +my $del = $node->background_psql('postgres'); +my $coord = $node->background_psql('postgres'); + +my $imp_pid = $imp->query("SELECT pg_backend_pid()"); +$imp_pid =~ s/\s+//g; +my $after_reading_importer_ip = + "compute-xid-horizons-after-reading-pid-$imp_pid"; + +$src->query("BEGIN ISOLATION LEVEL REPEATABLE READ"); +$src->query("SELECT * FROM race_test"); +my $token = $src->query("SELECT pg_export_snapshot()"); +$token =~ s/\s+//g; +diag("exported snapshot token: $token"); + +$del->query("DELETE FROM race_test WHERE id = 1"); +$del->query("COMMIT"); +diag("deleter committed"); + +# Advance the XID counter so that the horizon (latestCompletedXid + 1 when +# no backend has a valid xmin) is strictly greater than the deleter's XID. +for (my $i = 0; $i < 100; $i++) +{ + $node->safe_psql('postgres', "SELECT txid_current()"); +} +diag("100 filler XIDs consumed"); + +# No query after BEGIN: importer xmin stays Invalid until SET TRANSACTION +# SNAPSHOT, which is essential for this race. +$imp->query("BEGIN ISOLATION LEVEL REPEATABLE READ"); + +my $vac_pid = $vac->query("SELECT pg_backend_pid()"); +$vac_pid =~ s/\s+//g; +diag("VACUUM PID: $vac_pid"); + +$vac->query("SELECT injection_points_set_local()"); +$vac->query( + "SELECT injection_points_attach('vacuum-get-cutoffs-before-oldest-xmin', 'wait')"); +diag("VACUUM attached vacuum-get-cutoffs-before-oldest-xmin"); + +$vac->query_until(qr/vac_started/, + "\\echo vac_started\nVACUUM race_test;\n"); +diag("VACUUM started"); + +{ + my $blocked = 0; + for (my $i = 0; $i < 1800; $i++) + { + my $result = $coord->query( + "SELECT count(*) = 1 FROM pg_stat_activity" + . " WHERE pid = $vac_pid" + . " AND wait_event_type = 'InjectionPoint'" + . " AND wait_event = 'vacuum-get-cutoffs-before-oldest-xmin'"); + if ($result =~ /t/) + { + $blocked = 1; + last; + } + usleep(100_000); + } + die "VACUUM did not reach vacuum_get_cutoffs within 180s" + unless $blocked; +} +diag("VACUUM blocked before computing VACUUM cutoffs"); + +$coord->query( + "SELECT injection_points_detach('vacuum-get-cutoffs-before-oldest-xmin')"); +diag("detached vacuum-get-cutoffs-before-oldest-xmin"); + +$coord->query( + "SELECT injection_points_attach('$after_reading_importer_ip', 'wait')"); +diag("coordinator attached $after_reading_importer_ip"); + +$coord->query( + "SELECT injection_points_wakeup('vacuum-get-cutoffs-before-oldest-xmin')"); +diag("woke VACUUM from vacuum_get_cutoffs"); + +{ + my $blocked = 0; + for (my $i = 0; $i < 1800; $i++) + { + my $result = $coord->query( + "SELECT count(*) = 1 FROM pg_stat_activity" + . " WHERE pid = $vac_pid" + . " AND wait_event_type = 'InjectionPoint'" + . " AND wait_event = '$after_reading_importer_ip'"); + if ($result =~ /t/) + { + $blocked = 1; + last; + } + usleep(100_000); + } + die "VACUUM did not scan the importer within 180s" unless $blocked; +} +diag("VACUUM blocked after reading importer xmin"); + +$imp->query_until(qr/import_started/, + "\\echo import_started\nSET TRANSACTION SNAPSHOT '$token';\n" + . "\\echo import_done\n"); +diag("importer started SET TRANSACTION SNAPSHOT"); + +my $importer_waiting = 0; +my $importer_wait_state = ''; +for (my $i = 0; $i < 100; $i++) +{ + my $result = $coord->query( + "SELECT COALESCE(state, '') || '|' ||" + . " COALESCE(wait_event_type, '') || '|' ||" + . " COALESCE(wait_event, '')" + . " FROM pg_stat_activity" + . " WHERE pid = $imp_pid"); + $importer_wait_state = $result; + if ($result =~ /active\|LWLock\|ProcArray/) + { + $importer_waiting = 1; + last; + } + last if $result eq ''; + usleep(100_000); +} + +if ($importer_waiting) +{ + $coord->query("SELECT injection_points_wakeup('$after_reading_importer_ip')"); + $coord->query("SELECT injection_points_detach('$after_reading_importer_ip')"); + diag("woke VACUUM while source transaction is still open"); + $imp->query_until(qr/import_done/, ""); + $src->query("COMMIT"); +} +else +{ + $imp->query_until(qr/import_done/, ""); + $src->query("COMMIT"); + diag("source committed before waking VACUUM"); + $coord->query("SELECT injection_points_wakeup('$after_reading_importer_ip')"); + $coord->query("SELECT injection_points_detach('$after_reading_importer_ip')"); +} + +ok($importer_waiting, + "importing transaction waits for ProcArrayLock while VACUUM computes horizons"); + +{ + my $done = 0; + for (my $i = 0; $i < 1800; $i++) + { + my $result = $coord->query( + "SELECT count(*) = 1 FROM pg_stat_activity" + . " WHERE pid = $vac_pid" + . " AND state = 'idle'"); + if ($result =~ /t/) + { + $done = 1; + last; + } + usleep(100_000); +} +chomp($importer_wait_state); +diag("importer wait state: $importer_wait_state"); + die "VACUUM did not finish within 180s" unless $done; +} +diag("VACUUM finished"); + +my $count = $imp->query("SELECT count(*) FROM race_test"); +$count =~ s/\s+//g; +diag("importer sees $count row(s)"); + +is($count, 1, + "imported snapshot still sees the row after concurrent VACUUM") + or diag("BUG DETECTED: export-snapshot xmin race caused " + . "premature tuple removal (expected 1 row, got $count)"); + +$imp->query("COMMIT"); + +$node->stop; +done_testing(); -- 2.43.0 ------=_export_snapshot_xmin_race_v4--