On Tue, Jul 14, 2026 at 2:02 PM Masahiko Sawada <[email protected]> wrote:
>
> On Thu, Jul 9, 2026 at 9:23 PM Srinath Reddy Sadipiralla
> <[email protected]> wrote:
> >
> >
> >
> > ---------- Forwarded message ---------
> > From: Srinath Reddy Sadipiralla <[email protected]>
> > Date: Fri, Jul 10, 2026 at 9:48 AM
> > Subject: Fix "unexpected logical decoding status change" error; from 
> > concurrent logical decoding activation
> > To: PostgreSQL Hackers <[email protected]>
> > Cc: Masahiko Sawada <[email protected]>
> >
> >
> > Hi,
> >
> > While stress-testing REPACK CONCURRENTLY, I was in that
> > area looking at BUG #19519 (REPACK failing with "missingchunk
> > number N for toast value" [0]; note that one reproduces with plain
> > REPACK too, since it's the shared copy_table_data() path, so
> > it's independent of what follows), I hit a different, reproducible
> > failure under high concurrency:
> >     ERROR:  unexpected logical decoding status change 1
> >
> > I believe this is a race bug in the on-demand logical decoding
> > activation machinery (logicalctl.c), exposed by
> > REPACK CONCURRENTLY but not actually specific to it, as
> > this can be reproduced using pg_create_logical_replication_slot and 
> > CREATE_REPLICATION_SLOT.
> >
> > At wal_level = 'replica', logical decoding is toggled on the first time a
> > logical slot is created.  EnableLogicalDecoding() sets the shared
> > logical_decoding_enabled flag and writes an
> > XLOG_LOGICAL_DECODING_STATUS_CHANGE record so standbys
> > learn about the change.  On the decoding side, xlog_decode() treats
> > that record as unreachable:
> > elog(ERROR, "unexpected logical decoding status change %d", ...);
> >
> > The reasoning (per the comment there) is that no running decoder can ever
> > have such a record within the LSN range it scans: there is exactly one
> > enable record per disabled->enabled transition, and it precedes the
> > decoding start point reserved by any slot.
> >
> > That case does not hold under concurrency.  EnableLogicalDecoding()
> > does:
> >
> >     LWLockAcquire(LogicalDecodingControlLock, EXCLUSIVE);
> >     if (logical_decoding_enabled)     /* already on? -> done */
> >         { ... return; }
> >     LWLockRelease(...);
> >
> >     WaitForProcSignalBarrier(...);    /* lock released here */
> >
> >     LWLockAcquire(LogicalDecodingControlLock, EXCLUSIVE);
> >     logical_decoding_enabled = true;
> >     write_logical_decoding_status_update_record(true);   /* the record */
> >     LWLockRelease(...);
> >
> > The "already enabled?" check and the record write happen under two
> > separate lock acquisitions, with the barrier wait in between.  The lock
> > must be dropped across the barrier; backends absorbing the barrier
> > take LogicalDecodingControlLock in shared mode (via
> > IsXLogLogicalInfoEnabled()), so holding it across WaitForProcSignalBarrier()
> > would deadlock.
> >
> > So if two backends create the first logical slot(s) at the same time,
> > both can pass the initial check while the flag is still false, and both
> > end up writing a status-change record.  The second (redundant) record
> > lands after the decoding start point the other slot already reserved,
> > so that slot scans it and trips the elog() above.
> >
> > REPACK CONCURRENTLY makes this easy to hit because each operation
> > creates a temporary logical slot (max_repack_replication_slots), so firing
> > many REPACKs in parallel from the disabled state gives you many backends
> > racing to perform the very first activation.  But nothing here is 
> > REPACK-specific.
> >
> > To reproduce this i have added a test using logical-decoding-activation
> > injection point in 051_effective_wal_level.pl
> >
> > I also hit it with a stress script [1] which was again related to the
> > BUG #19519, instead of vacuum full i replaced it with REPACK (concurrently).
> >
> > To fix this I re-checked the flag after re-acquiring the lock and skipped
> > the state change/WAL write if another backend had already completed
> > the transition.
> >
> >     LWLockAcquire(LogicalDecodingControlLock, EXCLUSIVE);
> >
> >     if (LogicalDecodingCtl->logical_decoding_enabled)
> >     {
> >         LogicalDecodingCtl->pending_disable = false;
> >         LWLockRelease(LogicalDecodingControlLock);
> >         return;
> >     }
> >
> >     START_CRIT_SECTION();
> >
> > With this, only the backend that actually performs the disabled->enabled
> > transition writes a record, and every slot's start point stays at or after
> > it, restoring the case xlog_decode() depends on.
> >
> > Patch attached.  It fixes logicalctl.c and adds a regression test to
> > 051_effective_wal_level.pl reusing the existing injection-point
> > infrastructure.  With the fix the test passes; without it it fails with
> > the exact error above.
>
> Thank you for the report and the patch!
>
> I agree with your analysis and the patch basically looks good to me.
> Here are some review comments:
>
> + # Let the released backend finish creating its slot: feed running-xacts
> + # records until it reaches a consistent point (poll_query_until re-runs the
> + # query, so pg_log_standby_snapshot() is called until the slot is created).
> + $primary->poll_query_until(
> + 'postgres', qq[
> +select (pg_log_standby_snapshot() is not null)
> +  and exists (select 1 from pg_replication_slots
> +              where slot_name = 'slot_stray' and confirmed_flush_lsn
> is not null)
> +]);
>
> I don't think we need to call pg_log_standby_snapshot() until the slot
> is created since the slot creation writes the running-xacts record
> during the slot creation.
>
> ---
> + # Decoding the first slot must not stumble over a stray status-change 
> record.
> + my ($decode_rc, $decode_out, $decode_err) = $primary->psql(
> + 'postgres',
> + qq[select count(*) from pg_logical_slot_get_changes('slot_first',
> null, null)],
> + on_error_die => 0);
> + is($decode_rc, 0, "decoding a concurrently-created slot succeeds");
> + unlike(
> + $decode_err,
> + qr/unexpected logical decoding status change/,
> + "no redundant status-change record was decoded");
>
> We can use safe_psql() to check if the query successfully completes.
>
> I've made some cosmetic changes to the comment and the new test
> including the above comments. Please review it.

