sqlalchemy-events-lib 0.4.0__tar.gz → 0.5.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.
- sqlalchemy_events_lib-0.5.0/PKG-INFO +546 -0
- sqlalchemy_events_lib-0.5.0/README.md +515 -0
- sqlalchemy_events_lib-0.5.0/pyproject.toml +107 -0
- sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/__init__.py +52 -0
- sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/__main__.py +4 -0
- sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/alembic.py +111 -0
- sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/asgi.py +55 -0
- sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/cli.py +149 -0
- sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/config.py +38 -0
- sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/core.py +450 -0
- sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/decorators.py +41 -0
- {sqlalchemy_events_lib-0.4.0 → sqlalchemy_events_lib-0.5.0}/src/sqlalchemy_events/default_logger.py +4 -2
- sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/defaults.py +29 -0
- {sqlalchemy_events_lib-0.4.0 → sqlalchemy_events_lib-0.5.0}/src/sqlalchemy_events/discovery.py +18 -6
- sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/dispatchers.py +351 -0
- sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/events.py +43 -0
- sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/exceptions.py +34 -0
- sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/execution.py +392 -0
- sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/handlers.py +95 -0
- sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/init_triggers_strategies/base.py +161 -0
- sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/init_triggers_strategies/postgres_init_triggers.py +630 -0
- sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/listener.py +308 -0
- sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/metrics.py +76 -0
- sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/registry.py +110 -0
- sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/testing.py +108 -0
- sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/types.py +146 -0
- sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/utils.py +177 -0
- sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events_lib.egg-info/PKG-INFO +546 -0
- {sqlalchemy_events_lib-0.4.0 → sqlalchemy_events_lib-0.5.0}/src/sqlalchemy_events_lib.egg-info/SOURCES.txt +32 -6
- sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events_lib.egg-info/entry_points.txt +2 -0
- sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events_lib.egg-info/requires.txt +15 -0
- {sqlalchemy_events_lib-0.4.0 → sqlalchemy_events_lib-0.5.0}/src/sqlalchemy_events_lib.egg-info/top_level.txt +0 -1
- sqlalchemy_events_lib-0.5.0/src/tests/conftest.py +63 -0
- sqlalchemy_events_lib-0.5.0/src/tests/handlers.py +98 -0
- sqlalchemy_events_lib-0.5.0/src/tests/helpers.py +10 -0
- sqlalchemy_events_lib-0.5.0/src/tests/no_handlers.py +1 -0
- sqlalchemy_events_lib-0.5.0/src/tests/test_alembic.py +68 -0
- sqlalchemy_events_lib-0.5.0/src/tests/test_asgi.py +114 -0
- sqlalchemy_events_lib-0.5.0/src/tests/test_autodiscover.py +163 -0
- sqlalchemy_events_lib-0.5.0/src/tests/test_cli.py +136 -0
- sqlalchemy_events_lib-0.5.0/src/tests/test_config.py +34 -0
- sqlalchemy_events_lib-0.5.0/src/tests/test_core.py +309 -0
- sqlalchemy_events_lib-0.5.0/src/tests/test_decorators.py +60 -0
- sqlalchemy_events_lib-0.5.0/src/tests/test_dispatchers.py +191 -0
- sqlalchemy_events_lib-0.5.0/src/tests/test_events.py +13 -0
- sqlalchemy_events_lib-0.5.0/src/tests/test_execution.py +251 -0
- sqlalchemy_events_lib-0.5.0/src/tests/test_handlers.py +141 -0
- sqlalchemy_events_lib-0.5.0/src/tests/test_listener.py +110 -0
- sqlalchemy_events_lib-0.5.0/src/tests/test_logger.py +20 -0
- sqlalchemy_events_lib-0.5.0/src/tests/test_metrics.py +57 -0
- sqlalchemy_events_lib-0.5.0/src/tests/test_models.py +80 -0
- sqlalchemy_events_lib-0.5.0/src/tests/test_registry.py +86 -0
- sqlalchemy_events_lib-0.5.0/src/tests/test_testing.py +91 -0
- sqlalchemy_events_lib-0.5.0/src/tests/test_triggers.py +444 -0
- sqlalchemy_events_lib-0.5.0/src/tests/test_types.py +235 -0
- sqlalchemy_events_lib-0.5.0/src/tests/test_utils_more.py +78 -0
- sqlalchemy_events_lib-0.4.0/PKG-INFO +0 -210
- sqlalchemy_events_lib-0.4.0/README.md +0 -188
- sqlalchemy_events_lib-0.4.0/pyproject.toml +0 -45
- sqlalchemy_events_lib-0.4.0/src/sqlalchemy_events/__init__.py +0 -14
- sqlalchemy_events_lib-0.4.0/src/sqlalchemy_events/callbacks_strategies/base.py +0 -8
- sqlalchemy_events_lib-0.4.0/src/sqlalchemy_events/callbacks_strategies/postgres_callback.py +0 -64
- sqlalchemy_events_lib-0.4.0/src/sqlalchemy_events/core.py +0 -333
- sqlalchemy_events_lib-0.4.0/src/sqlalchemy_events/decorators.py +0 -25
- sqlalchemy_events_lib-0.4.0/src/sqlalchemy_events/events.py +0 -35
- sqlalchemy_events_lib-0.4.0/src/sqlalchemy_events/exceptions.py +0 -1
- sqlalchemy_events_lib-0.4.0/src/sqlalchemy_events/handlers.py +0 -50
- sqlalchemy_events_lib-0.4.0/src/sqlalchemy_events/init_triggers_strategies/__init__.py +0 -0
- sqlalchemy_events_lib-0.4.0/src/sqlalchemy_events/init_triggers_strategies/base.py +0 -13
- sqlalchemy_events_lib-0.4.0/src/sqlalchemy_events/init_triggers_strategies/postgres_init_triggers.py +0 -115
- sqlalchemy_events_lib-0.4.0/src/sqlalchemy_events/registry.py +0 -15
- sqlalchemy_events_lib-0.4.0/src/sqlalchemy_events/types.py +0 -40
- sqlalchemy_events_lib-0.4.0/src/sqlalchemy_events/utils.py +0 -35
- sqlalchemy_events_lib-0.4.0/src/sqlalchemy_events_lib.egg-info/PKG-INFO +0 -210
- sqlalchemy_events_lib-0.4.0/src/sqlalchemy_events_lib.egg-info/requires.txt +0 -5
- sqlalchemy_events_lib-0.4.0/src/tests/conftest.py +0 -55
- sqlalchemy_events_lib-0.4.0/src/tests/test_autodiscover.py +0 -147
- sqlalchemy_events_lib-0.4.0/src/tests/test_handlers.py +0 -79
- sqlalchemy_events_lib-0.4.0/src/tests/test_is_created_triggers.py +0 -49
- sqlalchemy_events_lib-0.4.0/src/tests/test_models.py +0 -16
- sqlalchemy_events_lib-0.4.0/src/tests/test_sa_events.py +0 -27
- sqlalchemy_events_lib-0.4.0/src/tests/test_with_events_deco.py +0 -45
- {sqlalchemy_events_lib-0.4.0 → sqlalchemy_events_lib-0.5.0}/LICENSE +0 -0
- {sqlalchemy_events_lib-0.4.0 → sqlalchemy_events_lib-0.5.0}/setup.cfg +0 -0
- {sqlalchemy_events_lib-0.4.0/src/sqlalchemy_events/callbacks_strategies → sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/init_triggers_strategies}/__init__.py +0 -0
- {sqlalchemy_events_lib-0.4.0 → sqlalchemy_events_lib-0.5.0}/src/sqlalchemy_events/py.typed +0 -0
- {sqlalchemy_events_lib-0.4.0 → sqlalchemy_events_lib-0.5.0}/src/sqlalchemy_events_lib.egg-info/dependency_links.txt +0 -0
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sqlalchemy-events-lib
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Event-driven extension for SQLAlchemy that enables listening to database CUD events. This library allows you to react to database changes in real time using a clean, declarative API.
|
|
5
|
+
Author-email: Alexey Kostarev <normjkeeewm@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/NormanwOw/SQLAlchemy-Events
|
|
8
|
+
Project-URL: Repository, https://github.com/NormanwOw/SQLAlchemy-Events
|
|
9
|
+
Project-URL: Issues, https://github.com/NormanwOw/SQLAlchemy-Events/issues
|
|
10
|
+
Keywords: sqlalchemy,postgresql,events,listen,notify
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: sqlalchemy[asyncio]<3,>=2.0.49
|
|
19
|
+
Provides-Extra: postgres
|
|
20
|
+
Requires-Dist: asyncpg<1,>=0.29; extra == "postgres"
|
|
21
|
+
Provides-Extra: alembic
|
|
22
|
+
Requires-Dist: alembic<2,>=1.13; extra == "alembic"
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: pytest<10,>=8; extra == "test"
|
|
25
|
+
Requires-Dist: pytest-asyncio<2,>=1; extra == "test"
|
|
26
|
+
Requires-Dist: pytest-cov<8,>=5; extra == "test"
|
|
27
|
+
Requires-Dist: asyncpg<1,>=0.29; extra == "test"
|
|
28
|
+
Requires-Dist: python-dotenv; extra == "test"
|
|
29
|
+
Requires-Dist: alembic<2,>=1.13; extra == "test"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# SQLAlchemy Events
|
|
33
|
+
|
|
34
|
+
Event-driven extension for SQLAlchemy that listens to PostgreSQL CUD events.
|
|
35
|
+
React to database changes in real time with a declarative API.
|
|
36
|
+
|
|
37
|
+
- **PostgreSQL only** (PG 10+)
|
|
38
|
+
- Async `LISTEN/NOTIFY`
|
|
39
|
+
- Handlers as module-level functions, `staticmethod` or `classmethod`
|
|
40
|
+
- SQLAlchemy `rows` payloads, composite/custom id columns, mixed-case tables
|
|
41
|
+
- Safe startup and DDL coordination with advisory locks
|
|
42
|
+
- Bounded queue, worker pool, event coalescing, retries, timeouts
|
|
43
|
+
- Metrics, health checks, CLI, ASGI integration, Alembic helpers and test utilities
|
|
44
|
+
|
|
45
|
+
## Requirements
|
|
46
|
+
|
|
47
|
+
- Python 3.12+
|
|
48
|
+
- PostgreSQL 10+
|
|
49
|
+
- `asyncpg` (`pip install sqlalchemy-events-lib[postgres]`)
|
|
50
|
+
- `alembic` only for the migration helpers
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install sqlalchemy-events-lib[postgres]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Quick start
|
|
59
|
+
|
|
60
|
+
### Models
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
import uuid
|
|
64
|
+
|
|
65
|
+
from sqlalchemy import UUID
|
|
66
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
67
|
+
|
|
68
|
+
from sqlalchemy_events import SaEvent, with_events
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Base(DeclarativeBase):
|
|
72
|
+
id: Mapped[uuid.UUID] = mapped_column(
|
|
73
|
+
UUID, nullable=False, primary_key=True, default=uuid.uuid4
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@with_events([SaEvent.INSERT, SaEvent.UPDATE, SaEvent.DELETE])
|
|
78
|
+
class UserModel(Base):
|
|
79
|
+
__tablename__ = 'users'
|
|
80
|
+
|
|
81
|
+
name: Mapped[str] = mapped_column()
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Handlers
|
|
85
|
+
|
|
86
|
+
Handlers can be module-level functions, staticmethods or classmethods.
|
|
87
|
+
Only the optional `rows` parameter is supported; all other parameters must
|
|
88
|
+
have defaults.
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from sqlalchemy_events import sa_insert_handler
|
|
92
|
+
from models import UserModel
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@sa_insert_handler(UserModel)
|
|
96
|
+
async def handle_user_insert(rows: list[str]) -> None:
|
|
97
|
+
print('Inserted rows:', rows)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`rows` contains the affected primary key values (or `__events_id__` columns).
|
|
101
|
+
For composite keys each item is a list, for example `[[1, 'a'], [2, 'b']]`.
|
|
102
|
+
|
|
103
|
+
Sync handlers are supported and run in a thread pool by default. Set
|
|
104
|
+
`sync_executor='process'` to use a process pool.
|
|
105
|
+
|
|
106
|
+
### Handlers in classes
|
|
107
|
+
|
|
108
|
+
Both decorator orders are supported.
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from sqlalchemy_events import (
|
|
112
|
+
sa_delete_handler,
|
|
113
|
+
sa_insert_handler,
|
|
114
|
+
sa_update_handler,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class UserEvents:
|
|
119
|
+
@sa_insert_handler(UserModel)
|
|
120
|
+
@staticmethod
|
|
121
|
+
async def on_insert(rows) -> None:
|
|
122
|
+
...
|
|
123
|
+
|
|
124
|
+
@sa_update_handler(UserModel)
|
|
125
|
+
@classmethod
|
|
126
|
+
async def on_update(cls, rows) -> None:
|
|
127
|
+
...
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
@sa_delete_handler(UserModel)
|
|
131
|
+
async def on_delete(rows) -> None:
|
|
132
|
+
...
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
- `staticmethod` receives only `rows`.
|
|
136
|
+
- `classmethod` receives `cls` and `rows`.
|
|
137
|
+
- Ordinary instance methods are **not supported**: the library has no instance
|
|
138
|
+
to bind to. They are rejected during startup validation. Use a
|
|
139
|
+
`staticmethod`, `classmethod`, or module-level function instead.
|
|
140
|
+
|
|
141
|
+
### Start the event system
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
import asyncio
|
|
145
|
+
|
|
146
|
+
from sqlalchemy_events import EventsConfig, SQLAlchemyEvents
|
|
147
|
+
from session import engine
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def main() -> None:
|
|
151
|
+
events = SQLAlchemyEvents(
|
|
152
|
+
engine=engine,
|
|
153
|
+
autodiscover_paths=['services.handlers'],
|
|
154
|
+
config=EventsConfig(),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
async with events:
|
|
158
|
+
await asyncio.Event().wait()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
asyncio.run(main())
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`autodiscover_paths` accepts module paths and packages. All modules with
|
|
165
|
+
handlers must be reachable from them. A missing or unimportable module raises
|
|
166
|
+
`HandlersDiscoveryError` with the module name and the original import error.
|
|
167
|
+
|
|
168
|
+
## How it works
|
|
169
|
+
|
|
170
|
+
1. `autodiscover_paths` modules are imported at startup.
|
|
171
|
+
2. Decorators register handlers in a process-wide registry.
|
|
172
|
+
3. `sync_triggers()` creates statement-level triggers and PL/pgSQL functions.
|
|
173
|
+
4. A trigger sends `pg_notify` with a JSON payload:
|
|
174
|
+
`op`, `schema`, `table`, `count` and optional `rows`.
|
|
175
|
+
5. The listener receives notifications; workers execute handlers with
|
|
176
|
+
timeouts, retries and coalescing.
|
|
177
|
+
|
|
178
|
+
Only models decorated with `@with_events` are instrumented.
|
|
179
|
+
|
|
180
|
+
## Delivery guarantees and multiple instances
|
|
181
|
+
|
|
182
|
+
`LISTEN/NOTIFY` is a **broadcast** mechanism:
|
|
183
|
+
|
|
184
|
+
- every running instance receives every notification and executes its own
|
|
185
|
+
registered handlers;
|
|
186
|
+
- with several instances, the same handler runs once **per instance** — this is
|
|
187
|
+
expected behavior, not deduplication;
|
|
188
|
+
- notifications are not persisted. Events emitted while no instance is
|
|
189
|
+
listening (and notifications during a reconnect) are lost;
|
|
190
|
+
- order is not guaranteed across sessions/transactions.
|
|
191
|
+
|
|
192
|
+
If you need exactly-once processing, `SKIP LOCKED`-based queues, retries or
|
|
193
|
+
replay, implement that in your application (for example, in the handler
|
|
194
|
+
itself). The library intentionally stays a thin NOTIFY transport.
|
|
195
|
+
|
|
196
|
+
Handlers should therefore be idempotent when multiple instances are running.
|
|
197
|
+
|
|
198
|
+
## Configuration
|
|
199
|
+
|
|
200
|
+
`EventsConfig` intentionally has a small surface:
|
|
201
|
+
|
|
202
|
+
| Option | Default | Description |
|
|
203
|
+
| --- | --- | --- |
|
|
204
|
+
| `schema` | `''` | Default schema; `current_schema()` is used when empty |
|
|
205
|
+
| `sync_executor` | `'thread'` | `'thread'` or `'process'` for sync handlers |
|
|
206
|
+
| `auto_sync` | `True` | Create/update triggers on startup |
|
|
207
|
+
|
|
208
|
+
Queue size, worker count, handler timeout, retries, coalescing, heartbeat,
|
|
209
|
+
reconnect backoff, lock timeouts and shutdown timeout are fixed internally to
|
|
210
|
+
sensible values.
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
from sqlalchemy_events import EventsConfig
|
|
214
|
+
|
|
215
|
+
config = EventsConfig(
|
|
216
|
+
schema='public',
|
|
217
|
+
sync_executor='process',
|
|
218
|
+
auto_sync=False,
|
|
219
|
+
)
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Startup and advisory locks
|
|
223
|
+
|
|
224
|
+
`sync_triggers()` runs in one transaction and takes
|
|
225
|
+
`pg_advisory_xact_lock` keyed by the schema and an internal channel constant.
|
|
226
|
+
Concurrent instances serialize their DDL, so functions and triggers are never
|
|
227
|
+
created concurrently. The lock is released automatically on commit, rollback
|
|
228
|
+
or connection loss.
|
|
229
|
+
|
|
230
|
+
- Functions carry a content marker in `COMMENT ON FUNCTION`; triggers are
|
|
231
|
+
compared by function name and arguments. Repeated starts are idempotent.
|
|
232
|
+
- DDL has a lock timeout and fails fast if a long transaction blocks startup,
|
|
233
|
+
instead of applying half of the changes.
|
|
234
|
+
- Crash or connection loss releases the advisory lock.
|
|
235
|
+
|
|
236
|
+
## Integrating into an application
|
|
237
|
+
|
|
238
|
+
### Project layout
|
|
239
|
+
|
|
240
|
+
A minimal integration keeps the engine, models, handlers and the event system
|
|
241
|
+
in separate modules:
|
|
242
|
+
|
|
243
|
+
```text
|
|
244
|
+
app/
|
|
245
|
+
db.py # AsyncEngine and session factory
|
|
246
|
+
models.py # SQLAlchemy models
|
|
247
|
+
handlers.py # @sa_* handlers (autodiscovered)
|
|
248
|
+
events.py # one SQLAlchemyEvents instance per process
|
|
249
|
+
main.py # FastAPI / asyncio / WSGI entry point
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
`app/db.py`:
|
|
253
|
+
|
|
254
|
+
```python
|
|
255
|
+
from sqlalchemy.ext.asyncio import (
|
|
256
|
+
async_sessionmaker,
|
|
257
|
+
create_async_engine,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
from app.config import DATABASE_URL
|
|
261
|
+
|
|
262
|
+
engine = create_async_engine(DATABASE_URL, pool_pre_ping=True)
|
|
263
|
+
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
`app/events.py`:
|
|
267
|
+
|
|
268
|
+
```python
|
|
269
|
+
from sqlalchemy_events import EventsConfig, SQLAlchemyEvents
|
|
270
|
+
|
|
271
|
+
from app.db import engine
|
|
272
|
+
|
|
273
|
+
events = SQLAlchemyEvents(
|
|
274
|
+
engine=engine,
|
|
275
|
+
autodiscover_paths=['app.handlers'],
|
|
276
|
+
config=EventsConfig(),
|
|
277
|
+
)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Create **one** `SQLAlchemyEvents` per process, at module level. Do not create
|
|
281
|
+
it per request. `autodiscover_paths` must be importable by that process; the
|
|
282
|
+
modules are imported at startup so the decorators register handlers.
|
|
283
|
+
|
|
284
|
+
### FastAPI
|
|
285
|
+
|
|
286
|
+
```python
|
|
287
|
+
from fastapi import FastAPI
|
|
288
|
+
from sqlalchemy_events import (
|
|
289
|
+
EventsConfig,
|
|
290
|
+
SQLAlchemyEvents,
|
|
291
|
+
SQLAlchemyEventsMiddleware,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
from app.db import engine
|
|
295
|
+
|
|
296
|
+
# In a real project this instance lives in app/events.py.
|
|
297
|
+
events = SQLAlchemyEvents(
|
|
298
|
+
engine=engine,
|
|
299
|
+
autodiscover_paths=['app.handlers'],
|
|
300
|
+
config=EventsConfig(),
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
app = FastAPI()
|
|
304
|
+
app.add_middleware(SQLAlchemyEventsMiddleware, events=events)
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
The middleware starts `events` on ASGI `lifespan.startup`, forwards lifespan
|
|
308
|
+
messages to the application and stops `events` on `lifespan.shutdown`.
|
|
309
|
+
It does not dispose the engine; dispose it in your own shutdown hook or
|
|
310
|
+
lifespan if needed.
|
|
311
|
+
|
|
312
|
+
Health and metrics endpoints:
|
|
313
|
+
|
|
314
|
+
```python
|
|
315
|
+
from fastapi import Response
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@app.get('/health')
|
|
319
|
+
async def health() -> dict[str, bool]:
|
|
320
|
+
state = await events.health()
|
|
321
|
+
return {'healthy': state.healthy}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@app.get('/metrics/events')
|
|
325
|
+
async def metrics() -> Response:
|
|
326
|
+
return Response(
|
|
327
|
+
events.prometheus_metrics(),
|
|
328
|
+
media_type='text/plain',
|
|
329
|
+
)
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
### FastAPI: multiple workers
|
|
333
|
+
|
|
334
|
+
```bash
|
|
335
|
+
uvicorn app.main:app --workers 4
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Each worker is a separate process with its own listener and handler registry.
|
|
339
|
+
PostgreSQL broadcasts every notification to every listener, so the same
|
|
340
|
+
handler runs four times. This is expected `LISTEN/NOTIFY` behavior.
|
|
341
|
+
|
|
342
|
+
- Make handlers idempotent, or
|
|
343
|
+
- run a single worker, or
|
|
344
|
+
- implement application-level deduplication/queueing (for example, a
|
|
345
|
+
`SKIP LOCKED` table processed by one worker).
|
|
346
|
+
|
|
347
|
+
The library deliberately does not deduplicate across processes.
|
|
348
|
+
|
|
349
|
+
### Regular asyncio application
|
|
350
|
+
|
|
351
|
+
```python
|
|
352
|
+
import asyncio
|
|
353
|
+
|
|
354
|
+
from sqlalchemy_events import EventsConfig, SQLAlchemyEvents
|
|
355
|
+
|
|
356
|
+
from app.db import engine
|
|
357
|
+
|
|
358
|
+
# In a real project this instance lives in app/events.py.
|
|
359
|
+
events = SQLAlchemyEvents(
|
|
360
|
+
engine=engine,
|
|
361
|
+
autodiscover_paths=['app.handlers'],
|
|
362
|
+
config=EventsConfig(),
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
async def run_app() -> None:
|
|
367
|
+
# aiohttp, aiogram, a custom server, background tasks, etc.
|
|
368
|
+
await asyncio.Event().wait()
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
async def main() -> None:
|
|
372
|
+
try:
|
|
373
|
+
async with events:
|
|
374
|
+
await run_app()
|
|
375
|
+
finally:
|
|
376
|
+
await engine.dispose()
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
if __name__ == '__main__':
|
|
380
|
+
try:
|
|
381
|
+
asyncio.run(main())
|
|
382
|
+
except KeyboardInterrupt:
|
|
383
|
+
pass
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
If your framework has explicit startup/shutdown hooks:
|
|
387
|
+
|
|
388
|
+
```python
|
|
389
|
+
await events.start()
|
|
390
|
+
try:
|
|
391
|
+
await run_app()
|
|
392
|
+
finally:
|
|
393
|
+
await events.stop()
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
`async with events` is a shortcut for the same `start()` / `stop()` pair.
|
|
397
|
+
|
|
398
|
+
### Sync or WSGI application
|
|
399
|
+
|
|
400
|
+
`LISTEN/NOTIFY` requires an async engine, so a pure sync WSGI app cannot run the
|
|
401
|
+
listener on its request thread. Run the event system in a **separate worker
|
|
402
|
+
process**:
|
|
403
|
+
|
|
404
|
+
`app/events_worker.py`:
|
|
405
|
+
|
|
406
|
+
```python
|
|
407
|
+
import asyncio
|
|
408
|
+
|
|
409
|
+
from app.db import engine
|
|
410
|
+
from app.events import events
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
async def main() -> None:
|
|
414
|
+
try:
|
|
415
|
+
async with events:
|
|
416
|
+
await asyncio.Event().wait()
|
|
417
|
+
finally:
|
|
418
|
+
await engine.dispose()
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
if __name__ == '__main__':
|
|
422
|
+
asyncio.run(main())
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
```bash
|
|
426
|
+
python -m app.events_worker
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
The worker can use its own `AsyncEngine` pointing to the same database. Triggers
|
|
430
|
+
live in PostgreSQL, so they fire for changes made by the WSGI app, by the event
|
|
431
|
+
worker or by any other service. The event worker only needs to be able to
|
|
432
|
+
import the handler modules.
|
|
433
|
+
|
|
434
|
+
If you run several copies of this worker, every copy executes the handlers —
|
|
435
|
+
same broadcast semantics as above.
|
|
436
|
+
|
|
437
|
+
### Pre-creating triggers in deployment
|
|
438
|
+
|
|
439
|
+
To keep DDL out of web workers, create triggers once during deploy:
|
|
440
|
+
|
|
441
|
+
```bash
|
|
442
|
+
sa-events sync --url "$DATABASE_URL" --paths app.handlers
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
Then start the application with synchronization disabled:
|
|
446
|
+
|
|
447
|
+
```python
|
|
448
|
+
events = SQLAlchemyEvents(
|
|
449
|
+
engine=engine,
|
|
450
|
+
autodiscover_paths=['app.handlers'],
|
|
451
|
+
config=EventsConfig(auto_sync=False),
|
|
452
|
+
)
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
Triggers and functions live in the database and survive restarts. The CLI and
|
|
456
|
+
the application use the same advisory lock, so concurrent deployments are safe.
|
|
457
|
+
|
|
458
|
+
### Handler dependencies
|
|
459
|
+
|
|
460
|
+
Handlers can use the same session factory as the application:
|
|
461
|
+
|
|
462
|
+
```python
|
|
463
|
+
from sqlalchemy_events import sa_insert_handler
|
|
464
|
+
|
|
465
|
+
from app.db import session_factory
|
|
466
|
+
from app.models import UserModel
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@sa_insert_handler(UserModel)
|
|
470
|
+
async def on_user_created(rows: list[int]) -> None:
|
|
471
|
+
async with session_factory() as session:
|
|
472
|
+
...
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
Keep handlers short. A slow handler holds a worker but does not block the
|
|
476
|
+
PostgreSQL connection that delivers notifications. The bounded queue reports
|
|
477
|
+
drops if handlers cannot keep up.
|
|
478
|
+
|
|
479
|
+
### Process lifetime checklist
|
|
480
|
+
|
|
481
|
+
- One `SQLAlchemyEvents` instance per process.
|
|
482
|
+
- Start it before serving traffic and stop it before disposing the engine.
|
|
483
|
+
- Use an async engine (`asyncpg`) for the listener.
|
|
484
|
+
- Make handlers idempotent when running more than one process.
|
|
485
|
+
- Do not create `SQLAlchemyEvents` inside a request or a short-lived task.
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
## Health and metrics
|
|
489
|
+
|
|
490
|
+
```python
|
|
491
|
+
health = await events.health()
|
|
492
|
+
|
|
493
|
+
if not health.healthy:
|
|
494
|
+
...
|
|
495
|
+
|
|
496
|
+
print(events.is_connected)
|
|
497
|
+
print(health.details['listener'])
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
`events.metrics` contains counters such as `notifications_received`,
|
|
501
|
+
`events_dropped`, `handler_failures`, `events_truncated` and
|
|
502
|
+
`listener_disconnects`. `events.prometheus_metrics()` returns Prometheus text.
|
|
503
|
+
|
|
504
|
+
## CLI
|
|
505
|
+
|
|
506
|
+
```bash
|
|
507
|
+
sa-events sync --url "$DATABASE_URL" --paths app.handlers --dry-run
|
|
508
|
+
sa-events sync --url "$DATABASE_URL" --paths app.handlers
|
|
509
|
+
sa-events list --url "$DATABASE_URL"
|
|
510
|
+
sa-events drop --url "$DATABASE_URL" --paths app.handlers
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
`drop` without `--paths` removes **all managed triggers and functions** in the
|
|
514
|
+
database. Use `--dry-run` to print SQL without executing it.
|
|
515
|
+
|
|
516
|
+
## Alembic
|
|
517
|
+
|
|
518
|
+
```python
|
|
519
|
+
from sqlalchemy_events.alembic import disable_events, enable_events
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def upgrade() -> None:
|
|
523
|
+
enable_events(UserModel)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def downgrade() -> None:
|
|
527
|
+
disable_events(UserModel)
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
The helpers take the same advisory lock as the application and execute their
|
|
531
|
+
DDL inside the migration transaction.
|
|
532
|
+
|
|
533
|
+
## Running tests
|
|
534
|
+
|
|
535
|
+
```bash
|
|
536
|
+
uv sync --dev
|
|
537
|
+
export DATABASE_URL="postgresql+asyncpg://postgres:postgres@localhost:5432/test_db"
|
|
538
|
+
uv run pytest -q --cov=sqlalchemy_events --cov-report=term-missing src/tests
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
The test suite recreates the `public` schema, so point `DATABASE_URL` only at a
|
|
542
|
+
disposable test database.
|
|
543
|
+
|
|
544
|
+
## License
|
|
545
|
+
|
|
546
|
+
MIT
|