xtr-orm-messenger 6.0.0__py3-none-any.whl
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/.agents/skills/xtr-orm-messenger/SKILL.md +108 -0
- xtr_orm_messenger/__init__.py +60 -0
- xtr_orm_messenger/exception/__init__.py +9 -0
- xtr_orm_messenger/exception/invalid_orm_dsn_error.py +34 -0
- xtr_orm_messenger/exception/orm_messenger_error.py +15 -0
- xtr_orm_messenger/exception/transport_error.py +32 -0
- xtr_orm_messenger/py.typed +0 -0
- xtr_orm_messenger/recipe/__init__.py +15 -0
- xtr_orm_messenger/recipe/manifest.toml +21 -0
- xtr_orm_messenger/transport/__init__.py +38 -0
- xtr_orm_messenger/transport/_database_errors.py +71 -0
- xtr_orm_messenger/transport/connection.py +438 -0
- xtr_orm_messenger/transport/messages_table.py +51 -0
- xtr_orm_messenger/transport/messages_table_driver.py +39 -0
- xtr_orm_messenger/transport/messages_tables.py +41 -0
- xtr_orm_messenger/transport/orm_options.py +145 -0
- xtr_orm_messenger/transport/orm_received_stamp.py +22 -0
- xtr_orm_messenger/transport/orm_receiver.py +214 -0
- xtr_orm_messenger/transport/orm_sender.py +51 -0
- xtr_orm_messenger/transport/orm_transport.py +170 -0
- xtr_orm_messenger/transport/orm_transport_factory.py +139 -0
- xtr_orm_messenger/transport/postgresql_notifier.py +105 -0
- xtr_orm_messenger/transport/stored_message.py +38 -0
- xtr_orm_messenger-6.0.0.dist-info/METADATA +280 -0
- xtr_orm_messenger-6.0.0.dist-info/RECORD +28 -0
- xtr_orm_messenger-6.0.0.dist-info/WHEEL +4 -0
- xtr_orm_messenger-6.0.0.dist-info/entry_points.txt +3 -0
- xtr_orm_messenger-6.0.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""The statements an ``orm://`` transport runs against its table.
|
|
2
|
+
|
|
3
|
+
Everything that touches the database is here, so the receiver and the sender
|
|
4
|
+
deal in rows and never in SQL. A worker claims the oldest message of its queue
|
|
5
|
+
that is due and not handed out, marking it handed out in the same transaction;
|
|
6
|
+
a message handed out and never settled is handed out again once
|
|
7
|
+
``redeliver_timeout`` has passed, the worker holding it being taken for dead.
|
|
8
|
+
Settling a message — handled or rejected — deletes it.
|
|
9
|
+
|
|
10
|
+
A claim selects the oldest due row ``FOR UPDATE SKIP LOCKED`` and marks it
|
|
11
|
+
handed out, in one transaction, so concurrent workers each take a different row
|
|
12
|
+
without waiting on each other. A database without row locks — SQLite — gets the
|
|
13
|
+
same statements with the lock clause left out; its single writer then turns two
|
|
14
|
+
workers reaching for one row into a "database is locked" error for the second,
|
|
15
|
+
which the receiver treats as a conflict worth trying again.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import replace
|
|
21
|
+
from datetime import UTC, datetime, timedelta
|
|
22
|
+
from typing import TYPE_CHECKING, Final, TypeVar, cast, final
|
|
23
|
+
|
|
24
|
+
import msgspec
|
|
25
|
+
from sqlalchemy import (
|
|
26
|
+
CursorResult,
|
|
27
|
+
MetaData,
|
|
28
|
+
and_,
|
|
29
|
+
delete,
|
|
30
|
+
func,
|
|
31
|
+
insert,
|
|
32
|
+
literal,
|
|
33
|
+
or_,
|
|
34
|
+
select,
|
|
35
|
+
update,
|
|
36
|
+
)
|
|
37
|
+
from sqlalchemy.dialects.postgresql.asyncpg import PGDialect_asyncpg
|
|
38
|
+
from sqlalchemy.dialects.postgresql.base import PGDialect
|
|
39
|
+
from sqlalchemy.exc import DBAPIError
|
|
40
|
+
from xtr_clock import SystemClock
|
|
41
|
+
from xtr_orm.exception import SessionUnavailableError
|
|
42
|
+
|
|
43
|
+
from ._database_errors import is_missing_table
|
|
44
|
+
from .messages_table import messages_table
|
|
45
|
+
from .postgresql_notifier import PostgreSqlNotifier
|
|
46
|
+
from .stored_message import StoredMessage
|
|
47
|
+
|
|
48
|
+
if TYPE_CHECKING:
|
|
49
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
|
|
50
|
+
|
|
51
|
+
from sqlalchemy import ColumnElement, Executable, RowMapping, Select, Table
|
|
52
|
+
from sqlalchemy import Connection as SyncConnection
|
|
53
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
|
54
|
+
from xtr_clock import ClockInterface
|
|
55
|
+
|
|
56
|
+
from .orm_options import OrmOptions
|
|
57
|
+
|
|
58
|
+
__all__ = ["LISTING_PAGE_SIZE", "Connection"]
|
|
59
|
+
|
|
60
|
+
_T = TypeVar("_T")
|
|
61
|
+
|
|
62
|
+
LISTING_PAGE_SIZE: Final = 100
|
|
63
|
+
"""How many rows :meth:`Connection.all` reads per statement when it lists every message."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@final
|
|
67
|
+
class Connection:
|
|
68
|
+
"""Sends, claims, settles and lists the messages of one queue of one table.
|
|
69
|
+
|
|
70
|
+
Built with what reaches the engine rather than the engine itself, so that
|
|
71
|
+
building one does no I/O and reads no configuration: the engine is asked
|
|
72
|
+
for the first time a statement runs.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
__slots__ = (
|
|
76
|
+
"_auto_setup",
|
|
77
|
+
"_clock",
|
|
78
|
+
"_engine_factory",
|
|
79
|
+
"_notifier",
|
|
80
|
+
"_notifier_resolved",
|
|
81
|
+
"_options",
|
|
82
|
+
"_session_factory",
|
|
83
|
+
"_table",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
engine_factory: Callable[[], Awaitable[AsyncEngine]],
|
|
89
|
+
options: OrmOptions,
|
|
90
|
+
*,
|
|
91
|
+
session_factory: Callable[[], Awaitable[AsyncSession]] | None = None,
|
|
92
|
+
clock: ClockInterface | None = None,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Run statements on the engine ``engine_factory`` returns, as ``options`` say.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
engine_factory: Returns the engine of the connection the table is
|
|
98
|
+
on; called each time a statement runs, so it must hand back the
|
|
99
|
+
same engine every time.
|
|
100
|
+
options: The table, the queue and how to treat them.
|
|
101
|
+
session_factory: Returns the session of the unit of work under
|
|
102
|
+
way, raising
|
|
103
|
+
:class:`~xtr_orm.exception.SessionUnavailableError` outside of
|
|
104
|
+
one; consulted to send when ``options.transactional`` is on.
|
|
105
|
+
clock: What reads the time; the system clock when left out.
|
|
106
|
+
"""
|
|
107
|
+
self._engine_factory = engine_factory
|
|
108
|
+
self._options = options
|
|
109
|
+
self._session_factory = session_factory
|
|
110
|
+
self._clock: ClockInterface = clock if clock is not None else SystemClock()
|
|
111
|
+
self._table: Table = messages_table(MetaData(), options.table_name)
|
|
112
|
+
self._auto_setup = options.auto_setup
|
|
113
|
+
self._notifier: PostgreSqlNotifier | None = None
|
|
114
|
+
self._notifier_resolved = False
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def options(self) -> OrmOptions:
|
|
118
|
+
"""The table, the queue and how this connection treats them."""
|
|
119
|
+
return self._options
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def table(self) -> Table:
|
|
123
|
+
"""The messages table, as this connection defines it."""
|
|
124
|
+
return self._table
|
|
125
|
+
|
|
126
|
+
async def send(self, body: str, headers: Mapping[str, str], delay_ms: int = 0) -> str:
|
|
127
|
+
"""Keep a message, due ``delay_ms`` milliseconds from now; return its id.
|
|
128
|
+
|
|
129
|
+
A positive delay is rounded up to a whole second: some databases keep
|
|
130
|
+
a datetime to the second, and dropping the fraction would make the
|
|
131
|
+
message due before the delay asked for.
|
|
132
|
+
|
|
133
|
+
With ``transactional`` on and a unit of work under way, the message is
|
|
134
|
+
written in that unit's session and is sent only once the unit commits.
|
|
135
|
+
"""
|
|
136
|
+
now = self._now()
|
|
137
|
+
available_at = now + timedelta(milliseconds=delay_ms)
|
|
138
|
+
if delay_ms > 0 and available_at.microsecond:
|
|
139
|
+
available_at = available_at.replace(microsecond=0) + timedelta(seconds=1)
|
|
140
|
+
statement = insert(self._table).values(
|
|
141
|
+
body=body,
|
|
142
|
+
headers=msgspec.json.encode(dict(headers)).decode(),
|
|
143
|
+
queue_name=self._options.queue,
|
|
144
|
+
created_at=now,
|
|
145
|
+
available_at=available_at,
|
|
146
|
+
)
|
|
147
|
+
session = await self._unit_session()
|
|
148
|
+
if session is not None:
|
|
149
|
+
return await self._send_in(session, statement)
|
|
150
|
+
return await self._with_setup(lambda: self._send_alone(statement))
|
|
151
|
+
|
|
152
|
+
async def get(self) -> StoredMessage | None:
|
|
153
|
+
"""Claim the oldest message of the queue that is due, or return ``None``.
|
|
154
|
+
|
|
155
|
+
The message is marked handed out in the same transaction, so no other
|
|
156
|
+
worker claims it until :meth:`ack`, :meth:`reject` or
|
|
157
|
+
``redeliver_timeout`` releases it.
|
|
158
|
+
"""
|
|
159
|
+
return await self._with_setup(self._claim)
|
|
160
|
+
|
|
161
|
+
async def ack(self, message_id: str) -> None:
|
|
162
|
+
"""Forget the message ``message_id``: it was handled."""
|
|
163
|
+
await self._with_setup(lambda: self._delete(message_id))
|
|
164
|
+
|
|
165
|
+
async def reject(self, message_id: str) -> None:
|
|
166
|
+
"""Forget the message ``message_id``: it failed for good.
|
|
167
|
+
|
|
168
|
+
The same as :meth:`ack`: whatever retries a message gets have run by
|
|
169
|
+
the time it is rejected, and keeping it would hand it out again.
|
|
170
|
+
"""
|
|
171
|
+
await self._with_setup(lambda: self._delete(message_id))
|
|
172
|
+
|
|
173
|
+
async def keepalive(self, message_id: str) -> None:
|
|
174
|
+
"""Mark the message ``message_id`` handed out now, restarting its redelivery clock.
|
|
175
|
+
|
|
176
|
+
For a handler that runs longer than ``redeliver_timeout``: called
|
|
177
|
+
while it runs, the message is not handed to another worker under it.
|
|
178
|
+
"""
|
|
179
|
+
now = self._now()
|
|
180
|
+
statement = (
|
|
181
|
+
update(self._table).where(self._table.c.id == int(message_id)).values(delivered_at=now)
|
|
182
|
+
)
|
|
183
|
+
await self._with_setup(lambda: self._execute(statement))
|
|
184
|
+
|
|
185
|
+
async def find(self, message_id: str) -> StoredMessage | None:
|
|
186
|
+
"""Return the message ``message_id`` of the queue, handed out or not, if it is kept."""
|
|
187
|
+
table = self._table
|
|
188
|
+
statement = select(table).where(
|
|
189
|
+
table.c.id == int(message_id), table.c.queue_name == self._options.queue
|
|
190
|
+
)
|
|
191
|
+
return await self._with_setup(lambda: self._first(statement))
|
|
192
|
+
|
|
193
|
+
async def all(self, limit: int | None = None) -> AsyncIterator[StoredMessage]:
|
|
194
|
+
"""Yield the messages of the queue a worker could claim now, oldest first.
|
|
195
|
+
|
|
196
|
+
With no ``limit``, every one is listed, read :data:`LISTING_PAGE_SIZE`
|
|
197
|
+
at a time so a long queue is never held in memory whole.
|
|
198
|
+
"""
|
|
199
|
+
table = self._table
|
|
200
|
+
now = self._now()
|
|
201
|
+
remaining = limit
|
|
202
|
+
after: StoredMessage | None = None
|
|
203
|
+
while remaining is None or remaining > 0:
|
|
204
|
+
size = LISTING_PAGE_SIZE if remaining is None else min(remaining, LISTING_PAGE_SIZE)
|
|
205
|
+
condition = self._available(now)
|
|
206
|
+
if after is not None:
|
|
207
|
+
condition = and_(
|
|
208
|
+
condition,
|
|
209
|
+
or_(
|
|
210
|
+
table.c.available_at > after.available_at,
|
|
211
|
+
and_(
|
|
212
|
+
table.c.available_at == after.available_at,
|
|
213
|
+
table.c.id > int(after.id),
|
|
214
|
+
),
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
statement = (
|
|
218
|
+
select(table)
|
|
219
|
+
.where(condition)
|
|
220
|
+
.order_by(table.c.available_at, table.c.id)
|
|
221
|
+
.limit(size)
|
|
222
|
+
)
|
|
223
|
+
page = await self._with_setup(lambda statement=statement: self._rows(statement))
|
|
224
|
+
for message in page:
|
|
225
|
+
yield message
|
|
226
|
+
if len(page) < size:
|
|
227
|
+
return
|
|
228
|
+
after = page[-1]
|
|
229
|
+
if remaining is not None:
|
|
230
|
+
remaining -= len(page)
|
|
231
|
+
|
|
232
|
+
async def count(self) -> int:
|
|
233
|
+
"""Return how many messages of the queue a worker could claim now."""
|
|
234
|
+
statement = (
|
|
235
|
+
select(func.count()).select_from(self._table).where(self._available(self._now()))
|
|
236
|
+
)
|
|
237
|
+
return await self._with_setup(lambda: self._scalar(statement))
|
|
238
|
+
|
|
239
|
+
async def setup(self) -> None:
|
|
240
|
+
"""Create the messages table if it does not exist; an existing one is left as it is."""
|
|
241
|
+
engine = await self._engine_factory()
|
|
242
|
+
async with engine.begin() as connection:
|
|
243
|
+
await connection.run_sync(self._create_table)
|
|
244
|
+
self._auto_setup = False
|
|
245
|
+
|
|
246
|
+
async def ping(self) -> bool:
|
|
247
|
+
"""Tell whether the database answers, opening a connection if the pool holds none."""
|
|
248
|
+
try:
|
|
249
|
+
await self._execute(select(literal(1)))
|
|
250
|
+
except DBAPIError:
|
|
251
|
+
return False
|
|
252
|
+
return True
|
|
253
|
+
|
|
254
|
+
async def wait(self) -> None:
|
|
255
|
+
"""Wait before a worker asks again, having found nothing due.
|
|
256
|
+
|
|
257
|
+
On PostgreSQL through asyncpg with ``use_notify``, until a message is
|
|
258
|
+
sent to the queue or ``check_delayed_interval`` passes, whichever is
|
|
259
|
+
first; anywhere else, ``poll_interval``.
|
|
260
|
+
|
|
261
|
+
The first time a PostgreSQL worker waits it only starts listening and
|
|
262
|
+
returns: a message sent between the poll that found nothing and the
|
|
263
|
+
moment the listening started announced itself to nobody, so the worker
|
|
264
|
+
asks once more before it waits for real.
|
|
265
|
+
"""
|
|
266
|
+
if not self._notifier_resolved:
|
|
267
|
+
await self._resolve_notifier()
|
|
268
|
+
if self._notifier is not None:
|
|
269
|
+
return
|
|
270
|
+
notifier = self._notifier
|
|
271
|
+
if notifier is None:
|
|
272
|
+
await self._clock.sleep_async(self._options.poll_interval)
|
|
273
|
+
return
|
|
274
|
+
options = self._options
|
|
275
|
+
await notifier.wait(min(options.check_delayed_interval, options.get_notify_timeout) / 1000)
|
|
276
|
+
|
|
277
|
+
async def close(self) -> None:
|
|
278
|
+
"""Release the connection a PostgreSQL worker listens for notifications on."""
|
|
279
|
+
notifier = self._notifier
|
|
280
|
+
self._notifier = None
|
|
281
|
+
self._notifier_resolved = False
|
|
282
|
+
if notifier is not None:
|
|
283
|
+
await notifier.close()
|
|
284
|
+
|
|
285
|
+
async def _with_setup(self, operation: Callable[[], Awaitable[_T]]) -> _T:
|
|
286
|
+
"""Run ``operation``; on a missing table, with ``auto_setup``, create it and run again."""
|
|
287
|
+
try:
|
|
288
|
+
return await operation()
|
|
289
|
+
except DBAPIError as error:
|
|
290
|
+
if not self._auto_setup or not is_missing_table(error):
|
|
291
|
+
raise
|
|
292
|
+
await self.setup()
|
|
293
|
+
return await operation()
|
|
294
|
+
|
|
295
|
+
async def _unit_session(self) -> AsyncSession | None:
|
|
296
|
+
"""Return the session to send in, or ``None`` to send in a transaction of its own."""
|
|
297
|
+
if not self._options.transactional or self._session_factory is None:
|
|
298
|
+
return None
|
|
299
|
+
try:
|
|
300
|
+
return await self._session_factory()
|
|
301
|
+
except SessionUnavailableError:
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
async def _send_in(self, session: AsyncSession, statement: Executable) -> str:
|
|
305
|
+
"""Write the message in ``session``, which its unit of work commits or rolls back.
|
|
306
|
+
|
|
307
|
+
The table is set up first when ``auto_setup`` is on, in a transaction of
|
|
308
|
+
its own: a failed statement inside the unit's transaction would abort
|
|
309
|
+
the handler's own changes along with it on some databases.
|
|
310
|
+
"""
|
|
311
|
+
if self._auto_setup:
|
|
312
|
+
await self.setup()
|
|
313
|
+
result = cast("CursorResult[tuple[object, ...]]", await session.execute(statement))
|
|
314
|
+
if isinstance(session.get_bind().dialect, PGDialect):
|
|
315
|
+
_ = await session.execute(self._notification())
|
|
316
|
+
return _identifier(result)
|
|
317
|
+
|
|
318
|
+
async def _send_alone(self, statement: Executable) -> str:
|
|
319
|
+
engine = await self._engine_factory()
|
|
320
|
+
async with engine.begin() as connection:
|
|
321
|
+
result = await connection.execute(statement)
|
|
322
|
+
if isinstance(connection.dialect, PGDialect):
|
|
323
|
+
_ = await connection.execute(self._notification())
|
|
324
|
+
return _identifier(result)
|
|
325
|
+
|
|
326
|
+
def _notification(self) -> Executable:
|
|
327
|
+
"""Announce a message on the table's channel, naming its queue.
|
|
328
|
+
|
|
329
|
+
Sent in the transaction that wrote the message, so a listener hears it
|
|
330
|
+
only once the message can be claimed.
|
|
331
|
+
"""
|
|
332
|
+
return select(func.pg_notify(self._options.table_name, self._options.queue))
|
|
333
|
+
|
|
334
|
+
async def _claim(self) -> StoredMessage | None:
|
|
335
|
+
now = self._now()
|
|
336
|
+
table = self._table
|
|
337
|
+
statement = (
|
|
338
|
+
select(table)
|
|
339
|
+
.where(self._available(now))
|
|
340
|
+
.order_by(table.c.available_at, table.c.id)
|
|
341
|
+
.limit(1)
|
|
342
|
+
.with_for_update(skip_locked=True)
|
|
343
|
+
)
|
|
344
|
+
engine = await self._engine_factory()
|
|
345
|
+
async with engine.begin() as connection:
|
|
346
|
+
row = (await connection.execute(statement)).mappings().first()
|
|
347
|
+
if row is None:
|
|
348
|
+
return None
|
|
349
|
+
stored = _stored(row)
|
|
350
|
+
_ = await connection.execute(
|
|
351
|
+
update(table).where(table.c.id == int(stored.id)).values(delivered_at=now)
|
|
352
|
+
)
|
|
353
|
+
return replace(stored, delivered_at=now)
|
|
354
|
+
|
|
355
|
+
def _available(self, now: datetime) -> ColumnElement[bool]:
|
|
356
|
+
"""Select the queue's messages that are due and not held by a worker that is still alive."""
|
|
357
|
+
table = self._table
|
|
358
|
+
handed_out_before = now - timedelta(seconds=self._options.redeliver_timeout)
|
|
359
|
+
return and_(
|
|
360
|
+
table.c.queue_name == self._options.queue,
|
|
361
|
+
or_(table.c.delivered_at.is_(None), table.c.delivered_at < handed_out_before),
|
|
362
|
+
table.c.available_at <= now,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
async def _delete(self, message_id: str) -> None:
|
|
366
|
+
await self._execute(delete(self._table).where(self._table.c.id == int(message_id)))
|
|
367
|
+
|
|
368
|
+
async def _execute(self, statement: Executable) -> None:
|
|
369
|
+
engine = await self._engine_factory()
|
|
370
|
+
async with engine.begin() as connection:
|
|
371
|
+
_ = await connection.execute(statement)
|
|
372
|
+
|
|
373
|
+
async def _first(self, statement: Executable) -> StoredMessage | None:
|
|
374
|
+
engine = await self._engine_factory()
|
|
375
|
+
async with engine.connect() as connection:
|
|
376
|
+
row = (await connection.execute(statement)).mappings().first()
|
|
377
|
+
return None if row is None else _stored(row)
|
|
378
|
+
|
|
379
|
+
async def _rows(self, statement: Executable) -> list[StoredMessage]:
|
|
380
|
+
engine = await self._engine_factory()
|
|
381
|
+
async with engine.connect() as connection:
|
|
382
|
+
rows = (await connection.execute(statement)).mappings().all()
|
|
383
|
+
return [_stored(row) for row in rows]
|
|
384
|
+
|
|
385
|
+
async def _scalar(self, statement: Select[int]) -> int:
|
|
386
|
+
engine = await self._engine_factory()
|
|
387
|
+
async with engine.connect() as connection:
|
|
388
|
+
return (await connection.execute(statement)).scalar_one()
|
|
389
|
+
|
|
390
|
+
def _create_table(self, connection: SyncConnection) -> None:
|
|
391
|
+
self._table.metadata.create_all(connection, tables=[self._table], checkfirst=True)
|
|
392
|
+
|
|
393
|
+
async def _resolve_notifier(self) -> None:
|
|
394
|
+
"""Start listening when this connection waits through notifications.
|
|
395
|
+
|
|
396
|
+
That is PostgreSQL through asyncpg with ``use_notify``: the listening
|
|
397
|
+
goes through asyncpg's own connection, so another PostgreSQL driver
|
|
398
|
+
polls instead.
|
|
399
|
+
"""
|
|
400
|
+
self._notifier_resolved = True
|
|
401
|
+
if not self._options.use_notify:
|
|
402
|
+
return
|
|
403
|
+
engine = await self._engine_factory()
|
|
404
|
+
if not isinstance(engine.dialect, PGDialect_asyncpg):
|
|
405
|
+
return
|
|
406
|
+
notifier = PostgreSqlNotifier(engine, self._options.table_name, self._options.queue)
|
|
407
|
+
await notifier.listen()
|
|
408
|
+
self._notifier = notifier
|
|
409
|
+
|
|
410
|
+
def _now(self) -> datetime:
|
|
411
|
+
"""Return the current instant as the naive UTC datetime the table keeps."""
|
|
412
|
+
point = self._clock.now().astimezone(UTC)
|
|
413
|
+
return datetime.combine(point.date(), point.time())
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _identifier(result: CursorResult[tuple[object, ...]]) -> str:
|
|
417
|
+
"""Return the id the database gave the row ``result`` inserted."""
|
|
418
|
+
key = result.inserted_primary_key
|
|
419
|
+
if key is None: # pragma: no cover — every supported database reports the key of an insert
|
|
420
|
+
message = "the database reported no key for the inserted message"
|
|
421
|
+
raise RuntimeError(message)
|
|
422
|
+
values = cast("Sequence[object]", key)
|
|
423
|
+
return str(values[0])
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _stored(row: RowMapping) -> StoredMessage:
|
|
427
|
+
"""Read one row of the table into a :class:`StoredMessage`."""
|
|
428
|
+
# The table's columns are untyped to SQLAlchemy; their types are the table's own.
|
|
429
|
+
values = cast("Mapping[str, object]", row)
|
|
430
|
+
return StoredMessage(
|
|
431
|
+
id=str(values["id"]),
|
|
432
|
+
body=cast("str", values["body"]),
|
|
433
|
+
headers=msgspec.json.decode(cast("str", values["headers"]), type=dict[str, str]),
|
|
434
|
+
queue=cast("str", values["queue_name"]),
|
|
435
|
+
created_at=cast("datetime", values["created_at"]),
|
|
436
|
+
available_at=cast("datetime", values["available_at"]),
|
|
437
|
+
delivered_at=cast("datetime | None", values["delivered_at"]),
|
|
438
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""The table an ``orm://`` transport keeps its messages in."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import BigInteger, Column, DateTime, Index, Integer, String, Table, Text
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from sqlalchemy import MetaData
|
|
11
|
+
|
|
12
|
+
__all__ = ["messages_table"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def messages_table(metadata: MetaData, table_name: str) -> Table:
|
|
16
|
+
"""Add the messages table ``table_name`` to ``metadata``, or return the one already there.
|
|
17
|
+
|
|
18
|
+
One definition serves the transport, its setup and the migrations a
|
|
19
|
+
connection writes, so the three never disagree on a column. Every instant
|
|
20
|
+
is stored as a naive UTC datetime: the one form every database compares
|
|
21
|
+
the same way.
|
|
22
|
+
|
|
23
|
+
The index follows the query a worker asks — a queue's messages that are
|
|
24
|
+
due and not handed out, oldest first.
|
|
25
|
+
"""
|
|
26
|
+
existing = metadata.tables.get(table_name)
|
|
27
|
+
if existing is not None:
|
|
28
|
+
return existing
|
|
29
|
+
return Table(
|
|
30
|
+
table_name,
|
|
31
|
+
metadata,
|
|
32
|
+
Column(
|
|
33
|
+
"id",
|
|
34
|
+
BigInteger().with_variant(Integer(), "sqlite"),
|
|
35
|
+
primary_key=True,
|
|
36
|
+
autoincrement=True,
|
|
37
|
+
),
|
|
38
|
+
Column("body", Text(), nullable=False),
|
|
39
|
+
Column("headers", Text(), nullable=False),
|
|
40
|
+
Column("queue_name", String(190), nullable=False),
|
|
41
|
+
Column("created_at", DateTime(), nullable=False),
|
|
42
|
+
Column("available_at", DateTime(), nullable=False),
|
|
43
|
+
Column("delivered_at", DateTime(), nullable=True),
|
|
44
|
+
Index(
|
|
45
|
+
f"{table_name}_queue_name_idx",
|
|
46
|
+
"queue_name",
|
|
47
|
+
"available_at",
|
|
48
|
+
"delivered_at",
|
|
49
|
+
"id",
|
|
50
|
+
),
|
|
51
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Handing the messages table over to a connection's migrations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, final
|
|
6
|
+
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
from xtr_orm import MappingDriverInterface
|
|
9
|
+
|
|
10
|
+
from .messages_table import messages_table
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from sqlalchemy import MetaData
|
|
14
|
+
|
|
15
|
+
__all__ = ["MessagesTableDriver"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@final
|
|
19
|
+
class MessagesTableDriver(MappingDriverInterface):
|
|
20
|
+
"""Adds the messages table to the definitions a connection's migrations compare with.
|
|
21
|
+
|
|
22
|
+
With it loaded, the table is written by the next revision like any of the
|
|
23
|
+
application's own, instead of created by the transport the first time it
|
|
24
|
+
runs.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
__slots__ = ("_table_name",)
|
|
28
|
+
|
|
29
|
+
def __init__(self, table_name: str) -> None:
|
|
30
|
+
"""Map the messages table named ``table_name``."""
|
|
31
|
+
self._table_name = table_name
|
|
32
|
+
|
|
33
|
+
@override
|
|
34
|
+
def load_metadata(self, metadata: MetaData, /) -> tuple[str, ...]:
|
|
35
|
+
"""Add the table to ``metadata`` unless it is there already; return what was added."""
|
|
36
|
+
if self._table_name in metadata.tables:
|
|
37
|
+
return ()
|
|
38
|
+
_ = messages_table(metadata, self._table_name)
|
|
39
|
+
return (self._table_name,)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Which messages tables a bus configuration keeps on a connection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
|
|
8
|
+
from .orm_options import ORM_SCHEME, OrmOptions
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from xtr_messenger import MessageBusConfig
|
|
12
|
+
|
|
13
|
+
__all__ = ["messages_tables"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def messages_tables(
|
|
17
|
+
config: MessageBusConfig, connection: str, default_connection: str
|
|
18
|
+
) -> tuple[str, ...]:
|
|
19
|
+
"""Return the tables the ``orm://`` transports of ``config`` keep on ``connection``.
|
|
20
|
+
|
|
21
|
+
What a connection's migrations need to write each transport's table like
|
|
22
|
+
one of the application's own: a transport naming no connection is on
|
|
23
|
+
``default_connection``. Each table is named once, in the order its first
|
|
24
|
+
transport is configured.
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
InvalidDsnError: When a transport's DSN carries no scheme.
|
|
28
|
+
UnknownTransportOptionError: When an ``orm://`` transport carries a
|
|
29
|
+
setting it does not accept.
|
|
30
|
+
InvalidTransportOptionError: When a setting's value is not what it
|
|
31
|
+
takes.
|
|
32
|
+
"""
|
|
33
|
+
found: dict[str, None] = {}
|
|
34
|
+
for transport in config.transports.values():
|
|
35
|
+
dsn = transport.parsed
|
|
36
|
+
if dsn.scheme != ORM_SCHEME:
|
|
37
|
+
continue
|
|
38
|
+
if (urlsplit(dsn.connection).netloc or default_connection) != connection:
|
|
39
|
+
continue
|
|
40
|
+
found[OrmOptions.from_settings(transport.settings).table_name] = None
|
|
41
|
+
return tuple(found)
|