While reviewing concurrency aspects of this code before pushing the
fix, I found other race conditions in the same area: logical decoding
can be deactivated while a logical slot is being created on a standby.

On standbys, logical decoding can be deactivated while a logical slot
is being created: either by replaying an
XLOG_LOGICAL_DECODING_STATUS_CHANGE record, or by the end-of-recovery
transition upon promotion, which deactivates logical decoding if no
valid logical slot exists. When logical decoding is deactivated, all
logical slots on the standby are invalidated, but this cannot find a
slot that is not visible yet. Therefore, a slot creation whose status
check interleaved with the deactivation could continue based on a
stale status. This affects two paths:

For regular slot creation, EnsureLogicalDecodingEnabled() assumed that
logical decoding must still be enabled during recovery since the
caller had already checked it. If a promotion interleaves as described
 above, the backend creating the slot fails with:

    TRAP: failed Assert("IsLogicalDecodingEnabled()"), File: "logicalctl.c"

For slot synchronization, the local slot could be created and
persisted based on the remote slot information fetched before the
deactivation was replayed, leaving a valid slot whose restart_lsn
precedes the deactivation. Decoding such a slot after a failover fails
 with:

    ERROR:  unexpected logical decoding status change 0

These races are confined to the narrow window between checking the
logical decoding status and the new slot becoming visible; once the
slot is visible, the invalidation performed by the deactivation
already covers it. So the fix is simple: re-check the logical decoding
status after the new slot becomes visible. Regular slot creation
raises an error and slot synchronization skips persisting the slot. If
the deactivation happens after the recheck instead, it is guaranteed
to invalidate the now-visible slot as usual. The attached 0002
implements this.

0001 is the fix for the originally reported issue, unchanged from the
previous version.

Regards,

