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,145 @@
|
|
|
1
|
+
"""The settings an ``orm://`` transport reads from its configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING, Final
|
|
8
|
+
|
|
9
|
+
from xtr_messenger import InvalidTransportOptionError
|
|
10
|
+
from xtr_messenger.transport.transport_options import (
|
|
11
|
+
as_bool,
|
|
12
|
+
as_float,
|
|
13
|
+
as_int,
|
|
14
|
+
reject_unknown_options,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Mapping
|
|
19
|
+
|
|
20
|
+
__all__ = ["DEFAULT_QUEUE", "DEFAULT_TABLE_NAME", "ORM_OPTIONS", "ORM_SCHEME", "OrmOptions"]
|
|
21
|
+
|
|
22
|
+
ORM_SCHEME: Final = "orm"
|
|
23
|
+
"""The DSN scheme the transport serves."""
|
|
24
|
+
|
|
25
|
+
DEFAULT_TABLE_NAME: Final = "messenger_messages"
|
|
26
|
+
"""The table messages are kept in when the configuration names none."""
|
|
27
|
+
|
|
28
|
+
DEFAULT_QUEUE: Final = "default"
|
|
29
|
+
"""The queue a transport reads and writes when the configuration names none."""
|
|
30
|
+
|
|
31
|
+
ORM_OPTIONS: Final = (
|
|
32
|
+
"auto_setup",
|
|
33
|
+
"check_delayed_interval",
|
|
34
|
+
"get_notify_timeout",
|
|
35
|
+
"poll_interval",
|
|
36
|
+
"queue",
|
|
37
|
+
"redeliver_timeout",
|
|
38
|
+
"table_name",
|
|
39
|
+
"transactional",
|
|
40
|
+
"use_notify",
|
|
41
|
+
)
|
|
42
|
+
"""Every setting an ``orm://`` transport accepts; any other is refused."""
|
|
43
|
+
|
|
44
|
+
_QUEUE_LENGTH: Final = 190
|
|
45
|
+
"""The width of the ``queue_name`` column, which indexes on every database at that size."""
|
|
46
|
+
|
|
47
|
+
_IDENTIFIER: Final = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
48
|
+
"""A table name: a plain identifier, so it names one table and one notification channel."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class OrmOptions:
|
|
53
|
+
"""How an ``orm://`` transport keeps, hands out and hands back its messages.
|
|
54
|
+
|
|
55
|
+
Attributes:
|
|
56
|
+
table_name: The table messages are kept in.
|
|
57
|
+
queue: The queue this transport reads and writes; transports sharing a
|
|
58
|
+
table but naming different queues never see each other's messages.
|
|
59
|
+
redeliver_timeout: Seconds after which a message handed to a worker
|
|
60
|
+
and never settled is handed out again — the worker is taken to have
|
|
61
|
+
died with it.
|
|
62
|
+
auto_setup: Create the table the first time an operation finds it
|
|
63
|
+
missing.
|
|
64
|
+
poll_interval: Seconds a worker waits before asking again when nothing
|
|
65
|
+
was due.
|
|
66
|
+
transactional: Write a message in the session of the unit of work
|
|
67
|
+
under way, so it commits or rolls back with the handler's own
|
|
68
|
+
changes; outside a unit it is written in a transaction of its own.
|
|
69
|
+
use_notify: On PostgreSQL, wake a waiting worker the moment a message
|
|
70
|
+
is sent instead of when its poll interval runs out.
|
|
71
|
+
check_delayed_interval: Milliseconds a worker waiting for a
|
|
72
|
+
notification waits at most before asking anyway — a delayed message
|
|
73
|
+
falling due sends none.
|
|
74
|
+
get_notify_timeout: Milliseconds one wait for a notification lasts at
|
|
75
|
+
most.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
table_name: str = DEFAULT_TABLE_NAME
|
|
79
|
+
queue: str = DEFAULT_QUEUE
|
|
80
|
+
redeliver_timeout: int = 3600
|
|
81
|
+
auto_setup: bool = True
|
|
82
|
+
poll_interval: float = 1.0
|
|
83
|
+
transactional: bool = False
|
|
84
|
+
use_notify: bool = True
|
|
85
|
+
check_delayed_interval: int = 60_000
|
|
86
|
+
get_notify_timeout: int = 60_000
|
|
87
|
+
|
|
88
|
+
def __post_init__(self) -> None:
|
|
89
|
+
"""Refuse values the transport would misread.
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
InvalidTransportOptionError: When a name is not a plain identifier,
|
|
93
|
+
a queue is empty or too long for its column, or a duration is
|
|
94
|
+
not positive.
|
|
95
|
+
"""
|
|
96
|
+
if _IDENTIFIER.fullmatch(self.table_name) is None:
|
|
97
|
+
raise InvalidTransportOptionError(
|
|
98
|
+
"table_name", self.table_name, "a plain identifier: letters, digits and '_'"
|
|
99
|
+
)
|
|
100
|
+
if not 0 < len(self.queue) <= _QUEUE_LENGTH:
|
|
101
|
+
raise InvalidTransportOptionError(
|
|
102
|
+
"queue", self.queue, f"between 1 and {_QUEUE_LENGTH} characters"
|
|
103
|
+
)
|
|
104
|
+
if self.redeliver_timeout <= 0:
|
|
105
|
+
raise InvalidTransportOptionError(
|
|
106
|
+
"redeliver_timeout", str(self.redeliver_timeout), "a positive number of seconds"
|
|
107
|
+
)
|
|
108
|
+
if self.poll_interval <= 0:
|
|
109
|
+
raise InvalidTransportOptionError(
|
|
110
|
+
"poll_interval", str(self.poll_interval), "a positive number of seconds"
|
|
111
|
+
)
|
|
112
|
+
for name, value in (
|
|
113
|
+
("check_delayed_interval", self.check_delayed_interval),
|
|
114
|
+
("get_notify_timeout", self.get_notify_timeout),
|
|
115
|
+
):
|
|
116
|
+
if value <= 0:
|
|
117
|
+
raise InvalidTransportOptionError(
|
|
118
|
+
name, str(value), "a positive number of milliseconds"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def from_settings(cls, settings: Mapping[str, str]) -> OrmOptions:
|
|
123
|
+
"""Read the options out of a transport's settings, the defaults filling the rest.
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
UnknownTransportOptionError: If a setting is not one this transport
|
|
127
|
+
accepts.
|
|
128
|
+
InvalidTransportOptionError: If a value is not what its setting
|
|
129
|
+
takes.
|
|
130
|
+
"""
|
|
131
|
+
reject_unknown_options(ORM_SCHEME, settings, ORM_OPTIONS)
|
|
132
|
+
base = cls()
|
|
133
|
+
return cls(
|
|
134
|
+
table_name=settings.get("table_name", base.table_name),
|
|
135
|
+
queue=settings.get("queue", base.queue),
|
|
136
|
+
redeliver_timeout=as_int(settings, "redeliver_timeout", base.redeliver_timeout),
|
|
137
|
+
auto_setup=as_bool(settings, "auto_setup", base.auto_setup),
|
|
138
|
+
poll_interval=as_float(settings, "poll_interval", base.poll_interval),
|
|
139
|
+
transactional=as_bool(settings, "transactional", base.transactional),
|
|
140
|
+
use_notify=as_bool(settings, "use_notify", base.use_notify),
|
|
141
|
+
check_delayed_interval=as_int(
|
|
142
|
+
settings, "check_delayed_interval", base.check_delayed_interval
|
|
143
|
+
),
|
|
144
|
+
get_notify_timeout=as_int(settings, "get_notify_timeout", base.get_notify_timeout),
|
|
145
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Which row of the messages table an envelope was received from."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import final
|
|
7
|
+
|
|
8
|
+
from xtr_messenger import NonSendableStampInterface
|
|
9
|
+
|
|
10
|
+
__all__ = ["OrmReceivedStamp"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@final
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class OrmReceivedStamp(NonSendableStampInterface):
|
|
16
|
+
"""The row an envelope came from, which settling it deletes.
|
|
17
|
+
|
|
18
|
+
Never sent on: it names a row of this table, which means nothing to a
|
|
19
|
+
transport the envelope is dispatched to next.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
id: str
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""The receive half of an ``orm://`` transport."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import secrets
|
|
7
|
+
from typing import TYPE_CHECKING, Final, final
|
|
8
|
+
|
|
9
|
+
from sqlalchemy.exc import DBAPIError
|
|
10
|
+
from typing_extensions import override
|
|
11
|
+
from xtr_clock import SystemClock
|
|
12
|
+
from xtr_messenger import (
|
|
13
|
+
EncodedEnvelope,
|
|
14
|
+
MessageDecodingFailedError,
|
|
15
|
+
ReceivedStamp,
|
|
16
|
+
ReceiverInterface,
|
|
17
|
+
TransportMessageIdStamp,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from xtr_orm_messenger.exception import TransportError
|
|
21
|
+
|
|
22
|
+
from ._database_errors import is_retryable
|
|
23
|
+
from .orm_received_stamp import OrmReceivedStamp
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
27
|
+
|
|
28
|
+
from xtr_clock import ClockInterface
|
|
29
|
+
from xtr_messenger import Envelope, SerializerInterface
|
|
30
|
+
|
|
31
|
+
from .connection import Connection
|
|
32
|
+
from .stored_message import StoredMessage
|
|
33
|
+
|
|
34
|
+
__all__ = ["MAX_RETRIES", "RECONNECT_TIMEOUT", "OrmReceiver"]
|
|
35
|
+
|
|
36
|
+
MAX_RETRIES: Final = 3
|
|
37
|
+
"""How many conflicts in a row a poll, or a settle, sits through before it gives up."""
|
|
38
|
+
|
|
39
|
+
RECONNECT_TIMEOUT: Final = 5
|
|
40
|
+
"""Seconds a settle waits for a lost database to come back before it gives up.
|
|
41
|
+
|
|
42
|
+
Shorter than a process supervisor usually gives a worker to stop.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
_FIRST_BACKOFF: Final = 0.1
|
|
46
|
+
"""Seconds before a settle that ran into another transaction tries again; doubled each time."""
|
|
47
|
+
|
|
48
|
+
#: Warnings go through the messenger channel, so an application capturing the
|
|
49
|
+
#: standard library's logging sees them beside the rest of the bus's output.
|
|
50
|
+
_LOGGER: Final = logging.getLogger("messenger")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@final
|
|
54
|
+
class OrmReceiver(ReceiverInterface):
|
|
55
|
+
"""Claims the messages of one queue, and deletes each once it is settled.
|
|
56
|
+
|
|
57
|
+
Workers polling one table run into each other's transactions; a conflict
|
|
58
|
+
says nothing about the message, so a poll sits through
|
|
59
|
+
:data:`MAX_RETRIES` of them in a row before it gives up, and a settle
|
|
60
|
+
tries again with a growing pause. A settle whose database went away waits
|
|
61
|
+
up to :data:`RECONNECT_TIMEOUT` seconds for it and tries once more: losing
|
|
62
|
+
an acknowledgement to a database restart would have the message handed
|
|
63
|
+
out, and handled, again.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
__slots__ = ("_clock", "_conflicts", "_connection", "_name", "_serializer")
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
connection: Connection,
|
|
71
|
+
serializer: SerializerInterface,
|
|
72
|
+
name: str,
|
|
73
|
+
clock: ClockInterface | None = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Receive from ``connection`` as the transport ``name``, decoding with ``serializer``."""
|
|
76
|
+
self._connection = connection
|
|
77
|
+
self._serializer = serializer
|
|
78
|
+
self._name = name
|
|
79
|
+
self._clock: ClockInterface = clock if clock is not None else SystemClock()
|
|
80
|
+
self._conflicts = 0
|
|
81
|
+
|
|
82
|
+
@override
|
|
83
|
+
async def get(self) -> AsyncIterator[Envelope]:
|
|
84
|
+
"""Yield each message as it falls due, waiting between polls that find none.
|
|
85
|
+
|
|
86
|
+
Never runs dry: a worker stops it by cancelling. A message is yielded
|
|
87
|
+
the moment it is claimed, with nothing awaited in between, so a
|
|
88
|
+
cancellation never loses one; and one lost anyway — the process
|
|
89
|
+
killed — is handed out again after ``redeliver_timeout``.
|
|
90
|
+
|
|
91
|
+
A message that does not decode can never be handled, so it is
|
|
92
|
+
rejected at once, its reason logged — never its body, which may carry
|
|
93
|
+
anything a producer put in a message.
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
TransportError: When the database refuses a poll for any reason
|
|
97
|
+
but a conflict, or for a conflict :data:`MAX_RETRIES` times in
|
|
98
|
+
a row.
|
|
99
|
+
"""
|
|
100
|
+
while True:
|
|
101
|
+
stored = await self._claim()
|
|
102
|
+
if stored is None:
|
|
103
|
+
await self._connection.wait()
|
|
104
|
+
continue
|
|
105
|
+
try:
|
|
106
|
+
envelope = self.decode(stored)
|
|
107
|
+
except MessageDecodingFailedError as error:
|
|
108
|
+
_LOGGER.warning(
|
|
109
|
+
"rejecting message %s of the %r transport: it does not decode (%s)",
|
|
110
|
+
stored.id,
|
|
111
|
+
self._name,
|
|
112
|
+
error.reason,
|
|
113
|
+
)
|
|
114
|
+
await self._settle(stored.id, self._connection.reject, "reject")
|
|
115
|
+
continue
|
|
116
|
+
yield envelope.with_stamps(ReceivedStamp(self._name))
|
|
117
|
+
|
|
118
|
+
@override
|
|
119
|
+
async def ack(self, envelope: Envelope) -> None:
|
|
120
|
+
"""Delete the message ``envelope`` was received as: it was handled.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
TransportError: When ``envelope`` was not received from this
|
|
124
|
+
transport, or the database refuses the delete.
|
|
125
|
+
"""
|
|
126
|
+
await self._settle(self._message_id(envelope), self._connection.ack, "acknowledge")
|
|
127
|
+
|
|
128
|
+
@override
|
|
129
|
+
async def reject(self, envelope: Envelope) -> None:
|
|
130
|
+
"""Delete the message ``envelope`` was received as: it failed for good.
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
TransportError: When ``envelope`` was not received from this
|
|
134
|
+
transport, or the database refuses the delete.
|
|
135
|
+
"""
|
|
136
|
+
await self._settle(self._message_id(envelope), self._connection.reject, "reject")
|
|
137
|
+
|
|
138
|
+
def decode(self, stored: StoredMessage) -> Envelope:
|
|
139
|
+
"""Rebuild the envelope ``stored`` keeps, stamped with the row it came from.
|
|
140
|
+
|
|
141
|
+
A transport message id the envelope carried from an earlier transport
|
|
142
|
+
is replaced by this row's, so the id it carries is the one that
|
|
143
|
+
settles it here.
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
MessageDecodingFailedError: When the row does not decode.
|
|
147
|
+
"""
|
|
148
|
+
envelope = self._serializer.decode(
|
|
149
|
+
EncodedEnvelope(body=stored.body, headers=dict(stored.headers))
|
|
150
|
+
)
|
|
151
|
+
return envelope.without_stamps(TransportMessageIdStamp).with_stamps(
|
|
152
|
+
OrmReceivedStamp(stored.id), TransportMessageIdStamp(stored.id)
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
async def _claim(self) -> StoredMessage | None:
|
|
156
|
+
"""Claim the next message, or return ``None`` for nothing due or a conflict to sit out."""
|
|
157
|
+
try:
|
|
158
|
+
stored = await self._connection.get()
|
|
159
|
+
except DBAPIError as error:
|
|
160
|
+
if not is_retryable(error):
|
|
161
|
+
raise TransportError(self._name, "get messages", str(error.orig)) from error
|
|
162
|
+
self._conflicts += 1
|
|
163
|
+
if self._conflicts >= MAX_RETRIES:
|
|
164
|
+
self._conflicts = 0
|
|
165
|
+
raise TransportError(self._name, "get messages", str(error.orig)) from error
|
|
166
|
+
return None
|
|
167
|
+
self._conflicts = 0
|
|
168
|
+
return stored
|
|
169
|
+
|
|
170
|
+
async def _settle(
|
|
171
|
+
self, message_id: str, operation: Callable[[str], Awaitable[None]], action: str
|
|
172
|
+
) -> None:
|
|
173
|
+
delay = _FIRST_BACKOFF
|
|
174
|
+
retries = 0
|
|
175
|
+
reconnected = False
|
|
176
|
+
while True:
|
|
177
|
+
try:
|
|
178
|
+
await operation(message_id)
|
|
179
|
+
except DBAPIError as error:
|
|
180
|
+
if is_retryable(error) and retries < MAX_RETRIES:
|
|
181
|
+
retries += 1
|
|
182
|
+
await self._clock.sleep_async(delay * _jitter())
|
|
183
|
+
delay *= 2
|
|
184
|
+
continue
|
|
185
|
+
if not reconnected and error.connection_invalidated and await self._reconnected():
|
|
186
|
+
reconnected = True
|
|
187
|
+
continue
|
|
188
|
+
raise TransportError(
|
|
189
|
+
self._name, f"{action} message {message_id}", str(error.orig)
|
|
190
|
+
) from error
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
async def _reconnected(self) -> bool:
|
|
194
|
+
"""Wait up to :data:`RECONNECT_TIMEOUT` seconds for the database to answer again."""
|
|
195
|
+
for _ in range(RECONNECT_TIMEOUT):
|
|
196
|
+
await self._clock.sleep_async(1)
|
|
197
|
+
if await self._connection.ping():
|
|
198
|
+
return True
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
def _message_id(self, envelope: Envelope) -> str:
|
|
202
|
+
stamp = envelope.last(OrmReceivedStamp)
|
|
203
|
+
if stamp is None:
|
|
204
|
+
raise TransportError(
|
|
205
|
+
self._name,
|
|
206
|
+
"settle a message",
|
|
207
|
+
"it carries no OrmReceivedStamp, so it was not received from this transport",
|
|
208
|
+
)
|
|
209
|
+
return stamp.id
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _jitter() -> float:
|
|
213
|
+
"""Return a factor within 10% of one, so workers that collided do not collide again."""
|
|
214
|
+
return 1 + (secrets.randbelow(21) - 10) / 100
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""The send half of an ``orm://`` transport."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, final
|
|
6
|
+
|
|
7
|
+
from sqlalchemy.exc import DBAPIError
|
|
8
|
+
from typing_extensions import override
|
|
9
|
+
from xtr_messenger import DelayStamp, SenderInterface, TransportMessageIdStamp
|
|
10
|
+
|
|
11
|
+
from xtr_orm_messenger.exception import TransportError
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from xtr_messenger import Envelope, SerializerInterface
|
|
15
|
+
|
|
16
|
+
from .connection import Connection
|
|
17
|
+
|
|
18
|
+
__all__ = ["OrmSender"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@final
|
|
22
|
+
class OrmSender(SenderInterface):
|
|
23
|
+
"""Writes each envelope it is handed into the table, due after its delay."""
|
|
24
|
+
|
|
25
|
+
__slots__ = ("_connection", "_name", "_serializer")
|
|
26
|
+
|
|
27
|
+
def __init__(self, connection: Connection, serializer: SerializerInterface, name: str) -> None:
|
|
28
|
+
"""Send through ``connection`` as the transport ``name``, encoding with ``serializer``."""
|
|
29
|
+
self._connection = connection
|
|
30
|
+
self._serializer = serializer
|
|
31
|
+
self._name = name
|
|
32
|
+
|
|
33
|
+
@override
|
|
34
|
+
async def send(self, envelope: Envelope) -> Envelope:
|
|
35
|
+
"""Keep ``envelope``, due after its :class:`~xtr_messenger.DelayStamp`, if any.
|
|
36
|
+
|
|
37
|
+
Returns it stamped with the id the row was given.
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
MessageEncodingFailedError: When the envelope does not encode.
|
|
41
|
+
TransportError: When the database refuses the insert.
|
|
42
|
+
"""
|
|
43
|
+
encoded = self._serializer.encode(envelope)
|
|
44
|
+
delay = envelope.last(DelayStamp)
|
|
45
|
+
try:
|
|
46
|
+
message_id = await self._connection.send(
|
|
47
|
+
encoded.body, encoded.headers, delay.delay_ms if delay is not None else 0
|
|
48
|
+
)
|
|
49
|
+
except DBAPIError as error:
|
|
50
|
+
raise TransportError(self._name, "send a message", str(error.orig)) from error
|
|
51
|
+
return envelope.with_stamps(TransportMessageIdStamp(message_id))
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Both halves of an ``orm://`` transport, and what can be asked of its table."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, final
|
|
6
|
+
|
|
7
|
+
from sqlalchemy.exc import DBAPIError
|
|
8
|
+
from typing_extensions import override
|
|
9
|
+
from xtr_messenger import TransportInterface
|
|
10
|
+
|
|
11
|
+
from xtr_orm_messenger.exception import TransportError
|
|
12
|
+
|
|
13
|
+
from .orm_received_stamp import OrmReceivedStamp
|
|
14
|
+
from .orm_receiver import OrmReceiver
|
|
15
|
+
from .orm_sender import OrmSender
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import AsyncIterator
|
|
19
|
+
|
|
20
|
+
from xtr_clock import ClockInterface
|
|
21
|
+
from xtr_messenger import Envelope, SerializerInterface
|
|
22
|
+
|
|
23
|
+
from .connection import Connection
|
|
24
|
+
|
|
25
|
+
__all__ = ["OrmTransport"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@final
|
|
29
|
+
class OrmTransport(TransportInterface):
|
|
30
|
+
"""Sends to, receives from and lists one queue of a messages table.
|
|
31
|
+
|
|
32
|
+
Beyond sending and receiving, the queue can be looked into — counted,
|
|
33
|
+
listed, one message found by id — and a message a long handler holds can
|
|
34
|
+
be kept from being handed out again.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
__slots__ = ("_connection", "_name", "_receiver", "_sender")
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
connection: Connection,
|
|
42
|
+
serializer: SerializerInterface,
|
|
43
|
+
*,
|
|
44
|
+
name: str = "orm",
|
|
45
|
+
clock: ClockInterface | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Use ``connection`` as the transport ``name``.
|
|
48
|
+
|
|
49
|
+
Both halves encode and decode with ``serializer``, so a producer and a
|
|
50
|
+
worker built from the same configuration always agree on the wire
|
|
51
|
+
format.
|
|
52
|
+
"""
|
|
53
|
+
self._connection = connection
|
|
54
|
+
self._name = name
|
|
55
|
+
self._receiver = OrmReceiver(connection, serializer, name, clock)
|
|
56
|
+
self._sender = OrmSender(connection, serializer, name)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def name(self) -> str:
|
|
60
|
+
"""The transport's name, which every envelope it receives is stamped with."""
|
|
61
|
+
return self._name
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def connection(self) -> Connection:
|
|
65
|
+
"""What runs this transport's statements."""
|
|
66
|
+
return self._connection
|
|
67
|
+
|
|
68
|
+
@override
|
|
69
|
+
async def send(self, envelope: Envelope) -> Envelope:
|
|
70
|
+
"""Keep ``envelope`` until a worker claims it; see :meth:`OrmSender.send`."""
|
|
71
|
+
return await self._sender.send(envelope)
|
|
72
|
+
|
|
73
|
+
@override
|
|
74
|
+
def get(self) -> AsyncIterator[Envelope]:
|
|
75
|
+
"""Yield each message as it falls due; see :meth:`OrmReceiver.get`."""
|
|
76
|
+
return self._receiver.get()
|
|
77
|
+
|
|
78
|
+
@override
|
|
79
|
+
async def ack(self, envelope: Envelope) -> None:
|
|
80
|
+
"""Delete the message ``envelope`` was received as: it was handled."""
|
|
81
|
+
await self._receiver.ack(envelope)
|
|
82
|
+
|
|
83
|
+
@override
|
|
84
|
+
async def reject(self, envelope: Envelope) -> None:
|
|
85
|
+
"""Delete the message ``envelope`` was received as: it failed for good."""
|
|
86
|
+
await self._receiver.reject(envelope)
|
|
87
|
+
|
|
88
|
+
async def keepalive(self, envelope: Envelope, seconds: int | None = None) -> None:
|
|
89
|
+
"""Keep the message ``envelope`` was received as from being handed out again.
|
|
90
|
+
|
|
91
|
+
Restarts its redelivery clock: call it while a handler runs longer
|
|
92
|
+
than ``redeliver_timeout``. ``seconds`` is how long the caller means to
|
|
93
|
+
wait before calling again, refused when longer than the timeout, which
|
|
94
|
+
would hand the message out in between.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
TransportError: When ``seconds`` is longer than
|
|
98
|
+
``redeliver_timeout``, ``envelope`` was not received from this
|
|
99
|
+
transport, or the database refuses the update.
|
|
100
|
+
"""
|
|
101
|
+
timeout = self._connection.options.redeliver_timeout
|
|
102
|
+
if seconds is not None and seconds > timeout:
|
|
103
|
+
raise TransportError(
|
|
104
|
+
self._name,
|
|
105
|
+
"keep a message alive",
|
|
106
|
+
f"{seconds} seconds is longer than its redeliver_timeout of {timeout}",
|
|
107
|
+
)
|
|
108
|
+
stamp = envelope.last(OrmReceivedStamp)
|
|
109
|
+
if stamp is None:
|
|
110
|
+
raise TransportError(
|
|
111
|
+
self._name,
|
|
112
|
+
"keep a message alive",
|
|
113
|
+
"it carries no OrmReceivedStamp, so it was not received from this transport",
|
|
114
|
+
)
|
|
115
|
+
try:
|
|
116
|
+
await self._connection.keepalive(stamp.id)
|
|
117
|
+
except DBAPIError as error:
|
|
118
|
+
raise TransportError(self._name, "keep a message alive", str(error.orig)) from error
|
|
119
|
+
|
|
120
|
+
async def find(self, message_id: str) -> Envelope | None:
|
|
121
|
+
"""Return the envelope kept as ``message_id`` in this queue, or ``None``.
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
MessageDecodingFailedError: When the row does not decode.
|
|
125
|
+
TransportError: When the database refuses the query.
|
|
126
|
+
"""
|
|
127
|
+
try:
|
|
128
|
+
stored = await self._connection.find(message_id)
|
|
129
|
+
except DBAPIError as error:
|
|
130
|
+
raise TransportError(self._name, "find a message", str(error.orig)) from error
|
|
131
|
+
return None if stored is None else self._receiver.decode(stored)
|
|
132
|
+
|
|
133
|
+
async def all(self, limit: int | None = None) -> AsyncIterator[Envelope]:
|
|
134
|
+
"""Yield the envelopes a worker could claim now, oldest first, ``limit`` at most.
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
MessageDecodingFailedError: When a row does not decode.
|
|
138
|
+
TransportError: When the database refuses the query.
|
|
139
|
+
"""
|
|
140
|
+
try:
|
|
141
|
+
async for stored in self._connection.all(limit):
|
|
142
|
+
yield self._receiver.decode(stored)
|
|
143
|
+
except DBAPIError as error:
|
|
144
|
+
raise TransportError(self._name, "list messages", str(error.orig)) from error
|
|
145
|
+
|
|
146
|
+
async def count(self) -> int:
|
|
147
|
+
"""Return how many messages a worker could claim now.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
TransportError: When the database refuses the query.
|
|
151
|
+
"""
|
|
152
|
+
try:
|
|
153
|
+
return await self._connection.count()
|
|
154
|
+
except DBAPIError as error:
|
|
155
|
+
raise TransportError(self._name, "count messages", str(error.orig)) from error
|
|
156
|
+
|
|
157
|
+
async def setup(self) -> None:
|
|
158
|
+
"""Create the messages table if it does not exist.
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
TransportError: When the database refuses to create it.
|
|
162
|
+
"""
|
|
163
|
+
try:
|
|
164
|
+
await self._connection.setup()
|
|
165
|
+
except DBAPIError as error:
|
|
166
|
+
raise TransportError(self._name, "create its table", str(error.orig)) from error
|
|
167
|
+
|
|
168
|
+
async def close(self) -> None:
|
|
169
|
+
"""Release the connection a worker listens for notifications on, if any."""
|
|
170
|
+
await self._connection.close()
|