Hi Shihao,
> 8. Standby promotion waits until every process has accepted a
> ProcSignalBarrier, even with wal_level=replica and no slots. A backend
> blocked sending a result to a client does not accept it. [...] promotion
> hung in 8 of 8 runs on Linux. [...] 18.6 promotes in 0.1 seconds.
I reproduced this on 19rc1 (0b8bff042a9) against 18.6, and tracked down why
it isn't always 8/8.
On promotion the startup process emits a ProcSignalBarrier (generation 1,
from UpdateLogicalDecodingStatusEndOfRecovery), even with wal_level=replica
and no slots. A backend blocked in secure_write() never accepts it: the
latch wakes the write and ProcessClientWriteInterrupt(true) runs, but it only
acts on ProcDiePending, so CHECK_FOR_INTERRUPTS() is never reached. The fix
looks like it belongs in that wait rather than in promotion.
Whether a given stalled backend blocks promotion depends on its local send
buffer, which is why it isn't 8/8 for everyone: unsaturated I got 28/28 hung
pinned to one CPU core but 0/11 on another, and there gdb shows the backend
absorbing the barrier after the kernel freed buffer space. Saturating the
send buffer first -- the state of a client that stalled a while ago -- gives
10/10 hung on 19rc1 and 0/10 on 18.6, on any core.
It is any backend blocked in a socket write, not only a SELECT: COPY ... TO
STDOUT left unread, a stalled pg_basebackup, and a crash failover all hang
too, while idle and idle-in-transaction clients do not. The same wait also
stops plain replay when the first logical slot is created on the primary,
which then blocks synchronous_commit=remote_apply commits there;
max_standby_streaming_delay does not help.
Your 949 s looks like the client's TCP retries running out. With a client
that stays connected but paused (an app thread, a psql in a pager) nothing on
the TCP side ends it: a promotion here was still hung at 450 s, zero-window
persist timer backing off, connection alive. tcp_user_timeout bounds the
wait.
Full write-up with the backtraces and the repro is attached. Happy to help
on the thread when you open it.
Regards,
Manu
# PG19: standby promotion (and standby replay) waits on a client that stopped
reading
Independent reproduction of item 8 (High) in Shihao Zhong's list
"[PG19] Bugs found comparing 19 with 18, list before I open threads"
(<cagrkxqsfdjqzp_kwrz7dv6lhx-csrkdnynnxgntrscenqtk...@mail.gmail.com>,
2026-10-13),
plus blast-radius checks he did not mention.
Builds: REL_19_STABLE at 0b8bff042a9 (19rc1) and REL_18_6 (724edf9bde9), both
`--enable-cassert --enable-debug --with-icu -O1`, Linux 7.2 x86_64. All
clusters are
throwaway pairs on tmpfs, ports 5618x/5629x. Timings come from cassert builds
and are only
there for the order of magnitude; what counts is the HUNG/PROMOTED outcome.
Everything below comes from the third full pass (`results/*.csv`). Earlier
passes are kept
in `results/run1/` and `results/run2/`. Section 2 explains why the third pass
changed the
harness.
## 1. Reproduction
Per run: a fresh primary and hot standby, `wal_level = replica`, **no
replication slots**.
One client on the standby sends
`select repeat('x', 8000) from generate_series(1, 100000000)` and never reads
again
(`repro/stall_client.py`, a ~70-line v3 protocol client that stays connected
and keeps
ACKing). `stall()` in `repro/lib.sh` waits for the backend to be in
`ClientWrite` with a
frozen `bytes_acked`, then **saturates** its local send buffer (section 2).
Then
`pg_promote(wait => false)`. HUNG means the standby was still in recovery
after 15 s.
| version | runs | HUNG | promotion | after `pg_terminate_backend()` of the
stalled backend |
|---|---|---|---|---|
| 19rc1 | 10 | **10** | n/a | promoted in 102-128 ms |
| 18.6 | 10 | 0 | 9-13 ms | n/a |
Controls on 19rc1, 3 runs each, all promoted in 10-24 ms: an idle connected
client, and an
`idle in transaction` session. So it takes a backend that is blocked in a
socket write.
`COPY ... TO STDOUT` not being read: HUNG 3/3 on 19, promoted 3/3 on 18.
**How long it lasts.** A promotion held behind a live, paused client was still
hung after
450 s (`results/D_long_hang_19.txt`): 90 "still waiting for backend with PID
..." lines,
backend in `ClientWrite`, `bytes_acked` frozen, the kernel's zero-window
persist timer
backing off from 4 to 11. The connection never died. We read Shihao's 949 s
as the case
where the probes stopped being answered (TCP retries run out). With a client
that is
still alive, for example a paused application thread or a `psql` sitting in a
pager,
nothing on the TCP side ends the wait. **949 s is not an upper bound.**
## 2. Mechanism, and why it is not 100% in the field
From a hung run (`results/evidence/19-select-run1.*`):
- `pg_stat_activity`: startup process `IPC / ProcSignalBarrier`, stalled backend
`Client / ClientWrite`.
- Standby log: `still waiting for backend with PID <stalled pid> to accept
ProcSignalBarrier`
every 5 s, right after `received promote request`.
- Startup backtrace: `WaitForProcSignalBarrier (generation=1) <-
UpdateLogicalDecodingStatusEndOfRecovery (logicalctl.c:757) <- StartupXLOG
(xlog.c:6279)`.
The barrier is emitted on every promotion, even with `wal_level = replica`
and no slots.
- Stalled backend backtrace: `secure_write (be-secure.c:347) <- internal_flush
<-
socket_putmessage <- printtup <- ExecutePlan`.
- gdb on the stalled backend: `ProcSignalBarrierPending = 1`, its slot's
`pss_barrierGeneration = 0` against a global 1. In `secure_write()` the
latch wakes the
wait and `ProcessClientWriteInterrupt(true)` runs, but that function only
acts on
`ProcDiePending`. The code goes back to `WaitEventSetWait`, and
`CHECK_FOR_INTERRUPTS()` is never reached.
**Whether a given stalled backend blocks promotion depends on its local send
buffer.**
Our first two passes, without the saturation step, hung 28/28 when run in one
CPU lane
but 0/11 in another lane or unpinned (`results/run2/`, and
`results/A_promote_select.csv` rows `select_unsaturated_laneB`: 0/5). In the
runs that
promoted, gdb shows the stalled backend **did** absorb the barrier (slot
generation 1,
nothing pending) and is still in `secure_write` / `ClientWrite` afterwards.
The send
queue shows how (`repro/diag/sendq.sh`, 3 runs, identical):
| server socket | before `pg_promote` | 2 s after |
|---|---|---|
| Send-Q (bytes queued, not acked) | 4 028 416 | 4 129 792 |
| sndbuf (`tb`) | 4 194 304 | 4 194 304 |
The peer window stays at zero, yet the backend queued ~100 KB more after the
promote
request. The kernel frees room in the local send buffer after the backend's
last
`EAGAIN` without reporting the socket writable, because epoll waits for a
threshold. The
barrier's own SIGUSR1 wakes the backend, the retried `write()` fits, the message
completes, `printtup` returns to `ExecutePlan`, `CHECK_FOR_INTERRUPTS()`
absorbs the
barrier, and the backend blocks again on the next row. Promotion goes through
by luck.
`stall()` therefore wakes the stalled backend with a harmless signal
(`pg_log_backend_memory_contexts()`) until its Send-Q stops growing, which is
the state
of a client that has been stuck for a while. With that step: 10/10 unpinned,
5/5 + 10/10
in a lane, 0/10 on 18.
For the thread: the failure is certain once the backend's send buffer is full,
which is
the steady state of a stuck client. A reproduction that sees "sometimes" is
seeing this
buffer effect, not a different bug. The same reasoning says that
`secure_write()`
should absorb barriers while it waits; relying on a write completing is what
makes it
racy.
Repro trap: shrinking the client's `SO_RCVBUF` makes the bug **not** reproduce.
The tiny
window keeps reopening, so the backend keeps completing flushes.
## 3. Blast radius
Stalled client as above, 15 s limit, 3 runs per cell unless noted
(`results/B_blast_barrier_users.csv`, `results/C_promotion_variants.csv`).
### 3a. New in 19, not in the report: standby **replay** stops, and can freeze
the primary
`EnableLogicalDecoding()` also waits for the barrier when the standby
**replays**
`XLOG_LOGICAL_DECODING_STATUS_CHANGE` (xlog.c:8793). The first logical slot
created on a
`wal_level = replica` primary therefore stops replay on every hot standby that
has a
stalled reader.
| case | 19rc1 | 18.6 |
|---|---|---|
| first logical slot on primary, stalled client on **standby**: replay |
**stopped 3/3** (startup in `ProcSignalBarrier`) | replayed in 11-12 ms [1] |
| same + `synchronous_commit = remote_apply`: a plain `INSERT` on the
**primary** | **commit blocked 3/3** in `SyncRep` until the standby backend is
killed | returned in 15-17 ms [1] |
| same, standby with `max_standby_streaming_delay = 1s` | **still stopped
3/3**: not a recovery conflict, nothing is cancelled | - |
| first logical slot on primary, stalled client on **primary**:
`pg_create_logical_replication_slot()` | **blocked 3/3** | returned in 15-16 ms
[1] |
[1] 18 cannot create a logical slot at `wal_level = replica`, so the 18 column
runs with
`wal_level = logical`, the configuration a user needed on 18 to do the same
thing.
In practice: someone creates a CDC or logical replication slot on the primary.
From then
on, one paused reader on a hot standby is enough to stop that standby's replay
with no
timeout. If the standby is a `remote_apply` synchronous standby, every commit
on the
primary waits too. Only the deactivation path (`DisableLogicalDecoding()`)
emits without
waiting.
### 3b. Other ProcSignalBarrier users: blocked too, but already in 18
| case | 19rc1 | 18.6 |
|---|---|---|
| `DROP DATABASE` on primary, stalled client on primary | blocked 3/3 | blocked
3/3 |
| `DROP DATABASE` replayed on standby, stalled client on standby | replay
stopped 3/3 | replay stopped 3/3 |
Same root cause (`PROCSIGNAL_BARRIER_SMGRRELEASE`, since 15). A fix that makes
`secure_write()` absorb barriers while blocked would cover these as well.
### 3c. Promotion variants
| case | 19rc1 | 18.6 |
|---|---|---|
| **failover after `kill -9` of the primary**, then promote | **HUNG 3/3** |
promoted, 12-13 ms |
| paused `pg_basebackup` reading from the standby (walsender in `ClientWrite`,
saturated) | **HUNG 4/4**, released 11-12 ms after terminating the walsender |
promoted 3/3, 12-30 ms |
| cascaded standby SIGSTOPped (its walsender on the standby) | promoted 3/3,
11-13 ms | promoted 3/3, 12-29 ms |
| `tcp_user_timeout = 20s` on the standby | promoted after **16.9-17.4 s** 3/3:
the backend dies with `could not send data to client: Connection timed out` |
promoted, 12-29 ms |
- A real failover hangs the same way. The trigger is the end of recovery, not
`pg_promote()`.
- A base-backup walsender is a stalled writer like any other. In the first
passes it
looked harmless (2 hangs out of 17) because of the send-buffer effect in
section 2. A
streaming walsender (cascade) does not block: it writes without blocking and
handles
interrupts in its own loop.
- Operator workaround that we measured to work: `tcp_user_timeout` bounds the
wait to
about its value (TCP keepalives do not, since they do not fire while data is
unacknowledged). Killing the backend named in the "still waiting for backend
with PID
..." log line also works.
## 4. What this adds to Shihao's item 8
1. Independent confirmation: 10/10 vs 0/10, mechanism confirmed with backtraces
and
barrier generations.
2. **New:** why the failure is not deterministic in the field: the barrier's
own signal
can squeeze one more message through a partly freed send buffer. This is
measured.
It also means the fix belongs in `secure_write()`'s wait.
3. **New:** replay of the logical-decoding status change on a standby has the
same wait.
A stalled reader on a hot standby stops replay with no timeout, and under
`remote_apply` it blocks commits on the primary.
`max_standby_streaming_delay` does
not help. Creating a logical slot on a replica-level primary also blocks
behind a
stalled reader on the primary.
4. The 949 s figure is not a ceiling: with a live paused client the hang did
not end in
450 s.
5. Crash failover and base-backup walsenders are affected. `tcp_user_timeout`
bounds it.
6. `DROP DATABASE` (primary and replay) has had the same failure since the
SMGRRELEASE
barrier. It is not a 19 regression, but it argues for fixing the absorb
side.
## 5. How the three passes compare
- Pass 1 (before a reboot wiped tmpfs), pass 2 and pass 3 agree on every case in
sections 3a and 3b.
- Pass 2 found the "sometimes" behaviour: it rarely hung outside one CPU lane,
the
base-backup walsender hung only sometimes, and one 450 s hold promoted at
once.
Section 2 explains all three, and pass 3 adds the saturation step.
- Harness failures we caught and excluded:
- Twice the standby did not start, which looked like a hang; `pair_up` now
reports
`SETUP_FAILED` and keeps the logs.
- Two cells ran after their shell lost write access when an act went to the
background.
- A `tcp_user_timeout` of 3 s expired before the promotion once saturation
added
seconds; it is now 20 s.
- `results/evidence/` holds the per-run activity, logs and backtraces of passes
2 and 3.
## Files
- `build/build.sh`: the two builds
- `repro/stall_client.py`: client that stops reading
- `repro/lib.sh`: ephemeral primary/standby/cascade harness; `stall()` with
settle and
saturation (`STALL_SATURATE=0` to see the luck)
- `repro/promote_hang.sh VER RUNS [HANG] [select|copyout|none|idletx]`: section
1
- `repro/blast.sh VER CASE [HANG]`: sections 3a-3c; `STANDBY_GUCS=` for extra
settings
- `repro/cells.sh`: runs cells with a 120 s cap each
- `repro/basebackup_stall.sh VER RUNS`: the paused base backup
- `repro/long_hang.sh VER SECS`: the long hold with TCP sampling
- `repro/diag/why_promoted.sh`, `repro/diag/sendq.sh`: section 2 diagnostics
- `results/`: CSVs, `D_long_hang_19.txt`, `evidence/`, `run1/`, `run2/`
- `shihao_thread.txt`: the source message
#!/usr/bin/env python3
"""A PostgreSQL client that sends one query and then stops reading.
Speaks just enough of the v3 protocol (startup, trust auth, simple Query) to
make a backend start streaming a large result, then never reads the socket
again. The kernel buffers fill and the backend blocks in secure_write()
(wait_event ClientWrite). The process stays alive and keeps ACKing TCP
zero-window probes, which is what a paused or wedged application looks like.
usage: stall_client.py PORT DBNAME SQL [--rcvbuf BYTES]
Prints "SENT <backend pid> <local port>" once the query is on the wire, then
sleeps until killed.
"""
import argparse
import os
import socket
import struct
import sys
import time
ap = argparse.ArgumentParser()
ap.add_argument("port", type=int)
ap.add_argument("dbname")
ap.add_argument("sql")
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--user", default=os.environ.get("USER", "postgres"))
ap.add_argument("--rcvbuf", type=int, default=0, help="0 = kernel default")
a = ap.parse_args()
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# Optional: shrink the receive buffer. Do NOT use this for the repro: with a
tiny
# buffer the kernel keeps reopening the window a few bytes at a time, the
backend
# completes a flush now and then, reaches CHECK_FOR_INTERRUPTS() and absorbs the
# barrier. The hang needs a window that stays at zero (default buffers do
that).
if a.rcvbuf:
s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, a.rcvbuf)
s.connect((a.host, a.port))
def recv_exact(n):
buf = b""
while len(buf) < n:
chunk = s.recv(n - len(buf))
if not chunk:
sys.exit("server closed the connection")
buf += chunk
return buf
def read_msg():
t = recv_exact(1)
(ln,) = struct.unpack("!I", recv_exact(4))
return t, recv_exact(ln - 4)
params = b"user\0" + a.user.encode() + b"\0database\0" + a.dbname.encode() +
b"\0\0"
s.sendall(struct.pack("!II", 8 + len(params), 196608) + params)
backend_pid = None
while True:
t, body = read_msg()
if t == b"R" and struct.unpack("!I", body[:4])[0] != 0:
sys.exit("only trust auth is supported")
if t == b"K":
backend_pid = struct.unpack("!I", body[:4])[0]
if t == b"E":
sys.exit("error during startup: %r" % body)
if t == b"Z":
break
q = a.sql.encode() + b"\0"
s.sendall(b"Q" + struct.pack("!I", 4 + len(q)) + q)
print("SENT", backend_pid, s.getsockname()[1], flush=True)
while True: # alive, connected, never reading
time.sleep(3600)
# lib.sh -- ephemeral primary/standby pairs for the PG19 promotion-hang repro.
# Source it; then `pair_up 19` or `pair_up 18`. Everything lives under
# $RUN (tmpfs), on high ports, and is torn down by pair_down. Never touches
# any other cluster on the machine.
#
# VER=19 -> REL_19_STABLE 0b8bff042a9 (19rc1) ports 56190/56191
# VER=18 -> REL_18_6 ports 56180/56181
set -u
HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
RUN=${RUN:-/tmp/shihao/run}
bin_of() { case $1 in 19) echo /tmp/shihao/pg19rc1/inst/bin;; 18) echo
/tmp/shihao/pg186/inst/bin;; esac; }
pport() { echo $((56100 + $1 * 10)); } # primary
sport() { echo $((56101 + $1 * 10)); } # standby
cport() { echo $((56102 + $1 * 10)); } # cascaded standby
pdir() { echo "$RUN/$1/primary"; }
sdir() { echo "$RUN/$1/standby"; }
cdir() { echo "$RUN/$1/cascade"; }
now_ms() { date +%s%3N; }
# q VER PORT SQL -- one-shot psql, tuples only, unaligned
q() { "$(bin_of "$1")/psql" -X -h 127.0.0.1 -p "$2" -d postgres -Atq -c "$3"; }
common_conf() { # $1 datadir $2 port
cat >> "$1/postgresql.conf" <<CONF
port = $2
listen_addresses = '127.0.0.1'
unix_socket_directories = '$1'
wal_level = replica
max_wal_senders = 10
hot_standby = on
fsync = off
log_line_prefix = '%m [%p] %b '
log_min_messages = log
logging_collector = off
shared_buffers = 64MB
CONF
}
pair_up() { # VER [cascade]
local v=$1 b; b=$(bin_of "$v")
pair_down "$v" >/dev/null 2>&1
mkdir -p "$RUN/$v"
"$b/initdb" -D "$(pdir "$v")" -A trust -U "$USER" --no-sync
>"$RUN/$v/initdb.log" 2>&1
common_conf "$(pdir "$v")" "$(pport "$v")"
echo "host replication all 127.0.0.1/32 trust" >> "$(pdir "$v")/pg_hba.conf"
"$b/pg_ctl" -D "$(pdir "$v")" -l "$RUN/$v/primary.log" -w start >/dev/null
# No replication slot on purpose: the report is about wal_level=replica, no
slots.
"$b/pg_basebackup" -h 127.0.0.1 -p "$(pport "$v")" -D "$(sdir "$v")" -R -X
stream --no-sync >/dev/null
sed -i "s/^port = .*/port = $(sport "$v")/; s#^unix_socket_directories =
.*#unix_socket_directories = '$(sdir "$v")'#" "$(sdir "$v")/postgresql.conf"
"$b/pg_ctl" -D "$(sdir "$v")" -l "$RUN/$v/standby.log" -w start >/dev/null
if [ "${2:-}" = cascade ]; then
"$b/pg_basebackup" -h 127.0.0.1 -p "$(sport "$v")" -D "$(cdir "$v")" -R -X
stream --no-sync >/dev/null
sed -i "s/^port = .*/port = $(cport "$v")/; s#^unix_socket_directories =
.*#unix_socket_directories = '$(cdir "$v")'#" "$(cdir "$v")/postgresql.conf"
"$b/pg_ctl" -D "$(cdir "$v")" -l "$RUN/$v/cascade.log" -w start >/dev/null
fi
# Fail loudly if a server did not come up: a dead standby looks exactly like a
# hung promotion to the callers ("still in recovery" never turns false).
if ! wait_until 30 "$v" "$(pport "$v")" "select count(*) >= 1 from
pg_stat_replication where state = 'streaming'" t \
|| [ "$(q "$v" "$(sport "$v")" "select pg_is_in_recovery()" 2>/dev/null)"
!= t ]; then
mkdir -p /tmp/shihao/evidence/setup-failed
cp "$RUN/$v"/*.log /tmp/shihao/evidence/setup-failed/ 2>/dev/null
echo "pair_up $v: setup failed" >&2
return 1
fi
}
pair_down() {
local v=$1 b d; b=$(bin_of "$v")
pkill -f "stall_client.py $(sport "$v") " 2>/dev/null
pkill -f "stall_client.py $(pport "$v") " 2>/dev/null
for d in "$(cdir "$v")" "$(sdir "$v")" "$(pdir "$v")"; do
[ -f "$d/postmaster.pid" ] && "$b/pg_ctl" -D "$d" -m immediate -w stop
>/dev/null 2>&1
done
rm -rf "${RUN:?}/$v"
}
# wait_until SECS VER PORT SQL EXPECTED -- poll every 10 ms; 0 if it matched
wait_until() {
local deadline=$(( $(now_ms) + $1 * 1000 ))
while [ "$(now_ms)" -lt "$deadline" ]; do
[ "$(q "$2" "$3" "$4" 2>/dev/null)" = "$5" ] && return 0
sleep 0.01
done
return 1
}
# stall VER PORT [SQL] -- start a non-reading client, echo its backend pid once
# that backend sits in wait_event ClientWrite
stall() {
local v=$1 port=$2 sql=${3:-"select repeat('x', 8000) from generate_series(1,
100000000)"} out pid
out=$(mktemp "$RUN/$v/stall.XXXX")
python3 "$HERE/stall_client.py" "$port" postgres "$sql" > "$out" 2>&1 &
for _ in $(seq 500); do grep -q '^SENT' "$out" && break; sleep 0.01; done
pid=$(awk '/^SENT/{print $2}' "$out")
[ -n "$pid" ] || { echo "stall client failed: $(cat "$out")" >&2; return 1; }
wait_until 20 "$v" "$port" "select wait_event from pg_stat_activity where pid
= $pid" ClientWrite \
|| { echo "backend $pid never reached ClientWrite" >&2; return 1; }
# ClientWrite is not enough. Right after the client's buffer fills, its
# kernel still reopens the window a few bytes at a time; a barrier that lands
# then gets absorbed because the backend completes a flush and reaches
# CHECK_FOR_INTERRUPTS(). Wait until the server socket stops moving data
# (bytes_acked unchanged for 2 s) so every run starts from a truly stuck
backend.
local lport last="" cur stable=0
lport=$(awk '/^SENT/{print $3}' "$out")
for _ in $(seq 60); do
cur=$(ss -tinH "( sport = :$port and dport = :$lport )" | grep -o
'bytes_acked:[0-9]*')
if [ -n "$cur" ] && [ "$cur" = "$last" ]; then stable=$((stable + 1)); else
stable=0; fi
[ $stable -ge 4 ] && break
last=$cur; sleep 0.5
done
[ $stable -ge 4 ] || echo "warning: socket of backend $pid never settled" >&2
# Settled bytes_acked is still not enough. The kernel can free room in the
LOCAL
# send buffer after the backend's last EAGAIN without reporting the socket as
# writable (epoll waits for a threshold). A barrier's SIGUSR1 then wakes the
# backend, the retried write() fits, the message completes, the next
# CHECK_FOR_INTERRUPTS() absorbs the barrier, and promotion goes through by
luck.
# Saturate first: wake the backend with a harmless signal until its Send-Q
stops
# growing. (STALL_SATURATE=0 skips this, to show the luck.)
if [ "${STALL_SATURATE:-1}" = 1 ]; then
local sq prev="" same=0
for _ in $(seq 40); do
q "$v" "$port" "select pg_log_backend_memory_contexts($pid)" >/dev/null
2>&1
sleep 0.2
sq=$(ss -tnH "( sport = :$port and dport = :$lport )" | awk '{print $3}')
if [ "$sq" = "$prev" ]; then same=$((same + 1)); else same=0; fi
[ $same -ge 3 ] && break
prev=$sq
done
[ $same -ge 3 ] || echo "warning: send queue of backend $pid kept growing"
>&2
fi
echo "$pid"
}