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,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()