gwk_kernel/wire/subscribe.rs
1//! Live subscriptions: the half of the protocol that arrives unasked.
2//!
3//! A subscription is a cursor and a loop. It reads forward from where the
4//! consumer last was, sends what it finds, and then waits for a reason to look
5//! again. Everything interesting is in what counts as a reason, and in what
6//! happens when the consumer stops keeping up.
7//!
8//! # Two wake-ups, because one of them is allowed to fail
9//!
10//! The append path already queues a `pg_notify` INSIDE its transaction, so a
11//! wake-up is delivered only if the append committed and never announces one
12//! that rolled back. That is the fast path and it is the normal path.
13//!
14//! It is not a guarantee. A notification can be lost — a dropped listener
15//! connection, a backend restart, a listener that had not reconnected yet — and
16//! PostgreSQL will not redeliver it. On a busy log that heals itself, because
17//! the next append wakes everyone anyway. On a log that goes quiet immediately
18//! after, it does not heal at all: the consumer waits for an append that has
19//! already happened, forever. So every subscription ALSO re-reads on
20//! [`SUBSCRIPTION_POLL_SECS`], which turns the worst case from unbounded into a
21//! number.
22//!
23//! The wake-up is a [`tokio::sync::watch`] rather than a notify, and that
24//! choice does real work: `changed()` is edge-triggered against a value the
25//! receiver has not yet observed, so an append landing BETWEEN a read and the
26//! wait is still pending when the wait begins. A primitive that only wakes
27//! current waiters would drop exactly that one, and it is the likeliest race
28//! here — the window is the width of a database round trip.
29//!
30//! # A slow consumer loses its subscription, not its connection
31//!
32//! Delivery is at-least-once and the cursor is what makes that safe: a consumer
33//! that reconnects from its last delivered sequence misses nothing and may see
34//! a repeat. When a consumer stops reading, the queue fills and the send blocks;
35//! after [`SLOW_CONSUMER_TIMEOUT_SECS`] the subscription gives up and says so
36//! with the cursor the consumer ACTUALLY received — not the one the kernel had
37//! reached — so resuming from it has no gap. The connection stays open and its
38//! other work carries on.
39//!
40//! "Actually received" is why a batch travels with a progress cell rather than
41//! alone. Queuing a batch is not delivering it: the write loop buffers up to
42//! `BATCH_QUEUE_DEPTH` of them, so the cursor the kernel has READ to can be
43//! thousands of events past the last frame that reached the socket — and a
44//! client resuming from that number would skip every event in between. The cell
45//! is written by the loop that owns the write half, after the frame is out, so
46//! the number this subscription closes with is one the client has seen. It can
47//! lag a frame that is being written as the timeout fires; lagging repeats an
48//! event, which at-least-once allows, where leading loses one.
49
50use std::sync::Arc;
51use std::sync::atomic::{AtomicU64, Ordering};
52use std::time::Duration;
53
54use gwk_domain::ids::{RequestId, Seq};
55use gwk_domain::port::EventStore;
56use gwk_domain::protocol::{
57 KernelErrorCode, SLOW_CONSUMER_TIMEOUT_SECS, SUBSCRIPTION_POLL_SECS, ServerControl,
58};
59use sqlx::PgPool;
60use sqlx::postgres::PgListener;
61use tokio::sync::{mpsc, watch};
62
63use super::serve::{Outgoing, fit_page};
64use crate::store::{EVENT_CHANNEL, PgEventStore};
65
66/// Events read per catch-up page.
67///
68/// A subscription that is far behind walks forward a page at a time rather than
69/// asking for everything at once; the byte cut in [`fit_page`] then trims that
70/// page to what one frame can carry.
71const SUBSCRIPTION_BATCH_ROWS: usize = 256;
72
73/// Turn database notifications into wake-ups, until the daemon stops.
74///
75/// The published value is a counter, not a watermark: subscriptions re-read
76/// from their own cursors anyway, so all this has to carry is "something
77/// changed". A counter is always different from the last one, which means a
78/// receiver can never mistake two appends for one.
79///
80/// A listener that cannot connect or that dies is not fatal, and deliberately
81/// so — it degrades a subscription from milliseconds to
82/// [`SUBSCRIPTION_POLL_SECS`], and a kernel that refused to serve at all
83/// because its notification channel was unavailable would be trading a slower
84/// stream for no stream.
85pub async fn watch_events(pool: PgPool, wake: Arc<watch::Sender<u64>>) {
86 let mut listener = match PgListener::connect_with(&pool).await {
87 Ok(listener) => listener,
88 Err(_) => return,
89 };
90 if listener.listen(EVENT_CHANNEL).await.is_err() {
91 return;
92 }
93 let mut tick = 0u64;
94 while listener.recv().await.is_ok() {
95 tick = tick.wrapping_add(1);
96 // `send_replace` rather than `send`: with no subscriptions on any
97 // connection there are no receivers, and a plain send would report that
98 // as an error on every single append.
99 wake.send_replace(tick);
100 }
101}
102
103/// Why a subscription stopped, when it stopped for a reason worth reporting.
104///
105/// The reason travels as a code and nothing more, because `StreamClosed` carries
106/// no message: what a client does about a stream that ended is resume from the
107/// cursor, and the two codes that reach it — a slow consumer and a failed read —
108/// are the whole of what it can branch on.
109enum Ended {
110 /// The consumer stopped reading and the queue stayed full.
111 SlowConsumer,
112 /// The log could not be read.
113 Storage,
114 /// The connection went away; nobody is left to tell.
115 Gone,
116}
117
118/// Deliver from `cursor` forward until the consumer stops keeping up or the
119/// connection ends.
120///
121/// `batches` is the queue the consumer drains and `responses` is the priority
122/// one, which is why a `StreamClosed` can still be delivered to a consumer
123/// whose batch queue is precisely the thing that is full.
124///
125/// `delivered` starts at the cursor this subscription was opened with, so a
126/// stream that ends having sent nothing reports where it began rather than the
127/// beginning of the log.
128pub(crate) async fn run(
129 log: Arc<PgEventStore>,
130 request_id: RequestId,
131 mut cursor: Option<Seq>,
132 batches: mpsc::Sender<Outgoing>,
133 responses: mpsc::Sender<ServerControl>,
134 mut wake: watch::Receiver<u64>,
135 delivered: Arc<AtomicU64>,
136) {
137 let ended = loop {
138 let events = match log.read_from(cursor, SUBSCRIPTION_BATCH_ROWS).await {
139 Ok(events) => events,
140 Err(_) => break Ended::Storage,
141 };
142 if events.is_empty() {
143 // Nothing to send: wait for a notification or for the poll interval
144 // to come round, whichever is first. `wake.changed()` was armed by
145 // not having been observed since the last loop, so an append that
146 // landed during the read above is already pending here.
147 tokio::select! {
148 changed = wake.changed() => {
149 if changed.is_err() {
150 // The listener task is gone. The poll still works, so
151 // this is slower, not broken.
152 tokio::time::sleep(Duration::from_secs(SUBSCRIPTION_POLL_SECS)).await;
153 }
154 }
155 () = tokio::time::sleep(Duration::from_secs(SUBSCRIPTION_POLL_SECS)) => {}
156 }
157 continue;
158 }
159
160 // The page is cut to what a frame can carry; whatever is left is picked
161 // up on the next turn of the loop, immediately, because the cursor moved.
162 let (events, _cut) = match fit_page(events) {
163 Ok(page) => page,
164 Err(_) => break Ended::Storage,
165 };
166 // `fit_page` always keeps its first item, so a non-empty read cannot cut
167 // to nothing — and if it ever did, standing still with a cursor that
168 // never moves would be the worse failure.
169 let Some(last) = events.last().map(|e| e.global_sequence) else {
170 break Ended::Storage;
171 };
172
173 let batch = Outgoing {
174 control: ServerControl::EventBatch {
175 request_id: request_id.clone(),
176 events,
177 cursor: last,
178 },
179 delivered: Some((Arc::clone(&delivered), last.value())),
180 };
181 match tokio::time::timeout(
182 Duration::from_secs(SLOW_CONSUMER_TIMEOUT_SECS),
183 batches.send(batch),
184 )
185 .await
186 {
187 // Queued, which is what lets the READ cursor advance: a batch that
188 // never reached the queue is re-read rather than skipped. Whether it
189 // was DELIVERED is a different question, answered by the cell above
190 // once the frame is written.
191 Ok(Ok(())) => cursor = Some(last),
192 Ok(Err(_)) => break Ended::Gone,
193 Err(_) => break Ended::SlowConsumer,
194 }
195 };
196
197 let code = match ended {
198 // Nobody is left to tell, and the connection's own teardown is already
199 // the message.
200 Ended::Gone => return,
201 Ended::SlowConsumer => KernelErrorCode::SlowConsumer,
202 Ended::Storage => KernelErrorCode::Storage,
203 };
204 // Not `cursor` — that is how far the kernel READ, and on a slow consumer it
205 // is exactly the queue's depth ahead of the last frame that left. The cell
206 // is what the client has seen, and a resume from it is gap-free.
207 let last_cursor = match delivered.load(Ordering::Acquire) {
208 // The subscription opened at the beginning and never got a frame out.
209 0 => None,
210 seq => Some(Seq::new(seq)),
211 };
212 let _ = responses
213 .send(ServerControl::StreamClosed {
214 request_id,
215 code,
216 last_cursor,
217 })
218 .await;
219}