sqlalchemy-events-lib 0.4.1__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.
Files changed (87) hide show
  1. sqlalchemy_events_lib-0.5.0/PKG-INFO +546 -0
  2. sqlalchemy_events_lib-0.5.0/README.md +515 -0
  3. sqlalchemy_events_lib-0.5.0/pyproject.toml +107 -0
  4. sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/__init__.py +52 -0
  5. sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/__main__.py +4 -0
  6. sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/alembic.py +111 -0
  7. sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/asgi.py +55 -0
  8. sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/cli.py +149 -0
  9. sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/config.py +38 -0
  10. sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/core.py +450 -0
  11. sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/decorators.py +41 -0
  12. {sqlalchemy_events_lib-0.4.1 → sqlalchemy_events_lib-0.5.0}/src/sqlalchemy_events/default_logger.py +4 -2
  13. sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/defaults.py +29 -0
  14. {sqlalchemy_events_lib-0.4.1 → sqlalchemy_events_lib-0.5.0}/src/sqlalchemy_events/discovery.py +18 -6
  15. sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/dispatchers.py +351 -0
  16. sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/events.py +43 -0
  17. sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/exceptions.py +34 -0
  18. sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/execution.py +392 -0
  19. sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/handlers.py +95 -0
  20. sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/init_triggers_strategies/base.py +161 -0
  21. sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/init_triggers_strategies/postgres_init_triggers.py +630 -0
  22. sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/listener.py +308 -0
  23. sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/metrics.py +76 -0
  24. sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/registry.py +110 -0
  25. sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/testing.py +108 -0
  26. sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/types.py +146 -0
  27. sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/utils.py +177 -0
  28. sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events_lib.egg-info/PKG-INFO +546 -0
  29. {sqlalchemy_events_lib-0.4.1 → sqlalchemy_events_lib-0.5.0}/src/sqlalchemy_events_lib.egg-info/SOURCES.txt +32 -6
  30. sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events_lib.egg-info/entry_points.txt +2 -0
  31. sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events_lib.egg-info/requires.txt +15 -0
  32. {sqlalchemy_events_lib-0.4.1 → sqlalchemy_events_lib-0.5.0}/src/sqlalchemy_events_lib.egg-info/top_level.txt +0 -1
  33. sqlalchemy_events_lib-0.5.0/src/tests/conftest.py +63 -0
  34. sqlalchemy_events_lib-0.5.0/src/tests/handlers.py +98 -0
  35. sqlalchemy_events_lib-0.5.0/src/tests/helpers.py +10 -0
  36. sqlalchemy_events_lib-0.5.0/src/tests/no_handlers.py +1 -0
  37. sqlalchemy_events_lib-0.5.0/src/tests/test_alembic.py +68 -0
  38. sqlalchemy_events_lib-0.5.0/src/tests/test_asgi.py +114 -0
  39. sqlalchemy_events_lib-0.5.0/src/tests/test_autodiscover.py +163 -0
  40. sqlalchemy_events_lib-0.5.0/src/tests/test_cli.py +136 -0
  41. sqlalchemy_events_lib-0.5.0/src/tests/test_config.py +34 -0
  42. sqlalchemy_events_lib-0.5.0/src/tests/test_core.py +309 -0
  43. sqlalchemy_events_lib-0.5.0/src/tests/test_decorators.py +60 -0
  44. sqlalchemy_events_lib-0.5.0/src/tests/test_dispatchers.py +191 -0
  45. sqlalchemy_events_lib-0.5.0/src/tests/test_events.py +13 -0
  46. sqlalchemy_events_lib-0.5.0/src/tests/test_execution.py +251 -0
  47. sqlalchemy_events_lib-0.5.0/src/tests/test_handlers.py +141 -0
  48. sqlalchemy_events_lib-0.5.0/src/tests/test_listener.py +110 -0
  49. sqlalchemy_events_lib-0.5.0/src/tests/test_logger.py +20 -0
  50. sqlalchemy_events_lib-0.5.0/src/tests/test_metrics.py +57 -0
  51. sqlalchemy_events_lib-0.5.0/src/tests/test_models.py +80 -0
  52. sqlalchemy_events_lib-0.5.0/src/tests/test_registry.py +86 -0
  53. sqlalchemy_events_lib-0.5.0/src/tests/test_testing.py +91 -0
  54. sqlalchemy_events_lib-0.5.0/src/tests/test_triggers.py +444 -0
  55. sqlalchemy_events_lib-0.5.0/src/tests/test_types.py +235 -0
  56. sqlalchemy_events_lib-0.5.0/src/tests/test_utils_more.py +78 -0
  57. sqlalchemy_events_lib-0.4.1/PKG-INFO +0 -210
  58. sqlalchemy_events_lib-0.4.1/README.md +0 -188
  59. sqlalchemy_events_lib-0.4.1/pyproject.toml +0 -45
  60. sqlalchemy_events_lib-0.4.1/src/sqlalchemy_events/__init__.py +0 -14
  61. sqlalchemy_events_lib-0.4.1/src/sqlalchemy_events/callbacks_strategies/base.py +0 -8
  62. sqlalchemy_events_lib-0.4.1/src/sqlalchemy_events/callbacks_strategies/postgres_callback.py +0 -64
  63. sqlalchemy_events_lib-0.4.1/src/sqlalchemy_events/core.py +0 -403
  64. sqlalchemy_events_lib-0.4.1/src/sqlalchemy_events/decorators.py +0 -25
  65. sqlalchemy_events_lib-0.4.1/src/sqlalchemy_events/events.py +0 -35
  66. sqlalchemy_events_lib-0.4.1/src/sqlalchemy_events/exceptions.py +0 -1
  67. sqlalchemy_events_lib-0.4.1/src/sqlalchemy_events/handlers.py +0 -50
  68. sqlalchemy_events_lib-0.4.1/src/sqlalchemy_events/init_triggers_strategies/__init__.py +0 -0
  69. sqlalchemy_events_lib-0.4.1/src/sqlalchemy_events/init_triggers_strategies/base.py +0 -13
  70. sqlalchemy_events_lib-0.4.1/src/sqlalchemy_events/init_triggers_strategies/postgres_init_triggers.py +0 -115
  71. sqlalchemy_events_lib-0.4.1/src/sqlalchemy_events/registry.py +0 -15
  72. sqlalchemy_events_lib-0.4.1/src/sqlalchemy_events/types.py +0 -40
  73. sqlalchemy_events_lib-0.4.1/src/sqlalchemy_events/utils.py +0 -35
  74. sqlalchemy_events_lib-0.4.1/src/sqlalchemy_events_lib.egg-info/PKG-INFO +0 -210
  75. sqlalchemy_events_lib-0.4.1/src/sqlalchemy_events_lib.egg-info/requires.txt +0 -5
  76. sqlalchemy_events_lib-0.4.1/src/tests/conftest.py +0 -55
  77. sqlalchemy_events_lib-0.4.1/src/tests/test_autodiscover.py +0 -147
  78. sqlalchemy_events_lib-0.4.1/src/tests/test_handlers.py +0 -79
  79. sqlalchemy_events_lib-0.4.1/src/tests/test_is_created_triggers.py +0 -49
  80. sqlalchemy_events_lib-0.4.1/src/tests/test_models.py +0 -16
  81. sqlalchemy_events_lib-0.4.1/src/tests/test_sa_events.py +0 -27
  82. sqlalchemy_events_lib-0.4.1/src/tests/test_with_events_deco.py +0 -45
  83. {sqlalchemy_events_lib-0.4.1 → sqlalchemy_events_lib-0.5.0}/LICENSE +0 -0
  84. {sqlalchemy_events_lib-0.4.1 → sqlalchemy_events_lib-0.5.0}/setup.cfg +0 -0
  85. {sqlalchemy_events_lib-0.4.1/src/sqlalchemy_events/callbacks_strategies → sqlalchemy_events_lib-0.5.0/src/sqlalchemy_events/init_triggers_strategies}/__init__.py +0 -0
  86. {sqlalchemy_events_lib-0.4.1 → sqlalchemy_events_lib-0.5.0}/src/sqlalchemy_events/py.typed +0 -0
  87. {sqlalchemy_events_lib-0.4.1 → 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