Core Problem: Initialization Ordering Bug with PROCSIGNAL_BARRIER_UPDATE_XLOG_LOGICAL_INFO
This thread addresses a subtle but real race condition in PostgreSQL 19devel backend initialization, introduced (or exposed) by commit 67c20979ce ("Toggle logical decoding dynamically based on logical slot presence"). That commit added the ability to dynamically switch wal_level behavior between replica and logical based on whether any logical replication slots exist, avoiding the requirement to permanently set wal_level = logical (which meaningfully inflates WAL volume). The mechanism uses a procsignal barrier — PROCSIGNAL_BARRIER_UPDATE_XLOG_LOGICAL_INFO — to notify all backends that they must refresh their per-process XLogLogicalInfo cache (which controls whether extra logical-decoding metadata, e.g. for TRUNCATE, REPLICA IDENTITY, toast rows, gets emitted into WAL).
The Race
In backend startup, the ordering was:
BaseInit()→ callsInitializeProcessXLogLogicalInfo()(snapshots the current logical-info state into the backend's local variable).ProcSignalInit()→ registers the backend in the ProcSignal array so it can receive barriers.
If a startup (e.g. pg_create_logical_replication_slot) emits EmitProcSignalBarrier(PROCSIGNAL_BARRIER_UPDATE_XLOG_LOGICAL_INFO) in the window between steps 1 and 2, the new backend:
- Has already read the old
XLogLogicalInfovalue in step 1. - Is not yet registered in ProcSignal, so the barrier emitter does not wait for it and the new backend never absorbs the barrier.
Result: the backend operates with a stale XLogLogicalInfo view and writes WAL that is inconsistent with the cluster's current logical-decoding requirements — e.g., skipping logical-decoding metadata that downstream slots need.
The fix mirrors an existing precedent: InitLocalDataChecksumState() was deliberately placed after ProcSignalInit() for the same barrier-safety reason (data checksums state is likewise toggled via procsignal barrier). The patch moves InitializeProcessXLogLogicalInfo() out of BaseInit() and puts it after ProcSignalInit() in InitPostgres().
Secondary Bug Discovered: Standalone Backend Path
Evan Li independently discovered a related but distinct bug by reasoning about the --single standalone backend path in InitPostgres(). In that path, StartupXLOG() runs inline (the standalone backend performs recovery itself), but InitializeProcessXLogLogicalInfo() was never invoked at all for this code path. Consequence:
- DBA stops a running cluster with active logical slots.
- DBA restarts in standalone mode for emergency maintenance, executes DML/DDL (e.g.,
TRUNCATE t1). - DBA restarts in normal multi-user mode.
- Downstream logical decoding consumers never see the TRUNCATE because the WAL written during standalone mode lacks the logical-decoding metadata.
Evan's reproducer demonstrates exactly this: after a TRUNCATE in standalone mode, pg_logical_slot_get_changes() returns only an empty BEGIN/COMMIT pair. This is a silent data-fidelity bug for logical replication correctness.
The 0002 patch adds an InitializeProcessXLogLogicalInfo() call after StartupXLOG() on the standalone path so the backend picks up the logical-info state reconstructed from recovery.
Design Discussion Points
Impact on Background Workers
Evan initially worried that moving the init out of BaseInit() would leave BackgroundWorkerMain bgworkers without a valid XLogLogicalInfo. Sawada clarified this is a non-issue: database-connected bgworkers go through InitPostgres() which performs both ProcSignalInit() and the logical-info init; bgworkers that don't connect to a database don't participate in the barrier protocol anyway (they shouldn't be writing logical-relevant WAL).
Feature-Request Tangent: Per-Slot Table Filtering
Evan raised a real operational pain point: even with 67c20979ce, enabling any logical slot forces logical-level WAL across the entire cluster, including tables that are never replicated. Users who archive WAL for years find this prohibitively expensive. The proposal: make the extra WAL metadata conditional on whether a table is actually included in some slot's publication set.
Sawada pushed back pragmatically: this would require (a) augmenting slot metadata with per-table inclusion info, and (b) a fast lookup on the WAL-write hot path to decide per-record whether to emit extra metadata. The overhead of this per-WAL-record check, plus cache-invalidation complexity when publications change, makes it a significant design effort — plausibly a v20+ feature. Sawada also suggested broader WAL compression as an alternative lever for reducing WAL volume.
Patch Evolution
- v1: Move
InitializeProcessXLogLogicalInfo()fromBaseInit()to afterProcSignalInit()inInitPostgres(). - v2/0001: Same, plus typo fix ("afater" → "after") contributed by Evan.
- v2/0002: Add
InitializeProcessXLogLogicalInfo()call afterStartupXLOG()on the standalone backend path. - v3: Sawada's consolidated version addressing Evan's bgworker concern (confirmed non-issue) and merging both fixes.
Sawada announced intent to commit v3 the following Monday barring objections — typical of a clear, well-scoped bug fix by an established committer.
Architectural Significance
The bug illustrates a general hazard of the procsignal-barrier pattern: any per-backend cached state that is updated via barrier must have its initial snapshot taken after the backend is visible in the ProcSignal array, otherwise the barrier emitter's "wait for everyone to absorb" guarantee doesn't cover backends currently in startup. The InitLocalDataChecksumState() precedent already established this rule; XLogLogicalInfo violated it. Any future barrier-managed state (e.g., were someone to add barrier-driven toggling of other GUC-like settings) must follow the same ordering discipline. It would be worth a comment or assertion framework to make this invariant explicit.
The standalone-path bug is a reminder that --single mode is easy to forget when adding new subsystems; it bypasses much of the normal startup machinery and is a recurring source of "we added X but not to the single-user path" oversights.