pg-sse 0.1.0__tar.gz
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-0.1.0/.gitignore +11 -0
- pg_sse-0.1.0/CHANGELOG.md +36 -0
- pg_sse-0.1.0/LICENSE +21 -0
- pg_sse-0.1.0/PKG-INFO +309 -0
- pg_sse-0.1.0/README.md +278 -0
- pg_sse-0.1.0/examples/chat.py +108 -0
- pg_sse-0.1.0/examples/index.html +33 -0
- pg_sse-0.1.0/llms.txt +30 -0
- pg_sse-0.1.0/pyproject.toml +84 -0
- pg_sse-0.1.0/src/pg_sse/__init__.py +20 -0
- pg_sse-0.1.0/src/pg_sse/_event.py +65 -0
- pg_sse-0.1.0/src/pg_sse/_hub.py +621 -0
- pg_sse-0.1.0/src/pg_sse/_sql.py +108 -0
- pg_sse-0.1.0/src/pg_sse/py.typed +0 -0
- pg_sse-0.1.0/tests/conftest.py +48 -0
- pg_sse-0.1.0/tests/helpers.py +39 -0
- pg_sse-0.1.0/tests/test_e2e.py +74 -0
- pg_sse-0.1.0/tests/test_event.py +58 -0
- pg_sse-0.1.0/tests/test_hub.py +524 -0
- pg_sse-0.1.0/tests/test_sql.py +170 -0
- pg_sse-0.1.0/tests/test_stream.py +430 -0
pg_sse-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows
|
|
4
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
|
+
|
|
6
|
+
## 0.1.0
|
|
7
|
+
|
|
8
|
+
First release.
|
|
9
|
+
|
|
10
|
+
- `Hub`: one LISTEN connection per process, fanned out to bounded per-stream queues, with caps, a stream age limit,
|
|
11
|
+
pings, periodic re-reads and reconnect with backoff.
|
|
12
|
+
- `hub.stream` yields encoded SSE frames for any async framework; `hub.sse` wraps it as a Starlette or FastAPI
|
|
13
|
+
response (`pg-sse[starlette]`, `pg-sse[fastapi]`).
|
|
14
|
+
- `trigger_sql` and `drop_trigger_sql`: a notify trigger per table, and an optional ordering trigger that keeps ids in
|
|
15
|
+
commit order within a key. The objects are named `"pg_sse$notify$<table>$<key>"` and
|
|
16
|
+
`"pg_sse$order$<table>$<order_column>"`; `$` cannot appear in a validated identifier, so two tables never share a
|
|
17
|
+
name.
|
|
18
|
+
- `Event`, `hub.wake`, `hub.stats` and `hub.subscribe` for custom loops.
|
|
19
|
+
- `read(cursor, wake)`: a `read` whose second parameter is named `wake`, or is required, gets a `Wake` saying why it
|
|
20
|
+
runs (`start`, `keys`, `signals`, `resync`, `reread`), for streams that tell the client what to refetch instead of
|
|
21
|
+
sending rows. A second parameter with a default under another name (`read(after, limit=500)`) is left alone, and so
|
|
22
|
+
is `*args`.
|
|
23
|
+
- `hub.wake(key, signal="wake", *, all_keys=True)`: a label the `Wake` carries, and `all_keys=False` to leave the
|
|
24
|
+
all-keys streams alone for ephemeral per-key state.
|
|
25
|
+
- A goodbye before a planned close: `retry:` always (1 s for `max_age` and `behind`, 1-5 s jittered for `shutdown`,
|
|
26
|
+
30 s for `busy`) and, with `Hub(end_event=...)` or `stream(end_event=...)`, a named event with `{"reason": ...}`.
|
|
27
|
+
- `Hub(listen_timeout=5.0)`: `lifespan` waits up to that long for the first LISTEN so a health check and the first
|
|
28
|
+
requests see a live listener. It never fails startup; `None` does not wait. Every stream re-reads when the listener
|
|
29
|
+
connects or reconnects, so a write committed before its LISTEN existed is never missed.
|
|
30
|
+
- The listener's connect has a 10 s timeout (unless the DSN sets `connect_timeout`) and its queries a 30 s statement
|
|
31
|
+
timeout.
|
|
32
|
+
- `lifespan` chains onto an existing SIGTERM/SIGINT handler; a default action is left alone, so a script with no
|
|
33
|
+
server still ends on SIGTERM.
|
|
34
|
+
- Identical wake-ups are queued once per stream, so a hot key costs one queue slot. A stream falls behind only when more
|
|
35
|
+
than `queue_size` distinct keys or signals arrive during one read.
|
|
36
|
+
- Python 3.11+, Postgres 14+, psycopg 3.
|
pg_sse-0.1.0/LICENSE
ADDED
|
@@ -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.
|
pg_sse-0.1.0/PKG-INFO
ADDED
|
@@ -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
|