--
Masahiko Sawada
Amazon Web Services: https://aws.amazon.com
From 548ebc5f8b52ef8fd3f0eec652fad55c4653965f Mon Sep 17 00:00:00 2001
From: Masahiko Sawada <[email protected]>
Date: Wed, 15 Jul 2026 11:49:28 -0700
Subject: [PATCH v3 2/2] Fix races between deactivation of logical decoding and
 slot creation.

On standbys, logical decoding can be deactivated while a logical slot
is being created: either by replaying an
XLOG_LOGICAL_DECODING_STATUS_CHANGE record, or by the end-of-recovery
transition upon promotion, which deactivates logical decoding if no
valid logical slot exists. Both could interleave with a check of the
logical decoding status performed before the new slot becomes visible,
because the slot invalidation executed as part of the deactivation
cannot find a slot that is not visible yet.

For regular slot creation on standbys, EnsureLogicalDecodingEnabled()
assumed that logical decoding must still be enabled during recovery
since the caller had already checked it, tripping an assertion failure
if a concurrent deactivation interleaved.

For slot synchronization, the local slot could be created and
persisted based on the remote slot information fetched before the
deactivation was replayed, leaving a valid slot whose restart_lsn
precedes the deactivation.

Fix both paths by re-checking the logical decoding status after the
new slot becomes visible: regular slot creation raises an error, and
slot synchronization skips persisting the slot. If the deactivation
happens after the re-check instead, it is guaranteed to invalidate the
now-visible slot.

Discussion: https://postgr.es/m/
Backpatch-through: 19
---
 src/backend/replication/logical/logicalctl.c  |  38 +++--
 src/backend/replication/logical/slotsync.c    |  37 ++++
 src/backend/replication/slot.c                |   2 +
 src/backend/replication/slotfuncs.c           |  13 +-
 src/backend/replication/walsender.c           |   4 +-
 .../recovery/t/051_effective_wal_level.pl     | 158 ++++++++++++++++++
 6 files changed, 237 insertions(+), 15 deletions(-)

diff --git a/src/backend/replication/logical/logicalctl.c b/src/backend/replication/logical/logicalctl.c
index 7074900514a..0a70af93bb5 100644
--- a/src/backend/replication/logical/logicalctl.c
+++ b/src/backend/replication/logical/logicalctl.c
@@ -274,11 +274,10 @@ abort_logical_decoding_activation(int code, Datum arg)
 /*
  * Enable logical decoding if disabled.
  *
- * If this function is called during recovery, it simply returns without
- * action since the logical decoding status change is not allowed during
- * this time. The logical decoding status depends on the status on the primary.
- * The caller should use CheckLogicalDecodingRequirements() before calling this
- * function to make sure that the logical decoding status can be modified.
+ * If this function is called during recovery, it just checks that logical
+ * decoding is still enabled, since the logical decoding status cannot be
+ * changed during this time. The logical decoding status depends on the
+ * status on the primary.
  *
  * Note that there is no interlock between logical decoding activation
  * and slot creation. To ensure enabling logical decoding, the caller
@@ -298,11 +297,19 @@ EnsureLogicalDecodingEnabled(void)
 	if (RecoveryInProgress())
 	{
 		/*
-		 * CheckLogicalDecodingRequirements() must have already errored out if
-		 * logical decoding is not enabled since we cannot enable the logical
-		 * decoding status during recovery.
+		 * The caller has already checked that logical decoding is enabled via
+		 * CheckLogicalDecodingRequirements(), but the status could have been
+		 * disabled concurrently before our slot being created: either by
+		 * replaying an XLOG_LOGICAL_DECODING_STATUS_CHANGE record, or by
+		 * UpdateLogicalDecodingStatusEndOfRecovery() upon promotion. We
+		 * cannot enable logical decoding during recovery, so raise an error.
 		 */
-		Assert(IsLogicalDecodingEnabled());
+		if (!IsLogicalDecodingEnabled())
+			ereport(ERROR,
+					(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+					 errmsg("logical decoding on standby requires \"effective_wal_level\" >= \"logical\" on the primary"),
+					 errdetail("Logical decoding was concurrently disabled during the logical replication slot creation.")));
+
 		return;
 	}
 
