From 5c4e1c43b66a2a0e23c63e6382bb80d8be5a58d4 Mon Sep 17 00:00:00 2001
From: Zsolt Parragi <zsolt.parragi@percona.com>
Date: Tue, 22 Sep 2026 21:16:06 +0000
Subject: [PATCH] Fix crash in logical replication apply worker at
 SERIALIZABLE.

Commit fb60892f403 made check_exclusion_or_unique_constraint() read the
conflicting tuple again with estate->es_snapshot at SERIALIZABLE, so
that SSI sees the read.  That assumed the EState came from
ExecutorStart(), which always sets es_snapshot.  The logical replication
apply worker builds its EState with CreateExecutorState() and never set
it, so detecting an insert_exists or update_exists conflict at
SERIALIZABLE handed a NULL snapshot to heap_fetch() and the worker died
with a segmentation fault.  The subscriber then restarted, replayed the
same change and crashed again, indefinitely.

The apply worker starts its transactions at the default isolation
level, so setting default_transaction_isolation to serializable for the
subscriber's database or for the subscription owner is enough to reach
this.

To fix, set es_snapshot in the apply worker's EState to the snapshot
that begin_replication_step() has already pushed for the change, as
executor code expects.
---
 src/backend/replication/logical/worker.c |  8 +++++
 src/test/subscription/t/035_conflicts.pl | 40 ++++++++++++++++++++++++
 2 files changed, 48 insertions(+)

diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 7781bb1c168..d166848ffe3 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -931,6 +931,14 @@ create_edata_for_relation(LogicalRepRelMapEntry *rel)
 
 	estate->es_output_cid = GetCurrentCommandId(true);
 
+	/*
+	 * Executor routines read the relation through es_snapshot.  Give them the
+	 * snapshot begin_replication_step() pushed for this change; the estate is
+	 * gone again before that snapshot is popped, so there is no need to
+	 * register it.
+	 */
+	estate->es_snapshot = GetActiveSnapshot();
+
 	/* Prepare to catch AFTER triggers. */
 	AfterTriggerBeginQuery();
 
diff --git a/src/test/subscription/t/035_conflicts.pl b/src/test/subscription/t/035_conflicts.pl
index 5804e38ed69..b9fc931c8cb 100644
--- a/src/test/subscription/t/035_conflicts.pl
+++ b/src/test/subscription/t/035_conflicts.pl
@@ -119,6 +119,46 @@ pass('multiple_unique_conflicts detected during update');
 # Truncate table to get rid of the error
 $node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
 
+##################################################
+# Test conflict detection at SERIALIZABLE
+##################################################
+
+# The apply worker runs its transactions at the subscriber's default
+# isolation level.  Make that serializable, and restart the worker so that
+# it picks up the new setting.
+$node_subscriber->safe_psql('postgres',
+	"ALTER DATABASE postgres SET default_transaction_isolation = 'serializable'"
+);
+$node_subscriber->safe_psql('postgres', "ALTER SUBSCRIPTION sub_tab DISABLE");
+$node_subscriber->poll_query_until('postgres',
+	"SELECT count(*) = 0 FROM pg_stat_activity WHERE backend_type = 'logical replication apply worker'"
+);
+$node_subscriber->safe_psql('postgres', "ALTER SUBSCRIPTION sub_tab ENABLE");
+
+$log_offset = -s $node_subscriber->logfile;
+
+# Insert data in the subscriber table
+$node_subscriber->safe_psql('postgres',
+	"INSERT INTO conf_tab VALUES (9,9,9);");
+
+# Insert a row with the same key in the publisher table
+$node_publisher->safe_psql('postgres',
+	"INSERT INTO conf_tab VALUES (9,10,11);");
+
+# Confirm that this causes an error on the subscriber
+$node_subscriber->wait_for_log(
+	qr/conflict detected on relation \"public.conf_tab\": conflict=insert_exists.*
+.*Could not apply remote change: remote row \(9, 10, 11\).*
+.*Key already exists in unique index \"conf_tab_pkey\", modified in transaction .*: key \(a\)=\(9\), local row \(9, 9, 9\)./,
+	$log_offset);
+
+pass('insert_exists detected at serializable isolation');
+
+# Truncate table to get rid of the error, and restore the default isolation
+# level
+$node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
+$node_subscriber->safe_psql('postgres',
+	"ALTER DATABASE postgres RESET default_transaction_isolation");
 
 ##################################################
 # Test multiple_unique_conflicts due to INSERT on a leaf partition
-- 
2.55.0

