Skip to content

PROTOTYPE: move NOTIFY queue insertion past commit (not for upstream)#85

Draft
NikolayS wants to merge 1 commit into
masterfrom
claude/notify-altb-prototype
Draft

PROTOTYPE: move NOTIFY queue insertion past commit (not for upstream)#85
NikolayS wants to merge 1 commit into
masterfrom
claude/notify-altb-prototype

Conversation

@NikolayS

Copy link
Copy Markdown
Owner

Measurement prototype for the LISTEN/NOTIFY commit-lock work in #82. Not a patch proposal — the known gaps below are disqualifying, and at least one of them is a semantics decision only a committer can make.

Companion PRs: #83 (docs, upstream-bound), #84 (benchmark harness, fork-local).

The change

PreCommit_Notify() takes a cluster-wide heavyweight lock and inserts notifications while holding it, and the lock isn't released until end of transaction — which is after RecordTransactionCommit(). So the serialized region contains XLogFlush() and SyncRepWaitForLSN(), notifying transactions cannot group-commit, and throughput is pinned near one commit round trip at a time regardless of concurrency.

This moves the insertion to PostCommitInsert_Notify(), called from CommitTransaction() immediately after ProcArrayEndTransaction(), serialized by a dedicated NotifyQueueInsertLock.

Why after the ProcArray removal, not before. The listener's gate is XidInMVCCSnapshot(), so the invariant is insertion order == snapshot visibility order, not commit-LSN order. Every entry is now inserted by a transaction that has already left the ProcArray, and a listener reads the queue head before taking its snapshot — so it can never observe an entry whose xid is still in progress. The invariant holds trivially rather than being derived from lock tenure. A backend queued on the insert lock also no longer pins its xid/xmin, so it can't hold back the global horizon or delay visibility of an already-durable commit.

Why an LWLock is a correctness requirement, not tidiness. Past the commit record, any ERROR reaches RecordTransactionAbort()'s "cannot abort transaction %u, it was already committed" PANIC. LockSharedObject() can throw: out of shared memory for the lock table (a routine, user-reachable error), OOM, deadlock detection (which bypasses interrupt holdoff), and AcceptInvalidationMessages() running catalog callbacks. LWLockAcquire() allocates nothing, consults no lock table, runs no deadlock detector, and is released by LWLockReleaseAll() during error recovery. The earlier placement was also inside HOLD_INTERRUPTS(), making the wait uncancellable and unkillable — pg_terminate_backend a no-op. That's gone too.

Measurements

4 vCPU, fsync=on, zero listeners, 3 reps, 10 s, median [min–max]. BEGIN; INSERT; NOTIFY; END vs the same transaction without NOTIFY:

clients control stock v1 (heavyweight, pre-ProcArray) this patch
4 2,059 1,033 2,130 2,129 [2109–2244]
16 7,811 1,027 7,767 8,155 [7961–8176]
64 16,307 1,055 10,965 16,507 [16402–16695]

15.7x over stock at 64 clients, and commit latency 60.7 ms → 3.9 ms — indistinguishable from the NOTIFY-free control. Arm verification sampled zero backends waiting on either Lock/object or LWLock/NotifyQueueInsert, i.e. the insert lock is not observably contended.

Note the v1 column: an earlier iteration that kept the heavyweight lock and inserted before ProcArrayEndTransaction() reached only 10,965 at 64 clients. Moving past the ProcArray removal and switching to an LWLock is worth the remaining ~50%.

Caveats: single 4-vCPU box, control cross-run variance is ~10% at 64 clients, and everything ≥8 clients is CPU-oversubscribed. Treat these as shape, not precision.

Known gaps — why this is not a patch

  • asyncQueueAddEntries() can still reach SlruReportIOError() (ENOSPC on pg_notify/, read/close failures), which would PANIC post-commit. Needs either pre-faulting the target pages during pre-commit, or a decision to degrade to WARNING and drop — which async.c's existing "notifications are not expected to survive database crashes" contract would justify. This is the gating item, and it's a semantics call for a committer.
  • asyncQueueIsFull() still ereport(ERROR)s from the post-commit path. Needs capacity (not positional) reservation during pre-commit. Positional reservation is a dead end — it re-fixes queue order at reservation time and reintroduces the serialization.
  • No test would catch an ordering regression. The async regression test and all 130 isolation tests pass, but they do not appear to exercise two concurrent notifiers racing a listener's snapshot. Passing tests are not evidence the ordering is right — that rests on the argument above. An injection-point test is required.
  • Not benchmarked with listeners attached, with a synchronous standby, or on hardware where numbers would be publishable.

