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 +20 -0
- pg_sse/_event.py +65 -0
- pg_sse/_hub.py +621 -0
- pg_sse/_sql.py +108 -0
- pg_sse/py.typed +0 -0
- pg_sse-0.1.0.dist-info/METADATA +309 -0
- pg_sse-0.1.0.dist-info/RECORD +9 -0
- pg_sse-0.1.0.dist-info/WHEEL +4 -0
- pg_sse-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
[](https://github.com/sandhusanthakumar/pg-sse/actions/workflows/ci.yml)
|
|
35
|
+
[](https://pypi.org/project/pg-sse/)
|
|
36
|
+
[](https://pypi.org/project/pg-sse/)
|
|
37
|
+
[](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,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.
|