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.
Files changed (28) hide show
  1. xtr_orm_messenger-6.0.0/LICENSE +22 -0
  2. xtr_orm_messenger-6.0.0/PKG-INFO +280 -0
  3. xtr_orm_messenger-6.0.0/README.md +253 -0
  4. xtr_orm_messenger-6.0.0/pyproject.toml +169 -0
  5. xtr_orm_messenger-6.0.0/pyproject.toml.orig +176 -0
  6. xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/.agents/skills/xtr-orm-messenger/SKILL.md +108 -0
  7. xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/__init__.py +60 -0
  8. xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/exception/__init__.py +9 -0
  9. xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/exception/invalid_orm_dsn_error.py +34 -0
  10. xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/exception/orm_messenger_error.py +15 -0
  11. xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/exception/transport_error.py +32 -0
  12. xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/py.typed +0 -0
  13. xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/recipe/__init__.py +15 -0
  14. xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/recipe/manifest.toml +21 -0
  15. xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/transport/__init__.py +38 -0
  16. xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/transport/_database_errors.py +71 -0
  17. xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/transport/connection.py +438 -0
  18. xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/transport/messages_table.py +51 -0
  19. xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/transport/messages_table_driver.py +39 -0
  20. xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/transport/messages_tables.py +41 -0
  21. xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/transport/orm_options.py +145 -0
  22. xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/transport/orm_received_stamp.py +22 -0
  23. xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/transport/orm_receiver.py +214 -0
  24. xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/transport/orm_sender.py +51 -0
  25. xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/transport/orm_transport.py +170 -0
  26. xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/transport/orm_transport_factory.py +139 -0
  27. xtr_orm_messenger-6.0.0/src/xtr_orm_messenger/transport/postgresql_notifier.py +105 -0
  28. 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).