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,108 @@
1
+ ---
2
+ name: xtr-orm-messenger
3
+ description: How to queue xtr-messenger messages in a database table with xtr-orm-messenger — an orm DSN naming an xtr-orm connection, OrmTransportFactory registered by the orm bundle, queues, delays, redelivery of messages a dead worker held, PostgreSQL notifications, sending in the unit of work's transaction, the messages table in migrations, and counting, listing or finding queued messages. Use when an application wants a message queue without a broker, installs xtr-messenger[orm], configures an orm transport, routes messages to the database, runs a worker on a table, migrates the messenger_messages table, or tests code that sends through the orm transport.
4
+ ---
5
+
6
+ # xtr-orm-messenger
7
+
8
+ A transport for xtr-messenger whose messages live in a table on a connection xtr-orm
9
+ configures. It ships no bundle: with the messenger and orm bundles active, the orm bundle
10
+ registers it once it is installed. For buses, routing and workers load the `xtr-messenger`
11
+ skill; for connections and migrations, `xtr-orm`.
12
+
13
+ ## Quick reference
14
+
15
+ - DSN — `orm://<connection>`, `orm://` for the default connection; queue with
16
+ `TransportConfig(dsn, queue="jobs")` or `?queue=jobs`, `default` otherwise.
17
+ - Options (DSN query or `options`) — `table_name` (`messenger_messages`), `redeliver_timeout`
18
+ (`3600` s), `auto_setup` (`true`), `poll_interval` (`1.0` s), `transactional` (`false`),
19
+ `use_notify` (`true`), `check_delayed_interval` / `get_notify_timeout` (`60000` ms).
20
+ - `OrmTransportFactory(registry, serializer, clock=None)` — not discovered by scheme: the orm
21
+ bundle registers it with the messenger's envelope serializer, or you pass it to
22
+ `MessageBusFactory` / `WorkerFactory`.
23
+ - Migrations — each connection's migrator adds the table of every `orm://` transport on it, so
24
+ `orm:migrations:diff` writes it; `messages_tables(bus_config, connection, default)` names them.
25
+ - Settling deletes the row; a row handed out longer than `redeliver_timeout` is claimed again.
26
+ - A `DelayStamp` makes the message due later, rounded up to a whole second.
27
+
28
+ ## Send and consume without a container
29
+
30
+ ```python
31
+ from xtr_messenger import (
32
+ JsonSerializer,
33
+ MessageBusConfig,
34
+ MessageBusFactory,
35
+ TransportConfig,
36
+ WorkerFactory,
37
+ )
38
+ from xtr_orm import ConnectionRegistry
39
+ from xtr_orm_messenger import OrmTransportFactory
40
+ from xtr_serializer import JsonEncoder, PydanticNormalizer, Serializer
41
+
42
+ registry = ConnectionRegistry()
43
+ registry.register("default", engine=engine, migrator=migrator, database=database)
44
+ wire = JsonSerializer(Serializer([PydanticNormalizer()], [JsonEncoder()]))
45
+ factory = OrmTransportFactory(registry, wire)
46
+ config = MessageBusConfig(
47
+ transports={"jobs": TransportConfig("orm://default", queue="jobs")},
48
+ routing={IngestDocument: "jobs"},
49
+ )
50
+
51
+ await MessageBusFactory(config, [factory]).bus().dispatch(IngestDocument(document_id=1))
52
+ await WorkerFactory(config, [factory]).worker(["jobs"]).run()
53
+ ```
54
+
55
+ Pass the same factory to the bus and the worker: it keeps one transport per name.
56
+
57
+ ## Send with the handler's changes
58
+
59
+ `TransportConfig("orm://default?transactional=true")` writes the message in the session of the
60
+ unit of work under way, so it is sent only when that unit commits. List `orm_transaction` in the
61
+ bus middleware, or commit the session yourself; outside a unit the message commits at once.
62
+
63
+ ## Look into a queue
64
+
65
+ ```python
66
+ transport = factory.transport("jobs", config.transports["jobs"])
67
+ await transport.count()
68
+ messages = [envelope async for envelope in transport.all(limit=10)]
69
+ await transport.find(message_id)
70
+ await transport.keepalive(envelope, seconds=600) # a handler running past redeliver_timeout
71
+ ```
72
+
73
+ ## Testing
74
+
75
+ Run against a SQLite file per test (`sqlite+aiosqlite:///{tmp_path / 'app.sqlite'}`) and freeze
76
+ a `MockClock`: `OrmTransportFactory(registry, wire, clock=clock)` makes delays and redelivery happen
77
+ when the test moves the clock. A worker's `get()` never runs dry — stop the worker from a
78
+ handler, or run it in a task and call `stop()`. `in-memory://` stays the transport for tests that
79
+ do not care where messages are kept.
80
+
81
+ ## Use in an application
82
+
83
+ 1. With `MessengerBundle` and `OrmBundle` already active, `uv add "xtr-messenger[orm]"`. Nothing
84
+ to activate: the orm bundle registers the transport. No driver to add either: the transport
85
+ uses the connection's, notifications included when that driver is asyncpg.
86
+ 2. In `<app>/config/packages/messenger.py`, name a transport `orm://default` and route messages
87
+ to it.
88
+ 3. Run `<script> orm:migrations:diff` — the next revision creates the messages table. Add
89
+ `auto_setup=false` to the DSN to leave the table to the migrations alone.
90
+ 4. Run a worker with `<script> messenger:consume <transport>`.
91
+ 5. Check with `<script> debug:config messenger` — the `orm://` transports are listed.
92
+
93
+ ## Errors
94
+
95
+ - `OrmMessengerError` — the base, itself a `MessageBusError`.
96
+ - `TransportError` — the database refused a send, claim, settle or listing, or an envelope was
97
+ settled on a transport it was not received from; `transport`, `operation`, `reason`.
98
+ - `InvalidOrmDsnError` — the DSN names a connection the registry does not hold; a `LookupError`.
99
+ - `UnknownTransportOptionError` / `InvalidTransportOptionError` — from xtr-messenger, for a
100
+ setting the transport does not take or a value it cannot read.
101
+
102
+ ## Do not
103
+
104
+ - Do not put a database URL in the DSN: it names a connection, configured in the orm.
105
+ - Do not rely on `transactional=true` without something committing the unit's session.
106
+ - Do not set `redeliver_timeout` below the time a handler takes, unless it calls `keepalive`.
107
+ - Do not expect notifications through a PostgreSQL driver other than asyncpg: it polls.
108
+ - Do not look for an `OrmMessengerBundle`: there is none; list `OrmBundle` and `MessengerBundle`.
@@ -0,0 +1,60 @@
1
+ """A database transport for xtr-messenger, on the connections xtr-orm configures.
2
+
3
+ Messages are kept in a table on one of the application's connections, named by
4
+ the DSN — ``orm://default``. A worker claims the oldest one of its queue that
5
+ is due, a delayed one falls due when its delay has passed, a message whose
6
+ worker died is handed out again, and on PostgreSQL a waiting worker is woken
7
+ the moment a message is sent.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from importlib.metadata import PackageNotFoundError, version
13
+
14
+ from .exception import InvalidOrmDsnError, OrmMessengerError, TransportError
15
+ from .transport import (
16
+ DEFAULT_QUEUE,
17
+ DEFAULT_TABLE_NAME,
18
+ ORM_OPTIONS,
19
+ ORM_SCHEME,
20
+ Connection,
21
+ MessagesTableDriver,
22
+ OrmOptions,
23
+ OrmReceivedStamp,
24
+ OrmReceiver,
25
+ OrmSender,
26
+ OrmTransport,
27
+ OrmTransportFactory,
28
+ StoredMessage,
29
+ messages_table,
30
+ messages_tables,
31
+ )
32
+
33
+ try:
34
+ __version__ = version("xtr-orm-messenger")
35
+ except PackageNotFoundError: # pragma: no cover
36
+ # Running from a source tree with no installed metadata to read; having no
37
+ # version is better than refusing to import.
38
+ __version__ = "0+unknown"
39
+
40
+ __all__ = [
41
+ "DEFAULT_QUEUE",
42
+ "DEFAULT_TABLE_NAME",
43
+ "ORM_OPTIONS",
44
+ "ORM_SCHEME",
45
+ "Connection",
46
+ "InvalidOrmDsnError",
47
+ "MessagesTableDriver",
48
+ "OrmMessengerError",
49
+ "OrmOptions",
50
+ "OrmReceivedStamp",
51
+ "OrmReceiver",
52
+ "OrmSender",
53
+ "OrmTransport",
54
+ "OrmTransportFactory",
55
+ "StoredMessage",
56
+ "TransportError",
57
+ "__version__",
58
+ "messages_table",
59
+ "messages_tables",
60
+ ]
@@ -0,0 +1,9 @@
1
+ """Everything this library raises, each one an :class:`OrmMessengerError`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .invalid_orm_dsn_error import InvalidOrmDsnError
6
+ from .orm_messenger_error import OrmMessengerError
7
+ from .transport_error import TransportError
8
+
9
+ __all__ = ["InvalidOrmDsnError", "OrmMessengerError", "TransportError"]
@@ -0,0 +1,34 @@
1
+ """An ``orm://`` DSN names a connection the application does not configure."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .orm_messenger_error import OrmMessengerError
6
+
7
+ __all__ = ["InvalidOrmDsnError"]
8
+
9
+
10
+ class InvalidOrmDsnError(OrmMessengerError, LookupError):
11
+ """An ``orm://`` DSN names a connection the application does not configure.
12
+
13
+ Also a :class:`LookupError`, as a missing key in any other mapping is.
14
+
15
+ Attributes:
16
+ transport: The transport whose DSN names it.
17
+ connection: The connection named.
18
+ known: Every connection that is configured.
19
+ """
20
+
21
+ transport: str
22
+ connection: str
23
+ known: tuple[str, ...]
24
+
25
+ def __init__(self, transport: str, connection: str, known: tuple[str, ...]) -> None:
26
+ """Record the transport, the connection it named and the ones that exist."""
27
+ self.transport = transport
28
+ self.connection = connection
29
+ self.known = known
30
+ names = ", ".join(f'"{each}"' for each in known) or "none"
31
+ super().__init__(
32
+ f'The "{transport}" transport names the connection "{connection}", which is not '
33
+ f"configured; the connections are: {names}."
34
+ )
@@ -0,0 +1,15 @@
1
+ """The root every error in this library derives from."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from xtr_messenger import MessageBusError
6
+
7
+ __all__ = ["OrmMessengerError"]
8
+
9
+
10
+ class OrmMessengerError(MessageBusError):
11
+ """Base class for every error raised by this library.
12
+
13
+ Also a :class:`~xtr_messenger.MessageBusError`, so code catching what the
14
+ bus can go wrong with catches this transport's failures too.
15
+ """
@@ -0,0 +1,32 @@
1
+ """The database refused what a transport asked of it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .orm_messenger_error import OrmMessengerError
6
+
7
+ __all__ = ["TransportError"]
8
+
9
+
10
+ class TransportError(OrmMessengerError):
11
+ """The database refused what a transport asked of it.
12
+
13
+ Raised from the driver's own error, which stays the cause, so nothing it
14
+ said is lost. The message names the transport and the operation, never the
15
+ connection's URL, whose credentials a traceback would otherwise carry.
16
+
17
+ Attributes:
18
+ transport: The transport the operation was for.
19
+ operation: What was asked — ``send``, ``get``, ``ack``, ...
20
+ reason: What went wrong.
21
+ """
22
+
23
+ transport: str
24
+ operation: str
25
+ reason: str
26
+
27
+ def __init__(self, transport: str, operation: str, reason: str) -> None:
28
+ """Record which transport failed, at what, and why."""
29
+ self.transport = transport
30
+ self.operation = operation
31
+ self.reason = reason
32
+ super().__init__(f'The "{transport}" transport could not {operation}: {reason}.')
File without changes
@@ -0,0 +1,15 @@
1
+ """This directory holds the package's recipe: its manifest.
2
+
3
+ The recipe is what *Use in an application* in the README lists, written as
4
+ data: the steps only a person can take — naming an ``orm://`` transport in the
5
+ message bus configuration and routing messages to it. An application applies it
6
+ with ``xtr-recipes recipes:sync``, which finds this module through the
7
+ ``xtr_recipes`` entry point.
8
+
9
+ Nothing is importable from here. The manifest is read as a file, so a recipe
10
+ is the same from a wheel as from a working tree.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ __all__: list[str] = []
@@ -0,0 +1,21 @@
1
+ # The recipe for xtr-orm-messenger: what *Use in an application* in the README
2
+ # takes, as data. Keep the two in step — the README is the source.
3
+ #
4
+ # No [bundles]: the package ships none — the orm bundle registers the transport
5
+ # factory when the messenger bundle is active too. No [files] and no [env]: a
6
+ # transport is named in the message bus configuration, which is the messenger's,
7
+ # and its DSN names a connection the orm already configures.
8
+
9
+ [gitignore]
10
+ # Nothing: the messages live in the database.
11
+ lines = []
12
+
13
+ [notes]
14
+ steps = [
15
+ "Name a transport orm://default in config/packages/messenger.py and route your messages to it: TransportConfig(\"orm://default\", queue=\"jobs\").",
16
+ "Run orm:migrations:diff: it writes the messages table of every orm:// transport on that connection. Add auto_setup=false to the DSN to leave the table to the migrations alone.",
17
+ ]
18
+ check = [
19
+ "<script> debug:bundles — messenger and orm are listed and active",
20
+ ]
21
+ run = ["<script> messenger:consume <transport>"]
@@ -0,0 +1,38 @@
1
+ """The ``orm://`` transport: messages kept in a table on an xtr-orm connection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .connection import LISTING_PAGE_SIZE, Connection
6
+ from .messages_table import messages_table
7
+ from .messages_table_driver import MessagesTableDriver
8
+ from .messages_tables import messages_tables
9
+ from .orm_options import DEFAULT_QUEUE, DEFAULT_TABLE_NAME, ORM_OPTIONS, ORM_SCHEME, OrmOptions
10
+ from .orm_received_stamp import OrmReceivedStamp
11
+ from .orm_receiver import MAX_RETRIES, RECONNECT_TIMEOUT, OrmReceiver
12
+ from .orm_sender import OrmSender
13
+ from .orm_transport import OrmTransport
14
+ from .orm_transport_factory import OrmTransportFactory
15
+ from .postgresql_notifier import PostgreSqlNotifier
16
+ from .stored_message import StoredMessage
17
+
18
+ __all__ = [
19
+ "DEFAULT_QUEUE",
20
+ "DEFAULT_TABLE_NAME",
21
+ "LISTING_PAGE_SIZE",
22
+ "MAX_RETRIES",
23
+ "ORM_OPTIONS",
24
+ "ORM_SCHEME",
25
+ "RECONNECT_TIMEOUT",
26
+ "Connection",
27
+ "MessagesTableDriver",
28
+ "OrmOptions",
29
+ "OrmReceivedStamp",
30
+ "OrmReceiver",
31
+ "OrmSender",
32
+ "OrmTransport",
33
+ "OrmTransportFactory",
34
+ "PostgreSqlNotifier",
35
+ "StoredMessage",
36
+ "messages_table",
37
+ "messages_tables",
38
+ ]
@@ -0,0 +1,71 @@
1
+ """Telling apart what a driver's error means, whichever database raised it.
2
+
3
+ Each database reports a deadlock, a lock it gave up waiting for or a missing
4
+ table its own way — PostgreSQL with an SQLSTATE, MySQL with an error number,
5
+ SQLite only in its message — and the driver's error is all SQLAlchemy hands
6
+ back. Reading it in one place keeps every caller to one question.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING, Final
12
+
13
+ if TYPE_CHECKING:
14
+ from sqlalchemy.exc import DBAPIError
15
+
16
+ __all__ = ["is_missing_table", "is_retryable"]
17
+
18
+ _RETRYABLE_SQLSTATES: Final = frozenset({"40001", "40P01", "55P03"})
19
+ """Serialization failure, deadlock, lock not available."""
20
+
21
+ _RETRYABLE_MYSQL_CODES: Final = frozenset({1205, 1213})
22
+ """Lock wait timeout, deadlock."""
23
+
24
+ _RETRYABLE_MESSAGES: Final = ("database is locked", "database table is locked")
25
+ """SQLite, whose errors carry no code a driver exposes."""
26
+
27
+ _MISSING_TABLE_SQLSTATE: Final = "42P01"
28
+ _MISSING_TABLE_MYSQL_CODE: Final = 1146
29
+ _MISSING_TABLE_MESSAGE: Final = "no such table"
30
+
31
+
32
+ def is_retryable(error: DBAPIError) -> bool:
33
+ """Tell whether ``error`` is a conflict with another transaction, worth trying again.
34
+
35
+ Workers claiming messages from one table run into each other: a deadlock,
36
+ a lock they waited too long for. Neither says anything about the message,
37
+ so the same statement a moment later goes through.
38
+ """
39
+ original = error.orig
40
+ return (
41
+ _sqlstate(original) in _RETRYABLE_SQLSTATES
42
+ or _mysql_code(original) in _RETRYABLE_MYSQL_CODES
43
+ or any(text in str(original) for text in _RETRYABLE_MESSAGES)
44
+ )
45
+
46
+
47
+ def is_missing_table(error: DBAPIError) -> bool:
48
+ """Tell whether ``error`` says the table the statement names does not exist."""
49
+ original = error.orig
50
+ return (
51
+ _sqlstate(original) == _MISSING_TABLE_SQLSTATE
52
+ or _mysql_code(original) == _MISSING_TABLE_MYSQL_CODE
53
+ or _MISSING_TABLE_MESSAGE in str(original)
54
+ )
55
+
56
+
57
+ def _sqlstate(original: BaseException | None) -> str | None:
58
+ """Return the SQLSTATE a PostgreSQL driver put on its error, if any."""
59
+ state: object = getattr(original, "sqlstate", None)
60
+ return state if isinstance(state, str) else None
61
+
62
+
63
+ def _mysql_code(original: BaseException | None) -> int | None:
64
+ """Return the error number a MySQL driver put first in its error's arguments, if any."""
65
+ if original is None:
66
+ return None
67
+ arguments: tuple[object, ...] = original.args
68
+ if not arguments:
69
+ return None
70
+ first = arguments[0]
71
+ return first if isinstance(first, int) and not isinstance(first, bool) else None