LISTEN/NOTIFY and the commit-time global lock: resource review + code analysis + benchmarks
Reading list for discussion, with per-resource notes, a code-level analysis of master
(as of 13b7a8a, PG20devel), and fresh benchmark numbers taken on that tree.
Short answer to "is it a real bottleneck?": yes, and it is still present in master today.
The v19 work (282b1cde) fixed a different bottleneck (listener wakeups). The
commit-time global lock that Recall.ai hit is untouched.
1. What the code actually does (master, src/backend/commands/async.c)
PreCommit_Notify() — async.c:1309 — for any transaction with pending notifications:
/*
* Serialize writers by acquiring a special lock that we hold till
* after commit. This ensures that queue entries appear in commit
* order ...
*
* The lock is on "database 0", which is pretty ugly but it doesn't
* seem worth inventing a special locktag category just for this.
*/
LockSharedObject(DatabaseRelationId, InvalidOid, 0, AccessExclusiveLock);
Properties worth being precise about:
- It is a heavyweight lock on a shared object (
classid 1262, objid 0), so it is
cluster-wide, not per-database. Notifiers in unrelated databases serialize against
each other. This is what shows up in logs as
AccessExclusiveLock on object 0 of class 1262 of database 0.
- It is held across the whole commit. In
CommitTransaction() (xact.c):
PreCommit_Notify() (:2371) → RecordTransactionCommit() (:2407, which does
XLogFlush() at :1544 and SyncRepWaitForLSN() at :1599) → heavyweight locks released
at ResourceOwnerRelease(RESOURCE_RELEASE_LOCKS) (:2480).
So the serialized region includes the WAL fsync and the synchronous-replication
round trip.
- It therefore disables group commit for notifiers. Only one notifying transaction can
be inside commit at a time, so each pays a full fsync serially instead of amortizing one
fsync across many concurrent committers. This is the mechanism behind Recall.ai's
"load up, CPU and I/O down" signature.
- Only transactions that actually issued NOTIFY take the lock — non-notifying transactions
never touch it. The blast radius spreads indirectly: notifiers stall while holding row
locks and open XIDs, and everything that queues behind those rows/XIDs stalls too.
SignalBackends() runs in AtCommit_Notify(), i.e. after the lock is released, so
the signal storm is not inside the serialized region — it is a separate problem.
- A bare
NOTIFY is not free of transaction cost: PreCommit_Notify() calls
GetCurrentTransactionId(), forcing XID assignment and a real commit record.
2. What PG19's 282b1cde did and did not do
282b1cde "Optimize LISTEN/NOTIFY via shared channel map and direct advancement"
(Joel Jacobson, committed by Tom Lane, Jan 2026) adds a shared dshash channel→listeners map
so that SignalBackends() wakes only genuinely interested listeners, and directly advances
the queue pointer of uninterested idle listeners instead of signalling them.
Before (v18, async.c SignalBackends()): every listener in the same database was signalled
on every notifying commit, regardless of channel — O(listeners) kill() syscalls plus
O(listeners) queue scans per notify.
After: targeted wakeups. Big win for many-channels/few-interested workloads — §3.3 measures
it at 24x–59x with 500 listeners.
But PreCommit_Notify() still takes the same LockSharedObject(... AccessExclusiveLock).
The code even acknowledges the dependency (async.c:1319):
Note: if the heavyweight lock were ever removed for scalability reasons, we could achieve
the same guarantee by holding NotifyQueueLock in EXCLUSIVE mode across all our insertions...
Residual cost after v19: SignalBackends() still scans all listeners under
NotifyQueueLock in EXCLUSIVE mode on every notifying commit (async.c:2341), so
per-commit work is still O(total listeners) even when nobody is interested — just without
the syscalls.
Also worth noting: master documentation still says nothing about any of this.
doc/src/sgml/ref/notify.sgml Notes covers only queue-full behaviour and 2PC.
3. Benchmarks on this tree
Two builds from this repo, same container (4 vCPU / 15 GB), fsync=on,
shared_buffers=2GB, stock config otherwise, 8 s pgbench runs:
- master =
13b7a8a (PG20devel) — includes the v19 fix 282b1cde
- v18 =
REL_18_BETA1 — before it
Two identical workloads, differing only in one statement:
-- control -- notify
BEGIN; BEGIN;
INSERT INTO t (v, pad) VALUES (...); INSERT INTO t (v, pad) VALUES (...);
NOTIFY hotchan, 'payload';
END; END;
3.1 Writer side — synchronous_commit=on, zero listeners
Nobody has issued LISTEN, so no wakeup, no signal, no queue scan, no thundering herd of any
kind is possible. This isolates the commit lock.
| clients |
control |
notify (master) |
notify (v18) |
notify/control |
| 1 |
842 |
731 |
615 |
0.87 |
| 2 |
1,107 |
1,020 |
998 |
0.92 |
| 4 |
2,224 |
1,147 |
1,036 |
0.52 |
| 8 |
3,934 |
1,042 |
1,021 |
0.26 |
| 16 |
8,240 |
1,034 |
989 |
0.13 |
| 32 |
11,909 |
932 |
901 |
0.08 |
| 64 |
16,397 |
951 |
906 |
0.06 |
(control shown from master; v18's control tracks it within noise — 764 / 1,999 / 4,199 /
7,926 / 11,982 / 16,937.)
Three things to take from this table:
- The control workload scales ~19x from 1 to 64 clients. The NOTIFY workload does not
scale at all — flat at ~1,000 TPS across a 64x concurrency range, slightly negative
past 16 clients. It is pinned at approximately the single-client commit rate, i.e.
1/fsync: the fingerprint of "one commit at a time, group commit disabled", which is
exactly what holding an AccessExclusiveLock across XLogFlush() produces.
- master and v18 are identical here (951 vs 906 at 64 clients). v19 changed nothing on
the writer side, as expected from the code.
- At 64 clients the cost of adding one
NOTIFY to a trivial transaction is 17x.
Wait-event sampling during the 32-client runs (20 samples of pg_stat_activity, active
client backends only):
| build |
workload |
wait_event_type / wait_event |
samples |
| master |
notify |
Lock / object |
610 |
| v18 |
notify |
Lock / object |
609 |
| master |
control |
LWLock / WALWrite |
373 |
| v18 |
control |
LWLock / WALWrite |
427 |
| master |
notify |
IO / WalSync |
18 |
| master |
control |
IO / WalSync |
18 |
Under NOTIFY, essentially every backend is parked on Lock / object — the heavyweight lock
on (classid 1262, objid 0) in database 0 — at all times, on both builds. The control runs
never wait on it once; their dominant wait is LWLock / WALWrite, i.e. backends piling into a
shared group commit — precisely the optimization the NOTIFY lock forbids.
3.2 Writer side — synchronous_commit=off
| clients |
control |
notify (master) |
notify (v18) |
| 1 |
5,542 |
4,319 |
4,402 |
| 2 |
12,313 |
9,253 |
8,849 |
| 4 |
43,066 |
19,932 |
30,162 |
| 16 |
41,475 |
22,138 |
22,329 |
| 32 |
39,624 |
14,169 |
14,664 |
| 64 |
35,495 |
12,260 |
12,815 |
(4 vCPUs, so everything ≥ 8 clients is CPU-oversubscribed and noisy.) With the fsync out of
the serialized region, NOTIFY reaches tens of thousands of TPS instead of one thousand. The
lock still costs ~3x at high concurrency, but it is no longer catastrophic.
This is the most important number pair in the whole discussion: same binary, same
workload, same lock — 17x penalty with synchronous_commit=on, ~3x with it off. The
severity of the global lock is proportional to how long a commit takes while holding it.
That is why this destroys production clusters on network storage and with synchronous
replicas (SyncRepWaitForLSN() is inside the lock too), and why it is easy to under-estimate
on a laptop.
3.3 Listener side — what v19 actually fixed (8 writers, synchronous_commit=off)
How much does one notifying commit cost as idle listeners pile up? Two shapes: uninterested
listeners (1000 distinct channels, notifier picks one nobody listens on — the case
282b1cde targets) and interested listeners (everyone on hotchan, everyone receives
everything). Each series normalized to its own 0-listener baseline.
Uninterested listeners
| listeners |
v18 TPS |
v18 vs 0 |
master TPS |
master vs 0 |
master/v18 |
| 0 |
26,476 |
1.00 |
24,964 |
1.00 |
0.94 |
| 50 |
4,279 |
0.16 |
19,466 |
0.78 |
4.5x |
| 200 |
1,044 |
0.04 |
11,820 |
0.47 |
11x |
| 500 |
385 |
0.01 |
9,313 |
0.37 |
24x |
Interested listeners (every listener receives every message)
| listeners |
v18 TPS |
v18 vs 0 |
master TPS |
master vs 0 |
master/v18 |
| 0 |
39,026 |
1.00 |
33,956 |
1.00 |
0.87 |
| 50 |
4,698 |
0.12 |
35,989 |
1.06 |
7.7x |
| 200 |
1,251 |
0.03 |
34,268 |
1.01 |
27x |
| 500 |
491 |
0.013 |
28,881 |
0.85 |
59x |
v19 is a genuinely large win — 24x to 59x at 500 listeners — and it deserves the credit.
On v18 a mere 50 listeners cost 6–8x throughput regardless of whether they cared about the
message, because SignalBackends() woke every listener in the database on every notifying
commit. That is the "thundering herd" Joel Jacobson was pointing at, and it was severe enough
to completely mask the lock in any benchmark that had listeners attached.
Two subtleties in the master column worth noting:
- 500 listeners actively consuming every message now cost the notifier only ~15%, while 500
uninterested listeners cost ~2.7x. That inversion is real: interested listeners get
wakeupPending set once and are skipped by subsequent commits (async.c:2314), whereas
uninterested ones fall into the second loop of SignalBackends() (async.c:2341), which
walks every listener on every notifying commit to direct-advance its queue pointer,
under NotifyQueueLock in EXCLUSIVE mode.
- So v19 converted an O(listeners) syscall storm into an O(listeners) scan under an
exclusive LWLock. Two orders of magnitude cheaper — still O(listeners) per commit.
4. The resources
Incident report: three outages 19–22 Mar 2025 on a write-heavy workload with tens of
thousands of concurrent writers. Signature: load and active sessions spike while CPU, disk
I/O and network all drop. They found AccessExclusiveLock on object 0 of class 1262 of database 0 in the logs, traced it to the PreCommit_Notify() comment, removed LISTEN/NOTIFY
from the one code path that used it, and shipped in under a day.
Considerations. The diagnosis is correct and matches the source exactly; the
"resources go down while load goes up" signature is the classic fingerprint of a serializing
mutex and is the most reusable part of the post. Three caveats for our discussion:
- The phrasing "global lock on the entire instance" is easy to misread. Non-notifying
transactions never acquire this lock. What is global is the scope (cluster-wide, all
databases) — not the set of victims. The damage reaches non-notifiers indirectly, via
row locks and XIDs held by stalled notifiers.
- The post doesn't identify the real amplifier: the lock is held across
XLogFlush() and
SyncRepWaitForLSN(), so notifying commits cannot group-commit. That is why the effect
is so violent on cloud storage / synchronous replicas and mild on a laptop with
synchronous_commit=off. See §3 — same binary, same workload, the gap moves from 17x to
~3x purely by changing synchronous_commit.
- No reproducible harness or TPS numbers were published, which is part of why the hackers
thread turned into an argument about which bottleneck was real.
Patch proposing to document, and backpatch to all supported versions: (1) the cluster-wide
commit lock, (2) O(N²) duplicate checking, (3) diagnosis via log_lock_waits, (4) logical
decoding as an alternative. Companion report on pgsql-docs two days later
(msg06859):
"engineers had to read the source to realize NOTIFY takes a global instance lock".
Considerations.
- Still not committed. Verified against this tree:
doc/src/sgml/ref/notify.sgml
Notes still covers only queue-full behaviour and the 2PC restriction. Nothing about the
lock. Given that a full fix is years out (§4.5), this remains the highest
value-per-byte change available, and the only one that helps users on v13–v18 today.
- The O(N²) point needs re-checking before re-posting. Per-transaction duplicate
elimination is not generally quadratic and has not been since PG13:
AsyncExistsPendingNotify() switches to a hash table once a transaction has
≥ MIN_HASHABLE_NOTIFIES (16) pending events — present in v18 (async.c:2294) and in
master (async.c:3169). Below 16 events the linear scan is deliberate and harmless.
What is genuinely still quadratic is the subtransaction merge path, which re-probes
each child notification against the parent list (async.c:2497 in master). Separately,
v19's new unique-channel-name list keeps an explicit O(N^2) fallback below the same
threshold (async.c:1253). The doc wording should target those specific cases rather
than make a blanket O(N²) claim, otherwise the patch is easy to reject on accuracy.
- Worth adding to the patch: that the lock is held across the WAL flush and sync-rep wait
(i.e. it defeats group commit), and that pg_notification_queue_usage() /
max_notify_queue_pages are the other operational cliff.
4.3 Postgres.FM — DBOS episode (24 Jul 2026)
Qian Li and Peter Kraft (DBOS) with Michael Christofides and Nikolay Samokhvalov. Covers the
Recall.ai post, the global lock, the v19 commit, and DBOS's use of Postgres as a durable
workflow/queue store rather than via LISTEN/NOTIFY.
Considerations. Useful mainly as the framing document — it is the bridge between the
incident report and the "actually scales" rebuttal, and it is where the "PG19 fixed it" belief
gets corrected in public. Worth pinning down on the record which of the two bottlenecks each
speaker means, because that ambiguity is what kept the hackers thread going for months.
Their original design issued a NOTIFY per stream write and plateaued at 2.9K writes/s
"without visibly consuming any Postgres resource (CPU, memory, or IOPS)" — the same
fingerprint Recall.ai reported. Their fix: buffer notifications in memory and flush them
periodically as batched transactions, so the lock is taken once per flush instead of once
per write, and individual writes can group-commit again between flushes. Result:
60K writes/s (~20x), 15–100 ms latency, with Postgres CPU finally saturated. Because a
crash can drop buffered notifications, readers also run a low-frequency fallback poll.
They state plainly that the v19 patch "does not resolve this bottleneck".
Considerations. The title is rhetorical; the content agrees with Recall.ai rather than
refuting it. What scales is amortized NOTIFY — fewer notifying commits — which is a
restatement of "the per-commit lock is the bottleneck". Two honest details worth keeping in
view: the fallback poll is an admission that batching weakens the delivery guarantee, and the
15–100 ms latency is the price paid for the 20x. This is currently the best-practice pattern
for anyone who must stay on LISTEN/NOTIFY: batch notifications, poll as a safety net.
The upstream attempt to actually fix it. Timeline below is summarized from the public
archive — worth spot-checking individual attributions against the original messages before
quoting anyone:
- Rishu Bagga (Jul 18): GUC
publish_out_of_order_notifications to skip the lock in
PreCommit_Notify(). ~185–190K → ~330–350K TPS (M2 MacBook Air, NOTIFY-only workload).
- Tom Lane (Jul 18): rejects a semantics-changing GUC on principle, and proposes a
redesign — WAL as the transport: write notification payloads to WAL in
PreCommit_Notify(), put the LSN in the commit record, and push only fixed-size
(xid, dbid, lsn) entries into the SLRU queue, so the serialized region shrinks to the
tiny queue insert. Listeners read payloads back from WAL. Also raises notifications on
standbys as a long-requested side benefit.
- Joel Jacobson (Jul 18–22): disputes that the lock is the main bottleneck — with a
realistic LISTEN+NOTIFY workload the out-of-order gain collapses from 2x to 14%, and
points at the kill(pid, SIGUSR1) "thundering herd" instead. But he also measures
1 listener + 1000 connections: 7,679 → 95,430 TPS without the lock (~12x). By Jul 30 he
concedes it is workload-dependent and both matter.
- Rishu Bagga (Sep 4–10): implements Tom's WAL design, v1–v4.
- Arseniy Mukhin / Matheus Alcantara / Masahiko Sawada (Sep 10–12): correctness
problems — the async WAL record is written after the commit record and can be lost on
crash; palloc() inside a critical section; MVCC visibility checks (XidInMVCCSnapshot)
cannot simply be dropped; WAL retention must be guaranteed while notifications are
unconsumed; two SignalBackends() call sites; regress/isolation/TAP failures.
Status: not committed.
Considerations. The central 2025 disagreement — lock vs. thundering herd — is now
resolved by construction. Joel's own v19 patch removed the herd; whatever remains must be
the lock, and §3.1 measures it on post-v19 master with zero listeners, where no herd can
exist. Joel's 14% figure was measured on a tree where the herd still dominated, so it should
not be carried forward as evidence that the lock is cheap — §3.3 measures that herd at
6–8x throughput loss with only 50 listeners on v18, more than enough to hide a 17x
writer-side effect behind it in any benchmark that had listeners attached. Both of them were
right about their own measurement; they were measuring different bottlenecks. Tom's
WAL-transport design is the right shape (it attacks exactly the "lock held across fsync"
property), but the review findings are fundamental rather than cosmetic, and durability of
notifications-in-WAL is a genuinely new problem for a subsystem that has always been
explicitly crash-lossy (async.c:34: "notifications are not expected to survive database
crashes"). Realistically this is a v20+ item, which raises the priority of §4.2.
4.6 (the second copy of the CAM527d8… link is the same thread as §4.2)
Product announcement for LISTEN/NOTIFY support in PgDog, a Rust pooler/sharding proxy. The
only scaling limit it names is connection count: "the problem arises once you start pushing
its max_connections limit", and "LISTEN/NOTIFY must share one database instance, so
traditional scaling techniques, like adding replicas, don't work". PgDog terminates pub/sub
in-process on Tokio broadcast channels and registers just one listener per PgDog instance, so
"Postgres only has to send a message as many times as there are instances of PgDog". Commands
are acknowledged immediately and executed in a background task, giving at-most-once delivery;
on sharded clusters the channel name is hashed and routed to a shard. No benchmarks, no
numbers, no mention of PG18/19.
Considerations. It does not conflate the writer- and listener-side bottlenecks — it
identifies neither. PreCommit_Notify(), the cluster-wide AccessExclusiveLock on
(1262, 0), the fact that it is held across XLogFlush()/SyncRepWaitForLSN(), and the
wakeup storm all go unmentioned. Multiplexing listeners attacks the listener side only —
exactly what 282b1cde has since largely fixed in core (§3.3: 500 interested listeners now
cost a notifier ~15%) — though it still helps against the residual O(listeners) scan. On the
writer side it does nothing: every client NOTIFY is forwarded as its own NOTIFY, so per-commit
lock tenure and the ~1/fsync ceiling of §3.1 are unchanged, and nothing indicates DBOS-style
batching (§4.4). Its one genuine writer-side win is left unremarked — hashing channels across
shards splits a per-cluster lock across separate clusters. Dated Aug 2025, so carrying no
"PG19 fixed it" claim is fair rather than an error; the closing advice (at-most-once, add a
polling fallback if you need guaranteed delivery) matches §4.4.
5. Where this leaves us
1. Yes, it is a real bottleneck, and it is still there. Not "was there before v19", not
"only with many listeners". On this master tree, with zero listeners, synchronous_commit=on
and 64 clients, adding one NOTIFY to a trivial INSERT transaction costs 17x throughput,
and pins the cluster at roughly one commit per fsync no matter how much concurrency you throw
at it. v18 and master measure identically on this path (906 vs 951 TPS at 64 clients),
which is the cleanest possible demonstration that v19 did not touch it.
2. There were always two separate bottlenecks, and the public discussion keeps merging
them. They have different victims and different fixes:
|
writer-side |
listener-side |
| mechanism |
heavyweight AccessExclusiveLock on (1262, 0) held across commit |
O(listeners) signals + queue scans per notifying commit |
| who suffers |
every notifying transaction, cluster-wide |
the listeners, and the notifier that has to signal them |
| status |
open — v18 and master measure identically |
fixed in v19 by 282b1cde — measured 24–59x better at 500 listeners (residual O(listeners) scan remains) |
| scales with |
commit duration (fsync, sync rep) × notifying commit rate |
number of listeners |
| measured cost |
17x at 64 clients, synchronous_commit=on, 0 listeners |
v18: 6–8x at just 50 listeners; master: 1.2–2.7x at 500 |
Recall.ai hit the first. Joel's 14% measurement in the hackers thread was taken on a tree
where the second one dominated and masked it. Now that v19 has removed the second, the first
is cleanly measurable — §3 is that measurement.
3. The severity is proportional to how long a commit takes while holding the lock. 17x
with synchronous_commit=on, ~3x with it off, same binary and workload. SyncRepWaitForLSN()
is inside the lock too, so a synchronous standby puts a network round trip in the serialized
region. The perverse consequence: the more durable your configuration, the worse
LISTEN/NOTIFY scales — and managed cloud Postgres on network storage with a sync replica is
the worst case, which is exactly the population that keeps filing these reports.
4. Why this is hard to fix (the part worth discussing). The lock is not protecting the
queue insert — NotifyQueueLock does that. It is enforcing queue order == commit order, so
that a listener reading the queue sequentially never encounters an entry from a
still-uncommitted transaction ahead of committed ones. asyncQueueProcessPageEntries()
stops at the first uncommitted entry by design. Release the lock any earlier and another
transaction can commit ahead of you, putting an uncommitted entry in front of committed ones;
the listener then stalls delivery of everything behind it (head-of-line blocking), which is
precisely the trade Rishu's out-of-order GUC was making. Tom's WAL-transport design is the
only proposal on the table that keeps ordering while shrinking the serialized region to a
fixed-size queue insert — ordering comes from the commit LSN instead of from lock tenure. Its
open problems (crash-durability of notification payloads now living in WAL, WAL retention
while notifications are unconsumed, critical-section violations) are real and not cosmetic,
for a subsystem whose contract has always been "notifications do not survive a crash"
(async.c:34).
5. What to do in the meantime.
For users, in rough order of value:
- Batch. One notifying commit carrying N notifications costs the same lock tenure as one
carrying one. This is the whole of DBOS's 20x, and it is available to everyone today.
Pair it with a low-frequency fallback poll if you need the delivery guarantee back.
- Never
NOTIFY per row from a row-level trigger — aggregate in a statement-level trigger or
a transition table and send once.
- If notification rate must track commit rate, LISTEN/NOTIFY is the wrong transport; use a
queue table with polling (SKIP LOCKED), or logical decoding for CDC.
- Diagnosis:
log_lock_waits = on, then look for AccessExclusiveLock on object 0 of class 1262 of database 0; or watch for wait_event_type = 'Lock' / wait_event = 'object' in
pg_stat_activity. The tell-tale is throughput collapsing while CPU and I/O go down.
- Also watch
pg_notification_queue_usage() and max_notify_queue_pages (default 1048576
pages = 8 GB) — the other operational cliff: a single listener sitting in a long
transaction stops queue truncation.
For upstream:
- Land the docs patch (§4.2), backpatched. A full fix is realistically v20+; users on
v13–v19 need to be able to find this out without reading async.c. Fix the O(N²) wording
first so it can't be rejected on accuracy.
- Revive Tom's WAL-transport direction with the September review findings addressed.
- Worth a separate look: the residual O(total listeners) scan in
SignalBackends()
(async.c:2341) which runs under NotifyQueueLock EXCLUSIVE on every notifying commit —
§3.3 measures 2.7x degradation at 500 uninterested listeners even post-v19.
6. Reproducer
Both builds came from this repo (13b7a8a and REL_18_BETA1), configured with
./configure --prefix=... --without-readline --without-zlib --without-icu. Run as a
non-root user.
writers.sh — writer-side matrix (control vs NOTIFY, zero listeners) + wait-event sampling
#!/bin/bash
# Writer-side test: control vs notify with ZERO listeners (isolates the commit lock)
# usage: writers.sh <PGBIN> <PGDATA> <PORT> <LABEL> <OUTDIR> [initdb]
set -u
PGBIN=$1; PGDATA=$2; PORT=$3; LABEL=$4; OUT=$5; DOINIT=${6:-no}
export PATH=$PGBIN:$PATH
export PGPORT=$PORT PGHOST=/tmp PGDATABASE=bench
DUR=${DUR:-8}
RES=$OUT/$LABEL.writers.csv
mkdir -p "$OUT"
if [ "$DOINIT" = initdb ]; then
rm -rf "$PGDATA"
initdb -D "$PGDATA" -U postgres --no-sync -A trust >/dev/null 2>&1
cat >> "$PGDATA/postgresql.conf" <<EOF
port = $PORT
unix_socket_directories = '/tmp'
listen_addresses = ''
shared_buffers = 2GB
max_connections = 1000
max_wal_size = 8GB
checkpoint_timeout = 30min
autovacuum = off
fsync = on
log_min_messages = warning
EOF
pg_ctl -D "$PGDATA" -l "$OUT/$LABEL.pglog" -w start >/dev/null 2>&1
createdb -U postgres bench 2>/dev/null
psql -U postgres -q -c "create table t(id bigserial primary key, v int, pad text)" 2>/dev/null
else
pg_ctl -D "$PGDATA" status >/dev/null 2>&1 || pg_ctl -D "$PGDATA" -l "$OUT/$LABEL.pglog" -w start >/dev/null 2>&1
fi
cat > /tmp/c_$LABEL.sql <<'EOF'
\set c random(1, 1000000)
BEGIN;
INSERT INTO t (v, pad) VALUES (:c, 'abcdefghij');
END;
EOF
cat > /tmp/n_$LABEL.sql <<'EOF'
\set c random(1, 1000000)
BEGIN;
INSERT INTO t (v, pad) VALUES (:c, 'abcdefghij');
NOTIFY hotchan, 'payload';
END;
EOF
run() { # script clients tag sc
local tps
tps=$(pgbench -U postgres -n -f "$1" -c "$2" -j "$(( $2 > 4 ? 4 : $2 ))" -T "$DUR" bench 2>/dev/null \
| grep -E '^tps' | head -1 | sed -E 's/tps = ([0-9.]+).*/\1/')
echo "$LABEL,$3,$4,0,$2,${tps:-ERR}" | tee -a "$RES"
}
echo "label,workload,sync_commit,listeners,clients,tps" > "$RES"
for sc in on off; do
psql -U postgres -q -c "alter system set synchronous_commit='$sc'" -c "select pg_reload_conf()" >/dev/null
for c in 1 2 4 8 16 32 64; do
run /tmp/c_$LABEL.sql "$c" control "$sc"
run /tmp/n_$LABEL.sql "$c" notify "$sc"
done
done
# wait-event sampling at 32 clients, sync on
psql -U postgres -q -c "alter system set synchronous_commit='on'" -c "select pg_reload_conf()" >/dev/null
for pair in "n_$LABEL.sql notify32" "c_$LABEL.sql control32"; do
set -- $pair
( pgbench -U postgres -n -f "/tmp/$1" -c 32 -j 4 -T 12 bench >/dev/null 2>&1 ) &
bp=$!
sleep 2
for i in $(seq 1 20); do
psql -U postgres -tAF, -c "select '$2', coalesce(wait_event_type,'CPU'), coalesce(wait_event,'-'), count(*) from pg_stat_activity where backend_type='client backend' and pid <> pg_backend_pid() and state='active' group by 1,2,3" >> "$OUT/$LABEL.waits.csv" 2>/dev/null
sleep 0.4
done
wait $bp
done
echo "DONE $LABEL writers"
listeners.sh — listener-scaling matrix (uninterested vs interested)
#!/bin/bash
# Listener-scaling test — hard reset (server restart) between configurations
# usage: listeners.sh <PGBIN> <PGDATA> <PORT> <LABEL> <OUTDIR>
set -u
PGBIN=$1; PGDATA=$2; PORT=$3; LABEL=$4; OUT=$5
export PATH=$PGBIN:$PATH
export PGPORT=$PORT PGHOST=/tmp PGDATABASE=bench
DUR=${DUR:-8}
RES=$OUT/$LABEL.listeners.csv
reset_server() {
pkill -u "$(id -un)" psql 2>/dev/null
pkill -u "$(id -un)" -f "sleep 100000" 2>/dev/null
pg_ctl -D "$PGDATA" -m immediate -w stop >/dev/null 2>&1
rm -f /tmp/lfifo.* 2>/dev/null
pg_ctl -D "$PGDATA" -l "$OUT/$LABEL.pglog" -w start >/dev/null 2>&1
psql -U postgres -q -c "alter system set synchronous_commit='off'" -c "select pg_reload_conf()" >/dev/null 2>&1
}
start_listeners() { # $1 = count, $2 = hot|spread
local n=$1 mode=$2 ch
for i in $(seq 1 "$n"); do
if [ "$mode" = hot ]; then ch=hotchan; else ch="idle$i"; fi
# FIFO keeps psql's stdin open so the session stays IDLE and actually drains
# the queue; a backend busy inside pg_sleep() would stall the queue tail.
mkfifo "/tmp/lfifo.$i" 2>/dev/null
( psql -U postgres -q -f "/tmp/lfifo.$i" >/dev/null 2>&1 ) &
( echo "LISTEN $ch;"; exec sleep 100000 ) > "/tmp/lfifo.$i" &
done
sleep 5
}
run() { # $1 script, $2 clients, $3 tag, $4 listeners
local live tps
live=$(psql -U postgres -tAc "select count(*) from pg_stat_activity where backend_type='client backend'" 2>/dev/null)
tps=$(pgbench -U postgres -n -f "$1" -c "$2" -j 4 -T "$DUR" bench 2>/dev/null \
| grep -E '^tps' | head -1 | sed -E 's/tps = ([0-9.]+).*/\1/')
echo "$LABEL,$3,$4,$live,$2,${tps:-ERR}" | tee -a "$RES"
}
reset_server
psql -U postgres -q -c "create table if not exists t(id bigserial primary key, v int, pad text)" >/dev/null 2>&1
cat > /tmp/nr_$LABEL.sql <<'EOF'
\set c random(1, 1000000)
\set ch random(1, 1000)
BEGIN;
INSERT INTO t (v, pad) VALUES (:c, 'abcdefghij');
SELECT pg_notify('cold' || :ch, 'payload');
END;
EOF
cat > /tmp/nh_$LABEL.sql <<'EOF'
\set c random(1, 1000000)
BEGIN;
INSERT INTO t (v, pad) VALUES (:c, 'abcdefghij');
NOTIFY hotchan, 'payload';
END;
EOF
echo "label,workload,listeners_requested,sessions_live,clients,tps" > "$RES"
for L in 0 50 200 500; do
reset_server
[ "$L" -gt 0 ] && start_listeners "$L" spread
run /tmp/nr_$LABEL.sql 8 notify_uninterested "$L"
done
for L in 0 50 200 500; do
reset_server
[ "$L" -gt 0 ] && start_listeners "$L" hot
run /tmp/nh_$LABEL.sql 8 notify_interested "$L"
done
reset_server
echo "DONE $LABEL listeners"
./writers.sh <PGBIN> <PGDATA> <PORT> <LABEL> <OUTDIR> initdb
./listeners.sh <PGBIN> <PGDATA> <PORT> <LABEL> <OUTDIR>
Caveats on the numbers: 4 vCPU container, so everything at >= 8 clients is
CPU-oversubscribed and the synchronous_commit=off rows are noisy; 8-second runs; single
container-local disk, no synchronous standby (which would make §3.1 dramatically worse,
since SyncRepWaitForLSN() is inside the lock). The synchronous_commit=on writer rows and
the listener-scaling rows are the ones that reproduce cleanly.
LISTEN/NOTIFY and the commit-time global lock: resource review + code analysis + benchmarks
Reading list for discussion, with per-resource notes, a code-level analysis of
master(as of
13b7a8a, PG20devel), and fresh benchmark numbers taken on that tree.Short answer to "is it a real bottleneck?": yes, and it is still present in
mastertoday.The v19 work (
282b1cde) fixed a different bottleneck (listener wakeups). Thecommit-time global lock that Recall.ai hit is untouched.
1. What the code actually does (master,
src/backend/commands/async.c)PreCommit_Notify()—async.c:1309— for any transaction with pending notifications:Properties worth being precise about:
classid 1262,objid 0), so it iscluster-wide, not per-database. Notifiers in unrelated databases serialize against
each other. This is what shows up in logs as
AccessExclusiveLock on object 0 of class 1262 of database 0.CommitTransaction()(xact.c):PreCommit_Notify()(:2371) →RecordTransactionCommit()(:2407, which doesXLogFlush()at :1544 andSyncRepWaitForLSN()at :1599) → heavyweight locks releasedat
ResourceOwnerRelease(RESOURCE_RELEASE_LOCKS)(:2480).So the serialized region includes the WAL fsync and the synchronous-replication
round trip.
be inside commit at a time, so each pays a full fsync serially instead of amortizing one
fsync across many concurrent committers. This is the mechanism behind Recall.ai's
"load up, CPU and I/O down" signature.
never touch it. The blast radius spreads indirectly: notifiers stall while holding row
locks and open XIDs, and everything that queues behind those rows/XIDs stalls too.
SignalBackends()runs inAtCommit_Notify(), i.e. after the lock is released, sothe signal storm is not inside the serialized region — it is a separate problem.
NOTIFYis not free of transaction cost:PreCommit_Notify()callsGetCurrentTransactionId(), forcing XID assignment and a real commit record.2. What PG19's
282b1cdedid and did not do282b1cde"Optimize LISTEN/NOTIFY via shared channel map and direct advancement"(Joel Jacobson, committed by Tom Lane, Jan 2026) adds a shared dshash channel→listeners map
so that
SignalBackends()wakes only genuinely interested listeners, and directly advancesthe queue pointer of uninterested idle listeners instead of signalling them.
Before (v18,
async.cSignalBackends()): every listener in the same database was signalledon every notifying commit, regardless of channel — O(listeners)
kill()syscalls plusO(listeners) queue scans per notify.
After: targeted wakeups. Big win for many-channels/few-interested workloads — §3.3 measures
it at 24x–59x with 500 listeners.
But
PreCommit_Notify()still takes the sameLockSharedObject(... AccessExclusiveLock).The code even acknowledges the dependency (
async.c:1319):Residual cost after v19:
SignalBackends()still scans all listeners underNotifyQueueLockin EXCLUSIVE mode on every notifying commit (async.c:2341), soper-commit work is still O(total listeners) even when nobody is interested — just without
the syscalls.
Also worth noting:
masterdocumentation still says nothing about any of this.doc/src/sgml/ref/notify.sgmlNotes covers only queue-full behaviour and 2PC.3. Benchmarks on this tree
Two builds from this repo, same container (4 vCPU / 15 GB),
fsync=on,shared_buffers=2GB, stock config otherwise, 8 s pgbench runs:13b7a8a(PG20devel) — includes the v19 fix282b1cdeREL_18_BETA1— before itTwo identical workloads, differing only in one statement:
3.1 Writer side —
synchronous_commit=on, zero listenersNobody has issued LISTEN, so no wakeup, no signal, no queue scan, no thundering herd of any
kind is possible. This isolates the commit lock.
(control shown from master; v18's control tracks it within noise — 764 / 1,999 / 4,199 /
7,926 / 11,982 / 16,937.)
Three things to take from this table:
scale at all — flat at ~1,000 TPS across a 64x concurrency range, slightly negative
past 16 clients. It is pinned at approximately the single-client commit rate, i.e.
1/fsync: the fingerprint of "one commit at a time, group commit disabled", which is
exactly what holding an
AccessExclusiveLockacrossXLogFlush()produces.the writer side, as expected from the code.
NOTIFYto a trivial transaction is 17x.Wait-event sampling during the 32-client runs (20 samples of
pg_stat_activity, activeclient backends only):
Under NOTIFY, essentially every backend is parked on
Lock / object— the heavyweight lockon
(classid 1262, objid 0)in database 0 — at all times, on both builds. The control runsnever wait on it once; their dominant wait is
LWLock / WALWrite, i.e. backends piling into ashared group commit — precisely the optimization the NOTIFY lock forbids.
3.2 Writer side —
synchronous_commit=off(4 vCPUs, so everything ≥ 8 clients is CPU-oversubscribed and noisy.) With the fsync out of
the serialized region, NOTIFY reaches tens of thousands of TPS instead of one thousand. The
lock still costs ~3x at high concurrency, but it is no longer catastrophic.
This is the most important number pair in the whole discussion: same binary, same
workload, same lock — 17x penalty with
synchronous_commit=on, ~3x with it off. Theseverity of the global lock is proportional to how long a commit takes while holding it.
That is why this destroys production clusters on network storage and with synchronous
replicas (
SyncRepWaitForLSN()is inside the lock too), and why it is easy to under-estimateon a laptop.
3.3 Listener side — what v19 actually fixed (8 writers,
synchronous_commit=off)How much does one notifying commit cost as idle listeners pile up? Two shapes: uninterested
listeners (1000 distinct channels, notifier picks one nobody listens on — the case
282b1cdetargets) and interested listeners (everyone onhotchan, everyone receiveseverything). Each series normalized to its own 0-listener baseline.
Uninterested listeners
Interested listeners (every listener receives every message)
v19 is a genuinely large win — 24x to 59x at 500 listeners — and it deserves the credit.
On v18 a mere 50 listeners cost 6–8x throughput regardless of whether they cared about the
message, because
SignalBackends()woke every listener in the database on every notifyingcommit. That is the "thundering herd" Joel Jacobson was pointing at, and it was severe enough
to completely mask the lock in any benchmark that had listeners attached.
Two subtleties in the master column worth noting:
uninterested listeners cost ~2.7x. That inversion is real: interested listeners get
wakeupPendingset once and are skipped by subsequent commits (async.c:2314), whereasuninterested ones fall into the second loop of
SignalBackends()(async.c:2341), whichwalks every listener on every notifying commit to direct-advance its queue pointer,
under
NotifyQueueLockin EXCLUSIVE mode.exclusive LWLock. Two orders of magnitude cheaper — still O(listeners) per commit.
4. The resources
4.1 Recall.ai — Postgres LISTEN/NOTIFY does not scale (Jul 2025)
Incident report: three outages 19–22 Mar 2025 on a write-heavy workload with tens of
thousands of concurrent writers. Signature: load and active sessions spike while CPU, disk
I/O and network all drop. They found
AccessExclusiveLock on object 0 of class 1262 of database 0in the logs, traced it to thePreCommit_Notify()comment, removed LISTEN/NOTIFYfrom the one code path that used it, and shipped in under a day.
Considerations. The diagnosis is correct and matches the source exactly; the
"resources go down while load goes up" signature is the classic fingerprint of a serializing
mutex and is the most reusable part of the post. Three caveats for our discussion:
transactions never acquire this lock. What is global is the scope (cluster-wide, all
databases) — not the set of victims. The damage reaches non-notifiers indirectly, via
row locks and XIDs held by stalled notifiers.
XLogFlush()andSyncRepWaitForLSN(), so notifying commits cannot group-commit. That is why the effectis so violent on cloud storage / synchronous replicas and mild on a laptop with
synchronous_commit=off. See §3 — same binary, same workload, the gap moves from 17x to~3x purely by changing
synchronous_commit.thread turned into an argument about which bottleneck was real.
4.2 pgsql-hackers — docs: LISTEN/NOTIFY performance considerations (Nikolay Samokhvalov, 10 Jul 2025)
Patch proposing to document, and backpatch to all supported versions: (1) the cluster-wide
commit lock, (2) O(N²) duplicate checking, (3) diagnosis via
log_lock_waits, (4) logicaldecoding as an alternative. Companion report on pgsql-docs two days later
(msg06859):
"engineers had to read the source to realize NOTIFY takes a global instance lock".
Considerations.
doc/src/sgml/ref/notify.sgmlNotes still covers only queue-full behaviour and the 2PC restriction. Nothing about the
lock. Given that a full fix is years out (§4.5), this remains the highest
value-per-byte change available, and the only one that helps users on v13–v18 today.
elimination is not generally quadratic and has not been since PG13:
AsyncExistsPendingNotify()switches to a hash table once a transaction has≥
MIN_HASHABLE_NOTIFIES(16) pending events — present in v18 (async.c:2294) and inmaster (
async.c:3169). Below 16 events the linear scan is deliberate and harmless.What is genuinely still quadratic is the subtransaction merge path, which re-probes
each child notification against the parent list (
async.c:2497in master). Separately,v19's new unique-channel-name list keeps an explicit
O(N^2)fallback below the samethreshold (
async.c:1253). The doc wording should target those specific cases ratherthan make a blanket O(N²) claim, otherwise the patch is easy to reject on accuracy.
(i.e. it defeats group commit), and that
pg_notification_queue_usage()/max_notify_queue_pagesare the other operational cliff.4.3 Postgres.FM — DBOS episode (24 Jul 2026)
Qian Li and Peter Kraft (DBOS) with Michael Christofides and Nikolay Samokhvalov. Covers the
Recall.ai post, the global lock, the v19 commit, and DBOS's use of Postgres as a durable
workflow/queue store rather than via LISTEN/NOTIFY.
Considerations. Useful mainly as the framing document — it is the bridge between the
incident report and the "actually scales" rebuttal, and it is where the "PG19 fixed it" belief
gets corrected in public. Worth pinning down on the record which of the two bottlenecks each
speaker means, because that ambiguity is what kept the hackers thread going for months.
4.4 DBOS — Postgres LISTEN/NOTIFY Actually Scales
Their original design issued a NOTIFY per stream write and plateaued at 2.9K writes/s
"without visibly consuming any Postgres resource (CPU, memory, or IOPS)" — the same
fingerprint Recall.ai reported. Their fix: buffer notifications in memory and flush them
periodically as batched transactions, so the lock is taken once per flush instead of once
per write, and individual writes can group-commit again between flushes. Result:
60K writes/s (~20x), 15–100 ms latency, with Postgres CPU finally saturated. Because a
crash can drop buffered notifications, readers also run a low-frequency fallback poll.
They state plainly that the v19 patch "does not resolve this bottleneck".
Considerations. The title is rhetorical; the content agrees with Recall.ai rather than
refuting it. What scales is amortized NOTIFY — fewer notifying commits — which is a
restatement of "the per-commit lock is the bottleneck". Two honest details worth keeping in
view: the fallback poll is an admission that batching weakens the delivery guarantee, and the
15–100 ms latency is the price paid for the 20x. This is currently the best-practice pattern
for anyone who must stay on LISTEN/NOTIFY: batch notifications, poll as a safety net.
4.5 pgsql-hackers — Proposal: Out-of-Order NOTIFY via GUC to Improve LISTEN/NOTIFY Throughput (Rishu Bagga, 18 Jul 2025)
The upstream attempt to actually fix it. Timeline below is summarized from the public
archive — worth spot-checking individual attributions against the original messages before
quoting anyone:
publish_out_of_order_notificationsto skip the lock inPreCommit_Notify(). ~185–190K → ~330–350K TPS (M2 MacBook Air, NOTIFY-only workload).redesign — WAL as the transport: write notification payloads to WAL in
PreCommit_Notify(), put the LSN in the commit record, and push only fixed-size(xid, dbid, lsn)entries into the SLRU queue, so the serialized region shrinks to thetiny queue insert. Listeners read payloads back from WAL. Also raises notifications on
standbys as a long-requested side benefit.
realistic LISTEN+NOTIFY workload the out-of-order gain collapses from 2x to 14%, and
points at the
kill(pid, SIGUSR1)"thundering herd" instead. But he also measures1 listener + 1000 connections: 7,679 → 95,430 TPS without the lock (~12x). By Jul 30 he
concedes it is workload-dependent and both matter.
problems — the async WAL record is written after the commit record and can be lost on
crash;
palloc()inside a critical section; MVCC visibility checks (XidInMVCCSnapshot)cannot simply be dropped; WAL retention must be guaranteed while notifications are
unconsumed; two
SignalBackends()call sites; regress/isolation/TAP failures.Status: not committed.
Considerations. The central 2025 disagreement — lock vs. thundering herd — is now
resolved by construction. Joel's own v19 patch removed the herd; whatever remains must be
the lock, and §3.1 measures it on post-v19
masterwith zero listeners, where no herd canexist. Joel's 14% figure was measured on a tree where the herd still dominated, so it should
not be carried forward as evidence that the lock is cheap — §3.3 measures that herd at
6–8x throughput loss with only 50 listeners on v18, more than enough to hide a 17x
writer-side effect behind it in any benchmark that had listeners attached. Both of them were
right about their own measurement; they were measuring different bottlenecks. Tom's
WAL-transport design is the right shape (it attacks exactly the "lock held across fsync"
property), but the review findings are fundamental rather than cosmetic, and durability of
notifications-in-WAL is a genuinely new problem for a subsystem that has always been
explicitly crash-lossy (
async.c:34: "notifications are not expected to survive databasecrashes"). Realistically this is a v20+ item, which raises the priority of §4.2.
4.6 (the second copy of the CAM527d8… link is the same thread as §4.2)
4.7 PgDog — Scaling Postgres LISTEN/NOTIFY (Lev Kokotov, 4 Aug 2025)
Product announcement for LISTEN/NOTIFY support in PgDog, a Rust pooler/sharding proxy. The
only scaling limit it names is connection count: "the problem arises once you start pushing
its
max_connectionslimit", and "LISTEN/NOTIFY must share one database instance, sotraditional scaling techniques, like adding replicas, don't work". PgDog terminates pub/sub
in-process on Tokio broadcast channels and registers just one listener per PgDog instance, so
"Postgres only has to send a message as many times as there are instances of PgDog". Commands
are acknowledged immediately and executed in a background task, giving at-most-once delivery;
on sharded clusters the channel name is hashed and routed to a shard. No benchmarks, no
numbers, no mention of PG18/19.
Considerations. It does not conflate the writer- and listener-side bottlenecks — it
identifies neither.
PreCommit_Notify(), the cluster-wideAccessExclusiveLockon(1262, 0), the fact that it is held acrossXLogFlush()/SyncRepWaitForLSN(), and thewakeup storm all go unmentioned. Multiplexing listeners attacks the listener side only —
exactly what
282b1cdehas since largely fixed in core (§3.3: 500 interested listeners nowcost a notifier ~15%) — though it still helps against the residual O(listeners) scan. On the
writer side it does nothing: every client NOTIFY is forwarded as its own NOTIFY, so per-commit
lock tenure and the ~1/fsync ceiling of §3.1 are unchanged, and nothing indicates DBOS-style
batching (§4.4). Its one genuine writer-side win is left unremarked — hashing channels across
shards splits a per-cluster lock across separate clusters. Dated Aug 2025, so carrying no
"PG19 fixed it" claim is fair rather than an error; the closing advice (at-most-once, add a
polling fallback if you need guaranteed delivery) matches §4.4.
5. Where this leaves us
1. Yes, it is a real bottleneck, and it is still there. Not "was there before v19", not
"only with many listeners". On this
mastertree, with zero listeners,synchronous_commit=onand 64 clients, adding one
NOTIFYto a trivial INSERT transaction costs 17x throughput,and pins the cluster at roughly one commit per fsync no matter how much concurrency you throw
at it. v18 and master measure identically on this path (906 vs 951 TPS at 64 clients),
which is the cleanest possible demonstration that v19 did not touch it.
2. There were always two separate bottlenecks, and the public discussion keeps merging
them. They have different victims and different fixes:
AccessExclusiveLockon(1262, 0)held across commit282b1cde— measured 24–59x better at 500 listeners (residual O(listeners) scan remains)synchronous_commit=on, 0 listenersRecall.ai hit the first. Joel's 14% measurement in the hackers thread was taken on a tree
where the second one dominated and masked it. Now that v19 has removed the second, the first
is cleanly measurable — §3 is that measurement.
3. The severity is proportional to how long a commit takes while holding the lock. 17x
with
synchronous_commit=on, ~3x with it off, same binary and workload.SyncRepWaitForLSN()is inside the lock too, so a synchronous standby puts a network round trip in the serialized
region. The perverse consequence: the more durable your configuration, the worse
LISTEN/NOTIFY scales — and managed cloud Postgres on network storage with a sync replica is
the worst case, which is exactly the population that keeps filing these reports.
4. Why this is hard to fix (the part worth discussing). The lock is not protecting the
queue insert —
NotifyQueueLockdoes that. It is enforcing queue order == commit order, sothat a listener reading the queue sequentially never encounters an entry from a
still-uncommitted transaction ahead of committed ones.
asyncQueueProcessPageEntries()stops at the first uncommitted entry by design. Release the lock any earlier and another
transaction can commit ahead of you, putting an uncommitted entry in front of committed ones;
the listener then stalls delivery of everything behind it (head-of-line blocking), which is
precisely the trade Rishu's out-of-order GUC was making. Tom's WAL-transport design is the
only proposal on the table that keeps ordering while shrinking the serialized region to a
fixed-size queue insert — ordering comes from the commit LSN instead of from lock tenure. Its
open problems (crash-durability of notification payloads now living in WAL, WAL retention
while notifications are unconsumed, critical-section violations) are real and not cosmetic,
for a subsystem whose contract has always been "notifications do not survive a crash"
(
async.c:34).5. What to do in the meantime.
For users, in rough order of value:
carrying one. This is the whole of DBOS's 20x, and it is available to everyone today.
Pair it with a low-frequency fallback poll if you need the delivery guarantee back.
NOTIFYper row from a row-level trigger — aggregate in a statement-level trigger ora transition table and send once.
queue table with polling (
SKIP LOCKED), or logical decoding for CDC.log_lock_waits = on, then look forAccessExclusiveLock on object 0 of class 1262 of database 0; or watch forwait_event_type = 'Lock'/wait_event = 'object'inpg_stat_activity. The tell-tale is throughput collapsing while CPU and I/O go down.pg_notification_queue_usage()andmax_notify_queue_pages(default 1048576pages = 8 GB) — the other operational cliff: a single listener sitting in a long
transaction stops queue truncation.
For upstream:
v13–v19 need to be able to find this out without reading
async.c. Fix the O(N²) wordingfirst so it can't be rejected on accuracy.
SignalBackends()(
async.c:2341) which runs underNotifyQueueLockEXCLUSIVE on every notifying commit —§3.3 measures 2.7x degradation at 500 uninterested listeners even post-v19.
6. Reproducer
Both builds came from this repo (
13b7a8aandREL_18_BETA1), configured with./configure --prefix=... --without-readline --without-zlib --without-icu. Run as anon-root user.
writers.sh— writer-side matrix (control vs NOTIFY, zero listeners) + wait-event samplinglisteners.sh— listener-scaling matrix (uninterested vs interested)Caveats on the numbers: 4 vCPU container, so everything at >= 8 clients is
CPU-oversubscribed and the
synchronous_commit=offrows are noisy; 8-second runs; singlecontainer-local disk, no synchronous standby (which would make §3.1 dramatically worse,
since
SyncRepWaitForLSN()is inside the lock). Thesynchronous_commit=onwriter rows andthe listener-scaling rows are the ones that reproduce cleanly.