@@ -604,10 +611,15 @@ UpdateLogicalDecodingStatusEndOfRecovery(void)
 	 * already occur due to the checkpointer's asynchronous deactivation
 	 * process.
 	 *
-	 * For 'disable' case, backend cannot create logical replication slots
-	 * during recovery (see checks in CheckLogicalDecodingRequirements()),
-	 * which prevents a race condition between disabling logical decoding and
-	 * concurrent slot creation.
+	 * For 'disable' case, a backend concurrently creating a logical slot on a
+	 * standby could have passed its CheckLogicalDecodingRequirements() check
+	 * while making its slot visible only after our slot check above. Such a
+	 * backend rechecks the status after creating the slot in
+	 * EnsureLogicalDecodingEnabled() and raises an error if logical decoding
+	 * has been disabled meanwhile, so it cannot end up with a logical slot
+	 * while logical decoding remains disabled. (If recovery has already ended
+	 * by the time of the recheck, the backend instead enables logical
+	 * decoding by itself, which is fine after promotion.)
 	 */
 	if (new_status != LogicalDecodingCtl->logical_decoding_enabled)
 	{
diff --git a/src/backend/replication/logical/slotsync.c b/src/backend/replication/logical/slotsync.c
index d1936823506..e52c22f0c24 100644
--- a/src/backend/replication/logical/slotsync.c
+++ b/src/backend/replication/logical/slotsync.c
@@ -67,6 +67,7 @@
 #include "pgstat.h"
 #include "postmaster/interrupt.h"
 #include "replication/logical.h"
+#include "replication/logicalctl.h"
 #include "replication/slotsync.h"
 #include "replication/snapbuild.h"
 #include "storage/ipc.h"
@@ -717,6 +718,42 @@ update_and_persist_local_synced_slot(RemoteSlot *remote_slot, Oid remote_dbid,
 		return false;
 	}
 
+	/*
+	 * Do not persist the slot if logical decoding got disabled concurrently.
+	 * This can happen if the last logical slot on the primary was dropped
+	 * and the corresponding XLOG_LOGICAL_DECODING_STATUS_CHANGE record was
+	 * replayed after we fetched the remote slot information: WAL records
+	 * following the slot's restart_lsn might lack the information required
+	 * by logical decoding, and the slot invalidation performed when
+	 * replaying the record could not find our slot as it was not visible
+	 * yet.
+	 *
+	 * It is important to perform this check after the slot became visible
+	 * and before persisting it. This way, even if the status change record
+	 * is replayed after this check, the replay will invalidate our slot.
+	 *
+	 * If the check fails, we keep the temporary slot and let the caller
+	 * retry; the next cycle fetches the remote slot information again and
+	 * will drop this slot as the remote slot no longer exists.
+	 *
+	 * XXX: this check cannot detect the case where logical decoding is
+	 * already re-enabled by a slot creation on the primary at this point.
+	 * Detecting that would require comparing the slot's restart_lsn with
+	 * the LSN at which logical decoding was last enabled.
+	 */
+	if (!IsLogicalDecodingEnabled())
+	{
+		ereport(LOG,
+				errmsg("could not synchronize replication slot \"%s\"",
+					   remote_slot->name),
+				errdetail("Logical decoding was concurrently disabled."));
+
+		if (slot_persistence_pending)
+			*slot_persistence_pending = true;
+
+		return false;
+	}
+
 	ReplicationSlotPersist();
 
 	ereport(LOG,
diff --git a/src/backend/replication/slot.c b/src/backend/replication/slot.c
index c7ab82a28e1..1a0ff682068 100644
--- a/src/backend/replication/slot.c
+++ b/src/backend/replication/slot.c
@@ -420,6 +420,8 @@ ReplicationSlotCreate(const char *name, bool db_specific,
 					errmsg("cannot enable failover for a temporary replication slot"));
 	}
 
+	INJECTION_POINT("replication-slot-create-begin", NULL);
+
 	/*
 	 * If some other backend ran this code concurrently with us, we'd likely
 	 * both allocate the same slot, and that would be bad.  We'd also be at
diff --git a/src/backend/replication/slotfuncs.c b/src/backend/replication/slotfuncs.c
index 16fbd383735..813180fece7 100644
--- a/src/backend/replication/slotfuncs.c
+++ b/src/backend/replication/slotfuncs.c
@@ -153,7 +153,18 @@ create_logical_replication_slot(char *name, char *plugin,
 	 * decoding context.
 	 */
 	EnsureLogicalDecodingEnabled();
-	Assert(IsLogicalDecodingEnabled());
+
+	/*
+	 * Outside of recovery, holding a valid logical slot prevents logical
+	 * decoding from being disabled. During recovery, however, replaying a
+	 * status change record can disable it at any time regardless of slot
+	 * existence, so we cannot assert that it is still enabled here. That is
+	 * harmless: such a replay invalidates our already-visible slot, so this
+	 * slot creation fails afterwards (by a recovery conflict or the
+	 * requirement re-check in CreateInitDecodingContext()) instead of leaving
+	 * a usable slot behind.
+	 */
+	Assert(RecoveryInProgress() || IsLogicalDecodingEnabled());
 
 	/*
 	 * Create logical decoding context to find start point or, if we don't
diff --git a/src/backend/replication/walsender.c b/src/backend/replication/walsender.c
index 35ebc7e61c8..c65dd324325 100644
--- a/src/backend/replication/walsender.c
+++ b/src/backend/replication/walsender.c
@@ -1372,7 +1372,9 @@ CreateReplicationSlot(CreateReplicationSlotCmd *cmd)
 		 * logical decoding context.
 		 */
 		EnsureLogicalDecodingEnabled();
-		Assert(IsLogicalDecodingEnabled());
+
+		/* See the comment in create_logical_replication_slot() */
+		Assert(RecoveryInProgress() || IsLogicalDecodingEnabled());
 
 		ctx = CreateInitDecodingContext(cmd->plugin, NIL, need_full_snapshot,
 										false,
diff --git a/src/test/recovery/t/051_effective_wal_level.pl b/src/test/recovery/t/051_effective_wal_level.pl
index 2cf2ea6546d..a685c2cdcd9 100644
--- a/src/test/recovery/t/051_effective_wal_level.pl
+++ b/src/test/recovery/t/051_effective_wal_level.pl
@@ -507,6 +507,164 @@ select pg_cancel_backend(pid) from pg_stat_activity where query ~ 'slot_canceled
 	test_wal_level($primary, "replica|logical",
 		"effective_wal_level remains 'logical' even after the concurrent activation is interrupted"
 	);
+
+	# Test races between the deactivation of logical decoding and slot
+	# creation during recovery. Logical decoding can be deactivated while a
+	# logical slot is being created on a standby, either by replaying an
+	# XLOG_LOGICAL_DECODING_STATUS_CHANGE record or by the end-of-recovery
+	# transition upon promotion. Slot creation paths must detect the
+	# deactivation after their slot becomes visible and fail cleanly,
+	# instead of leaving a slot that cannot be decoded.
+
+	# Restore the disabled state, and disable autovacuum so that the primary
+	# assigns no XIDs, keeping the slot synchronization test below
+	# deterministic.
+	$primary->safe_psql('postgres',
+		qq[select pg_drop_replication_slot('test_slot2')]);
+	wait_for_logical_decoding_disabled($primary);
+	$primary->safe_psql(
+		'postgres', qq[
+alter system set autovacuum = off;
+select pg_reload_conf();
+]);
+
+	# Initialize standby5 node, setting up the requirements for slot
+	# synchronization.
+	$primary->safe_psql('postgres',
+		qq[select pg_create_physical_replication_slot('phys_slot')]);
+	my $standby5 = PostgreSQL::Test::Cluster->new('standby5');
+	$standby5->init_from_backup($primary, 'my_backup', has_streaming => 1);
+	my $connstr = $primary->connstr;
+	$standby5->append_conf(
+		'postgresql.conf', qq[
+primary_slot_name = 'phys_slot'
+primary_conninfo = '$connstr dbname=postgres'
+hot_standby_feedback = on
+]);
+	$standby5->start;
+
+	# Test that slot synchronization doesn't persist a slot whose remote
+	# slot information was fetched before a deactivation was replayed.
+	# Otherwise, the slot would survive with a restart_lsn preceding the
+	# deactivation, as the slot invalidation performed by the replay cannot
+	# find the slot that is not visible yet.
+
+	# Enable logical decoding by creating a failover-enabled slot, and wait
+	# for the standby to replay the activation.
+	$primary->safe_psql('postgres',
+		qq[select pg_create_logical_replication_slot('sync_slot', 'test_decoding', false, false, true)]
+	);
+	$primary->wait_for_replay_catchup($standby5);
+	test_wal_level($standby5, "replica|logical",
+		"logical decoding got activated on standby5");
+
+	# Start slot synchronization, stopping it after fetching the remote slot
+	# information but before creating the local slot.
+	my $psql_sync_slot = $standby5->background_psql('postgres');
+	$psql_sync_slot->query_until(
+		qr/sync_slots/,
+		q(\echo sync_slots
+select injection_points_set_local();
+select injection_points_attach('replication-slot-create-begin', 'wait');
+select pg_sync_replication_slots();
+));
+	$standby5->wait_for_event('client backend',
+		'replication-slot-create-begin');
+	note("injection_point 'replication-slot-create-begin' is reached");
+
+	# Drop the last logical slot on the primary and wait for the standby to
+	# replay the deactivation.
+	$primary->safe_psql('postgres',
+		qq[select pg_drop_replication_slot('sync_slot')]);
+	wait_for_logical_decoding_disabled($primary);
+	$primary->wait_for_replay_catchup($standby5);
+	test_wal_level($standby5, "replica|replica",
+		"logical decoding got deactivated on standby5");
+
+	# Resume the slot synchronization; it must skip persisting the slot.
+	$log_offset = -s $standby5->logfile;
+	$standby5->safe_psql('postgres',
+		qq[select injection_points_wakeup('replication-slot-create-begin')]);
+	$standby5->wait_for_log(
+		qr/could not synchronize replication slot "sync_slot"/, $log_offset);
+	$psql_sync_slot->quit;
+	is( $standby5->safe_psql(
+			'postgres', qq[select count(*) from pg_replication_slots]),
+		'0',
+		"no synced slot is left behind on standby5");
+
+	# Test that logical slot creation on a standby fails cleanly if logical
+	# decoding is concurrently deactivated by the end-of-recovery transition
+	# upon promotion, which cannot find the slot that is not visible yet.
+
+	# Enable logical decoding again.
+	$primary->safe_psql('postgres',
+		qq[select pg_create_logical_replication_slot('test_slot3', 'test_decoding')]
+	);
+	$primary->wait_for_replay_catchup($standby5);
+	test_wal_level($standby5, "replica|logical",
+		"logical decoding got activated again on standby5");
+
+	# Hold the startup process right after the end-of-recovery status
+	# change, before recovery actually ends.
+	$standby5->safe_psql('postgres',
+		qq[select injection_points_attach('startup-logical-decoding-status-change-end-of-recovery', 'wait')]
+	);
+
+	# Start creating a logical slot, stopping it before the slot becomes
+	# visible.
+	$psql_create_slot =
+	  $standby5->background_psql('postgres', on_error_stop => 0);
+	$psql_create_slot->query_until(
+		qr/create_standby5_slot/,
+		q(\echo create_standby5_slot
+select injection_points_set_local();
+select injection_points_attach('replication-slot-create-begin', 'wait');
+select pg_create_logical_replication_slot('standby5_slot', 'test_decoding');
+));
+	$standby5->wait_for_event('client backend',
+		'replication-slot-create-begin');
+	note("injection_point 'replication-slot-create-begin' is reached");
+
+	# Promote the standby without waiting; the startup process deactivates
+	# logical decoding as no logical slot is visible, and stops at the
+	# injection point, still in recovery.
+	$standby5->safe_psql('postgres', qq[select pg_promote(false)]);
+	$standby5->wait_for_event('startup',
+		'startup-logical-decoding-status-change-end-of-recovery');
+
+	# Resume the slot creation; it must fail cleanly.
+	$log_offset = -s $standby5->logfile;
+	$standby5->safe_psql('postgres',
+		qq[select injection_points_wakeup('replication-slot-create-begin')]);
+	$standby5->wait_for_log(
+		qr/ERROR: .* logical decoding on standby requires "effective_wal_level" >= "logical" on the primary/,
+		$log_offset);
+	$psql_create_slot->quit;
+	is( $standby5->safe_psql(
+			'postgres', qq[select count(*) from pg_replication_slots]),
+		'0',
+		"no slot is left behind on standby5");
+
+	# Let the promotion complete.
+	$standby5->safe_psql('postgres',
+		qq[select injection_points_wakeup('startup-logical-decoding-status-change-end-of-recovery')]
+	);
+	$standby5->safe_psql('postgres',
+		qq[select injection_points_detach('startup-logical-decoding-status-change-end-of-recovery')]
+	);
+	$standby5->poll_query_until('postgres',
+		qq[select not pg_is_in_recovery()])
+	  or die "timed out waiting for promotion";
+
+	# Retrying the slot creation on the promoted standby5 must succeed and
+	# activate logical decoding.
+	$standby5->safe_psql('postgres',
+		qq[select pg_create_logical_replication_slot('standby5_slot', 'test_decoding')]
+	);
+	test_wal_level($standby5, "replica|logical",
+		"logical decoding got activated on the promoted standby5 after retry"
+	);
 }
 
 $primary->stop;
-- 
2.54.0

From c3201621a6f9e067ba35e51f1ec5a91624907967 Mon Sep 17 00:00:00 2001
From: Masahiko Sawada <[email protected]>
Date: Tue, 14 Jul 2026 12:22:24 -0700
Subject: [PATCH v3 1/2] Fix race condition when enabling logical decoding
 concurrently.

With wal_level = 'replica', logical decoding is enabled on demand
when the first logical replication slot is created:

When enabling logical decoding, EnableLogicalDecoding() flips the
shared logical_decoding_enabled flag and writes an
XLOG_LOGICAL_DECODING_STATUS_CHANGE record so that standbys follow the
status change. The initial "already enabled?" check and the WAL record
write happen under two separate acquisitions of
LogicalDecodingControlLock, since the lock must be released while
waiting for the ProcSignalBarrier: processes absorbing the barrier
acquire the same lock in shared mode.

Consequently, if two backends concurrently created the first logical
slots, both could pass the initial check and both write a
status-change record. The redundant record lands after the decoding
start point already reserved by the other backend's slot, so decoding
that slot processes the record and fails with "unexpected logical
decoding status change", as xlog_decode() assumes that no such record
can appear within the WAL range any slot decodes.

Fix by re-checking the status after re-acquiring the lock, so that
only the backend that actually performs the disabled->enabled
transition writes the WAL record.

Reported-by: Srinath Reddy Sadipiralla <[email protected]>
Author: Srinath Reddy Sadipiralla <[email protected]>
Discussion: https://postgr.es/m/cafc+b6oyzmagp7f0ivrhfzt46-cjvctru9pwumncem-52yj...@mail.gmail.com
Backpatch-through: 19
---
 src/backend/replication/logical/logicalctl.c  | 11 ++++
 .../recovery/t/051_effective_wal_level.pl     | 61 ++++++++++++++++++-
 2 files changed, 69 insertions(+), 3 deletions(-)

diff --git a/src/backend/replication/logical/logicalctl.c b/src/backend/replication/logical/logicalctl.c
index c11d1316450..7074900514a 100644
--- a/src/backend/replication/logical/logicalctl.c
+++ b/src/backend/replication/logical/logicalctl.c
@@ -384,6 +384,17 @@ EnableLogicalDecoding(void)
 
 	LWLockAcquire(LogicalDecodingControlLock, LW_EXCLUSIVE);
 
+	/*
+	 * Re-check whether logical decoding got enabled while we waited for the
+	 * barrier above.
+	 */
+	if (LogicalDecodingCtl->logical_decoding_enabled)
+	{
+		LogicalDecodingCtl->pending_disable = false;
+		LWLockRelease(LogicalDecodingControlLock);
+		return;
+	}
+
 	START_CRIT_SECTION();
 
 	/*
diff --git a/src/test/recovery/t/051_effective_wal_level.pl b/src/test/recovery/t/051_effective_wal_level.pl
index d4bc7f0aa40..2cf2ea6546d 100644
--- a/src/test/recovery/t/051_effective_wal_level.pl
+++ b/src/test/recovery/t/051_effective_wal_level.pl
@@ -326,11 +326,12 @@ $standby3->safe_psql('postgres',
 
 $standby3->stop;
 
-# Test the race condition at end of the recovery between the startup and logical
-# decoding status change. This test requires injection points enabled.
 if (   $ENV{enable_injection_points} eq 'yes'
 	&& $primary->check_extension('injection_points'))
 {
+	# Test the race condition at end of the recovery between the startup and logical
+	# decoding status change. This test requires injection points enabled.
+
 	# Initialize standby4 and start it.
 	my $standby4 = PostgreSQL::Test::Cluster->new('standby4');
 	$standby4->init_from_backup($primary, 'my_backup', has_streaming => 1);
@@ -381,9 +382,63 @@ if (   $ENV{enable_injection_points} eq 'yes'
 	test_wal_level($primary, "replica|replica",
 		"effective_wal_level got decreased to 'replica' on primary");
 
+	# Test that concurrent activations don't write redundant status-change records.
+
+	# Start a psql session and stop it in the middle of the activation process.
+	my $psql_create_slot = $primary->background_psql('postgres');
+	$psql_create_slot->query_until(
+		qr/create_slot_1/,
+		q(\echo create_slot_1
+select injection_points_set_local();
+select injection_points_attach('logical-decoding-activation', 'wait');
+select pg_create_logical_replication_slot('slot_1', 'test_decoding');
+));
+	$primary->wait_for_event('client backend', 'logical-decoding-activation');
+	note("injection_point 'logical-decoding-activation' is reached");
+
+	# A second backend concurrently enables logical decoding and finishes creating
+	# its slot, writing the status-change record. The slot reserves its decoding
+	# start point after its own status-change record.
+	$primary->safe_psql('postgres',
+		qq[select pg_create_logical_replication_slot('slot_2', 'test_decoding')]
+	);
+	test_wal_level($primary, "replica|logical",
+		"logical decoding enabled by the first of two concurrent activations"
+	);
+
+	# Resume the first backend to complete the slot creation. It must not write
+	# a second redundant status-change record as logical decoding is already
+	# enabled.
+	$primary->safe_psql('postgres',
+		qq[select injection_points_wakeup('logical-decoding-activation')]);
+
+	# Let the released backend finish creating its slot.
+	$psql_create_slot->quit;
+
+	# Decode from slot_2, whose start point precedes where a redundant
+	# status-change record would have been written; this fails in xlog_decode()
+	# if one exists.
+	is( $primary->safe_psql(
+			'postgres',
+			qq[SELECT count(*) FROM pg_logical_slot_get_changes('slot_2', NULL, NULL, 'skip-empty-xacts', '1')]
+		),
+		0,
+		'decoding a concurrently-created slot succeeds');
+
+	# Restore the disabled state for the tests that follow.
+	$primary->safe_psql(
+		'postgres',
+		qq[
+select pg_drop_replication_slot('slot_1');
+select pg_drop_replication_slot('slot_2');
+]);
+	wait_for_logical_decoding_disabled($primary);
+
+	# Test a race when logical decoding activation is concurrently interrupted.
+
 	# Start a psql session to test the case where the activation process is
 	# interrupted.
-	my $psql_create_slot = $primary->background_psql('postgres');
+	$psql_create_slot = $primary->background_psql('postgres');
 
 	# Start the logical decoding activation process upon creating the logical
 	# slot, but it will wait due to the injection point.
-- 
2.54.0

Reply via email to