Archive-fed logical decoding: pausing recovery on slot conflict

Started by Andrey Borodin5 months ago3 messageshackers
Beta feature

Hackorum builds and tests every patch posted to the lists, not only commitfest submissions. This is Hackorum's own CI rather than the PostgreSQL project's, and it is still under testing - please report anything that looks wrong.

won't retrysuccessCI history

You can run a PostgreSQL built from this patch straight from Docker, with no checkout and no build:

docker run --rm -p 5432:5432 ghcr.io/hackorum-dev/postgres-patch:t139867
psql -h localhost -U postgres

Built from patchset v2 (message #2), September 23, 2026 at 06:48 PM.

Every patchset is also pushed to a branch of our PostgreSQL fork, so you can check out the same tree CI built. Without a PostgreSQL checkout:

git clone --branch t139867_2 https://github.com/hackorum-dev/postgres.git

In a checkout you already have, add the fork once:

git remote add hackorum https://github.com/hackorum-dev/postgres.git

then, for this patchset and every later one:

git fetch hackorum t139867_2 && git checkout t139867_2

Patchset v2 (message #2) is on t139867_2

Jump to latest
#1Andrey Borodin
amborodin@acm.org

Hi hackers!

We would like to get the community's opinion on an architecture for
running logical decoding on a standby that is fed only by the WAL
archive (restore_command), with no streaming link to the primary. The
goal is continuous CDC into analytics systems straight from an archive,
without adding load or a feedback dependency on the primary.

Three AI-written commits are attached. They are meant to make the idea
concrete and testable for this discussion, not as a finished patch yet.
We made several iterations of editorialization and sharpening idea and
implementation.

The problem
-----------

Logical decoding on a standby (PG16+) keeps the catalog readable for
decoding by holding back catalog_xmin, and it relies on the primary
holding its own catalog_xmin back via hot_standby_feedback over a
streaming connection. An archive-fed standby has no walreceiver and
therefore no feedback channel: the primary vacuums catalog tuples
freely, ships the resulting WAL to the archive, and when the standby
replays those records they conflict with the local logical slot's
catalog_xmin. The slot is invalidated, in practice roughly
2 * autovacuum_naptime after it is created.

As far as we can tell, this catalog conflict is the only fundamental
blocker for archive-fed decoding. All such conflicts funnel through a
single choke point, ResolveRecoveryConflictWithSnapshot(), which
invalidates logical slots only for catalog relations:

if (IsLogicalDecodingEnabled() && isCatalogRel)
InvalidateObsoleteReplicationSlots(RS_INVAL_HORIZON, ...);

The records that reach it with a catalog conflict horizon are:

* Heap2 PRUNE records on catalog relations
(PRUNE_ON_ACCESS, PRUNE_VACUUM_SCAN, PRUNE_VACUUM_CLEANUP;
flag XLHP_IS_CATALOG_REL)
* B-tree delete and page-reuse records on catalog indexes
(isCatalogRel)

System catalogs are indexed only with B-tree, so in practice the index
side is always B-tree; the other AMs' vacuum records route through the
same choke point but never carry a catalog horizon here.

The remaining logical-slot invalidation causes are not fundamental to
archive-fed decoding and are already in the DBA's hands:
RS_INVAL_WAL_LEVEL (set wal_level=logical on the primary) and
RS_INVAL_WAL_REMOVED (retain enough WAL on the standby, e.g. via
max_slot_wal_keep_size). Both are pre-existing knobs an operator
already manages.

Please correct us if there is a second fundamental obstacle we have
missed -- that is one of the main things we would like to confirm.

The proposed approach
---------------------

Instead of supplying the missing feedback, absorb the conflict on the
consumer side. A new GUC, recovery_pause_on_logical_slot_conflict
(default off), changes what happens at that choke point: when replay is
about to invalidate an active logical slot, recovery pauses instead.
Recovery resumes as soon as no slot still blocks the conflict. In the
common case that happens the moment the consumer's decoding advances the
slot's catalog_xmin past the conflict horizon, which can be well before
the consumer reaches the pause LSN; only a slot that is still holding
catalog_xmin back (e.g. a long-running decoded transaction) has to be
drained all the way to the pause LSN. For any slot that did drain to the
pause LSN, recovery advances its catalog_xmin past the horizon so the
following InvalidateObsoleteReplicationSlots() is a no-op; replay then
continues to the next conflict.

* The hot path when the GUC is off is a single boolean early-return.
* It reuses the existing SetRecoveryPause / recoveryNotPausedCV
machinery; no new shared memory.
* Auto-resume: a periodic re-scan lets replay continue the moment
nothing blocks the conflict. A slot stops blocking when its
catalog_xmin advances past the conflict horizon (the normal path,
via the consumer decoding/confirming, often before the pause LSN),
or when it drains past the pause LSN, is dropped, advanced, or
invalidated for another reason. This lets it run as an unattended
service. pg_wal_replay_resume() remains a manual "give up on this
slot and let it invalidate" escape hatch, and pg_promote() still
breaks out via CheckForStandbyTrigger().
* Crash safety: after advancing catalog_xmin in memory, dirty slots
are flushed with CheckPointReplicationSlots(false) before replay
proceeds, upholding the write-before-memory-update invariant that
LogicalConfirmReceivedLocation already relies on.

How we test it
--------------

The in-tree TAP test (054_recovery_pause_on_slot_conflict.pl) builds a
workload designed to break a logical slot, and checks that it breaks
without the feature and survives with it:

1. Bring up an archive-only standby from a basebackup whose archive
contains a standby snapshot but no catalog-prune WAL yet, and
create a logical slot while it is still consistent.
2. Churn the primary's catalog (transient tables, ANALYZE, VACUUM of
pg_class / pg_attribute / pg_statistic, etc.) so the archive then
carries catalog-prune records whose horizon overtakes the slot.
3. Run two standbys from the same archive: with the GUC off the slot
is invalidated (the upstream behaviour, and a check that the test
actually reproduces the conflict); with the GUC on a drain-and-
resume loop keeps the slot alive and decodes the full change
stream.

A third standby checks that an explicit operator pg_wal_replay_pause()
is not cleared by the GUC's auto-resume.

We also ran an end-to-end field test outside core, on a real
archive-only standby recovering from object storage via WAL-G, with a
pgbench workload plus deliberate catalog churn on the primary and a
pg_recvlogical consumer on the standby. The consumer was forced to lag
so the slot fell behind the prune horizon; recovery paused, the
consumer caught up, recovery auto-resumed, and the full change stream
arrived with no gaps -- while a GUC-off control standby lost its slot.
Design and results [0]https://github.com/NikolayS/postgres/issues/43.

Questions for the list
----------------------

1. Direction. Is "pause recovery until the consumer catches up" an
acceptable shape for this, or is the right long-term answer a
feedback mechanism that does not require a streaming connection
(e.g. an out-of-band way to publish a decoding standby's catalog_xmin
back to the primary)? Pausing trades standby freshness for slot
survival, which is fine for a dedicated decoding replica but bad for
one that is also an HA target.

2. API. A cluster-wide GUC that stalls all of replay for the benefit
of one slot is coarse. Would a per-slot property be cleaner --
e.g. a slot option that opts that slot into "hold recovery rather
than invalidate me", so unrelated standbys and slots are unaffected?
That also makes the backpressure explicit: a slow consumer on one
slot deliberately holds the standby back.

3. Writing slot fields from the startup process. Advancing
catalog_xmin during recovery, and flushing slots from the startup
process, is new. Does the crash-safety argument above hold up,
and are there concurrency concerns beyond the synced-slot and
in-progress-snapbuild cases the commits already skip?

4. Fit with existing CDC installations. From a consumer's point of
view (pg_recvlogical, Debezium, etc.) this looks like ordinary
streaming from a standby with occasional stalls, so it should drop
into existing pipelines. Is that the right integration point, or
would operators prefer a separate "decoding from archive" tool that
never runs a full standby at all?

We are most interested in (1) and (2): whether this is the right layer
to solve the problem, and whether the interface can be made narrower
and less surprising than a global recovery pause.

Best regards, Andrey, Kirk, Nik.

[0]: https://github.com/NikolayS/postgres/issues/43

Attachments:

t139867_1
v1-0001-xlogrecovery-make-ConfirmRecoveryPaused-and-Check.patchapplication/octet-stream; name=v1-0001-xlogrecovery-make-ConfirmRecoveryPaused-and-Check.patch; x-unix-mode=0644Download+8-5
v1-0003-Auto-resume-recovery-once-the-logical-slot-confli.patchapplication/octet-stream; name=v1-0003-Auto-resume-recovery-once-the-logical-slot-confli.patch; x-unix-mode=0644Download+255-93
v1-0002-Add-recovery_pause_on_logical_slot_conflict-GUC.patchapplication/octet-stream; name=v1-0002-Add-recovery_pause_on_logical_slot_conflict-GUC.patch; x-unix-mode=0644Download+597-9
#2Andrey Borodin
amborodin@acm.org
In reply to: Andrey Borodin (#1)
Re: Archive-fed logical decoding: pausing recovery on slot conflict

Hi hackers,

Here is v2. Self-review found that v1 advanced catalog_xmin from the
startup process once confirmed_flush_lsn reached the pause point. That
is not safe: confirmed output does not prove that logical decoding no
longer needs older catalog rows.

V2 leaves horizon advancement entirely to logical decoding and
auto-resumes only after the slot's effective horizons no longer
conflict. It now uses the same predicate as normal slot invalidation,
including both xmin horizons and shared catalogs. It also avoids
pausing before Hot Standby is active and prevents auto-resume from
overriding a separate manual pause.

There is an important limitation: a pause does not guarantee that every
slot can be preserved. If the WAL already replayed does not let logical
decoding derive a sufficiently recent safe horizon, the operator must
drop the slot or resume recovery and let it be invalidated.

The TAP test now uses one archive-only standby and covers automatic
resume, a concurrent manual pause, a shared-catalog conflict, and
promotion from the conflict pause.

PFA v2.

Thank you!

Best regards, Andrey Borodin.

Attachments:

t139867_2
v2-0001-Pause-recovery-before-invalidating-logical-slots.patchapplication/octet-stream; name=v2-0001-Pause-recovery-before-invalidating-logical-slots.patch; x-unix-mode=0644Download+269-28
v2-0002-Test-recovery-pause-on-logical-slot-conflict.patchapplication/octet-stream; name=v2-0002-Test-recovery-pause-on-logical-slot-conflict.patch; x-unix-mode=0644Download+251-1
#3Ajin Cherian
itsajin@gmail.com
In reply to: Andrey Borodin (#2)
Re: Archive-fed logical decoding: pausing recovery on slot conflict

On Sat, Sep 5, 2026 at 11:15 PM Andrey Borodin <x4mmm@yandex-team.ru> wrote:

Hi hackers,

Here is v2. Self-review found that v1 advanced catalog_xmin from the
startup process once confirmed_flush_lsn reached the pause point. That
is not safe: confirmed output does not prove that logical decoding no
longer needs older catalog rows.

Hi Andrey, Kirk, Nik,

First up, nice idea. I like it and it's simple. But one thing kept
nagging at me as I read through this, there's no ceiling on how long
recovery can stay paused.

You do mention this in the docs. Pausing doesn't guarantee the slot
survives, and if it can't, the operator has to step in and either drop
the slot or resume recovery manually. That's honest, but it does mean
the problem doesn't go away, it just moves to whoever's on call. And
the scenario I keep picturing is: the consumer just dies -
pg_recvlogical segfaulting, whatever. Recovery pauses, waiting for a
consumer that's never coming back, and as far as I can tell it just
sits there. Forever, or until someone happens to notice.

That's actually a bit worse than what happens today. Right now, a slow
consumer gets its slot killed and the standby moves on. Not great, but
at least it's self-limiting and it leaves a visible trace
(invalidation_reason, something monitoring can catch). With this
patch, a dead consumer instead quietly turns into a standby that's
stuck in the past indefinitely. If that standby feeds anything else, a
cascading replica, that stall is arguably a worse failure mode than
the one you're fixing.

Two things that I think would fix this without fighting the design
you've already got:

First, just put a limit on it. Something like a max-pause-duration
setting: if the wait drags past that, give up and let it fall back to
today's behavior (resume, let the slot get invalidated normally). That
keeps the "unattended" promise: worst case, it degrades to exactly
what happens now, rather than hanging indefinitely. And it slots in
naturally, since WaitForRecoveryToResume() already has a loop that
could just as easily check elapsed time alongside the conflict
predicate.

Second, even with a cap, it'd be nice if a long pause said something
more than once. Right now you get a single log line the moment the
pause starts, and after that the only way to know you're still stuck
is to go poll pg_get_wal_replay_pause_state() yourself and remember
when it started. A repeated warning every so often while it's stuck
would mean you find out from the logs.

I don't think either of these needs to block the core idea. The whole
appeal of the feature is that it can run unattended; a pause with no
ceiling quietly takes that back.

Regards,
Ajin Cherian
Fujitsu Australia