xtr-orm-messenger 6.0.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.
- xtr_orm_messenger-6.0.0/LICENSE +22 -0
- xtr_orm_messenger-6.0.0/PKG-INFO +280 -0
- xtr_orm_messenger-6.0.0/README.md +253 -0
- xtr_orm_messenger-6.0.0/pyproject.toml +169 -0
- xtr_orm_messenger-6.0.0/pyproject.toml.orig +176 -0
- xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/.agents/skills/xtr-orm-messenger/SKILL.md +108 -0
- xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/__init__.py +60 -0
- xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/exception/__init__.py +9 -0
- xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/exception/invalid_orm_dsn_error.py +34 -0
- xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/exception/orm_messenger_error.py +15 -0
- xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/exception/transport_error.py +32 -0
- xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/py.typed +0 -0
- xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/recipe/__init__.py +15 -0
- xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/recipe/manifest.toml +21 -0
- xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/transport/__init__.py +38 -0
- xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/transport/_database_errors.py +71 -0
- xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/transport/connection.py +438 -0
- xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/transport/messages_table.py +51 -0
- xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/transport/messages_table_driver.py +39 -0
- xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/transport/messages_tables.py +41 -0
- xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/transport/orm_options.py +145 -0
- xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/transport/orm_received_stamp.py +22 -0
- xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/transport/orm_receiver.py +214 -0
- xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/transport/orm_sender.py +51 -0
- xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/transport/orm_transport.py +170 -0
- xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/transport/orm_transport_factory.py +139 -0
- xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/transport/postgresql_notifier.py +105 -0
- xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/transport/stored_message.py +38 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xterr
|
|
4
|
+
Copyright (c) 2018-present Fabien Potencier
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xtr-orm-messenger
|
|
3
|
+
Version: 6.0.0
|
|
4
|
+
Summary: A database transport for xtr-messenger: messages kept in a table on an xtr-orm connection, claimed by workers, delayed, redelivered and woken by notifications.
|
|
5
|
+
Keywords: message-bus,message-queue,transport,database,sqlalchemy,asyncio
|
|
6
|
+
Author: Xterr
|
|
7
|
+
Author-email: Xterr <me@xterr.dev>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Database
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Dist: msgspec>=0.18
|
|
20
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.41
|
|
21
|
+
Requires-Dist: typing-extensions>=4.4
|
|
22
|
+
Requires-Dist: xtr-clock>=6.0,<7
|
|
23
|
+
Requires-Dist: xtr-messenger>=6.0,<7
|
|
24
|
+
Requires-Dist: xtr-orm>=6.0,<7
|
|
25
|
+
Requires-Python: >=3.11
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
<div align="center">
|
|
29
|
+
|
|
30
|
+
# xtr-orm-messenger
|
|
31
|
+
|
|
32
|
+
**A database transport for xtr-messenger: messages kept in a table on an xtr-orm connection, claimed by workers, delayed, redelivered and woken by notifications.**
|
|
33
|
+
|
|
34
|
+
<img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
|
|
35
|
+
<img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
|
|
36
|
+
<img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
|
|
37
|
+
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Why?
|
|
43
|
+
|
|
44
|
+
A queue needs a broker, and a broker is one more server to run, back up and watch. An
|
|
45
|
+
application that already has a database can keep its messages there: in a table, on the
|
|
46
|
+
connection [xtr-orm](../xtr-orm) already configures, sent in the same transaction as the
|
|
47
|
+
changes they announce if it wants.
|
|
48
|
+
|
|
49
|
+
- 🗄️ **No broker** — `orm://default` is the application's own `default` connection.
|
|
50
|
+
- 🔒 **Workers never collide** — each claims the oldest due message `FOR UPDATE SKIP LOCKED`.
|
|
51
|
+
- ⏰ **Delays** — a `DelayStamp` makes a message due later.
|
|
52
|
+
- ♻️ **Nothing is lost to a dead worker** — a message never settled is handed out again.
|
|
53
|
+
- 🔔 **No polling on PostgreSQL** — a waiting worker is woken the moment a message is sent.
|
|
54
|
+
- 🤝 **Sent with the handler's changes** — `transactional=true` writes the message in the unit
|
|
55
|
+
of work's session, so it commits or rolls back with them.
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
uv add xtr-orm-messenger
|
|
61
|
+
uv add "xtr-messenger[orm]" # the same, from the message bus's side
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
It needs [xtr-messenger](../xtr-messenger) and [xtr-orm](../xtr-orm), which come with it. It
|
|
65
|
+
brings no database driver: a transport writes through the connection its DSN names, with the
|
|
66
|
+
driver the application installed for that connection — xtr-orm's `postgres`, `mysql` or
|
|
67
|
+
`sqlite` extra. On PostgreSQL through asyncpg, a waiting worker is woken by notifications; with
|
|
68
|
+
any other driver it polls. It ships no bundle: with a
|
|
69
|
+
container, the [orm bundle](../xtr-orm#message-bus-transport) registers the transport once this
|
|
70
|
+
package is installed.
|
|
71
|
+
|
|
72
|
+
## Quick start
|
|
73
|
+
|
|
74
|
+
Without a container, hand the factory the connections it builds on and the envelope serializer
|
|
75
|
+
the bus and its workers share:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from xtr_messenger import (
|
|
79
|
+
JsonSerializer,
|
|
80
|
+
MessageBusConfig,
|
|
81
|
+
MessageBusFactory,
|
|
82
|
+
TransportConfig,
|
|
83
|
+
WorkerFactory,
|
|
84
|
+
)
|
|
85
|
+
from xtr_orm import ConnectionRegistry
|
|
86
|
+
from xtr_orm_messenger import OrmTransportFactory
|
|
87
|
+
from xtr_serializer import JsonEncoder, PydanticNormalizer, Serializer
|
|
88
|
+
|
|
89
|
+
registry = ConnectionRegistry()
|
|
90
|
+
registry.register("default", engine=engine, migrator=migrator, database=database)
|
|
91
|
+
|
|
92
|
+
config = MessageBusConfig(
|
|
93
|
+
transports={"jobs": TransportConfig("orm://default", queue="jobs")},
|
|
94
|
+
routing={IngestDocument: "jobs"},
|
|
95
|
+
)
|
|
96
|
+
wire = JsonSerializer(Serializer([PydanticNormalizer()], [JsonEncoder()]))
|
|
97
|
+
factory = OrmTransportFactory(registry, wire)
|
|
98
|
+
|
|
99
|
+
await MessageBusFactory(config, [factory]).bus().dispatch(IngestDocument(document_id=1))
|
|
100
|
+
await WorkerFactory(config, [factory]).worker(["jobs"]).run()
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The bus writes the message into `messenger_messages`; the worker claims it, hands it to its
|
|
104
|
+
handlers and deletes it. With a [container](#use-in-an-application) all of that is wired for you.
|
|
105
|
+
|
|
106
|
+
## How it works
|
|
107
|
+
|
|
108
|
+
| Step | What happens |
|
|
109
|
+
| --- | --- |
|
|
110
|
+
| Send | One row: the encoded envelope, its queue, `created_at`, and `available_at` — later by the delay, rounded up to a whole second |
|
|
111
|
+
| Claim | In one transaction, the oldest row of the queue that is due and not handed out is selected `FOR UPDATE SKIP LOCKED` and stamped `delivered_at` |
|
|
112
|
+
| Handle | The worker dispatches it; the envelope carries `OrmReceivedStamp` and `TransportMessageIdStamp`, both the row's id |
|
|
113
|
+
| Settle | Acknowledged or rejected, the row is deleted |
|
|
114
|
+
| Redeliver | A row handed out more than `redeliver_timeout` seconds ago is claimable again: its worker is taken for dead |
|
|
115
|
+
|
|
116
|
+
A worker that finds nothing due waits `poll_interval` seconds before asking again. On
|
|
117
|
+
PostgreSQL through asyncpg it listens instead: every send announces itself on the table's
|
|
118
|
+
channel, naming its queue, and the worker asks again at once — or after
|
|
119
|
+
`check_delayed_interval`, for a delayed message falling due. Listening holds one connection of
|
|
120
|
+
the pool for as long as the worker runs.
|
|
121
|
+
|
|
122
|
+
SQLite has no row locks, so the lock clause is left out there. A second worker reaching for the
|
|
123
|
+
row another is claiming gets "database is locked", which counts as a conflict and is tried again.
|
|
124
|
+
|
|
125
|
+
Conflicts between workers — a deadlock, a lock wait that timed out — say nothing about the
|
|
126
|
+
message. A poll sits through 3 in a row before it raises; an acknowledgement or a rejection
|
|
127
|
+
tries again with a pause doubling from 100 ms. One whose database went away waits up to 5
|
|
128
|
+
seconds for it and is replayed once: losing an acknowledgement to a restart would hand the
|
|
129
|
+
message out again.
|
|
130
|
+
|
|
131
|
+
A message that does not decode can never be handled, so it is rejected at once, its reason
|
|
132
|
+
logged on the `messenger` logger — never its body.
|
|
133
|
+
|
|
134
|
+
## The DSN
|
|
135
|
+
|
|
136
|
+
```sh
|
|
137
|
+
# .env
|
|
138
|
+
MESSENGER_DSN=orm://default
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The format is `orm://<connection_name>`: the name of a connection the application configures
|
|
142
|
+
in xtr-orm, for when it has several and the messages go on one other than `default`; `orm://`
|
|
143
|
+
alone is the default connection. The URL, the driver and the pool are the connection's. The
|
|
144
|
+
transport creates a table named `messenger_messages` the first time it runs. To name the table
|
|
145
|
+
otherwise, give `table_name` in the DSN:
|
|
146
|
+
|
|
147
|
+
```sh
|
|
148
|
+
# .env
|
|
149
|
+
MESSENGER_DSN=orm://default?table_name=your_custom_table_name
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The queue a transport reads and writes is `queue` — `?queue=jobs`, or
|
|
153
|
+
`TransportConfig(dsn, queue="jobs")` — the setting every xtr-messenger transport takes for it.
|
|
154
|
+
|
|
155
|
+
## Options
|
|
156
|
+
|
|
157
|
+
Every option may go in the DSN's query string or in `TransportConfig.options`; `options` wins.
|
|
158
|
+
An unknown one is refused with `UnknownTransportOptionError`, a bad value with
|
|
159
|
+
`InvalidTransportOptionError`.
|
|
160
|
+
|
|
161
|
+
| Option | Default | What it sets |
|
|
162
|
+
| --- | --- | --- |
|
|
163
|
+
| `queue` | `default` | the queue this transport reads and writes; also `TransportConfig(queue=...)` |
|
|
164
|
+
| `table_name` | `messenger_messages` | the table, a plain identifier |
|
|
165
|
+
| `redeliver_timeout` | `3600` | seconds after which a message handed out and never settled is handed out again |
|
|
166
|
+
| `auto_setup` | `true` | create the table the first time an operation finds it missing |
|
|
167
|
+
| `poll_interval` | `1.0` | seconds a worker waits before asking again when nothing was due |
|
|
168
|
+
| `transactional` | `false` | write the message in the session of the unit of work under way |
|
|
169
|
+
| `use_notify` | `true` | on PostgreSQL through asyncpg, wait for notifications instead of polling |
|
|
170
|
+
| `check_delayed_interval` | `60000` | milliseconds a listening worker waits at most before asking anyway |
|
|
171
|
+
| `get_notify_timeout` | `60000` | milliseconds one wait for a notification lasts at most |
|
|
172
|
+
|
|
173
|
+
`orm://` with no name uses the default connection. A name the application did not configure is
|
|
174
|
+
refused with `InvalidOrmDsnError` when the transport is built.
|
|
175
|
+
|
|
176
|
+
### Sending with the handler's changes
|
|
177
|
+
|
|
178
|
+
With `transactional=true`, a message dispatched while a message is handled — or a request, or a
|
|
179
|
+
command: any unit of work — is written in that unit's session and sent only when the unit
|
|
180
|
+
commits. Run the `orm_transaction` middleware, or commit yourself: a session never commits on
|
|
181
|
+
its own. Outside a unit of work the message is written in a transaction of its own, as it is
|
|
182
|
+
without the option.
|
|
183
|
+
|
|
184
|
+
## Looking into a queue
|
|
185
|
+
|
|
186
|
+
`OrmTransport` is a `TransportInterface` with more to ask:
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
transport = factory.transport("jobs", config.transports["jobs"])
|
|
190
|
+
|
|
191
|
+
await transport.count() # what a worker could claim now
|
|
192
|
+
async for envelope in transport.all(limit=10):
|
|
193
|
+
... # the same, oldest first
|
|
194
|
+
await transport.find(message_id) # one message, handed out or not
|
|
195
|
+
await transport.keepalive(envelope, seconds=600) # a long handler keeps its message
|
|
196
|
+
await transport.setup() # create the table ahead of time
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`keepalive` restarts a message's redelivery clock; `seconds` is how long until the next call,
|
|
200
|
+
refused when longer than `redeliver_timeout`.
|
|
201
|
+
|
|
202
|
+
## Use in an application
|
|
203
|
+
|
|
204
|
+
Everything adding this package to an application takes — and, read backwards, what removing it
|
|
205
|
+
undoes. It ships no bundle: the [orm bundle](../xtr-orm#message-bus-transport) registers the
|
|
206
|
+
transport, built on its connections, when the messenger bundle is active too.
|
|
207
|
+
|
|
208
|
+
- **Install** — `uv add xtr-orm-messenger`, or `uv add "xtr-messenger[orm]"`, beside
|
|
209
|
+
[xtr-messenger](../xtr-messenger/README.md#use-in-an-application) and
|
|
210
|
+
[xtr-orm](../xtr-orm/README.md#use-in-an-application) set up as their *Use in an application*
|
|
211
|
+
says. The database driver is the connection's own.
|
|
212
|
+
- **Recipe** — `uv run xtr-recipes recipes:sync` writes nothing: there is no bundle to list, no
|
|
213
|
+
configuration file, environment variable or ignore line. It prints the step naming a
|
|
214
|
+
transport, which a recipe cannot make for you.
|
|
215
|
+
- **Configure** — name a transport `orm://default` in `<app>/config/packages/messenger.py` and
|
|
216
|
+
route messages to it, as below.
|
|
217
|
+
- **Run** — `<script> orm:migrations:diff` writes the messages table of every `orm://`
|
|
218
|
+
transport into its connection's next revision; until it is applied, the transport creates the
|
|
219
|
+
table the first time it runs, unless `auto_setup=false`. A worker is
|
|
220
|
+
`<script> messenger:consume <transport>`.
|
|
221
|
+
- **Remove** — drop the `orm://` transports and the routing to them, and a migration dropping
|
|
222
|
+
the table. Then `uv remove xtr-orm-messenger`.
|
|
223
|
+
- **Check** — `debug:config messenger` lists the `orm://` transports.
|
|
224
|
+
|
|
225
|
+
Everything else — the bundles, the connections, the bus — is xtr-orm's and xtr-messenger's.
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
# app/config/packages/messenger.py
|
|
229
|
+
from xtr_dependency_injection import configure
|
|
230
|
+
from xtr_messenger.bundle import MessageBusConfig, TransportConfig
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@configure
|
|
234
|
+
def messenger() -> MessageBusConfig:
|
|
235
|
+
return MessageBusConfig(
|
|
236
|
+
transports={"jobs": TransportConfig("orm://default?auto_setup=false", queue="jobs")},
|
|
237
|
+
routing={IngestDocument: "jobs"},
|
|
238
|
+
)
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
The orm bundle registers `OrmTransportFactory` with the messenger, built on its
|
|
242
|
+
`ConnectionRegistry` and the messenger's envelope serializer, and consulted ahead of the
|
|
243
|
+
factories found by DSN scheme. A bus and every worker of the container share it, and so share
|
|
244
|
+
each transport. When the kernel shuts down, the connections workers listened on are given back.
|
|
245
|
+
|
|
246
|
+
Without a container, `messages_tables(bus_config, connection, default_connection)` names the
|
|
247
|
+
tables the transports of a bus configuration keep on a connection, and `MessagesTableDriver`
|
|
248
|
+
adds one to the `MetaData` a connection's migrations compare with.
|
|
249
|
+
|
|
250
|
+
## Errors
|
|
251
|
+
|
|
252
|
+
Everything this library raises derives from `OrmMessengerError`, itself a
|
|
253
|
+
`MessageBusError` of [xtr-messenger](../xtr-messenger):
|
|
254
|
+
|
|
255
|
+
| Error | Raised when |
|
|
256
|
+
| --- | --- |
|
|
257
|
+
| `TransportError` | the database refuses what a transport asked — sending, claiming, settling, listing — or an envelope settled here was not received here; the driver's error is its cause |
|
|
258
|
+
| `InvalidOrmDsnError` | an `orm://` DSN names a connection the application does not configure; also a `LookupError` |
|
|
259
|
+
|
|
260
|
+
## Development
|
|
261
|
+
|
|
262
|
+
Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
|
|
263
|
+
`packages/xtr-orm-messenger`; run the commands below from there. The `python-xtr-orm-messenger`
|
|
264
|
+
repository is a read-only copy, so send issues and pull requests to the monorepo.
|
|
265
|
+
|
|
266
|
+
```sh
|
|
267
|
+
uv sync
|
|
268
|
+
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Every test runs on SQLite files. The PostgreSQL suite under `tests/postgresql/` runs when
|
|
272
|
+
`XTR_TEST_POSTGRES_URL` names a database, as an asyncpg URL:
|
|
273
|
+
|
|
274
|
+
```sh
|
|
275
|
+
XTR_TEST_POSTGRES_URL=postgresql+asyncpg://user:password@127.0.0.1/test uv run pytest tests/postgresql
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
## License
|
|
279
|
+
|
|
280
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# xtr-orm-messenger
|
|
4
|
+
|
|
5
|
+
**A database transport for xtr-messenger: messages kept in a table on an xtr-orm connection, claimed by workers, delayed, redelivered and woken by notifications.**
|
|
6
|
+
|
|
7
|
+
<img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
|
|
8
|
+
<img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
|
|
9
|
+
<img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
|
|
10
|
+
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Why?
|
|
16
|
+
|
|
17
|
+
A queue needs a broker, and a broker is one more server to run, back up and watch. An
|
|
18
|
+
application that already has a database can keep its messages there: in a table, on the
|
|
19
|
+
connection [xtr-orm](../xtr-orm) already configures, sent in the same transaction as the
|
|
20
|
+
changes they announce if it wants.
|
|
21
|
+
|
|
22
|
+
- 🗄️ **No broker** — `orm://default` is the application's own `default` connection.
|
|
23
|
+
- 🔒 **Workers never collide** — each claims the oldest due message `FOR UPDATE SKIP LOCKED`.
|
|
24
|
+
- ⏰ **Delays** — a `DelayStamp` makes a message due later.
|
|
25
|
+
- ♻️ **Nothing is lost to a dead worker** — a message never settled is handed out again.
|
|
26
|
+
- 🔔 **No polling on PostgreSQL** — a waiting worker is woken the moment a message is sent.
|
|
27
|
+
- 🤝 **Sent with the handler's changes** — `transactional=true` writes the message in the unit
|
|
28
|
+
of work's session, so it commits or rolls back with them.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
uv add xtr-orm-messenger
|
|
34
|
+
uv add "xtr-messenger[orm]" # the same, from the message bus's side
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
It needs [xtr-messenger](../xtr-messenger) and [xtr-orm](../xtr-orm), which come with it. It
|
|
38
|
+
brings no database driver: a transport writes through the connection its DSN names, with the
|
|
39
|
+
driver the application installed for that connection — xtr-orm's `postgres`, `mysql` or
|
|
40
|
+
`sqlite` extra. On PostgreSQL through asyncpg, a waiting worker is woken by notifications; with
|
|
41
|
+
any other driver it polls. It ships no bundle: with a
|
|
42
|
+
container, the [orm bundle](../xtr-orm#message-bus-transport) registers the transport once this
|
|
43
|
+
package is installed.
|
|
44
|
+
|
|
45
|
+
## Quick start
|
|
46
|
+
|
|
47
|
+
Without a container, hand the factory the connections it builds on and the envelope serializer
|
|
48
|
+
the bus and its workers share:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from xtr_messenger import (
|
|
52
|
+
JsonSerializer,
|
|
53
|
+
MessageBusConfig,
|
|
54
|
+
MessageBusFactory,
|
|
55
|
+
TransportConfig,
|
|
56
|
+
WorkerFactory,
|
|
57
|
+
)
|
|
58
|
+
from xtr_orm import ConnectionRegistry
|
|
59
|
+
from xtr_orm_messenger import OrmTransportFactory
|
|
60
|
+
from xtr_serializer import JsonEncoder, PydanticNormalizer, Serializer
|
|
61
|
+
|
|
62
|
+
registry = ConnectionRegistry()
|
|
63
|
+
registry.register("default", engine=engine, migrator=migrator, database=database)
|
|
64
|
+
|
|
65
|
+
config = MessageBusConfig(
|
|
66
|
+
transports={"jobs": TransportConfig("orm://default", queue="jobs")},
|
|
67
|
+
routing={IngestDocument: "jobs"},
|
|
68
|
+
)
|
|
69
|
+
wire = JsonSerializer(Serializer([PydanticNormalizer()], [JsonEncoder()]))
|
|
70
|
+
factory = OrmTransportFactory(registry, wire)
|
|
71
|
+
|
|
72
|
+
await MessageBusFactory(config, [factory]).bus().dispatch(IngestDocument(document_id=1))
|
|
73
|
+
await WorkerFactory(config, [factory]).worker(["jobs"]).run()
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The bus writes the message into `messenger_messages`; the worker claims it, hands it to its
|
|
77
|
+
handlers and deletes it. With a [container](#use-in-an-application) all of that is wired for you.
|
|
78
|
+
|
|
79
|
+
## How it works
|
|
80
|
+
|
|
81
|
+
| Step | What happens |
|
|
82
|
+
| --- | --- |
|
|
83
|
+
| Send | One row: the encoded envelope, its queue, `created_at`, and `available_at` — later by the delay, rounded up to a whole second |
|
|
84
|
+
| Claim | In one transaction, the oldest row of the queue that is due and not handed out is selected `FOR UPDATE SKIP LOCKED` and stamped `delivered_at` |
|
|
85
|
+
| Handle | The worker dispatches it; the envelope carries `OrmReceivedStamp` and `TransportMessageIdStamp`, both the row's id |
|
|
86
|
+
| Settle | Acknowledged or rejected, the row is deleted |
|
|
87
|
+
| Redeliver | A row handed out more than `redeliver_timeout` seconds ago is claimable again: its worker is taken for dead |
|
|
88
|
+
|
|
89
|
+
A worker that finds nothing due waits `poll_interval` seconds before asking again. On
|
|
90
|
+
PostgreSQL through asyncpg it listens instead: every send announces itself on the table's
|
|
91
|
+
channel, naming its queue, and the worker asks again at once — or after
|
|
92
|
+
`check_delayed_interval`, for a delayed message falling due. Listening holds one connection of
|
|
93
|
+
the pool for as long as the worker runs.
|
|
94
|
+
|
|
95
|
+
SQLite has no row locks, so the lock clause is left out there. A second worker reaching for the
|
|
96
|
+
row another is claiming gets "database is locked", which counts as a conflict and is tried again.
|
|
97
|
+
|
|
98
|
+
Conflicts between workers — a deadlock, a lock wait that timed out — say nothing about the
|
|
99
|
+
message. A poll sits through 3 in a row before it raises; an acknowledgement or a rejection
|
|
100
|
+
tries again with a pause doubling from 100 ms. One whose database went away waits up to 5
|
|
101
|
+
seconds for it and is replayed once: losing an acknowledgement to a restart would hand the
|
|
102
|
+
message out again.
|
|
103
|
+
|
|
104
|
+
A message that does not decode can never be handled, so it is rejected at once, its reason
|
|
105
|
+
logged on the `messenger` logger — never its body.
|
|
106
|
+
|
|
107
|
+
## The DSN
|
|
108
|
+
|
|
109
|
+
```sh
|
|
110
|
+
# .env
|
|
111
|
+
MESSENGER_DSN=orm://default
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The format is `orm://<connection_name>`: the name of a connection the application configures
|
|
115
|
+
in xtr-orm, for when it has several and the messages go on one other than `default`; `orm://`
|
|
116
|
+
alone is the default connection. The URL, the driver and the pool are the connection's. The
|
|
117
|
+
transport creates a table named `messenger_messages` the first time it runs. To name the table
|
|
118
|
+
otherwise, give `table_name` in the DSN:
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
# .env
|
|
122
|
+
MESSENGER_DSN=orm://default?table_name=your_custom_table_name
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The queue a transport reads and writes is `queue` — `?queue=jobs`, or
|
|
126
|
+
`TransportConfig(dsn, queue="jobs")` — the setting every xtr-messenger transport takes for it.
|
|
127
|
+
|
|
128
|
+
## Options
|
|
129
|
+
|
|
130
|
+
Every option may go in the DSN's query string or in `TransportConfig.options`; `options` wins.
|
|
131
|
+
An unknown one is refused with `UnknownTransportOptionError`, a bad value with
|
|
132
|
+
`InvalidTransportOptionError`.
|
|
133
|
+
|
|
134
|
+
| Option | Default | What it sets |
|
|
135
|
+
| --- | --- | --- |
|
|
136
|
+
| `queue` | `default` | the queue this transport reads and writes; also `TransportConfig(queue=...)` |
|
|
137
|
+
| `table_name` | `messenger_messages` | the table, a plain identifier |
|
|
138
|
+
| `redeliver_timeout` | `3600` | seconds after which a message handed out and never settled is handed out again |
|
|
139
|
+
| `auto_setup` | `true` | create the table the first time an operation finds it missing |
|
|
140
|
+
| `poll_interval` | `1.0` | seconds a worker waits before asking again when nothing was due |
|
|
141
|
+
| `transactional` | `false` | write the message in the session of the unit of work under way |
|
|
142
|
+
| `use_notify` | `true` | on PostgreSQL through asyncpg, wait for notifications instead of polling |
|
|
143
|
+
| `check_delayed_interval` | `60000` | milliseconds a listening worker waits at most before asking anyway |
|
|
144
|
+
| `get_notify_timeout` | `60000` | milliseconds one wait for a notification lasts at most |
|
|
145
|
+
|
|
146
|
+
`orm://` with no name uses the default connection. A name the application did not configure is
|
|
147
|
+
refused with `InvalidOrmDsnError` when the transport is built.
|
|
148
|
+
|
|
149
|
+
### Sending with the handler's changes
|
|
150
|
+
|
|
151
|
+
With `transactional=true`, a message dispatched while a message is handled — or a request, or a
|
|
152
|
+
command: any unit of work — is written in that unit's session and sent only when the unit
|
|
153
|
+
commits. Run the `orm_transaction` middleware, or commit yourself: a session never commits on
|
|
154
|
+
its own. Outside a unit of work the message is written in a transaction of its own, as it is
|
|
155
|
+
without the option.
|
|
156
|
+
|
|
157
|
+
## Looking into a queue
|
|
158
|
+
|
|
159
|
+
`OrmTransport` is a `TransportInterface` with more to ask:
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
transport = factory.transport("jobs", config.transports["jobs"])
|
|
163
|
+
|
|
164
|
+
await transport.count() # what a worker could claim now
|
|
165
|
+
async for envelope in transport.all(limit=10):
|
|
166
|
+
... # the same, oldest first
|
|
167
|
+
await transport.find(message_id) # one message, handed out or not
|
|
168
|
+
await transport.keepalive(envelope, seconds=600) # a long handler keeps its message
|
|
169
|
+
await transport.setup() # create the table ahead of time
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`keepalive` restarts a message's redelivery clock; `seconds` is how long until the next call,
|
|
173
|
+
refused when longer than `redeliver_timeout`.
|
|
174
|
+
|
|
175
|
+
## Use in an application
|
|
176
|
+
|
|
177
|
+
Everything adding this package to an application takes — and, read backwards, what removing it
|
|
178
|
+
undoes. It ships no bundle: the [orm bundle](../xtr-orm#message-bus-transport) registers the
|
|
179
|
+
transport, built on its connections, when the messenger bundle is active too.
|
|
180
|
+
|
|
181
|
+
- **Install** — `uv add xtr-orm-messenger`, or `uv add "xtr-messenger[orm]"`, beside
|
|
182
|
+
[xtr-messenger](../xtr-messenger/README.md#use-in-an-application) and
|
|
183
|
+
[xtr-orm](../xtr-orm/README.md#use-in-an-application) set up as their *Use in an application*
|
|
184
|
+
says. The database driver is the connection's own.
|
|
185
|
+
- **Recipe** — `uv run xtr-recipes recipes:sync` writes nothing: there is no bundle to list, no
|
|
186
|
+
configuration file, environment variable or ignore line. It prints the step naming a
|
|
187
|
+
transport, which a recipe cannot make for you.
|
|
188
|
+
- **Configure** — name a transport `orm://default` in `<app>/config/packages/messenger.py` and
|
|
189
|
+
route messages to it, as below.
|
|
190
|
+
- **Run** — `<script> orm:migrations:diff` writes the messages table of every `orm://`
|
|
191
|
+
transport into its connection's next revision; until it is applied, the transport creates the
|
|
192
|
+
table the first time it runs, unless `auto_setup=false`. A worker is
|
|
193
|
+
`<script> messenger:consume <transport>`.
|
|
194
|
+
- **Remove** — drop the `orm://` transports and the routing to them, and a migration dropping
|
|
195
|
+
the table. Then `uv remove xtr-orm-messenger`.
|
|
196
|
+
- **Check** — `debug:config messenger` lists the `orm://` transports.
|
|
197
|
+
|
|
198
|
+
Everything else — the bundles, the connections, the bus — is xtr-orm's and xtr-messenger's.
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
# app/config/packages/messenger.py
|
|
202
|
+
from xtr_dependency_injection import configure
|
|
203
|
+
from xtr_messenger.bundle import MessageBusConfig, TransportConfig
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@configure
|
|
207
|
+
def messenger() -> MessageBusConfig:
|
|
208
|
+
return MessageBusConfig(
|
|
209
|
+
transports={"jobs": TransportConfig("orm://default?auto_setup=false", queue="jobs")},
|
|
210
|
+
routing={IngestDocument: "jobs"},
|
|
211
|
+
)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
The orm bundle registers `OrmTransportFactory` with the messenger, built on its
|
|
215
|
+
`ConnectionRegistry` and the messenger's envelope serializer, and consulted ahead of the
|
|
216
|
+
factories found by DSN scheme. A bus and every worker of the container share it, and so share
|
|
217
|
+
each transport. When the kernel shuts down, the connections workers listened on are given back.
|
|
218
|
+
|
|
219
|
+
Without a container, `messages_tables(bus_config, connection, default_connection)` names the
|
|
220
|
+
tables the transports of a bus configuration keep on a connection, and `MessagesTableDriver`
|
|
221
|
+
adds one to the `MetaData` a connection's migrations compare with.
|
|
222
|
+
|
|
223
|
+
## Errors
|
|
224
|
+
|
|
225
|
+
Everything this library raises derives from `OrmMessengerError`, itself a
|
|
226
|
+
`MessageBusError` of [xtr-messenger](../xtr-messenger):
|
|
227
|
+
|
|
228
|
+
| Error | Raised when |
|
|
229
|
+
| --- | --- |
|
|
230
|
+
| `TransportError` | the database refuses what a transport asked — sending, claiming, settling, listing — or an envelope settled here was not received here; the driver's error is its cause |
|
|
231
|
+
| `InvalidOrmDsnError` | an `orm://` DSN names a connection the application does not configure; also a `LookupError` |
|
|
232
|
+
|
|
233
|
+
## Development
|
|
234
|
+
|
|
235
|
+
Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
|
|
236
|
+
`packages/xtr-orm-messenger`; run the commands below from there. The `python-xtr-orm-messenger`
|
|
237
|
+
repository is a read-only copy, so send issues and pull requests to the monorepo.
|
|
238
|
+
|
|
239
|
+
```sh
|
|
240
|
+
uv sync
|
|
241
|
+
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Every test runs on SQLite files. The PostgreSQL suite under `tests/postgresql/` runs when
|
|
245
|
+
`XTR_TEST_POSTGRES_URL` names a database, as an asyncpg URL:
|
|
246
|
+
|
|
247
|
+
```sh
|
|
248
|
+
XTR_TEST_POSTGRES_URL=postgresql+asyncpg://user:password@127.0.0.1/test uv run pytest tests/postgresql
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## License
|
|
252
|
+
|
|
253
|
+
MIT — see [LICENSE](LICENSE).
|