Relationship to upstream work

This is much smaller than Tom Lane's WAL-transport design (~1/20th), which Rishu Bagga has implemented to v9 with two commitfest entries currently in "Needs review". It is not a competing proposal — if the WAL transport lands it subsumes this. The interest here is that the throughput appears to come from relocating the serialized region rather than from moving notifications into WAL, which is a much smaller change to argue about.

🤖 Generated with Claude Code

https://claude.ai/code/session_01M3RLizCyeXBBFJdBHGDvKW


Generated by Claude Code

Measurement prototype for the LISTEN/NOTIFY commit-lock work in #82.
Not a patch proposal -- see "Known gaps" below.

PreCommit_Notify() takes a cluster-wide heavyweight lock and inserts the
transaction's notifications while holding it; the lock is not released
until end of transaction.  Since that is after RecordTransactionCommit(),
the serialized region contains XLogFlush() and SyncRepWaitForLSN(), so
notifying transactions cannot group-commit and throughput is pinned near
one commit round trip at a time regardless of concurrency.

Move the insertion into PostCommitInsert_Notify(), called from
CommitTransaction() immediately after ProcArrayEndTransaction(), and
serialize it with a dedicated NotifyQueueInsertLock instead.

Placing it after the ProcArray removal rather than before is what makes
this work.  The listener's gate is XidInMVCCSnapshot(), so the invariant
that matters is insertion order == snapshot visibility order, not
commit-LSN order.  Every entry is now inserted by a transaction that has
already left the ProcArray, and a listener reads the queue head before
taking its snapshot, so it can never observe an entry whose xid is still
in progress.  The invariant holds trivially rather than being derived
from how long a lock is held.  A backend queued on the insert lock also
no longer pins its xid/xmin, so it cannot hold back the global horizon
or delay the visibility of an already-durable commit.

Using an LWLock rather than the heavyweight lock is a correctness
requirement, not a tidiness one.  Past the commit record, any ERROR
reaches RecordTransactionAbort()'s "cannot abort transaction %u, it was
already committed" PANIC.  LockSharedObject() can throw -- out of shared
memory for the lock table, OOM, deadlock detection, and
AcceptInvalidationMessages() running catalog callbacks.  LWLockAcquire()
allocates nothing, consults no lock table, runs no deadlock detector,
and is released by LWLockReleaseAll() during error recovery.  The old
placement was also inside HOLD_INTERRUPTS(), which made the wait
uncancellable and unkillable; that is gone too.

Measured on 4 vCPU, fsync=on, zero listeners, 3 reps, median of
"BEGIN; INSERT; NOTIFY; END" versus the same transaction without NOTIFY:

  clients   control   stock   this patch
        4     2,059   1,033        2,129
       16     7,811   1,027        8,155
       64    16,307   1,055       16,507

15.7x over stock at 64 clients, with commit latency falling from 60.7ms
to 3.9ms -- indistinguishable from the NOTIFY-free control.

Known gaps that must be closed before this is a real patch:

- asyncQueueAddEntries() can still reach SlruReportIOError() (ENOSPC on
  pg_notify/, read or close failures), which would PANIC post-commit.
  Needs either pre-faulting of the target pages during pre-commit, or a
  decision to degrade to WARNING and drop, which async.c's existing
  "notifications are not expected to survive database crashes" contract
  would justify.  That is a semantics call for a committer.
- asyncQueueIsFull() still ereport(ERROR)s from the post-commit path.
  Needs capacity (not positional) reservation during pre-commit.
- No test would catch an ordering regression.  The async regression test
  and all 130 isolation tests pass, but they do not appear to exercise
  two concurrent notifiers racing a listener's snapshot.  An
  injection-point test is required.
- Not benchmarked with listeners attached, with a synchronous standby,
  or on hardware where the numbers would be publishable.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01M3RLizCyeXBBFJdBHGDvKW
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant