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 1wqp1X-002QYU-2O for pgsql-hackers@arkaria.postgresql.org; Mon, 03 Aug 2026 09:31:55 +0000 Received: from localhost ([127.0.0.1] helo=malur.postgresql.org) by malur.postgresql.org with esmtp (Exim 4.96) (envelope-from ) id 1wqp1U-0056SA-20 for pgsql-hackers@arkaria.postgresql.org; Mon, 03 Aug 2026 09:31:52 +0000 Received: from magus.postgresql.org ([2a02:c0:301:0:ffff::29]) by malur.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (Exim 4.96) (envelope-from ) id 1wqp1U-0056Rm-0v for pgsql-hackers@lists.postgresql.org; Mon, 03 Aug 2026 09:31:52 +0000 Received: from mail-pf1-x42e.google.com ([2607:f8b0:4864:20::42e]) by magus.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 (Exim 4.98.2) (envelope-from ) id 1wqp1S-00000001kdG-0cDr for pgsql-hackers@postgresql.org; Mon, 03 Aug 2026 09:31:52 +0000 Received: by mail-pf1-x42e.google.com with SMTP id d2e1a72fcca58-8487088510aso3199432b3a.0 for ; Mon, 03 Aug 2026 02:31:49 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20251104; t=1785749507; x=1786354307; darn=postgresql.org; h=content-transfer-encoding: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=4xOuaEJMYJCil+vlN+WjO/mNs8/bO5ZNa46FEna6Qw4=; b=oQr2A+EUAtN+G98psMojPssyRA0dw41eKVZFSPjyAEi2dfeDs+zi4YrAj2bnADPKTs 1St//raV8cFR1yGlInHopv2zpveBobbJit1jWc6QM7fSBcuEsGovxfH6W6/uicYQvGaP FDRE1Zfse9Lg+g24liUk0KzMrPL7K8I8eENSH99YTWjfOKU92aIAEPFpOezMbbpMDzPY 11jy/coYPXgJpP3nAaWFEJKv/mj9pM1KFkl0QK2uUYN4GL/gyPnGbk2ZWQyty6MWIQeU MhJXpC53wViVP4ZZFv0ojwoOtTBvrIiZYojD6m3DDy5On0lTxzWlWDNg00FAP60fUUgy Fj6A== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20251104; t=1785749507; x=1786354307; h=content-transfer-encoding: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=4xOuaEJMYJCil+vlN+WjO/mNs8/bO5ZNa46FEna6Qw4=; b=GMueo5tBcS6cKR1Jw1V+jufkDG8udUpLZ7A1oIkTY8gkH8DWHkmUQF3sewbettw5FU YvlVtEPZO55I4OlmBCwA6JG7Pak1xDeZtO7wNDCyQTUihRZFSW4awKL9OwFCLnA9ugDG wsWueTaXjXRvh8VLZq6725dYtaJQKR1MZl6DsIGE0AO3I+qOQ0Hx7PFVkgUaJlSfc+OZ 8Ulj4H7JPoPdIcVPlVMc5UNhAGdP9WrWhzolGeRedIplePbHvWZA5IVJ95sZQU2t6AQY SMOz15jsMWk94qfMoz/TL2v6LTg0C5STt5v/rt5gQAON1vUi2HZBgXAGPL7f+jTGhxZc 7Yfg== X-Gm-Message-State: AOJu0YxEsM+BaS7ESb9GQcUOMofEHE679Ul9DsRCSDhq2iysRAz3gCbb fIO2Kn9kf3gazrOTBjCt5w5+1eyrJQTDGrvERkj9uPcQPSegSFaKaXD1pBFXzQ== X-Gm-Gg: AR+sD11+3cFNjUM/de4Rbw+pjz5rmnHXmbyWZqGUJVH28zvGKISbbehw/mkom8BVNSy Rr6oSE7t9rstpBKXr5SYmd4om33b8A7pVTfsMwenZso3pKRT+5eytNNyMQRFr3TmA2H2QKCfrP3 DSLfL7a7y/HK/8WpJ+ID+MTgqce1jb/NWaRBCFH6/CSjMz1g9SnclrVKQtdac0BX/86oCFt7zop +IKEU8iQyDZ+IuuudDWN0kYpRv76EijxPHlsC5VLh+QNkm4fgPYp8E0Gsp6fIl4yh/TC2MfQyw0 pJNlyLj6hCMAW49F9hgBt300fICQvdypTT/lOMP5CGCU3cjiA3Zbe2qc8yKqpI65xC7zrjOHpZb 5ns/TBY5K/QZyXSy9ZvYH+8m+KvHEBklauipvkxOnm4AwX8pWJ6ubCNZBVVPtDJw7cFmw6e0QPv FM4G3FCHcur/wMWCQErl4A5qNqbLWlHM44FZKEsZYg9ylG79STB2U0kTE= X-Received: by 2002:a05:6a00:8c2:b0:845:e19a:1188 with SMTP id d2e1a72fcca58-84ee48d167dmr7871995b3a.52.1785749507404; Mon, 03 Aug 2026 02:31:47 -0700 (PDT) Received: from redmi.lan ([1.85.10.142]) by smtp.gmail.com with ESMTPSA id d2e1a72fcca58-84edc2a8223sm3364634b3a.28.2026.08.03.02.31.44 (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256); Mon, 03 Aug 2026 02:31:46 -0700 (PDT) From: "chee.wooson" To: pgsql-hackers@postgresql.org Cc: "chee.wooson" Subject: [PATCH v3] Fix exported snapshot xmin handoff race Date: Mon, 3 Aug 2026 17:31:41 +0800 Message-ID: <20260803093141.4010626-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-Transfer-Encoding: 8bit List-Id: List-Help: List-Subscribe: List-Post: List-Owner: List-Archive: Archived-At: Precedence: bulk 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