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.
@@ -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)