Expand description
Live subscriptions: the half of the protocol that arrives unasked.
A subscription is a cursor and a loop. It reads forward from where the consumer last was, sends what it finds, and then waits for a reason to look again. Everything interesting is in what counts as a reason, and in what happens when the consumer stops keeping up.
§Two wake-ups, because one of them is allowed to fail
The append path already queues a pg_notify INSIDE its transaction, so a
wake-up is delivered only if the append committed and never announces one
that rolled back. That is the fast path and it is the normal path.
It is not a guarantee. A notification can be lost — a dropped listener
connection, a backend restart, a listener that had not reconnected yet — and
PostgreSQL will not redeliver it. On a busy log that heals itself, because
the next append wakes everyone anyway. On a log that goes quiet immediately
after, it does not heal at all: the consumer waits for an append that has
already happened, forever. So every subscription ALSO re-reads on
SUBSCRIPTION_POLL_SECS, which turns the worst case from unbounded into a
number.
The wake-up is a tokio::sync::watch rather than a notify, and that
choice does real work: changed() is edge-triggered against a value the
receiver has not yet observed, so an append landing BETWEEN a read and the
wait is still pending when the wait begins. A primitive that only wakes
current waiters would drop exactly that one, and it is the likeliest race
here — the window is the width of a database round trip.
§A slow consumer loses its subscription, not its connection
Delivery is at-least-once and the cursor is what makes that safe: a consumer
that reconnects from its last delivered sequence misses nothing and may see
a repeat. When a consumer stops reading, the queue fills and the send blocks;
after SLOW_CONSUMER_TIMEOUT_SECS the subscription gives up and says so
with the cursor the consumer ACTUALLY received — not the one the kernel had
reached — so resuming from it has no gap. The connection stays open and its
other work carries on.
“Actually received” is why a batch travels with a progress cell rather than
alone. Queuing a batch is not delivering it: the write loop buffers up to
BATCH_QUEUE_DEPTH of them, so the cursor the kernel has READ to can be
thousands of events past the last frame that reached the socket — and a
client resuming from that number would skip every event in between. The cell
is written by the loop that owns the write half, after the frame is out, so
the number this subscription closes with is one the client has seen. It can
lag a frame that is being written as the timeout fires; lagging repeats an
event, which at-least-once allows, where leading loses one.
Functions§
- watch_
events - Turn database notifications into wake-ups, until the daemon stops.