pg-sse 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pg_sse/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """Live updates from the Postgres you already run, as server-sent events."""
2
+
3
+ from ._event import Event
4
+ from ._hub import CLOSING, DROPPED, RESYNC, Hub, HubStats, Subscription, TooManySubscribers, Wake
5
+ from ._sql import drop_trigger_sql, trigger_sql
6
+
7
+ __all__ = [
8
+ "CLOSING",
9
+ "DROPPED",
10
+ "RESYNC",
11
+ "Event",
12
+ "Hub",
13
+ "HubStats",
14
+ "Subscription",
15
+ "TooManySubscribers",
16
+ "Wake",
17
+ "drop_trigger_sql",
18
+ "trigger_sql",
19
+ ]
20
+ __version__ = "0.1.0"
pg_sse/_event.py ADDED
@@ -0,0 +1,65 @@
1
+ import json
2
+ import re
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ # The only line endings the SSE parser knows; str.splitlines would also split on U+2028 and friends.
8
+ _LINE_BREAK = re.compile(r"\r\n|\r|\n")
9
+ _FORBIDDEN_IN_FIELD = re.compile(r"[\r\n\x00]")
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class Event:
14
+ """
15
+ One server-sent event.
16
+
17
+ - `data`: a str is sent as is; anything else is sent as JSON.
18
+ - `id`: the cursor after this event. It is sent as the SSE id (so a browser reconnects with it as Last-Event-ID)
19
+ and handed back to your `read` as the cursor, unchanged.
20
+ - `event`: the SSE event name; None means the browser's default, "message".
21
+ - `final`: end the stream after this event, e.g. when the conversation it follows is closed.
22
+ """
23
+
24
+ data: Any
25
+ id: Any = None
26
+ event: str | None = None
27
+ final: bool = False
28
+
29
+
30
+ def encode(event: Event, default: Callable[[Any], Any] | None = None) -> str:
31
+ """`default` is `json.dumps`'s: what to do with data that is not JSON-native (e.g. `str` for a datetime)."""
32
+ lines = []
33
+ if event.id is not None:
34
+ lines.append(f"id: {_field('id', str(event.id))}")
35
+ if event.event is not None:
36
+ lines.append(f"event: {_field('event', event.event)}")
37
+ data = event.data if isinstance(event.data, str) else json.dumps(event.data, separators=(",", ":"), default=default)
38
+ lines.extend(f"data: {line}" for line in _LINE_BREAK.split(data))
39
+ return "\n".join(lines) + "\n\n"
40
+
41
+
42
+ def encode_cursor(cursor: Any) -> str:
43
+ """An id with no data: the browser records it as its Last-Event-ID and dispatches nothing."""
44
+ return f"id: {_field('id', str(cursor))}\n\n"
45
+
46
+
47
+ def encode_goodbye(event_name: str | None, reason: str, retry_ms: int) -> str:
48
+ """
49
+ The last frame before a planned close. `retry:` sets the client's reconnect delay and dispatches nothing by
50
+ itself; the named event, with the reason as data, is sent only when asked for. No `id:`, so the client's
51
+ Last-Event-ID stays at the last real event.
52
+ """
53
+ lines = [f"retry: {retry_ms}"]
54
+ if event_name is not None:
55
+ lines += [
56
+ f"event: {_field('event', event_name)}",
57
+ f"data: {json.dumps({'reason': reason}, separators=(',', ':'))}",
58
+ ]
59
+ return "\n".join(lines) + "\n\n"
60
+
61
+
62
+ def _field(name: str, value: str) -> str:
63
+ if _FORBIDDEN_IN_FIELD.search(value):
64
+ raise ValueError(f"an SSE {name} cannot contain a line break or NUL: {value!r}")
65
+ return value
pg_sse/_hub.py ADDED
@@ -0,0 +1,621 @@
1
+ """
2
+ The hub: one LISTEN connection per process, fanned out to every open stream.
3
+
4
+ A notification carries only a key and means "re-read from your cursor"; it never carries the data. So a lost
5
+ notification costs a delay, never an event:
6
+
7
+ - every stream subscribes before its catch-up read, so a write between the two is a queued wake-up, not a gap;
8
+ - after the listener connects or reconnects, every stream re-reads (anything committed before its LISTEN existed
9
+ was missed);
10
+ - every stream re-reads when nothing woke it for a while anyway.
11
+
12
+ The listener is a thread holding one autocommit connection; each notification is handed to the event loop, where
13
+ each stream has a bounded queue. A full queue ends that stream instead of blocking or growing: the browser reconnects
14
+ with Last-Event-ID and catches up from the database.
15
+ """
16
+
17
+ import asyncio
18
+ import contextlib
19
+ import inspect
20
+ import logging
21
+ import random
22
+ import signal
23
+ import threading
24
+ import time
25
+ from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Mapping
26
+ from contextlib import asynccontextmanager
27
+ from dataclasses import dataclass
28
+ from types import FrameType
29
+ from typing import TYPE_CHECKING, Any, NoReturn
30
+
31
+ import psycopg
32
+ from psycopg import sql
33
+ from psycopg.conninfo import conninfo_to_dict, make_conninfo
34
+
35
+ from ._event import Event, encode, encode_cursor, encode_goodbye
36
+
37
+ if TYPE_CHECKING:
38
+ from starlette.responses import StreamingResponse
39
+
40
+ logger = logging.getLogger("pg_sse")
41
+
42
+ # A queue holds ("notify", key) for a NOTIFY, ("wake", key, signal) for a `hub.wake`, or ("marker", m) with one of:
43
+ RESYNC = "\x00resync"
44
+ DROPPED = "\x00dropped"
45
+ CLOSING = "\x00closing"
46
+ QueueItem = tuple[str, ...]
47
+
48
+ # The listener runs a trivial query this often, so a half-open connection (no FIN, no notifications) is noticed and
49
+ # replaced; TCP keepalives bound how long that query can hang.
50
+ _KEEPALIVE_QUERY_SECONDS = 60.0
51
+ _TCP_KEEPALIVES = {"keepalives": 1, "keepalives_idle": 30, "keepalives_interval": 10, "keepalives_count": 3}
52
+ # A connect that hangs (a black-holed host) must not pin the listener thread; a DSN's own connect_timeout wins.
53
+ _CONNECT_TIMEOUT_SECONDS = 10
54
+ # The listener runs nothing but LISTEN and SELECT 1, so a query that takes this long is stuck.
55
+ _STATEMENT_TIMEOUT = "30s"
56
+ _BACKOFF_START_SECONDS = 0.5
57
+ _BACKOFF_MAX_SECONDS = 30.0
58
+ # How often the listener wakes from a quiet connection to check whether it was asked to stop.
59
+ _LISTEN_POLL_SECONDS = 1.0
60
+
61
+ STREAM_HEADERS = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
62
+
63
+ # Before a planned close, the last frame says why and how long the client should wait before reconnecting (ms).
64
+ _RETRY_MS = {"max_age": 1000, "behind": 1000, "busy": 30_000}
65
+ _SHUTDOWN_RETRY_MS = (1000, 5000) # jittered, so a deploy's reconnects are spread out
66
+
67
+ Read = Callable[..., Iterable[Event] | Awaitable[Iterable[Event]]] # read(cursor) or read(cursor, wake)
68
+ Start = Callable[[], Any]
69
+
70
+
71
+ class TooManySubscribers(Exception):
72
+ """A cap is reached or the hub is shutting down. `Hub.sse` turns it into a 503 with Retry-After."""
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class HubStats:
77
+ """
78
+ A snapshot for a health check or a metrics endpoint.
79
+
80
+ - `open_streams`: subscriptions currently open.
81
+ - `refused_total`, `dropped_total`: streams refused at a cap (or during shutdown) and streams ended for falling
82
+ behind, since the process started.
83
+ - `listener_connected`: the listener holds its LISTEN right now.
84
+ - `listener_reconnects_total`: how often the listener got its connection back after losing it.
85
+ - `last_notification_at`: Unix time of the last notification the listener received, or None if none yet.
86
+ """
87
+
88
+ open_streams: int
89
+ refused_total: int
90
+ dropped_total: int
91
+ listener_connected: bool
92
+ listener_reconnects_total: int
93
+ last_notification_at: float | None
94
+
95
+
96
+ @dataclass(frozen=True, slots=True)
97
+ class Wake:
98
+ """
99
+ Why `read` is running, for a `read(cursor, wake)`. Several causes can arrive together: a burst is one read.
100
+
101
+ - `start`: the stream's first read.
102
+ - `keys`: keys with a database notification since the last read.
103
+ - `signals`: labels from `hub.wake(key, signal=...)` since the last read.
104
+ - `resync`: the listener connected or reconnected, so anything may have changed.
105
+ - `reread`: nothing woke the stream for `reread_seconds`.
106
+ """
107
+
108
+ start: bool = False
109
+ keys: frozenset[str] = frozenset()
110
+ signals: frozenset[str] = frozenset()
111
+ resync: bool = False
112
+ reread: bool = False
113
+
114
+ @property
115
+ def data_may_have_changed(self) -> bool:
116
+ """True unless the only causes are `hub.wake` signals."""
117
+ return self.start or self.resync or self.reread or bool(self.keys)
118
+
119
+
120
+ class Subscription:
121
+ """One stream's queue. `key` is what it watches, or None for every key. Touched only on the event loop."""
122
+
123
+ def __init__(self, key: str | None, owner: str | None, queue_size: int) -> None:
124
+ self.key = key
125
+ self.owner = owner
126
+ self.queue: asyncio.Queue[QueueItem] = asyncio.Queue(queue_size)
127
+ # What is queued, so a repeat of it is not queued again: the queue holds at most `queue_size` distinct items.
128
+ self._pending: set[QueueItem] = set()
129
+ self.ended = False
130
+
131
+ def offer(self, item: QueueItem) -> bool:
132
+ """Queues a wake-up. False when the queue was full, so this call ended the stream instead."""
133
+ if self.ended or item in self._pending: # a duplicate wake-up adds nothing to the next read
134
+ return True
135
+ try:
136
+ self.queue.put_nowait(item)
137
+ except asyncio.QueueFull:
138
+ self._end(DROPPED)
139
+ return False
140
+ self._pending.add(item)
141
+ return True
142
+
143
+ def _end(self, marker: str) -> None:
144
+ """Replaces whatever is queued with a marker the stream reads as its last item."""
145
+ self.ended = True
146
+ while not self.queue.empty():
147
+ self.queue.get_nowait()
148
+ self._pending.clear()
149
+ self.queue.put_nowait(("marker", marker))
150
+
151
+ def close(self) -> None:
152
+ if not self.ended:
153
+ self._end(CLOSING)
154
+
155
+ async def wait(self, timeout: float) -> list[QueueItem] | None: # noqa: ASYNC109 -- the ping interval, handed to wait_for
156
+ """Everything queued once there is anything (a burst costs one re-read), or None if the timeout came first."""
157
+ try:
158
+ first = await asyncio.wait_for(self.queue.get(), timeout)
159
+ except TimeoutError:
160
+ return None
161
+ items = [first]
162
+ while not self.queue.empty():
163
+ items.append(self.queue.get_nowait())
164
+ self._pending.difference_update(items)
165
+ return items
166
+
167
+
168
+ class Hub:
169
+ """
170
+ Live updates for one NOTIFY channel.
171
+
172
+ hub = Hub(DIRECT_DATABASE_URL, channel="room_change")
173
+ app = FastAPI(lifespan=hub.lifespan)
174
+
175
+ @app.get("/rooms/{room}/stream")
176
+ async def stream(room: str, last_event_id: Annotated[str | None, Header()] = None):
177
+ return hub.sse(room, read=lambda after: read_after(room, after), cursor=..., start=...)
178
+
179
+ `conninfo` must be a direct connection: behind a transaction-mode pooler (PgBouncer in transaction mode, the
180
+ pooled URLs Supabase and Neon hand out) LISTEN succeeds and never delivers, and streams only update on their
181
+ periodic re-read.
182
+ """
183
+
184
+ def __init__(
185
+ self,
186
+ conninfo: str,
187
+ channel: str,
188
+ *,
189
+ max_subscribers: int = 500,
190
+ max_per_owner: int = 5,
191
+ queue_size: int = 64,
192
+ max_stream_seconds: float = 15 * 60,
193
+ ping_seconds: tuple[float, float] = (15.0, 25.0),
194
+ reread_seconds: float = 60.0,
195
+ json_default: Callable[[Any], Any] | None = None,
196
+ end_event: str | None = None,
197
+ handle_signals: bool = True,
198
+ listen_timeout: float | None = 5.0,
199
+ ) -> None:
200
+ if not channel or "\x00" in channel or len(channel.encode()) > 63:
201
+ raise ValueError("channel must be a non-empty identifier of at most 63 bytes")
202
+ if queue_size < 1:
203
+ raise ValueError("queue_size must be at least 1 (a zero-size asyncio queue is unbounded)")
204
+ if ping_seconds[0] <= 0 or ping_seconds[0] > ping_seconds[1]:
205
+ raise ValueError("ping_seconds must be (low, high) with 0 < low <= high")
206
+ if reread_seconds <= 0:
207
+ raise ValueError("reread_seconds must be greater than 0")
208
+ self.conninfo = conninfo
209
+ self.channel = channel
210
+ # Open streams per process; past it a stream is refused with a 503.
211
+ self.max_subscribers = max_subscribers
212
+ # Open streams per (key, owner), so one client cannot fill the process cap alone.
213
+ self.max_per_owner = max_per_owner
214
+ self.queue_size = queue_size
215
+ # A stream is authenticated once, when it opens; ending it after this long bounds how long a revoked session
216
+ # keeps one. The browser reconnects at once with Last-Event-ID.
217
+ self.max_stream_seconds = max_stream_seconds
218
+ # A stream sends a `: ping` comment this often while idle, so a proxy does not time the connection out. It
219
+ # costs no database read. Jittered so streams opened together do not all ping together. Keep a proxy's read
220
+ # timeout above the high end.
221
+ self.ping_seconds = ping_seconds
222
+ # A stream re-reads when nothing woke it for this long: the net under every lost notification, so the worst
223
+ # case for one is this delay. Streams read on their own clocks, so this is a steady trickle, not a spike.
224
+ self.reread_seconds = reread_seconds
225
+ # Passed to `json.dumps(default=...)` for Event data that is not JSON-native, e.g. `str` for datetimes.
226
+ self.json_default = json_default
227
+ # Before a planned close (age limit, shutdown, fell behind, busy) a stream always sends `retry:`, which
228
+ # EventSource applies by itself. With a name here it also sends that event with {"reason": ...}, for clients
229
+ # that are not EventSource and want to know why.
230
+ self.end_event = end_event
231
+ self.handle_signals = handle_signals
232
+ # How long `lifespan` waits for the first LISTEN before it lets the app start, so a health check and the first
233
+ # requests see a live listener. Startup proceeds either way: the listener keeps retrying and `stats` reports it.
234
+ self.listen_timeout = listen_timeout
235
+ self._loop: asyncio.AbstractEventLoop | None = None
236
+ self._subscriptions: set[Subscription] = set()
237
+ self._thread: threading.Thread | None = None
238
+ self._stop = threading.Event()
239
+ # Set while the listener holds a LISTEN: report it from a health check.
240
+ self.listening = threading.Event()
241
+ self.closing = False
242
+ self._signal_hooks: dict[signal.Signals, _SignalHook] = {}
243
+ self._refused = 0
244
+ self._dropped = 0
245
+ self._reconnects = 0
246
+ self._last_notification_at: float | None = None
247
+
248
+ def stats(self) -> HubStats:
249
+ return HubStats(
250
+ open_streams=len(self._subscriptions),
251
+ refused_total=self._refused,
252
+ dropped_total=self._dropped,
253
+ listener_connected=self.listening.is_set(),
254
+ listener_reconnects_total=self._reconnects,
255
+ last_notification_at=self._last_notification_at,
256
+ )
257
+
258
+ # --- streams --------------------------------------------------------------------------------
259
+
260
+ def stream(
261
+ self,
262
+ key: str | None,
263
+ read: Read,
264
+ *,
265
+ cursor: Any = None,
266
+ start: Start | None = None,
267
+ owner: str | None = None,
268
+ end_event: str | None = None,
269
+ ) -> AsyncIterator[str]:
270
+ """
271
+ The encoded SSE frames for one key (None: every key on the channel), for any framework to send as
272
+ `text/event-stream`.
273
+
274
+ - `read(cursor)` returns the events after `cursor`, oldest first, each with its `id` set to the cursor after
275
+ it. It may be a plain function (run in a thread) or async. It runs once at the start and again on every
276
+ wake-up and after `reread_seconds` of quiet, so it must be cheap when nothing changed. A `read` that takes
277
+ a second argument gets a `Wake` saying why it runs.
278
+ - `cursor` is where to start, already parsed from the client (Last-Event-ID or a query parameter). Validate it
279
+ first: it is client input.
280
+ - `start()` gives the current cursor when `cursor` is None, so the stream starts from now. It runs after the
281
+ subscription, so nothing written in between is missed. Without it, `read(None)` decides what None means.
282
+ - `owner` (a user id, a session) applies the per-owner cap. Streams without an owner have no such cap.
283
+ - `end_event`, when given, overrides the hub's: the event name sent with the reason before a planned close.
284
+
285
+ Call it from the event loop, after any 404 or permission check: the caps are checked here, so
286
+ `TooManySubscribers` is raised before any response exists. The stream subscribes when its first frame is read
287
+ and unsubscribes when it ends, however it ends.
288
+ """
289
+ self._require_loop()
290
+ self._check_caps(key, owner)
291
+ if end_event is None:
292
+ end_event = self.end_event
293
+ return self._events(key, owner, read, _accepts_wake(read), cursor, start, end_event)
294
+
295
+ def sse(
296
+ self,
297
+ key: str | None,
298
+ read: Read,
299
+ *,
300
+ cursor: Any = None,
301
+ start: Start | None = None,
302
+ owner: str | None = None,
303
+ end_event: str | None = None,
304
+ headers: Mapping[str, str] | None = None,
305
+ ) -> "StreamingResponse":
306
+ """
307
+ `stream` as a Starlette/FastAPI `text/event-stream` response. Arguments as for `stream`; past a cap it raises
308
+ a 503 with `Retry-After: 30`. Needs `pip install "pg-sse[starlette]"`. Call it from an `async def` route.
309
+ """
310
+ try:
311
+ from starlette.exceptions import HTTPException
312
+ from starlette.responses import StreamingResponse
313
+ except ImportError:
314
+ raise ImportError('hub.sse needs Starlette: pip install "pg-sse[starlette]" (or use hub.stream)') from None
315
+ try:
316
+ frames = self.stream(key, read, cursor=cursor, start=start, owner=owner, end_event=end_event)
317
+ except TooManySubscribers as err:
318
+ logger.warning("pg_sse refused a stream (%s)", err)
319
+ raise HTTPException(
320
+ status_code=503, detail="Live updates are unavailable.", headers={"Retry-After": "30"}
321
+ ) from None
322
+ return StreamingResponse(frames, media_type="text/event-stream", headers={**STREAM_HEADERS, **(headers or {})})
323
+
324
+ async def _events(
325
+ self,
326
+ key: str | None,
327
+ owner: str | None,
328
+ read: Read,
329
+ wants_wake: bool,
330
+ cursor: Any,
331
+ start: Start | None,
332
+ end_event: str | None,
333
+ ) -> AsyncIterator[str]:
334
+ try:
335
+ subscription = self.subscribe(key, owner)
336
+ except TooManySubscribers: # a cap filled, or shutdown began, since `stream` checked
337
+ yield _goodbye(end_event, "shutdown" if self.closing else "busy")
338
+ return
339
+ deadline = time.monotonic() + self.max_stream_seconds
340
+ try:
341
+ if cursor is None and start is not None:
342
+ cursor = await _call(start)
343
+ if cursor is not None:
344
+ # The browser reconnects from here even if no event ever arrives on this stream.
345
+ yield encode_cursor(cursor)
346
+ wake = Wake(start=True)
347
+ while True:
348
+ for event in await (_call(read, cursor, wake) if wants_wake else _call(read, cursor)):
349
+ if not isinstance(event, Event):
350
+ raise TypeError(f"read must return pg_sse.Event items, got {type(event).__name__}")
351
+ yield encode(event, self.json_default)
352
+ if event.id is not None:
353
+ cursor = event.id
354
+ if event.final:
355
+ return
356
+ reread_at = time.monotonic() + self.reread_seconds
357
+ while True: # idle: ping until something wakes the stream or a re-read is due
358
+ now = time.monotonic()
359
+ if now >= deadline:
360
+ yield _goodbye(end_event, "max_age")
361
+ return
362
+ if now >= reread_at:
363
+ wake = Wake(reread=True)
364
+ break
365
+ timeout = min(random.uniform(*self.ping_seconds), deadline - now, reread_at - now) # noqa: S311 -- jitter
366
+ woke = await subscription.wait(timeout)
367
+ if woke is None:
368
+ # Every timeout pings, even the one that leads to a re-read: the re-read may send nothing,
369
+ # and the silence must never outlast a ping interval.
370
+ if time.monotonic() < deadline:
371
+ yield ": ping\n\n"
372
+ elif subscription.ended: # the marker is the only item left: dropped, or closed at shutdown
373
+ yield _goodbye(end_event, "shutdown" if woke[0] == ("marker", CLOSING) else "behind")
374
+ return
375
+ else:
376
+ wake = _wake_from(woke)
377
+ break
378
+ except Exception:
379
+ logger.exception("pg_sse stream for key %r failed; the client reconnects", key)
380
+ finally:
381
+ self.unsubscribe(subscription)
382
+
383
+ # --- subscriptions (on the event loop) ---------------------------------------------------------
384
+
385
+ def subscribe(self, key: str | None, owner: str | None = None) -> Subscription:
386
+ """The low-level API under `stream`: a queue of notifications, wakes and markers. Unsubscribe in a `finally`."""
387
+ self._require_loop()
388
+ self._check_caps(key, owner)
389
+ subscription = Subscription(key, owner, self.queue_size)
390
+ self._subscriptions.add(subscription)
391
+ return subscription
392
+
393
+ def _check_caps(self, key: str | None, owner: str | None) -> None:
394
+ if self.closing:
395
+ self._refuse("shutting down")
396
+ if len(self._subscriptions) >= self.max_subscribers:
397
+ self._refuse("too many open streams")
398
+ # Without an owner there is nobody to cap: anonymous streams on one key are not one client.
399
+ if owner is not None and sum(1 for s in self._subscriptions if s.key == key and s.owner == owner) >= (
400
+ self.max_per_owner
401
+ ):
402
+ self._refuse("too many open streams for this owner")
403
+
404
+ def _refuse(self, reason: str) -> NoReturn:
405
+ self._refused += 1
406
+ raise TooManySubscribers(reason)
407
+
408
+ def unsubscribe(self, subscription: Subscription) -> None:
409
+ self._subscriptions.discard(subscription)
410
+
411
+ def wake(self, key: str, signal: str = "wake", *, all_keys: bool = True) -> None:
412
+ """
413
+ Wakes one key's streams for a change that is not a database write (someone is typing). Safe from any thread.
414
+
415
+ A `read(cursor, wake)` sees `signal` in `wake.signals`. With `all_keys=False` the streams watching every key
416
+ (`key=None`) are left alone: right for ephemeral per-key state that an all-keys view has nothing to show for.
417
+ """
418
+ self._post(self._dispatch, ("wake", key, signal), key, all_keys)
419
+
420
+ def close_all(self) -> None:
421
+ """Ends every stream and refuses new ones, so a shutdown does not wait out the graceful-shutdown timeout."""
422
+ self.closing = True
423
+ for subscription in list(self._subscriptions):
424
+ subscription.close()
425
+
426
+ def _require_loop(self) -> None:
427
+ try:
428
+ running = asyncio.get_running_loop()
429
+ except RuntimeError:
430
+ raise RuntimeError(
431
+ "pg_sse: call hub.stream(), hub.sse() or hub.subscribe() from an `async def` route"
432
+ ) from None
433
+ if self._loop is None:
434
+ raise RuntimeError(
435
+ "pg_sse: the hub is not running; use FastAPI(lifespan=hub.lifespan) or `async with hub.lifespan()`"
436
+ )
437
+ if running is not self._loop:
438
+ raise RuntimeError("pg_sse: the hub runs on another event loop")
439
+
440
+ def _dispatch(self, item: QueueItem, key: str | None = None, all_keys: bool = True) -> None:
441
+ """Offers `item` to the streams for `key` and (unless `all_keys` is False) the all-keys streams; to every
442
+ stream when `key` is None."""
443
+ for subscription in list(self._subscriptions):
444
+ if key is None or subscription.key == key or (all_keys and subscription.key is None):
445
+ self._offer(subscription, item)
446
+
447
+ def _offer(self, subscription: Subscription, item: QueueItem) -> None:
448
+ if not subscription.offer(item):
449
+ # Dropped for falling behind: its stream ends on its next read, and the hub forgets it now.
450
+ self._dropped += 1
451
+ self._subscriptions.discard(subscription)
452
+
453
+ # --- lifecycle --------------------------------------------------------------------------------
454
+
455
+ @asynccontextmanager
456
+ async def lifespan(self, _app: Any = None) -> AsyncIterator[None]:
457
+ """`FastAPI(lifespan=hub.lifespan)`, or `async with hub.lifespan():` inside your own lifespan."""
458
+ loop = asyncio.get_running_loop()
459
+ self.start(loop)
460
+ try:
461
+ if self.handle_signals:
462
+ self._install_signal_hook(loop)
463
+ if self.listen_timeout is not None:
464
+ await asyncio.to_thread(self.listening.wait, self.listen_timeout)
465
+ yield
466
+ finally:
467
+ self.close_all()
468
+ self._remove_signal_hook()
469
+ await asyncio.to_thread(self.stop)
470
+
471
+ def start(self, loop: asyncio.AbstractEventLoop | None = None) -> None:
472
+ if self._thread is not None:
473
+ return
474
+ self._loop = loop or asyncio.get_running_loop()
475
+ # One flag per start, held by its own thread: a listener that `stop` gave up waiting for (a hung connect) must
476
+ # never see the flag of a later start cleared and resume.
477
+ self._stop = threading.Event()
478
+ self.closing = False
479
+ self._thread = threading.Thread(target=self._listen, args=(self._stop,), name="pg-sse-listener", daemon=True)
480
+ self._thread.start()
481
+
482
+ def stop(self) -> None:
483
+ thread, self._thread = self._thread, None
484
+ if thread is None:
485
+ return
486
+ self._stop.set()
487
+ self.listening.clear()
488
+ thread.join(timeout=_LISTEN_POLL_SECONDS + 1) # the listener checks the flag every poll
489
+
490
+ def _install_signal_hook(self, loop: asyncio.AbstractEventLoop) -> None:
491
+ """
492
+ uvicorn waits for open connections to finish before it runs the lifespan's shutdown, so an idle stream would
493
+ hold a deploy for the whole graceful-shutdown timeout. Chain onto SIGTERM/SIGINT (after the server installed
494
+ its own handlers) to end the streams the moment the signal arrives. A no-op off the main thread.
495
+ """
496
+ if threading.current_thread() is not threading.main_thread():
497
+ return
498
+ for sig in (signal.SIGTERM, signal.SIGINT):
499
+ if sig in self._signal_hooks:
500
+ continue
501
+ previous = signal.getsignal(sig)
502
+ if previous == signal.SIG_DFL:
503
+ # The default action terminates the process at once, which already ends every stream. Nothing to
504
+ # shorten, and chaining would swallow the signal: a script with no server would never exit.
505
+ continue
506
+ hook = _SignalHook(previous)
507
+
508
+ def handler(signum: int, frame: FrameType | None, hook: _SignalHook = hook) -> None:
509
+ if hook.active and not loop.is_closed():
510
+ loop.call_soon_threadsafe(self.close_all)
511
+ if callable(hook.previous):
512
+ hook.previous(signum, frame)
513
+
514
+ hook.handler = handler
515
+ signal.signal(sig, handler)
516
+ self._signal_hooks[sig] = hook
517
+
518
+ def _remove_signal_hook(self) -> None:
519
+ """Puts the handlers back, so entering `lifespan` again does not stack another layer on this one."""
520
+ for sig, hook in self._signal_hooks.items():
521
+ # Someone wrapped a handler around ours since (another hub, say): removing ours would remove theirs too, so
522
+ # ours stays in the chain, inert, and only passes the signal on.
523
+ hook.active = False
524
+ if hook.previous is not None and signal.getsignal(sig) is hook.handler:
525
+ signal.signal(sig, hook.previous)
526
+ self._signal_hooks.clear()
527
+
528
+ # --- the listener thread ----------------------------------------------------------------------
529
+
530
+ def _post(self, callback: Callable[..., None], *args: Any) -> None:
531
+ loop = self._loop
532
+ if loop is not None and not loop.is_closed():
533
+ with contextlib.suppress(RuntimeError): # the loop closed between the check and the call
534
+ loop.call_soon_threadsafe(callback, *args)
535
+
536
+ def _listen(self, stop: threading.Event) -> None:
537
+ settings: dict[str, Any] = dict(_TCP_KEEPALIVES)
538
+ if "connect_timeout" not in conninfo_to_dict(self.conninfo):
539
+ settings["connect_timeout"] = _CONNECT_TIMEOUT_SECONDS
540
+ conninfo = make_conninfo(self.conninfo, **settings)
541
+ backoff = _BACKOFF_START_SECONDS
542
+ connected_before = False
543
+ while not stop.is_set():
544
+ try:
545
+ with psycopg.connect(conninfo, autocommit=True, application_name="pg-sse-listener") as conn:
546
+ if stop.is_set(): # `stop` gave up on this thread while it was connecting: it is not the listener
547
+ return
548
+ conn.execute(f"SET statement_timeout = '{_STATEMENT_TIMEOUT}'")
549
+ conn.execute(sql.SQL("LISTEN {}").format(sql.Identifier(self.channel)))
550
+ backoff = _BACKOFF_START_SECONDS
551
+ if connected_before:
552
+ self._reconnects += 1
553
+ connected_before = True
554
+ # Anything committed before this LISTEN existed (first connect or reconnect) was missed: every
555
+ # stream re-reads. Posted before `listening` is set, so whoever sees it set subscribes after the
556
+ # marker has been dispatched. With no stream open yet it is a no-op.
557
+ self._post(self._dispatch, ("marker", RESYNC))
558
+ self.listening.set()
559
+ last_checked = time.monotonic()
560
+ while not stop.is_set():
561
+ if time.monotonic() - last_checked >= _KEEPALIVE_QUERY_SECONDS:
562
+ conn.execute("SELECT 1")
563
+ last_checked = time.monotonic()
564
+ for notification in conn.notifies(timeout=_LISTEN_POLL_SECONDS):
565
+ self._last_notification_at = time.time()
566
+ self._post(self._dispatch, ("notify", notification.payload), notification.payload)
567
+ except Exception:
568
+ if stop.is_set(): # `stop` already cleared `listening`; a later start may have set it again
569
+ return
570
+ self.listening.clear()
571
+ logger.warning("pg_sse listener lost its connection; retrying in %.1fs", backoff, exc_info=True)
572
+ connected_before = True
573
+ stop.wait(backoff * random.uniform(0.5, 1.0)) # noqa: S311 -- reconnect jitter
574
+ backoff = min(backoff * 2, _BACKOFF_MAX_SECONDS)
575
+
576
+
577
+ @dataclass(slots=True)
578
+ class _SignalHook:
579
+ """A handler this hub installed over `previous`. Once `active` is False it only passes the signal on."""
580
+
581
+ previous: Any
582
+ handler: Callable[[int, FrameType | None], None] | None = None
583
+ active: bool = True
584
+
585
+
586
+ def _goodbye(event_name: str | None, reason: str) -> str:
587
+ retry_ms = random.randint(*_SHUTDOWN_RETRY_MS) if reason == "shutdown" else _RETRY_MS[reason] # noqa: S311
588
+ return encode_goodbye(event_name, reason, retry_ms)
589
+
590
+
591
+ def _wake_from(items: list[QueueItem]) -> Wake:
592
+ keys = frozenset(item[1] for item in items if item[0] == "notify")
593
+ signals = frozenset(item[2] for item in items if item[0] == "wake")
594
+ return Wake(keys=keys, signals=signals, resync=("marker", RESYNC) in items)
595
+
596
+
597
+ def _accepts_wake(fn: Callable[..., Any]) -> bool:
598
+ """
599
+ Whether `read` is read(cursor, wake): its second positional parameter is named `wake` or has no default. A
600
+ defaulted second parameter of another name (`limit=500`) is not a wake, and neither is `*args`. Handles lambdas,
601
+ partials (their bound arguments are gone from the signature) and methods.
602
+ """
603
+ try:
604
+ parameters = inspect.signature(fn).parameters.values()
605
+ except (TypeError, ValueError): # no signature available (some builtins): assume read(cursor)
606
+ return False
607
+ positional = [p for p in parameters if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)]
608
+ if len(positional) < 2:
609
+ return False
610
+ second = positional[1]
611
+ return second.name == "wake" or second.default is second.empty
612
+
613
+
614
+ async def _call(fn: Callable[..., Any], *args: Any) -> Any:
615
+ """Awaits an async callable on the loop; runs a plain one in a thread (a lambda returning a coroutine works too)."""
616
+ if inspect.iscoroutinefunction(fn):
617
+ return await fn(*args)
618
+ result = await asyncio.to_thread(fn, *args)
619
+ if inspect.isawaitable(result):
620
+ return await result
621
+ return result
pg_sse/_sql.py ADDED
@@ -0,0 +1,108 @@
1
+ import re
2
+
3
+ _IDENTIFIER = re.compile(r"^[a-z_][a-z0-9_]{0,62}$")
4
+
5
+
6
+ def trigger_sql(table: str, key_column: str, channel: str, *, order_column: str | None = None) -> str:
7
+ """
8
+ The SQL that wires a table to a channel: run it once, in a migration.
9
+
10
+ - A notify trigger sends `pg_notify(channel, key)` for every inserted, updated or deleted row (both keys when an
11
+ update moves a row). A trigger, not a call in each writer, so a cron job or a psql session cannot forget it.
12
+ NOTIFY is delivered on commit: a rolled-back write announces nothing.
13
+ - With `order_column` (a serial, bigserial or identity column, usually `id`), a second trigger makes that column
14
+ follow commit order within a key: an insert locks its key until it commits, then draws the next value. Without
15
+ it, a reader at cursor 11 can skip id 10 committing late. The trigger replaces any value an INSERT supplies.
16
+
17
+ The order trigger takes an advisory lock on the key until the inserting transaction ends, so other inserts to
18
+ that key wait, and two transactions inserting into keys in opposite order can deadlock: keep that transaction
19
+ short. The lock is `pg_advisory_xact_lock(hashtext(channel), hashtext(key))`, and advisory locks share one
20
+ namespace per database, so an application taking the same two-int lock would serialise against these inserts.
21
+ `hashtext` is 32-bit, so two different keys can collide and briefly make each other's inserts wait: a delay,
22
+ never wrong ids.
23
+
24
+ Names are plain lower-case identifiers; `table` may be `schema.table`. Postgres 14 or later. The objects it creates
25
+ are named `"pg_sse$notify$<table>$<key_column>"` and `"pg_sse$order$<table>$<order_column>"`: a trigger and a
26
+ function each, in the table's schema.
27
+ """
28
+ name, qualified, prefix = _parse(table, key_column, channel, order_column)
29
+ notify_fn = _name("pg_sse", "notify", name, key_column)
30
+ statements = []
31
+ if order_column:
32
+ # Without a sequence the first insert would fail on a null id; fail when the trigger is installed instead.
33
+ statements.append(f"""DO $pg_sse$
34
+ BEGIN
35
+ IF pg_get_serial_sequence('{qualified}', '{order_column}') IS NULL THEN
36
+ RAISE EXCEPTION 'pg_sse: {qualified}."{order_column}" has no sequence; '
37
+ 'order_column must be serial, bigserial or identity';
38
+ END IF;
39
+ END
40
+ $pg_sse$;""")
41
+ statements += [
42
+ f"""CREATE OR REPLACE FUNCTION {prefix}"{notify_fn}"() RETURNS trigger LANGUAGE plpgsql AS $pg_sse$
43
+ BEGIN
44
+ IF TG_OP = 'DELETE' THEN
45
+ PERFORM pg_notify('{channel}', OLD."{key_column}"::text);
46
+ RETURN OLD;
47
+ END IF;
48
+ IF TG_OP = 'UPDATE' AND OLD."{key_column}" IS DISTINCT FROM NEW."{key_column}" THEN
49
+ PERFORM pg_notify('{channel}', OLD."{key_column}"::text);
50
+ END IF;
51
+ PERFORM pg_notify('{channel}', NEW."{key_column}"::text);
52
+ RETURN NEW;
53
+ END;
54
+ $pg_sse$;""",
55
+ f'CREATE OR REPLACE TRIGGER "{notify_fn}" AFTER INSERT OR UPDATE OR DELETE ON {qualified} '
56
+ f'FOR EACH ROW EXECUTE FUNCTION {prefix}"{notify_fn}"();',
57
+ ]
58
+ if order_column:
59
+ order_fn = _name("pg_sse", "order", name, order_column)
60
+ statements += [
61
+ f"""CREATE OR REPLACE FUNCTION {prefix}"{order_fn}"() RETURNS trigger LANGUAGE plpgsql AS $pg_sse$
62
+ BEGIN
63
+ PERFORM pg_advisory_xact_lock(hashtext('{channel}'), hashtext(NEW."{key_column}"::text));
64
+ NEW."{order_column}" := nextval(pg_get_serial_sequence('{qualified}', '{order_column}'));
65
+ RETURN NEW;
66
+ END;
67
+ $pg_sse$;""",
68
+ f'CREATE OR REPLACE TRIGGER "{order_fn}" BEFORE INSERT ON {qualified} '
69
+ f'FOR EACH ROW EXECUTE FUNCTION {prefix}"{order_fn}"();',
70
+ ]
71
+ return "\n\n".join(statements) + "\n"
72
+
73
+
74
+ def drop_trigger_sql(table: str, key_column: str, *, order_column: str | None = None) -> str:
75
+ """The SQL that undoes `trigger_sql` (same arguments, no channel needed): for a downgrade migration."""
76
+ name, qualified, prefix = _parse(table, key_column, None, order_column)
77
+ names = [_name("pg_sse", "notify", name, key_column)]
78
+ if order_column:
79
+ names.append(_name("pg_sse", "order", name, order_column))
80
+ statements = []
81
+ for fn in names:
82
+ statements += [f'DROP TRIGGER IF EXISTS "{fn}" ON {qualified};', f'DROP FUNCTION IF EXISTS {prefix}"{fn}"();']
83
+ return "\n".join(statements) + "\n"
84
+
85
+
86
+ def _parse(table: str, key_column: str, channel: str | None, order_column: str | None) -> tuple[str, str, str]:
87
+ """Validates the identifiers; returns the table name, the quoted table and the quoted schema prefix."""
88
+ schema, _, name = table.rpartition(".")
89
+ parts = [name, key_column, *([schema] if schema else [])]
90
+ parts += [part for part in (channel, order_column) if part is not None]
91
+ for part in parts:
92
+ _check(part)
93
+ qualified = f'"{schema}"."{name}"' if schema else f'"{name}"'
94
+ prefix = f'"{schema}".' if schema else ""
95
+ return name, qualified, prefix
96
+
97
+
98
+ def _check(identifier: str) -> None:
99
+ if not _IDENTIFIER.match(identifier):
100
+ raise ValueError(f"pg_sse: {identifier!r} is not a plain lower-case identifier (a-z, 0-9, _; at most 63 bytes)")
101
+
102
+
103
+ def _name(*parts: str) -> str:
104
+ # `$` is legal in a PostgreSQL identifier and cannot appear in a validated one, so no two tables share a name.
105
+ name = "$".join(parts)
106
+ if len(name) > 63:
107
+ raise ValueError(f"pg_sse: the generated name {name!r} is longer than 63 bytes; shorten the table or column")
108
+ return name
pg_sse/py.typed ADDED
File without changes
@@ -0,0 +1,309 @@
1
+ Metadata-Version: 2.5
2
+ Name: pg-sse
3
+ Version: 0.1.0
4
+ Summary: Live updates from the Postgres you already run: LISTEN/NOTIFY wake-ups, re-reads from a cursor, served as server-sent events from any async Python framework (Starlette and FastAPI built in).
5
+ Project-URL: Homepage, https://github.com/sandhusanthakumar/pg-sse
6
+ Project-URL: Issues, https://github.com/sandhusanthakumar/pg-sse/issues
7
+ Project-URL: Changelog, https://github.com/sandhusanthakumar/pg-sse/blob/main/CHANGELOG.md
8
+ Author: Sandhu Santhakumar
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: fastapi,listen,notify,postgres,postgresql,realtime,server-sent-events,sse,starlette
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Framework :: AsyncIO
14
+ Classifier: Framework :: FastAPI
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Database
22
+ Classifier: Topic :: Internet :: WWW/HTTP
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.11
25
+ Requires-Dist: psycopg>=3.2.1
26
+ Provides-Extra: fastapi
27
+ Requires-Dist: fastapi>=0.110; extra == 'fastapi'
28
+ Provides-Extra: starlette
29
+ Requires-Dist: starlette>=0.36; extra == 'starlette'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # pg-sse
33
+
34
+ [![CI](https://github.com/sandhusanthakumar/pg-sse/actions/workflows/ci.yml/badge.svg)](https://github.com/sandhusanthakumar/pg-sse/actions/workflows/ci.yml)
35
+ [![PyPI](https://img.shields.io/pypi/v/pg-sse)](https://pypi.org/project/pg-sse/)
36
+ [![Python versions](https://img.shields.io/pypi/pyversions/pg-sse)](https://pypi.org/project/pg-sse/)
37
+ [![License](https://img.shields.io/pypi/l/pg-sse)](LICENSE)
38
+
39
+ Live updates from the Postgres you already run. No Redis, no broker, no WebSocket service: a table trigger sends `NOTIFY`, one listener per process wakes the open streams, and each stream re-reads from its cursor and sends server-sent events. Works with Starlette and FastAPI out of the box, and with any other framework through `hub.stream`.
40
+
41
+ ```sh
42
+ pip install "pg-sse[starlette]" # or "pg-sse[fastapi]"; plain "pg-sse" is enough for hub.stream
43
+ pip install "psycopg[binary]" # on a machine without libpq; see the psycopg install docs for the alternatives
44
+ ```
45
+
46
+ ## The one rule
47
+
48
+ A notification carries only a key (a room id, a conversation id) and means **"something changed here, re-read from your cursor"**. It never carries the data. So a lost notification costs a delay, never an event, and there's no 8000-byte payload limit to hit.
49
+
50
+ ## Why not X
51
+
52
+ - **Polling.** Every client hits the database on a timer, so load grows with clients times frequency, and latency is half the interval. Here an idle stream costs one read a minute, and a write reaches clients within milliseconds.
53
+ - **WebSockets.** You usually end up running a second service and writing your own reconnect and catch-up logic. `EventSource` reconnects by itself and sends `Last-Event-ID`, and one-way updates are all most pages need.
54
+ - **Redis pub/sub.** It is another system to run, secure and keep consistent with your database, and a message missed during a disconnect is gone. Here the database is the source of truth and a missed wake-up costs a delay.
55
+ - **Supabase Realtime.** It ties you to one vendor's platform and its protocol. This runs against any Postgres 14+ you can open a direct connection to.
56
+
57
+ ## Use it
58
+
59
+ **1. Once, in a migration:** wire the table to a channel.
60
+
61
+ ```python
62
+ from pg_sse import trigger_sql
63
+
64
+ conn.execute(trigger_sql("messages", key_column="room", channel="room_change", order_column="id"))
65
+ ```
66
+
67
+ That creates two triggers:
68
+
69
+ | Trigger | Why |
70
+ |---|---|
71
+ | `pg_notify('room_change', room)` after every insert, update and delete | No writer can forget it: not a cron job, not a psql session. Delivered on commit; a rollback announces nothing. |
72
+ | With `order_column` (a serial, bigserial or identity column): ids follow commit order within a key | Without it, a reader at cursor 11 can skip id 10 committing late. An insert locks its key until it commits, then draws the next id (see Limits for the cost). |
73
+
74
+ **2. The app:**
75
+
76
+ ```python
77
+ from typing import Annotated
78
+ from fastapi import FastAPI, Header
79
+ from pg_sse import Event, Hub
80
+
81
+ hub = Hub(DIRECT_DATABASE_URL, channel="room_change") # a direct connection, not a pooled one
82
+ app = FastAPI(lifespan=hub.lifespan)
83
+
84
+
85
+ def read_after(room: str, after: int) -> list[Event]:
86
+ rows = db.fetch("SELECT id, body FROM messages WHERE room = %s AND id > %s ORDER BY id LIMIT 500", (room, after))
87
+ return [Event({"id": id, "body": body}, id=id) for id, body in rows]
88
+
89
+
90
+ def latest_id(room: str) -> int:
91
+ return db.fetchval("SELECT coalesce(max(id), 0) FROM messages WHERE room = %s", (room,))
92
+
93
+
94
+ @app.get("/rooms/{room}/stream")
95
+ async def stream(room: str, last_event_id: Annotated[str | None, Header()] = None):
96
+ cursor = int(last_event_id) if last_event_id and last_event_id.isdigit() else None # client input: parse it
97
+ return hub.sse(room, read=lambda after: read_after(room, after), cursor=cursor, start=lambda: latest_id(room))
98
+ ```
99
+
100
+ **3. The browser:**
101
+
102
+ ```js
103
+ const source = new EventSource("/rooms/lobby/stream");
104
+ source.onmessage = (e) => render(JSON.parse(e.data));
105
+ ```
106
+
107
+ `EventSource` reconnects by itself and sends `Last-Event-ID`; the stream catches up from there.
108
+
109
+ A full runnable app, with async reads on a connection pool and a browser page: [examples/chat.py](examples/chat.py).
110
+
111
+ ## Other frameworks
112
+
113
+ `hub.sse` is a thin wrapper over `hub.stream`, which yields encoded SSE frames (`str`) and knows nothing about Starlette. It raises `TooManySubscribers` at the call, before any response exists, so turn that into your framework's 503. The Django and Litestar snippets below are illustrative, not tested in CI.
114
+
115
+ ```python
116
+ # Django (ASGI; run the hub's lifespan from your ASGI app or a lifespan wrapper)
117
+ from django.http import HttpResponse, StreamingHttpResponse
118
+
119
+
120
+ async def stream(request, room):
121
+ try:
122
+ frames = hub.stream(room, read=lambda after: read_after(room, after), start=lambda: latest_id(room))
123
+ except TooManySubscribers:
124
+ return HttpResponse(status=503, headers={"Retry-After": "30"})
125
+ return StreamingHttpResponse(frames, content_type="text/event-stream", headers={"Cache-Control": "no-cache"})
126
+ ```
127
+
128
+ ```python
129
+ # Litestar
130
+ from litestar import get
131
+ from litestar.response import Stream
132
+
133
+
134
+ @get("/rooms/{room:str}/stream")
135
+ async def stream(room: str) -> Stream:
136
+ frames = hub.stream(room, read=lambda after: read_after(room, after), start=lambda: latest_id(room))
137
+ return Stream(
138
+ frames, media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
139
+ )
140
+ ```
141
+
142
+ Aiohttp and Quart work the same way: write each frame to the response as it arrives.
143
+
144
+ ## Signal streams
145
+
146
+ A stream doesn't have to carry data. It can tell the page *what* to refetch: "this list changed", "only the typing indicator changed", "you may have missed something, refetch everything". For that, give `read` a second parameter named `wake` (or a required second parameter). It receives a `Wake` that says why the read is running; several causes can arrive together, since a burst is one read.
147
+
148
+ | Field | Meaning |
149
+ |---|---|
150
+ | `start` | The stream's first read. |
151
+ | `keys` | Keys with a database notification since the last read. |
152
+ | `signals` | Labels from `hub.wake(key, signal=...)` since the last read. |
153
+ | `resync` | The listener connected or reconnected: anything may have changed. |
154
+ | `reread` | Nothing woke the stream for `reread_seconds`. |
155
+
156
+ `wake.data_may_have_changed` is True unless the only causes are `hub.wake` signals.
157
+
158
+ ```python
159
+ from pg_sse import Event, Wake
160
+
161
+
162
+ def signals(cursor, wake: Wake) -> list[Event]:
163
+ if wake.start:
164
+ return [Event({}, event="ready")] # the page loads everything once
165
+ if wake.resync or wake.reread:
166
+ return [Event({}, event="resync")] # anything may have changed
167
+ if wake.keys:
168
+ return [Event({"keys": sorted(wake.keys)}, event="change")]
169
+ if "typing" in wake.signals:
170
+ return [Event({}, event="typing")] # refetch only the typing indicator
171
+ return []
172
+
173
+
174
+ @app.get("/inbox/stream")
175
+ async def inbox(user: User = Depends(current_user)):
176
+ return hub.sse(None, read=signals, owner=user.id) # every key on the channel
177
+
178
+
179
+ # elsewhere, from any thread:
180
+ hub.wake(room_id, signal="typing", all_keys=False)
181
+ ```
182
+
183
+ A one-parameter `read(cursor)` works too, and so does one whose second parameter has a default under another name (`read(after, limit=500)`): only a parameter named `wake`, or a required one, receives the `Wake`. `hub.wake(key)` reaches the streams for `key` and the all-keys streams (`key=None`); `all_keys=False` keeps it on its key, which is right for ephemeral per-key state (typing, presence, a cursor position) that an all-keys view has nothing to show for. Database notifications always reach the all-keys streams: they report real writes.
184
+
185
+ ## What `hub.sse` does for you
186
+
187
+ - **Subscribes before the catch-up read**, so a write between the two is a queued wake-up, not a gap.
188
+ - **Calls `start()` after subscribing** when there's no cursor, and sends that cursor as the first frame's `id`, so a reconnect resumes from it even if no event arrived.
189
+ - **Re-reads on every wake-up**, and a burst of notifications costs one read.
190
+ - **Pings every 15–25 s** (`ping_seconds`) with an SSE comment, so proxies do not time an idle stream out. A ping costs no database read.
191
+ - **Re-reads after 60 s of quiet** (`reread_seconds`) even when nothing woke it. This is the safety net under every lost notification: the worst case is a one-minute delay.
192
+ - **Wakes every stream after the listener connects or reconnects**, since anything committed before its LISTEN existed (at startup, or while it was down) was missed. Reconnects with backoff; a `SELECT 1` every minute and TCP keepalives catch a half-open connection.
193
+ - **Bounded queue per stream** (64). Identical wake-ups are queued once, so a hot key costs one slot, not one per write. A stream ends, rather than buffers, when more than `queue_size` distinct keys or signals arrive during one read (only an all-keys stream on a wide channel gets there); the browser reconnects and catches up.
194
+ - **Caps** open streams per process (500) and, when you pass `owner`, per key and owner (5). Past a cap, `hub.sse` raises a `503` with `Retry-After: 30`. Without `owner`, one client can hold every slot in a worker; pass `owner` (user id, session, or client address) on public endpoints.
195
+ - **Ends streams after 15 minutes.** A stream is authenticated once, when it opens; this bounds how long a revoked session keeps one.
196
+ - **Ends every stream on SIGTERM**, so a deploy doesn't wait out uvicorn's graceful-shutdown timeout. It chains onto the server's handler; with no handler installed (a plain script) the default action is left alone and ends the process as usual.
197
+ - **Says goodbye before a planned close.** The last frame carries `retry:`, which `EventSource` applies by itself, and, with `end_event` set, a named event with the reason:
198
+
199
+ | Reason | When | `retry` (ms) | What the client should do |
200
+ |---|---|---|---|
201
+ | `max_age` | The stream reached `max_stream_seconds`. | 1000 | Reconnect now, with its cursor. |
202
+ | `shutdown` | SIGTERM, or the lifespan ending. | 1000–5000, random | Reconnect after the delay. The jitter spreads a deploy's reconnects out. |
203
+ | `behind` | The stream's queue filled. | 1000 | Reconnect and catch up from its cursor. |
204
+ | `busy` | A cap filled between the check in `hub.sse` and the stream starting. | 30000 | Back off, as for a `503` with `Retry-After: 30`. |
205
+
206
+ There is no goodbye after `Event(final=True)`, since the application already said what it meant, or when `read` raised, since the stream's state is unknown and an abrupt close is the honest signal.
207
+
208
+ ```js
209
+ // EventSource: nothing to write. `retry` is applied automatically; the reason is there if you want it.
210
+ source.addEventListener("end", (e) => console.debug("stream ended:", JSON.parse(e.data).reason));
211
+ ```
212
+
213
+ ```ts
214
+ // A fetch-based reader decides for itself.
215
+ if (event.event === "end") {
216
+ const { reason } = JSON.parse(event.data);
217
+ scheduleReconnect(reason === "busy" ? 30_000 : event.retry ?? 1_000);
218
+ }
219
+ ```
220
+
221
+ ## API
222
+
223
+ | | |
224
+ |---|---|
225
+ | `Hub(conninfo, channel, *, max_subscribers=500, max_per_owner=5, queue_size=64, max_stream_seconds=900, ping_seconds=(15, 25), reread_seconds=60, json_default=None, end_event=None, handle_signals=True, listen_timeout=5.0)` | One per channel per process. `max_per_owner` applies only to streams opened with an `owner`; anonymous streams are limited by `max_subscribers` alone. `queue_size` must be at least 1. `json_default` is `json.dumps`'s `default` for `Event` data that is not JSON-native: `json_default=str` is the common choice for datetimes, UUIDs and Decimals. `end_event` names the event sent with the reason before a planned close; off by default, the `retry:` alone is always sent. `listen_timeout` is how many seconds `lifespan` waits for the first LISTEN before the app starts (`None`: do not wait); it never fails startup. |
226
+ | `hub.lifespan` | `FastAPI(lifespan=hub.lifespan)`, or `async with hub.lifespan():` inside your own lifespan. Chains onto an existing SIGTERM/SIGINT handler (a default action is left alone) and restores it when it exits. Starts without a database: the listener keeps retrying and `listener_connected` says whether it is live. |
227
+ | `hub.stream(key, read, *, cursor=None, start=None, owner=None, end_event=None)` | The primary API: an async iterator of encoded SSE frames, for any framework. `key=None` streams every key on the channel. `key` is the text form of the key column (`str(room_id)` for an integer column): the notification payload is `key::text`, and a NULL key notifies `""`. `read` takes `(cursor)` or `(cursor, wake)`, and returns a list, not a generator: a generator would run its queries on the event loop. It and `start` may be plain functions (run in a thread) or async. `end_event` overrides the hub's. Raises `TooManySubscribers` when it is called. |
228
+ | `hub.sse(key, read, *, cursor=None, start=None, owner=None, end_event=None, headers=None)` | `stream` as a Starlette/FastAPI response (needs the `starlette` extra). Past a cap it raises a 503 with `Retry-After`. |
229
+ | `Event(data, id=None, event=None, final=False)` | `data`: a str as is, anything else as JSON. `id`: the cursor after this event, handed back to `read` unchanged. `final=True` ends the stream after it (a closed conversation). |
230
+ | `Wake(start=False, keys=frozenset(), signals=frozenset(), resync=False, reread=False)` | Why `read` runs, for a `read(cursor, wake)`. `data_may_have_changed` is True unless the only causes are `hub.wake` signals. |
231
+ | `hub.wake(key, signal="wake", *, all_keys=True)` | Wakes one key's streams for a change that isn't a database write (someone is typing). Safe from any thread. `signal` shows up in `wake.signals`; `all_keys=False` leaves the all-keys streams alone. |
232
+ | `hub.stats()` | A frozen `HubStats`: `open_streams`, `refused_total`, `dropped_total`, `listener_connected`, `listener_reconnects_total`, `last_notification_at` (Unix time or `None`). Counters are per process and start at zero. |
233
+ | `hub.listening` | A `threading.Event`, set while the listener holds its LISTEN. `hub.stats().listener_connected` says the same. |
234
+ | `hub.subscribe(key, owner)` / `hub.unsubscribe(s)` | The low-level queue under `stream`, for a custom stream loop. |
235
+ | `trigger_sql(table, key_column, channel, *, order_column=None)` | The trigger SQL. Plain lower-case names; `table` may be `schema.table`. `order_column` must be a serial, bigserial or identity column; installing fails with a clear error otherwise. Creates a function and a trigger named `"pg_sse$notify$<table>$<key>"` (and `"pg_sse$order$<table>$<order_column>"`), which is what you will see in `\d`. Postgres 14+. |
236
+ | `drop_trigger_sql(table, key_column, *, order_column=None)` | The SQL that removes what `trigger_sql` created, for a downgrade migration. |
237
+
238
+ ### Health check
239
+
240
+ ```python
241
+ import dataclasses
242
+
243
+ from fastapi.responses import JSONResponse
244
+
245
+
246
+ @app.get("/healthz")
247
+ async def healthz():
248
+ stats = hub.stats()
249
+ return JSONResponse(dataclasses.asdict(stats), status_code=200 if stats.listener_connected else 503)
250
+ ```
251
+
252
+ ## Testing your app
253
+
254
+ The hub needs a real Postgres; there is nothing to mock, because the database is the source of truth. Point the tests at a scratch database and enter the hub's lifespan. Starlette's `TestClient` runs the lifespan off the main thread, where the signal hook is skipped anyway, but pass `handle_signals=False` in tests so a test never touches the process's signal handlers.
255
+
256
+ ```python
257
+ hub = Hub(TEST_DSN, channel="room_change", handle_signals=False)
258
+ with TestClient(app) as client: # runs the lifespan
259
+ ...
260
+ ```
261
+
262
+ ## Multiple workers
263
+
264
+ Each process (each uvicorn or gunicorn worker) holds one LISTEN connection and its own caps and counters. Nothing is shared between workers and nothing needs to be: Postgres delivers every notification to every listener, and each worker wakes its own streams. Budget one connection per worker.
265
+
266
+ ## Limits
267
+
268
+ - **LISTEN needs a direct connection.** Behind a transaction-mode pooler (PgBouncer in transaction mode, the pooled connection strings Supabase and Neon give out) LISTEN succeeds and nothing ever arrives; streams then update only on the re-read. Give the hub the direct URL; your queries can still use the pooler.
269
+ - **The cursor sees new rows, not updates.** If a stream must show edits, have `read` return the current state alongside new rows, or use a cursor that moves on update (a version column with `order_column`).
270
+ - **Sync reads run in a thread pool.** A plain-function `read` or `start` runs in asyncio's default executor (`min(32, cpu + 4)` threads), and under Starlette other sync routes share anyio's pool of 40. Hundreds of streams re-reading at once queue behind it. Prefer async `read` and `start` on an `psycopg_pool.AsyncConnectionPool`; see [examples/chat.py](examples/chat.py). Use `functools.partial(read_after, room)` rather than a lambda to keep an async function off the pool.
271
+ - **`order_column` serialises inserts per key.** The ordering trigger takes `pg_advisory_xact_lock` on the key and holds it until the transaction ends, so every other insert to that key waits for the whole transaction. Two transactions inserting into keys in opposite order can deadlock, and Postgres aborts one of them. Keep the transaction that inserts short, or skip `order_column` and tolerate a late id arriving on the next re-read. The lock is `pg_advisory_xact_lock(hashtext(channel), hashtext(key))`; advisory locks share one namespace per database, so an application taking the same two-int lock would serialise against these inserts, and since `hashtext` is 32-bit two different keys can collide and briefly wait on each other (a delay, never wrong ids).
272
+ - **Per process.** Each worker runs its own listener, which works, but the caps are per process.
273
+ - **The database may be down at startup.** `lifespan` waits up to `listen_timeout` for the first LISTEN and then starts anyway; until the listener connects, streams update on their re-read and `listener_connected` is false.
274
+ - **Disconnect detection depends on the server.** Starlette 1.7 under an ASGI server that advertises `spec_version` 2.4 no longer listens for `http.disconnect` during a streaming response: a dead client is noticed at the next write, at most `ping_seconds[1]` later, and its slot is held until then. uvicorn 0.54 advertises 2.3 and frees the slot at once.
275
+ - **psycopg 3** for the listener; your own queries can use any driver.
276
+ - **A framework for the response.** `hub.sse` needs Starlette or FastAPI; any other framework uses `hub.stream`.
277
+
278
+ ## For AI coding agents
279
+
280
+ If you are generating code with this library, follow these rules:
281
+
282
+ 1. Run `trigger_sql(table, key_column, channel, order_column="id")` once in a migration. Do not call `pg_notify` from application code, and do not put row data in a notification.
283
+ 2. Give `Hub` a **direct** database URL (not a pooled / PgBouncer / Supabase port 6543 / Neon `-pooler` URL).
284
+ 3. Register the hub with `FastAPI(lifespan=hub.lifespan)`, or `async with hub.lifespan():` inside an existing lifespan.
285
+ 4. Call `hub.sse(...)` (Starlette/FastAPI) or `hub.stream(...)` (any framework; catch `TooManySubscribers`) from an `async def` route, after any 404 or permission check.
286
+ 5. `read(after)` returns `pg_sse.Event` items **after** the cursor, oldest first, each with `id` set. Use `WHERE id > %s ORDER BY id LIMIT n`.
287
+ 6. Parse `Last-Event-ID` before passing it as `cursor`; it is client input.
288
+ 7. Pass `start=` (the current max id) when the stream should start from now. Do not compute it before calling `hub.sse`: that reintroduces a gap.
289
+ 8. Prefer async `read` and `start` on a connection pool; plain functions run in a limited thread pool.
290
+ 9. In the browser use `EventSource`; it resumes with `Last-Event-ID` by itself.
291
+ 10. `read(cursor, wake)` is optional: use it only when the stream must tell the client *what kind* of change happened (see Signal streams). A stream that sends rows keeps `read(cursor)`.
292
+ 11. Use `hub.wake(key, signal=..., all_keys=False)` for ephemeral per-key state (typing, presence). The default also wakes the all-keys streams.
293
+ 12. Set `Hub(end_event="end")` when the client is not `EventSource`, so it can read the reason and the retry before a planned close.
294
+
295
+ ## Development
296
+
297
+ See [CONTRIBUTING.md](CONTRIBUTING.md). In short:
298
+
299
+ ```sh
300
+ uv sync
301
+ PG_SSE_TEST_DSN=postgresql://sse:sse@127.0.0.1:55432/pg_sse_test uv run pytest --cov
302
+ uv run ruff check . && uv run mypy
303
+ ```
304
+
305
+ The tests create and drop a schema per test in the database you point them at; use a scratch database.
306
+
307
+ ## License
308
+
309
+ MIT
@@ -0,0 +1,9 @@
1
+ pg_sse/__init__.py,sha256=LcjoW4M-tZv9oMJ-OLE6d3k_wfL-3iclyXKjTpt-3eE,472
2
+ pg_sse/_event.py,sha256=7bQI8DQKaUt2jgsIokhdM4YaD3uK7CtwQ7Fi8PloNXI,2540
3
+ pg_sse/_hub.py,sha256=jDxT47BvUhShWhPtQnf33gjRQoE7avekIN5g8JAouH4,29702
4
+ pg_sse/_sql.py,sha256=sqb0RDhVL9u0uXxUz4t_p-RDuZD0HzZGo08vq06kLDo,5485
5
+ pg_sse/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ pg_sse-0.1.0.dist-info/METADATA,sha256=EN4GOej_p-ahGEzJ_YinQjID60PPG4Ts5nuutQcaywE,21934
7
+ pg_sse-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
8
+ pg_sse-0.1.0.dist-info/licenses/LICENSE,sha256=Ml3YR3qGMwvxt9lCtrt3Gr00WiNPwOYWPRC4kaU82ao,1075
9
+ pg_sse-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sandhu Santhakumar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.