acemq_amqp.patterns
The things everybody writes on top of a broker, written once.
Nothing here is new. Every service that consumes a queue eventually grows a way to ignore a message it has already handled, a way to publish and commit without losing one, a way to ask a question and wait for the answer. They get written again in each service, slightly differently, and the differences are where the bugs live.
These are the same patterns as the Go library's patterns package and mean
the same things, because a routing slip written by a Go service has to be
readable by a Python one. What they are not is the same API: Go takes a
ctx and returns an error, and Python takes keyword arguments and raises.
The behaviour is portable; the shape is native.
Everything in here is built out of the public library — handlers, envelopes, publishers — so nothing is possible with a pattern that would not be possible without it. That is deliberate: a pattern you cannot walk away from is a trap.
1# Copyright 2026 AceMQ. 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# https://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14 15"""The things everybody writes on top of a broker, written once. 16 17Nothing here is new. Every service that consumes a queue eventually grows a way 18to ignore a message it has already handled, a way to publish and commit without 19losing one, a way to ask a question and wait for the answer. They get written 20again in each service, slightly differently, and the differences are where the 21bugs live. 22 23These are the same patterns as the Go library's ``patterns`` package and mean 24the same things, because a routing slip written by a Go service has to be 25readable by a Python one. What they are *not* is the same API: Go takes a 26``ctx`` and returns an ``error``, and Python takes keyword arguments and raises. 27The behaviour is portable; the shape is native. 28 29Everything in here is built out of the public library — handlers, envelopes, 30publishers — so nothing is possible with a pattern that would not be possible 31without it. That is deliberate: a pattern you cannot walk away from is a trap. 32""" 33 34from __future__ import annotations 35 36from .claimcheck import ( 37 DEFAULT_THRESHOLD, 38 ClaimCheckCodec, 39 ClaimCheckStore, 40 FilesystemClaimCheckStore, 41 InMemoryClaimCheckStore, 42 claim_key_of, 43 is_claim_check, 44) 45from .consumergroup import ConsumerGroup 46from .idempotency import ( 47 DEFAULT_MEMORY, 48 Claim, 49 IdempotencyStore, 50 InMemoryIdempotencyStore, 51 idempotent, 52) 53from .ordered import ( 54 PartitionKey, 55 by_correlation, 56 by_header, 57 ordered, 58 partition, 59 partitioned_routing_key, 60) 61from .outbox import ( 62 DEFAULT_BATCH, 63 DEFAULT_INTERVAL, 64 InMemoryOutboxStore, 65 OutboxRecord, 66 OutboxRelay, 67 OutboxStore, 68 record, 69) 70from .pipeline import ( 71 NOTHING, 72 Middleware, 73 chain, 74 then, 75 with_idempotency, 76 with_logging, 77 with_ordering, 78 with_timeout, 79) 80from .replay import ( 81 HEADER_REPLAY_COUNT, 82 HEADER_REPLAYED_AT, 83 HEADER_REPLAYED_FROM, 84 ReplayError, 85 ReplayFilter, 86 ReplayResult, 87 replay, 88) 89from .requestreply import ( 90 DEFAULT_TIMEOUT, 91 HEADER_ERROR, 92 HEADER_REPLY_TO, 93 Requester, 94 RequestTimeoutError, 95 Responder, 96 ResponderError, 97 ResponderHandle, 98 serve, 99) 100from .routingslip import ( 101 HEADER_ROUTE, 102 HEADER_ROUTING_SLIP, 103 RoutingSlip, 104 SlipForm, 105 Stage, 106 Step, 107 follow_slip, 108 route_of, 109 slip_from, 110 start, 111) 112from .saga import Saga, SagaAction, SagaResult, SagaStep 113from .scheduler import ( 114 DEFAULT_SCHEDULE_PREFETCH, 115 HEADER_SCHEDULE_CONTENT_TYPE, 116 HEADER_SCHEDULE_DUE_AT, 117 HEADER_SCHEDULE_EXCHANGE, 118 HEADER_SCHEDULE_ROUTING_KEY, 119 SCHEDULE_DUE, 120 SCHEDULE_EXCHANGE, 121 SCHEDULE_RUNGS, 122 SCHEDULED_TYPE, 123 Scheduler, 124 schedule_rung_args, 125 schedule_rung_name, 126 schedule_topology, 127) 128from .schema import ( 129 InMemorySchemaRegistry, 130 SchemaDefinition, 131 SchemaNotFoundError, 132 SchemaRegistry, 133 fingerprint, 134) 135from .sql import ( 136 DEFAULT_CLAIM_TIMEOUT, 137 DEFAULT_IDEMPOTENCY_TABLE, 138 DEFAULT_OUTBOX_MAX_ATTEMPTS, 139 DEFAULT_OUTBOX_TABLE, 140 DEFAULT_REGISTRY_TABLE, 141 DEFAULT_RETENTION, 142 DbConnection, 143 DbCursor, 144 SqlIdempotencyStore, 145 SqlOutboxStore, 146 SqlSchemaRegistry, 147 create_schema, 148 schema_ddl, 149) 150from .streams import ( 151 DEFAULT_STREAM_PREFETCH, 152 StreamOffset, 153 StreamRetention, 154 StreamRetryError, 155 declare_stream, 156 from_first, 157 from_last, 158 from_next, 159 from_offset, 160 from_timestamp, 161 read_stream, 162 stream, 163) 164 165__all__ = [ 166 "DEFAULT_BATCH", 167 "DEFAULT_CLAIM_TIMEOUT", 168 "DEFAULT_IDEMPOTENCY_TABLE", 169 "DEFAULT_INTERVAL", 170 "DEFAULT_MEMORY", 171 "DEFAULT_OUTBOX_MAX_ATTEMPTS", 172 "DEFAULT_OUTBOX_TABLE", 173 "DEFAULT_REGISTRY_TABLE", 174 "DEFAULT_RETENTION", 175 "DEFAULT_SCHEDULE_PREFETCH", 176 "DEFAULT_STREAM_PREFETCH", 177 "DEFAULT_THRESHOLD", 178 "DEFAULT_TIMEOUT", 179 "HEADER_ERROR", 180 "HEADER_REPLAYED_AT", 181 "HEADER_REPLAYED_FROM", 182 "HEADER_REPLAY_COUNT", 183 "HEADER_REPLY_TO", 184 "HEADER_ROUTE", 185 "HEADER_ROUTING_SLIP", 186 "HEADER_SCHEDULE_CONTENT_TYPE", 187 "HEADER_SCHEDULE_DUE_AT", 188 "HEADER_SCHEDULE_EXCHANGE", 189 "HEADER_SCHEDULE_ROUTING_KEY", 190 "NOTHING", 191 "SCHEDULED_TYPE", 192 "SCHEDULE_DUE", 193 "SCHEDULE_EXCHANGE", 194 "SCHEDULE_RUNGS", 195 "Claim", 196 "ClaimCheckCodec", 197 "ClaimCheckStore", 198 "ConsumerGroup", 199 "DbConnection", 200 "DbCursor", 201 "FilesystemClaimCheckStore", 202 "IdempotencyStore", 203 "InMemoryClaimCheckStore", 204 "InMemoryIdempotencyStore", 205 "InMemoryOutboxStore", 206 "InMemorySchemaRegistry", 207 "Middleware", 208 "OutboxRecord", 209 "OutboxRelay", 210 "OutboxStore", 211 "PartitionKey", 212 "ReplayError", 213 "ReplayFilter", 214 "ReplayResult", 215 "RequestTimeoutError", 216 "Requester", 217 "Responder", 218 "ResponderError", 219 "ResponderHandle", 220 "RoutingSlip", 221 "Saga", 222 "SagaAction", 223 "SagaResult", 224 "SagaStep", 225 "Scheduler", 226 "SchemaDefinition", 227 "SchemaNotFoundError", 228 "SchemaRegistry", 229 "SlipForm", 230 "SqlIdempotencyStore", 231 "SqlOutboxStore", 232 "SqlSchemaRegistry", 233 "Stage", 234 "Step", 235 "StreamOffset", 236 "StreamRetention", 237 "StreamRetryError", 238 "by_correlation", 239 "by_header", 240 "chain", 241 "claim_key_of", 242 "create_schema", 243 "declare_stream", 244 "fingerprint", 245 "follow_slip", 246 "from_first", 247 "from_last", 248 "from_next", 249 "from_offset", 250 "from_timestamp", 251 "idempotent", 252 "is_claim_check", 253 "ordered", 254 "partition", 255 "partitioned_routing_key", 256 "read_stream", 257 "record", 258 "replay", 259 "route_of", 260 "schedule_rung_args", 261 "schedule_rung_name", 262 "schedule_topology", 263 "schema_ddl", 264 "serve", 265 "slip_from", 266 "start", 267 "stream", 268 "then", 269 "with_idempotency", 270 "with_logging", 271 "with_ordering", 272 "with_timeout", 273]
53class Claim(Enum): 54 """What a store found when it was asked to claim a message.""" 55 56 #: Nobody had it, or the last holder's lease had run out: it is ours to run. 57 CLAIMED = "claimed" 58 #: It was handled and confirmed. Accept it without running anything. 59 DUPLICATE = "duplicate" 60 #: Somebody holds a live claim and has not confirmed. Neither run it nor 61 #: accept it: put it back and look again later. 62 IN_PROGRESS = "in_progress"
What a store found when it was asked to claim a message.
303class ClaimCheckCodec: 304 """Puts a large payload aside and sends the key instead:: 305 306 from acemq_amqp.codec import JsonCodec 307 from acemq_amqp.patterns import ClaimCheckCodec, FilesystemClaimCheckStore 308 309 store = FilesystemClaimCheckStore("/mnt/payloads") 310 codec = ClaimCheckCodec(JsonCodec(), store) 311 312 mq = await Connection.open(url, codec=codec) 313 314 A payload smaller than ``threshold`` goes on the wire inline and a larger 315 one goes to the store, and the three bytes at the front of the body say 316 which. A body this codec did not write is handed to the delegate untouched, 317 which is what makes it safe to introduce on a queue that already has 318 messages in it. 319 320 :param delegate: the codec that turns payloads into bytes. What gets stored 321 or inlined is its output 322 :param store: where large payloads go 323 :param threshold: payloads of at least this many bytes are offloaded. Zero 324 offloads everything, which is occasionally what a store-backed audit 325 trail wants 326 """ 327 328 def __init__( 329 self, 330 delegate: Codec, 331 store: ClaimCheckStore, 332 *, 333 threshold: int = DEFAULT_THRESHOLD, 334 ) -> None: 335 if threshold < 0: 336 raise ValueError( 337 f"acemq: a claim-check threshold cannot be negative, got {threshold}" 338 ) 339 self._delegate = delegate 340 self._store = store 341 self._threshold = threshold 342 343 @property 344 def delegate(self) -> Codec: 345 """The wrapped codec, whose output is what gets stored or inlined.""" 346 return self._delegate 347 348 @property 349 def store(self) -> ClaimCheckStore: 350 """Where large payloads go.""" 351 return self._store 352 353 @property 354 def threshold(self) -> int: 355 """Payloads of at least this many bytes are offloaded.""" 356 return self._threshold 357 358 @property 359 def content_type(self) -> str: 360 """The delegate's. A claim-checked document is still a document.""" 361 return self._delegate.content_type 362 363 def encode(self, payload: Any) -> bytes: 364 encoded = self._delegate.encode(payload) 365 if len(encoded) < self._threshold: 366 return _frame(_INLINE, encoded) 367 return _frame(_CHECKED, self._store.put(encoded).encode("utf-8")) 368 369 def decode(self, body: bytes, content_type: str | None = None) -> Any: 370 if not _is_framed(body): 371 # Written before this codec was introduced, or by a publisher that 372 # does not use it. Reading it as the delegate would is the only 373 # useful answer, and it is what makes adding a claim check to a live 374 # queue safe. 375 return self._delegate.decode(body, content_type) 376 377 rest = body[_HEADER:] 378 if body[2] == _INLINE: 379 return self._delegate.decode(rest, content_type) 380 381 key = rest.decode("utf-8") 382 content = self._store.get(key) 383 if content is None: 384 # Fatal, not retryable: the payload is not coming back, and a 385 # message that keeps being redelivered for it only holds a queue 386 # open until it ages out. 387 raise FatalError( 388 f"acemq: the claim check {key!r} is not in the store, so this message " 389 "cannot be read. The payload was removed while a message referring to it " 390 "was still deliverable — the store's retention has to outlast every queue, " 391 "every dead-letter queue, and any replay somebody might do by hand." 392 ) 393 return self._delegate.decode(content, content_type) 394 395 def can_decode(self, content_type: str | None) -> bool: 396 """Whatever the delegate accepts. A claim check does not change what the 397 message is.""" 398 return self._delegate.can_decode(content_type) 399 400 def __repr__(self) -> str: 401 return f"ClaimCheckCodec({self._delegate!r}, above={self._threshold} bytes)"
Puts a large payload aside and sends the key instead::
from acemq_amqp.codec import JsonCodec
from acemq_amqp.patterns import ClaimCheckCodec, FilesystemClaimCheckStore
store = FilesystemClaimCheckStore("/mnt/payloads")
codec = ClaimCheckCodec(JsonCodec(), store)
mq = await Connection.open(url, codec=codec)
A payload smaller than threshold goes on the wire inline and a larger
one goes to the store, and the three bytes at the front of the body say
which. A body this codec did not write is handed to the delegate untouched,
which is what makes it safe to introduce on a queue that already has
messages in it.
Parameters
- delegate: the codec that turns payloads into bytes. What gets stored or inlined is its output
- store: where large payloads go
- threshold: payloads of at least this many bytes are offloaded. Zero offloads everything, which is occasionally what a store-backed audit trail wants
328 def __init__( 329 self, 330 delegate: Codec, 331 store: ClaimCheckStore, 332 *, 333 threshold: int = DEFAULT_THRESHOLD, 334 ) -> None: 335 if threshold < 0: 336 raise ValueError( 337 f"acemq: a claim-check threshold cannot be negative, got {threshold}" 338 ) 339 self._delegate = delegate 340 self._store = store 341 self._threshold = threshold
343 @property 344 def delegate(self) -> Codec: 345 """The wrapped codec, whose output is what gets stored or inlined.""" 346 return self._delegate
The wrapped codec, whose output is what gets stored or inlined.
348 @property 349 def store(self) -> ClaimCheckStore: 350 """Where large payloads go.""" 351 return self._store
Where large payloads go.
353 @property 354 def threshold(self) -> int: 355 """Payloads of at least this many bytes are offloaded.""" 356 return self._threshold
Payloads of at least this many bytes are offloaded.
358 @property 359 def content_type(self) -> str: 360 """The delegate's. A claim-checked document is still a document.""" 361 return self._delegate.content_type
The delegate's. A claim-checked document is still a document.
369 def decode(self, body: bytes, content_type: str | None = None) -> Any: 370 if not _is_framed(body): 371 # Written before this codec was introduced, or by a publisher that 372 # does not use it. Reading it as the delegate would is the only 373 # useful answer, and it is what makes adding a claim check to a live 374 # queue safe. 375 return self._delegate.decode(body, content_type) 376 377 rest = body[_HEADER:] 378 if body[2] == _INLINE: 379 return self._delegate.decode(rest, content_type) 380 381 key = rest.decode("utf-8") 382 content = self._store.get(key) 383 if content is None: 384 # Fatal, not retryable: the payload is not coming back, and a 385 # message that keeps being redelivered for it only holds a queue 386 # open until it ages out. 387 raise FatalError( 388 f"acemq: the claim check {key!r} is not in the store, so this message " 389 "cannot be read. The payload was removed while a message referring to it " 390 "was still deliverable — the store's retention has to outlast every queue, " 391 "every dead-letter queue, and any replay somebody might do by hand." 392 ) 393 return self._delegate.decode(content, content_type)
395 def can_decode(self, content_type: str | None) -> bool: 396 """Whatever the delegate accepts. A claim check does not change what the 397 message is.""" 398 return self._delegate.can_decode(content_type)
Whatever the delegate accepts. A claim check does not change what the message is.
88@runtime_checkable 89class ClaimCheckStore(Protocol): 90 """Where a payload too large for a broker actually goes. 91 92 Three methods, so a store backed by S3, Azure Blob Storage, a filesystem or 93 a database table is a small class. Nothing here knows about messaging: the 94 store holds bytes under a key and hands them back, and the codec is what 95 turns that into a claim check on the wire. 96 97 Synchronous, unlike the idempotency and outbox seams, because 98 :class:`~acemq_amqp.codec.Codec` is synchronous — a codec is called from 99 inside the publish and consume paths of both the async and the blocking API, 100 and a store that had to be awaited could not serve the second. A store 101 talking to a network should hold a small pool and block on it. 102 103 **Retention is the part that goes wrong.** The store and the queue have 104 different lifetimes and nothing enforces a relationship between them. A 105 message replayed a month later carries a key, and if the store expired that 106 key the replay produces a message nobody can read — worse than a lost 107 message, because it looks like a message and fails deep inside a consumer 108 rather than visibly. So the store's retention must exceed every retention 109 that could bring a message back: queue TTLs, dead-letter queues, and however 110 long somebody might sit on a message before replaying it by hand. When in 111 doubt, longer. 112 113 Implementations must be safe to use from several threads, because one is 114 shared by every publisher and consumer on a connection. 115 """ 116 117 def put(self, content: bytes) -> str: 118 """Stores a payload. 119 120 :param content: the bytes 121 :returns: the key the message will carry, which must be unique for the 122 life of the store 123 """ 124 125 def get(self, key: str) -> bytes | None: 126 """Redeems a claim check. 127 128 :param key: what the message carried 129 :returns: the payload, or ``None`` when the store no longer holds it — 130 which is retention having expired underneath a message that outlived 131 it 132 """ 133 134 def delete(self, key: str) -> None: 135 """Removes a payload. 136 137 Not called by the codec. Deleting on read would break the second 138 consumer of the same message, and deleting on acknowledgement would 139 break a replay — so when a payload may be removed is a retention 140 decision, and retention decisions belong to whoever owns the data. 141 142 :param key: what to remove 143 """
Where a payload too large for a broker actually goes.
Three methods, so a store backed by S3, Azure Blob Storage, a filesystem or a database table is a small class. Nothing here knows about messaging: the store holds bytes under a key and hands them back, and the codec is what turns that into a claim check on the wire.
Synchronous, unlike the idempotency and outbox seams, because
~acemq_amqp.codec.Codec is synchronous — a codec is called from
inside the publish and consume paths of both the async and the blocking API,
and a store that had to be awaited could not serve the second. A store
talking to a network should hold a small pool and block on it.
Retention is the part that goes wrong. The store and the queue have different lifetimes and nothing enforces a relationship between them. A message replayed a month later carries a key, and if the store expired that key the replay produces a message nobody can read — worse than a lost message, because it looks like a message and fails deep inside a consumer rather than visibly. So the store's retention must exceed every retention that could bring a message back: queue TTLs, dead-letter queues, and however long somebody might sit on a message before replaying it by hand. When in doubt, longer.
Implementations must be safe to use from several threads, because one is shared by every publisher and consumer on a connection.
1771def _no_init_or_replace_init(self, *args, **kwargs): 1772 cls = type(self) 1773 1774 if cls._is_protocol: 1775 raise TypeError('Protocols cannot be instantiated') 1776 1777 # Already using a custom `__init__`. No need to calculate correct 1778 # `__init__` to call. This can lead to RecursionError. See bpo-45121. 1779 if cls.__init__ is not _no_init_or_replace_init: 1780 return 1781 1782 # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`. 1783 # The first instantiation of the subclass will call `_no_init_or_replace_init` which 1784 # searches for a proper new `__init__` in the MRO. The new `__init__` 1785 # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent 1786 # instantiation of the protocol subclass will thus use the new 1787 # `__init__` and no longer call `_no_init_or_replace_init`. 1788 for base in cls.__mro__: 1789 init = base.__dict__.get('__init__', _no_init_or_replace_init) 1790 if init is not _no_init_or_replace_init: 1791 cls.__init__ = init 1792 break 1793 else: 1794 # should not happen 1795 cls.__init__ = object.__init__ 1796 1797 cls.__init__(self, *args, **kwargs)
117 def put(self, content: bytes) -> str: 118 """Stores a payload. 119 120 :param content: the bytes 121 :returns: the key the message will carry, which must be unique for the 122 life of the store 123 """
Stores a payload.
Parameters
- content: the bytes :returns: the key the message will carry, which must be unique for the life of the store
125 def get(self, key: str) -> bytes | None: 126 """Redeems a claim check. 127 128 :param key: what the message carried 129 :returns: the payload, or ``None`` when the store no longer holds it — 130 which is retention having expired underneath a message that outlived 131 it 132 """
Redeems a claim check.
Parameters
- key: what the message carried
:returns: the payload, or
Nonewhen the store no longer holds it — which is retention having expired underneath a message that outlived it
134 def delete(self, key: str) -> None: 135 """Removes a payload. 136 137 Not called by the codec. Deleting on read would break the second 138 consumer of the same message, and deleting on acknowledgement would 139 break a replay — so when a payload may be removed is a retention 140 decision, and retention decisions belong to whoever owns the data. 141 142 :param key: what to remove 143 """
Removes a payload.
Not called by the codec. Deleting on read would break the second consumer of the same message, and deleting on acknowledgement would break a replay — so when a payload may be removed is a retention decision, and retention decisions belong to whoever owns the data.
Parameters
- key: what to remove
41class ConsumerGroup: 42 """Consumers reading one queue, closed as one thing. 43 44 Built by :meth:`start`, because a group that has not started is a group 45 holding nothing:: 46 47 async with await ConsumerGroup.start(mq, "orders", 4, handle): 48 ... 49 """ 50 51 def __init__(self, queue: str, consumers: list[Consumer]) -> None: 52 self._queue = queue 53 self._consumers = consumers 54 55 @classmethod 56 async def start( 57 cls, 58 connection: Connection, 59 queue: str, 60 size: int, 61 handler: Handler, 62 *, 63 codec: Codec | None = None, 64 retry: RetryPolicy | None = None, 65 prefetch: int | None = None, 66 concurrency: int = 1, 67 tag: str = "", 68 args: Any = None, 69 declare: bool = True, 70 ) -> ConsumerGroup: 71 """Starts ``size`` consumers over one queue. 72 73 If one fails to start, the ones already running are closed before the 74 failure is raised. A half-started group is worse than none: it holds 75 messages that nothing is going to finish handling, and the caller that 76 saw the exception has no handle to close it with. 77 78 :param connection: whose consumers these are 79 :param queue: what they read 80 :param size: how many 81 :param handler: what each does with a message 82 :param codec: a codec other than the connection's 83 :param retry: a policy other than the connection's 84 :param prefetch: how many unacknowledged messages each holds 85 :param concurrency: how many messages each works on at once 86 :param tag: what to call them to the broker, numbered from 1. Each gets 87 its own name so the management interface shows which consumer is 88 holding a message rather than four identical rows 89 :param args: broker-specific consumer arguments 90 :param declare: declare the queues a failed message goes to before 91 subscribing. On by default; see 92 :meth:`acemq_amqp.Connection.consume`. Every consumer in the group 93 declares the same things, which is a few extra round trips at 94 start-up and nothing else — the declarations are identical, so all 95 but the first are no-ops 96 :returns: the running group 97 """ 98 if size < 1: 99 raise ValueError(f"acemq: a consumer group needs at least one consumer, got {size}") 100 101 base = tag or f"acemq-{queue}" 102 started: list[Consumer] = [] 103 group = cls(queue, started) 104 try: 105 for number in range(1, size + 1): 106 started.append( 107 await connection.consume( 108 queue, 109 handler, 110 codec=codec, 111 retry=retry, 112 prefetch=prefetch, 113 concurrency=concurrency, 114 tag=f"{base}-{number}", 115 args=args, 116 declare=declare, 117 ) 118 ) 119 except BaseException: 120 await group.close() 121 raise 122 return group 123 124 @property 125 def queue(self) -> str: 126 """What the group is reading.""" 127 return self._queue 128 129 @property 130 def size(self) -> int: 131 """How many consumers are running.""" 132 return len(self._consumers) 133 134 async def close(self) -> None: 135 """Stops every consumer and waits for the handlers already running. 136 137 All of them are closed even when one fails, and the first failure is 138 raised afterwards. Stopping at the first one would leave the rest 139 running after a shutdown the caller believes happened, which is worse 140 than the failure that started it. 141 """ 142 stopping = list(self._consumers) 143 self._consumers.clear() 144 145 first: BaseException | None = None 146 for consumer in stopping: 147 try: 148 await consumer.close() 149 except BaseException as failure: 150 first = first or failure 151 if first is not None: 152 raise first 153 154 async def __aenter__(self) -> ConsumerGroup: 155 return self 156 157 async def __aexit__(self, *_: object) -> None: 158 await self.close()
Consumers reading one queue, closed as one thing.
Built by start(), because a group that has not started is a group
holding nothing::
async with await ConsumerGroup.start(mq, "orders", 4, handle):
...
55 @classmethod 56 async def start( 57 cls, 58 connection: Connection, 59 queue: str, 60 size: int, 61 handler: Handler, 62 *, 63 codec: Codec | None = None, 64 retry: RetryPolicy | None = None, 65 prefetch: int | None = None, 66 concurrency: int = 1, 67 tag: str = "", 68 args: Any = None, 69 declare: bool = True, 70 ) -> ConsumerGroup: 71 """Starts ``size`` consumers over one queue. 72 73 If one fails to start, the ones already running are closed before the 74 failure is raised. A half-started group is worse than none: it holds 75 messages that nothing is going to finish handling, and the caller that 76 saw the exception has no handle to close it with. 77 78 :param connection: whose consumers these are 79 :param queue: what they read 80 :param size: how many 81 :param handler: what each does with a message 82 :param codec: a codec other than the connection's 83 :param retry: a policy other than the connection's 84 :param prefetch: how many unacknowledged messages each holds 85 :param concurrency: how many messages each works on at once 86 :param tag: what to call them to the broker, numbered from 1. Each gets 87 its own name so the management interface shows which consumer is 88 holding a message rather than four identical rows 89 :param args: broker-specific consumer arguments 90 :param declare: declare the queues a failed message goes to before 91 subscribing. On by default; see 92 :meth:`acemq_amqp.Connection.consume`. Every consumer in the group 93 declares the same things, which is a few extra round trips at 94 start-up and nothing else — the declarations are identical, so all 95 but the first are no-ops 96 :returns: the running group 97 """ 98 if size < 1: 99 raise ValueError(f"acemq: a consumer group needs at least one consumer, got {size}") 100 101 base = tag or f"acemq-{queue}" 102 started: list[Consumer] = [] 103 group = cls(queue, started) 104 try: 105 for number in range(1, size + 1): 106 started.append( 107 await connection.consume( 108 queue, 109 handler, 110 codec=codec, 111 retry=retry, 112 prefetch=prefetch, 113 concurrency=concurrency, 114 tag=f"{base}-{number}", 115 args=args, 116 declare=declare, 117 ) 118 ) 119 except BaseException: 120 await group.close() 121 raise 122 return group
Starts size consumers over one queue.
If one fails to start, the ones already running are closed before the failure is raised. A half-started group is worse than none: it holds messages that nothing is going to finish handling, and the caller that saw the exception has no handle to close it with.
Parameters
- connection: whose consumers these are
- queue: what they read
- size: how many
- handler: what each does with a message
- codec: a codec other than the connection's
- retry: a policy other than the connection's
- prefetch: how many unacknowledged messages each holds
- concurrency: how many messages each works on at once
- tag: what to call them to the broker, numbered from 1. Each gets its own name so the management interface shows which consumer is holding a message rather than four identical rows
- args: broker-specific consumer arguments
- declare: declare the queues a failed message goes to before
subscribing. On by default; see
acemq_amqp.Connection.consume(). Every consumer in the group declares the same things, which is a few extra round trips at start-up and nothing else — the declarations are identical, so all but the first are no-ops :returns: the running group
124 @property 125 def queue(self) -> str: 126 """What the group is reading.""" 127 return self._queue
What the group is reading.
129 @property 130 def size(self) -> int: 131 """How many consumers are running.""" 132 return len(self._consumers)
How many consumers are running.
134 async def close(self) -> None: 135 """Stops every consumer and waits for the handlers already running. 136 137 All of them are closed even when one fails, and the first failure is 138 raised afterwards. Stopping at the first one would leave the rest 139 running after a shutdown the caller believes happened, which is worse 140 than the failure that started it. 141 """ 142 stopping = list(self._consumers) 143 self._consumers.clear() 144 145 first: BaseException | None = None 146 for consumer in stopping: 147 try: 148 await consumer.close() 149 except BaseException as failure: 150 first = first or failure 151 if first is not None: 152 raise first
Stops every consumer and waits for the handlers already running.
All of them are closed even when one fails, and the first failure is raised afterwards. Stopping at the first one would leave the rest running after a shutdown the caller believes happened, which is worse than the failure that started it.
143@runtime_checkable 144class DbConnection(Protocol): 145 """As much of a DB-API 2.0 connection as this module uses. 146 147 Deliberately small. Anything following PEP 249 has these four, so nothing 148 here is tied to a driver — and a caller's connection, whatever framework it 149 came out of, is one of these. 150 """ 151 152 def cursor(self) -> DbCursor: 153 """A cursor on this connection.""" 154 155 def commit(self) -> None: 156 """Commits the open transaction.""" 157 158 def rollback(self) -> None: 159 """Abandons the open transaction.""" 160 161 def close(self) -> None: 162 """Releases the connection."""
As much of a DB-API 2.0 connection as this module uses.
Deliberately small. Anything following PEP 249 has these four, so nothing here is tied to a driver — and a caller's connection, whatever framework it came out of, is one of these.
1771def _no_init_or_replace_init(self, *args, **kwargs): 1772 cls = type(self) 1773 1774 if cls._is_protocol: 1775 raise TypeError('Protocols cannot be instantiated') 1776 1777 # Already using a custom `__init__`. No need to calculate correct 1778 # `__init__` to call. This can lead to RecursionError. See bpo-45121. 1779 if cls.__init__ is not _no_init_or_replace_init: 1780 return 1781 1782 # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`. 1783 # The first instantiation of the subclass will call `_no_init_or_replace_init` which 1784 # searches for a proper new `__init__` in the MRO. The new `__init__` 1785 # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent 1786 # instantiation of the protocol subclass will thus use the new 1787 # `__init__` and no longer call `_no_init_or_replace_init`. 1788 for base in cls.__mro__: 1789 init = base.__dict__.get('__init__', _no_init_or_replace_init) 1790 if init is not _no_init_or_replace_init: 1791 cls.__init__ = init 1792 break 1793 else: 1794 # should not happen 1795 cls.__init__ = object.__init__ 1796 1797 cls.__init__(self, *args, **kwargs)
123class DbCursor(Protocol): 124 """As much of a DB-API 2.0 cursor as this module uses.""" 125 126 @property 127 def rowcount(self) -> int: 128 """How many rows the last statement affected.""" 129 130 def execute(self, operation: str, parameters: Any = ..., /) -> Any: 131 """Runs one statement.""" 132 133 def fetchone(self) -> Any: 134 """The next row, or ``None``.""" 135 136 def fetchall(self) -> Any: 137 """Every remaining row.""" 138 139 def close(self) -> None: 140 """Releases the cursor."""
As much of a DB-API 2.0 cursor as this module uses.
1771def _no_init_or_replace_init(self, *args, **kwargs): 1772 cls = type(self) 1773 1774 if cls._is_protocol: 1775 raise TypeError('Protocols cannot be instantiated') 1776 1777 # Already using a custom `__init__`. No need to calculate correct 1778 # `__init__` to call. This can lead to RecursionError. See bpo-45121. 1779 if cls.__init__ is not _no_init_or_replace_init: 1780 return 1781 1782 # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`. 1783 # The first instantiation of the subclass will call `_no_init_or_replace_init` which 1784 # searches for a proper new `__init__` in the MRO. The new `__init__` 1785 # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent 1786 # instantiation of the protocol subclass will thus use the new 1787 # `__init__` and no longer call `_no_init_or_replace_init`. 1788 for base in cls.__mro__: 1789 init = base.__dict__.get('__init__', _no_init_or_replace_init) 1790 if init is not _no_init_or_replace_init: 1791 cls.__init__ = init 1792 break 1793 else: 1794 # should not happen 1795 cls.__init__ = object.__init__ 1796 1797 cls.__init__(self, *args, **kwargs)
130 def execute(self, operation: str, parameters: Any = ..., /) -> Any: 131 """Runs one statement."""
Runs one statement.
200class FilesystemClaimCheckStore: 201 """A claim-check store on a filesystem. 202 203 Useful where the filesystem is shared and durable — an NFS mount, a 204 persistent volume — and the honest middle ground between a dictionary and 205 object storage. On a container's local disk it is the in-memory store with 206 extra steps: the consumer is on another host and finds nothing. 207 208 Object storage is the usual right answer, and a store in front of S3 or 209 Azure Blob Storage is three short methods. This one exists because "write it 210 to the mount everything already has" is a real deployment and not a bad one. 211 212 **Writes are atomic.** The payload is written to a temporary file and moved 213 into place. Without that, a consumer fast enough to read the key before the 214 writer finished gets a truncated payload and a parse error somewhere 215 unhelpful — and messaging is exactly the arrangement that makes a consumer 216 that fast normal rather than unlikely. 217 218 :param directory: where payloads are written; created if it is not there 219 """ 220 221 #: A key reaches the filesystem as a path segment, so it is checked rather 222 #: than trusted. Every key this store issues is a UUID; one arriving from a 223 #: message is whatever a publisher put there, and ``../../etc/passwd`` is a 224 #: key too. The same expression as Java's, so the two stores accept and 225 #: refuse the same keys over one shared mount. 226 _SAFE_KEY = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}") 227 228 def __init__(self, directory: str | os.PathLike[str]) -> None: 229 self._directory = Path(directory) 230 try: 231 self._directory.mkdir(parents=True, exist_ok=True) 232 except OSError as failure: 233 raise AceMQError( 234 f"acemq: could not create the claim-check directory " 235 f"{self._directory}: {failure}" 236 ) from failure 237 238 @property 239 def directory(self) -> Path: 240 """Where the payloads are.""" 241 return self._directory 242 243 def put(self, content: bytes) -> str: 244 key = str(uuid.uuid4()) 245 target = self._directory / key 246 try: 247 handle, staging = tempfile.mkstemp( 248 prefix=key, suffix=".partial", dir=self._directory 249 ) 250 try: 251 with os.fdopen(handle, "wb") as writing: 252 writing.write(content) 253 # Moved into place, so a reader sees the whole payload or no 254 # payload. os.replace is atomic within a filesystem, which is 255 # the case here because the staging file was made in the same 256 # directory on purpose. 257 os.replace(staging, target) 258 except BaseException: 259 # The staging file is ours and nothing refers to it, so a failed 260 # write leaves nothing behind rather than a .partial somebody 261 # has to explain later. 262 Path(staging).unlink(missing_ok=True) 263 raise 264 except OSError as failure: 265 raise AceMQError( 266 f"acemq: could not write the claim-check payload {key}: {failure}" 267 ) from failure 268 return key 269 270 def get(self, key: str) -> bytes | None: 271 try: 272 return self._path_for(key).read_bytes() 273 except FileNotFoundError: 274 # Absent rather than failed: a key the store no longer holds is a 275 # retention answer, and the codec turns it into a message that 276 # explains itself. 277 return None 278 except OSError as failure: 279 raise AceMQError( 280 f"acemq: could not read the claim-check payload {key}: {failure}" 281 ) from failure 282 283 def delete(self, key: str) -> None: 284 try: 285 self._path_for(key).unlink(missing_ok=True) 286 except OSError as failure: 287 raise AceMQError( 288 f"acemq: could not delete the claim-check payload {key}: {failure}" 289 ) from failure 290 291 def _path_for(self, key: str) -> Path: 292 if not self._SAFE_KEY.fullmatch(key): 293 raise AceMQError( 294 f"acemq: {key!r} is not a key this store issued. A key becomes a path " 295 "segment, so one arriving from a message is checked rather than trusted." 296 ) 297 return self._directory / key 298 299 def __repr__(self) -> str: 300 return f"FilesystemClaimCheckStore({self._directory})"
A claim-check store on a filesystem.
Useful where the filesystem is shared and durable — an NFS mount, a persistent volume — and the honest middle ground between a dictionary and object storage. On a container's local disk it is the in-memory store with extra steps: the consumer is on another host and finds nothing.
Object storage is the usual right answer, and a store in front of S3 or Azure Blob Storage is three short methods. This one exists because "write it to the mount everything already has" is a real deployment and not a bad one.
Writes are atomic. The payload is written to a temporary file and moved into place. Without that, a consumer fast enough to read the key before the writer finished gets a truncated payload and a parse error somewhere unhelpful — and messaging is exactly the arrangement that makes a consumer that fast normal rather than unlikely.
Parameters
- directory: where payloads are written; created if it is not there
228 def __init__(self, directory: str | os.PathLike[str]) -> None: 229 self._directory = Path(directory) 230 try: 231 self._directory.mkdir(parents=True, exist_ok=True) 232 except OSError as failure: 233 raise AceMQError( 234 f"acemq: could not create the claim-check directory " 235 f"{self._directory}: {failure}" 236 ) from failure
238 @property 239 def directory(self) -> Path: 240 """Where the payloads are.""" 241 return self._directory
Where the payloads are.
243 def put(self, content: bytes) -> str: 244 key = str(uuid.uuid4()) 245 target = self._directory / key 246 try: 247 handle, staging = tempfile.mkstemp( 248 prefix=key, suffix=".partial", dir=self._directory 249 ) 250 try: 251 with os.fdopen(handle, "wb") as writing: 252 writing.write(content) 253 # Moved into place, so a reader sees the whole payload or no 254 # payload. os.replace is atomic within a filesystem, which is 255 # the case here because the staging file was made in the same 256 # directory on purpose. 257 os.replace(staging, target) 258 except BaseException: 259 # The staging file is ours and nothing refers to it, so a failed 260 # write leaves nothing behind rather than a .partial somebody 261 # has to explain later. 262 Path(staging).unlink(missing_ok=True) 263 raise 264 except OSError as failure: 265 raise AceMQError( 266 f"acemq: could not write the claim-check payload {key}: {failure}" 267 ) from failure 268 return key
270 def get(self, key: str) -> bytes | None: 271 try: 272 return self._path_for(key).read_bytes() 273 except FileNotFoundError: 274 # Absent rather than failed: a key the store no longer holds is a 275 # retention answer, and the codec turns it into a message that 276 # explains itself. 277 return None 278 except OSError as failure: 279 raise AceMQError( 280 f"acemq: could not read the claim-check payload {key}: {failure}" 281 ) from failure
65@runtime_checkable 66class IdempotencyStore(Protocol): 67 """Remembers which messages have been handled.""" 68 69 async def first_time(self, key: str) -> bool: 70 """Records a key and says whether it had not been seen before. 71 72 It has to be atomic. Two consumers handling the same message at the same 73 moment must not both be told they are first, which is the entire job: 74 a check followed by a write is not this, however short the gap looks. 75 76 :param key: what identifies the message 77 :returns: whether this is the first time 78 """ 79 80 async def forget(self, key: str) -> None: 81 """Removes a key, so a message that failed can be tried again. 82 83 :param key: what identifies the message 84 """
Remembers which messages have been handled.
1771def _no_init_or_replace_init(self, *args, **kwargs): 1772 cls = type(self) 1773 1774 if cls._is_protocol: 1775 raise TypeError('Protocols cannot be instantiated') 1776 1777 # Already using a custom `__init__`. No need to calculate correct 1778 # `__init__` to call. This can lead to RecursionError. See bpo-45121. 1779 if cls.__init__ is not _no_init_or_replace_init: 1780 return 1781 1782 # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`. 1783 # The first instantiation of the subclass will call `_no_init_or_replace_init` which 1784 # searches for a proper new `__init__` in the MRO. The new `__init__` 1785 # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent 1786 # instantiation of the protocol subclass will thus use the new 1787 # `__init__` and no longer call `_no_init_or_replace_init`. 1788 for base in cls.__mro__: 1789 init = base.__dict__.get('__init__', _no_init_or_replace_init) 1790 if init is not _no_init_or_replace_init: 1791 cls.__init__ = init 1792 break 1793 else: 1794 # should not happen 1795 cls.__init__ = object.__init__ 1796 1797 cls.__init__(self, *args, **kwargs)
69 async def first_time(self, key: str) -> bool: 70 """Records a key and says whether it had not been seen before. 71 72 It has to be atomic. Two consumers handling the same message at the same 73 moment must not both be told they are first, which is the entire job: 74 a check followed by a write is not this, however short the gap looks. 75 76 :param key: what identifies the message 77 :returns: whether this is the first time 78 """
Records a key and says whether it had not been seen before.
It has to be atomic. Two consumers handling the same message at the same moment must not both be told they are first, which is the entire job: a check followed by a write is not this, however short the gap looks.
Parameters
- key: what identifies the message :returns: whether this is the first time
80 async def forget(self, key: str) -> None: 81 """Removes a key, so a message that failed can be tried again. 82 83 :param key: what identifies the message 84 """
Removes a key, so a message that failed can be tried again.
Parameters
- key: what identifies the message
146class InMemoryClaimCheckStore: 147 """A claim-check store in a dictionary. 148 149 **Not for production, and the reason is the point of the pattern.** The 150 payloads are held in the publisher's own memory — which is where they were 151 going to be anyway, so this takes them off the broker and does nothing else. 152 A claim check that does not outlive the process that wrote it is a message 153 nobody else can read, and every consumer in another process gets "the claim 154 check is not in the store". 155 156 It is genuinely useful in a test, where the publisher and the consumer are 157 the same process and the thing being proved is the framing rather than the 158 storage. 159 """ 160 161 def __init__(self) -> None: 162 self._contents: dict[str, bytes] = {} 163 # A plain lock, as in the idempotency store: it is held for a dictionary 164 # operation and never across an await, so a store shared with the 165 # blocking API's worker threads is safe too. 166 self._lock = threading.Lock() 167 168 def put(self, content: bytes) -> str: 169 key = str(uuid.uuid4()) 170 with self._lock: 171 # bytes() rather than the object handed over, because the caller owns 172 # what it gave us and a codec is entitled to reuse a buffer. A store 173 # that keeps somebody else's bytearray is a store whose contents 174 # change after they were stored. 175 self._contents[key] = bytes(content) 176 return key 177 178 def get(self, key: str) -> bytes | None: 179 with self._lock: 180 return self._contents.get(key) 181 182 def delete(self, key: str) -> None: 183 with self._lock: 184 self._contents.pop(key, None) 185 186 def clear(self) -> None: 187 """Empties the store, which is what a test between cases wants.""" 188 with self._lock: 189 self._contents.clear() 190 191 def __len__(self) -> int: 192 """How many payloads are held.""" 193 with self._lock: 194 return len(self._contents) 195 196 def __repr__(self) -> str: 197 return f"InMemoryClaimCheckStore({len(self)} held)"
A claim-check store in a dictionary.
Not for production, and the reason is the point of the pattern. The payloads are held in the publisher's own memory — which is where they were going to be anyway, so this takes them off the broker and does nothing else. A claim check that does not outlive the process that wrote it is a message nobody else can read, and every consumer in another process gets "the claim check is not in the store".
It is genuinely useful in a test, where the publisher and the consumer are the same process and the thing being proved is the framing rather than the storage.
168 def put(self, content: bytes) -> str: 169 key = str(uuid.uuid4()) 170 with self._lock: 171 # bytes() rather than the object handed over, because the caller owns 172 # what it gave us and a codec is entitled to reuse a buffer. A store 173 # that keeps somebody else's bytearray is a store whose contents 174 # change after they were stored. 175 self._contents[key] = bytes(content) 176 return key
87class InMemoryIdempotencyStore: 88 """Remembers keys in this process, for a while. 89 90 Right behind one consumer and wrong the moment there are two: each has its 91 own memory, so both will believe they are first. It is also lost on restart, 92 which turns every message in flight into a duplicate. 93 94 Use it in tests and in a single-worker service. Anything else wants a store 95 the workers share — ideally the same database the work is written to, in the 96 same transaction, which is the only arrangement that actually holds. 97 98 :param ttl: how long a key is remembered. Without a window the memory grows 99 for as long as the process lives, so there has to be one; it should be 100 comfortably longer than the longest a message can go on being retried 101 """ 102 103 def __init__(self, ttl: timedelta = DEFAULT_MEMORY) -> None: 104 if ttl <= timedelta(0): 105 raise ValueError(f"acemq: an idempotency window must be positive, got {ttl}") 106 self._ttl = ttl 107 # When the key was claimed, and whether that claim became a fact. The 108 # window is also the lease: an unconfirmed claim is "in progress" until 109 # it ages out, and then the key is free again. 110 self._seen: dict[str, tuple[datetime, bool]] = {} 111 # A plain lock rather than an asyncio one: it is held for a dictionary 112 # operation and never across an await, and this way a store shared by 113 # the blocking API's worker threads is safe too. 114 self._lock = threading.Lock() 115 116 async def claim(self, key: str) -> Claim: 117 """Claims a key, or says why not: done already, or still being done.""" 118 now = datetime.now(timezone.utc) 119 with self._lock: 120 self._sweep(now) 121 held = self._seen.get(key) 122 if held is None: 123 self._seen[key] = (now, False) 124 return Claim.CLAIMED 125 return Claim.DUPLICATE if held[1] else Claim.IN_PROGRESS 126 127 async def first_time(self, key: str) -> bool: 128 return await self.claim(key) is Claim.CLAIMED 129 130 async def confirm(self, key: str) -> None: 131 """Records that the claimed message really was handled.""" 132 with self._lock: 133 held = self._seen.get(key) 134 if held is not None: 135 # In place, so insertion order — which the sweep relies on — holds. 136 self._seen[key] = (held[0], True) 137 138 async def forget(self, key: str) -> None: 139 with self._lock: 140 self._seen.pop(key, None) 141 142 def _sweep(self, now: datetime) -> None: 143 """Drops what has aged out, from the front. 144 145 Swept here rather than on a timer, so the store owns no task and has 146 nothing to close. It costs only what it removes: keys are inserted in 147 the order they are first seen and never reordered, so everything expired 148 is a prefix and the walk stops at the first key still inside the window. 149 """ 150 cutoff = now - self._ttl 151 while self._seen: 152 oldest = next(iter(self._seen)) 153 if self._seen[oldest][0] > cutoff: 154 return 155 del self._seen[oldest] 156 157 def __len__(self) -> int: 158 """How many keys are remembered, for a test that wants to know the 159 window is doing its job.""" 160 with self._lock: 161 return len(self._seen)
Remembers keys in this process, for a while.
Right behind one consumer and wrong the moment there are two: each has its own memory, so both will believe they are first. It is also lost on restart, which turns every message in flight into a duplicate.
Use it in tests and in a single-worker service. Anything else wants a store the workers share — ideally the same database the work is written to, in the same transaction, which is the only arrangement that actually holds.
Parameters
- ttl: how long a key is remembered. Without a window the memory grows for as long as the process lives, so there has to be one; it should be comfortably longer than the longest a message can go on being retried
103 def __init__(self, ttl: timedelta = DEFAULT_MEMORY) -> None: 104 if ttl <= timedelta(0): 105 raise ValueError(f"acemq: an idempotency window must be positive, got {ttl}") 106 self._ttl = ttl 107 # When the key was claimed, and whether that claim became a fact. The 108 # window is also the lease: an unconfirmed claim is "in progress" until 109 # it ages out, and then the key is free again. 110 self._seen: dict[str, tuple[datetime, bool]] = {} 111 # A plain lock rather than an asyncio one: it is held for a dictionary 112 # operation and never across an await, and this way a store shared by 113 # the blocking API's worker threads is safe too. 114 self._lock = threading.Lock()
116 async def claim(self, key: str) -> Claim: 117 """Claims a key, or says why not: done already, or still being done.""" 118 now = datetime.now(timezone.utc) 119 with self._lock: 120 self._sweep(now) 121 held = self._seen.get(key) 122 if held is None: 123 self._seen[key] = (now, False) 124 return Claim.CLAIMED 125 return Claim.DUPLICATE if held[1] else Claim.IN_PROGRESS
Claims a key, or says why not: done already, or still being done.
130 async def confirm(self, key: str) -> None: 131 """Records that the claimed message really was handled.""" 132 with self._lock: 133 held = self._seen.get(key) 134 if held is not None: 135 # In place, so insertion order — which the sweep relies on — holds. 136 self._seen[key] = (held[0], True)
Records that the claimed message really was handled.
151class InMemoryOutboxStore: 152 """An outbox in this process. 153 154 It has none of the property the pattern exists for: nothing here shares a 155 transaction with a database, so a crash between the work committing and the 156 record being written loses the message exactly as publishing directly would. 157 It is for tests, and for seeing the shape of the thing before writing the 158 version that matters. 159 """ 160 161 #: Failures before a record is left alone. The same ten Ruby's SQL outbox uses, 162 #: so a record that is stuck is stuck after the same number of tries in both. 163 DEFAULT_MAX_ATTEMPTS = 10 164 165 def __init__(self, max_attempts: int = DEFAULT_MAX_ATTEMPTS) -> None: 166 if max_attempts < 1: 167 raise ValueError("acemq: max_attempts must be at least 1") 168 self._max_attempts = max_attempts 169 self._records: dict[str, OutboxRecord] = {} 170 # A plain lock, as in the idempotency store: it is held for a dictionary 171 # operation and never across an await, so a store written to from the 172 # blocking API's worker threads is safe too. 173 self._lock = threading.Lock() 174 175 async def add(self, entry: OutboxRecord) -> None: 176 if not entry.id: 177 raise ValueError("acemq: an outbox record needs an id") 178 with self._lock: 179 # Kept rather than replaced. A caller retrying its own transaction is 180 # the ordinary case, and the second write is the same message. 181 self._records.setdefault(entry.id, entry) 182 183 async def pending(self, limit: int) -> Sequence[OutboxRecord]: 184 with self._lock: 185 waiting = sorted( 186 ( 187 entry 188 for entry in self._records.values() 189 if entry.attempts < self._max_attempts 190 ), 191 key=lambda entry: entry.created_at, 192 ) 193 return waiting[:limit] if limit > 0 else waiting 194 195 async def mark_published(self, entry_id: str) -> None: 196 with self._lock: 197 self._records.pop(entry_id, None) 198 199 async def record_failure(self, entry_id: str, error: str) -> None: 200 """Counts a failed publish against the record, and says why. 201 202 The record is kept either way. One nothing could publish is evidence: 203 somebody has to be able to read it, fix whatever refuses it and release it, 204 and deleting it would be the silent loss this pattern exists to prevent 205 arrived at by a different route. 206 """ 207 with self._lock: 208 entry = self._records.get(entry_id) 209 if entry is None: 210 return 211 self._records[entry_id] = replace( 212 entry, attempts=entry.attempts + 1, last_error=error 213 ) 214 215 def retired(self) -> Sequence[OutboxRecord]: 216 """Records left alone because publishing them kept failing. 217 218 For an operator, and for a test: these are the ones the relay has stopped 219 trying, and nothing else will surface them. 220 """ 221 with self._lock: 222 return [ 223 entry 224 for entry in self._records.values() 225 if entry.attempts >= self._max_attempts 226 ] 227 228 def __len__(self) -> int: 229 """How many records are held, retired ones included.""" 230 with self._lock: 231 return len(self._records)
An outbox in this process.
It has none of the property the pattern exists for: nothing here shares a transaction with a database, so a crash between the work committing and the record being written loses the message exactly as publishing directly would. It is for tests, and for seeing the shape of the thing before writing the version that matters.
165 def __init__(self, max_attempts: int = DEFAULT_MAX_ATTEMPTS) -> None: 166 if max_attempts < 1: 167 raise ValueError("acemq: max_attempts must be at least 1") 168 self._max_attempts = max_attempts 169 self._records: dict[str, OutboxRecord] = {} 170 # A plain lock, as in the idempotency store: it is held for a dictionary 171 # operation and never across an await, so a store written to from the 172 # blocking API's worker threads is safe too. 173 self._lock = threading.Lock()
175 async def add(self, entry: OutboxRecord) -> None: 176 if not entry.id: 177 raise ValueError("acemq: an outbox record needs an id") 178 with self._lock: 179 # Kept rather than replaced. A caller retrying its own transaction is 180 # the ordinary case, and the second write is the same message. 181 self._records.setdefault(entry.id, entry)
183 async def pending(self, limit: int) -> Sequence[OutboxRecord]: 184 with self._lock: 185 waiting = sorted( 186 ( 187 entry 188 for entry in self._records.values() 189 if entry.attempts < self._max_attempts 190 ), 191 key=lambda entry: entry.created_at, 192 ) 193 return waiting[:limit] if limit > 0 else waiting
199 async def record_failure(self, entry_id: str, error: str) -> None: 200 """Counts a failed publish against the record, and says why. 201 202 The record is kept either way. One nothing could publish is evidence: 203 somebody has to be able to read it, fix whatever refuses it and release it, 204 and deleting it would be the silent loss this pattern exists to prevent 205 arrived at by a different route. 206 """ 207 with self._lock: 208 entry = self._records.get(entry_id) 209 if entry is None: 210 return 211 self._records[entry_id] = replace( 212 entry, attempts=entry.attempts + 1, last_error=error 213 )
Counts a failed publish against the record, and says why.
The record is kept either way. One nothing could publish is evidence: somebody has to be able to read it, fix whatever refuses it and release it, and deleting it would be the silent loss this pattern exists to prevent arrived at by a different route.
215 def retired(self) -> Sequence[OutboxRecord]: 216 """Records left alone because publishing them kept failing. 217 218 For an operator, and for a test: these are the ones the relay has stopped 219 trying, and nothing else will surface them. 220 """ 221 with self._lock: 222 return [ 223 entry 224 for entry in self._records.values() 225 if entry.attempts >= self._max_attempts 226 ]
Records left alone because publishing them kept failing.
For an operator, and for a test: these are the ones the relay has stopped trying, and nothing else will surface them.
116class InMemorySchemaRegistry: 117 """Schemas kept in this process. 118 119 It is not a registry in the sense that matters. Nothing is shared between 120 processes, so a consumer cannot look up a schema a producer registered 121 somewhere else — which is the entire point of having one. It is here for 122 tests, and for seeing the shape of the thing before choosing a real one. 123 """ 124 125 def __init__(self) -> None: 126 self._by_id: dict[int, SchemaDefinition] = {} 127 self._by_subject: dict[str, list[SchemaDefinition]] = {} 128 self._by_fingerprint: dict[tuple[str, str], SchemaDefinition] = {} 129 self._next_id = 1 130 self._lock = threading.Lock() 131 132 async def register( 133 self, subject: str, format: str, definition: str 134 ) -> SchemaDefinition: 135 if not subject or not definition: 136 raise ValueError("acemq: a schema needs a subject and a definition") 137 138 print_of = fingerprint(definition) 139 with self._lock: 140 # The same definition registered twice is the same schema. 141 already = self._by_fingerprint.get((subject, print_of)) 142 if already is not None: 143 return already 144 145 versions = self._by_subject.setdefault(subject, []) 146 schema = SchemaDefinition( 147 id=self._next_id, 148 subject=subject, 149 version=len(versions) + 1, 150 format=format, 151 definition=definition, 152 fingerprint=print_of, 153 ) 154 self._next_id += 1 155 self._by_id[schema.id] = schema 156 versions.append(schema) 157 self._by_fingerprint[(subject, print_of)] = schema 158 return schema 159 160 async def by_id(self, identifier: int) -> SchemaDefinition: 161 with self._lock: 162 found = self._by_id.get(identifier) 163 if found is None: 164 raise SchemaNotFoundError(f"acemq: no schema with id {identifier}") 165 return found 166 167 async def latest(self, subject: str) -> SchemaDefinition: 168 with self._lock: 169 versions = self._by_subject.get(subject) or [] 170 if not versions: 171 raise SchemaNotFoundError(f"acemq: no schema for subject {subject!r}") 172 return versions[-1] 173 174 async def versions(self, subject: str) -> Sequence[SchemaDefinition]: 175 with self._lock: 176 return list(self._by_subject.get(subject) or [])
Schemas kept in this process.
It is not a registry in the sense that matters. Nothing is shared between processes, so a consumer cannot look up a schema a producer registered somewhere else — which is the entire point of having one. It is here for tests, and for seeing the shape of the thing before choosing a real one.
132 async def register( 133 self, subject: str, format: str, definition: str 134 ) -> SchemaDefinition: 135 if not subject or not definition: 136 raise ValueError("acemq: a schema needs a subject and a definition") 137 138 print_of = fingerprint(definition) 139 with self._lock: 140 # The same definition registered twice is the same schema. 141 already = self._by_fingerprint.get((subject, print_of)) 142 if already is not None: 143 return already 144 145 versions = self._by_subject.setdefault(subject, []) 146 schema = SchemaDefinition( 147 id=self._next_id, 148 subject=subject, 149 version=len(versions) + 1, 150 format=format, 151 definition=definition, 152 fingerprint=print_of, 153 ) 154 self._next_id += 1 155 self._by_id[schema.id] = schema 156 versions.append(schema) 157 self._by_fingerprint[(subject, print_of)] = schema 158 return schema
74@dataclass(frozen=True, slots=True) 75class OutboxRecord: 76 """A message that has been decided but not yet published. 77 78 :param id: the message identifier, and what stops the relay publishing the 79 same record twice 80 :param exchange: where it is going, empty for the default exchange 81 :param routing_key: what it is published under 82 :param body: the already-encoded payload. Bytes rather than a value, because 83 the record outlives the process that wrote it and the class it was 84 written from may not survive the next deployment 85 :param content_type: what the body was encoded as 86 :param headers: the envelope, rendered 87 :param created_at: when it was written, which is the order the relay 88 publishes in 89 :param attempts: how many times publishing this record has failed. A record 90 the broker will never take would otherwise be retried on every sweep and 91 block everything written after it, because the relay publishes in order 92 and stops at the first failure 93 :param last_error: what went wrong the last time, so an operator finding a 94 stuck record can read the reason off the record instead of correlating a 95 log line from ten sweeps ago 96 """ 97 98 id: str 99 exchange: str 100 routing_key: str 101 body: bytes 102 content_type: str = "" 103 headers: Mapping[str, Any] = field(default_factory=dict) 104 created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) 105 attempts: int = 0 106 last_error: str = ""
A message that has been decided but not yet published.
Parameters
- id: the message identifier, and what stops the relay publishing the same record twice
- exchange: where it is going, empty for the default exchange
- routing_key: what it is published under
- body: the already-encoded payload. Bytes rather than a value, because the record outlives the process that wrote it and the class it was written from may not survive the next deployment
- content_type: what the body was encoded as
- headers: the envelope, rendered
- created_at: when it was written, which is the order the relay publishes in
- attempts: how many times publishing this record has failed. A record the broker will never take would otherwise be retried on every sweep and block everything written after it, because the relay publishes in order and stops at the first failure
- last_error: what went wrong the last time, so an operator finding a stuck record can read the reason off the record instead of correlating a log line from ten sweeps ago
294class OutboxRelay: 295 """Publishes what the outbox holds, and removes what the broker confirmed. 296 297 Deliberately at-least-once. A record is removed only after the publish has 298 returned, so a crash in between sends it a second time; removing it first 299 would lose it instead, and a repeated message is a problem a consumer can 300 solve while a lost one is not. 301 302 Every record it handles is counted on ``acemq.outbox.total`` and every 303 record it publishes is timed on ``acemq.outbox.lag``, both through the 304 connection's observer — see :meth:`sweep` for why those are the only two 305 signals it can raise, and :meth:`sweep` again for what an application can 306 add on top by calling it inside a span of its own. 307 308 :param connection: where to publish 309 :param store: what to publish from 310 :param interval: how often to sweep 311 :param batch: how many records one sweep publishes. A bound rather than a 312 tuning knob: without one, a relay starting up against a backlog holds 313 every waiting record in memory at once 314 """ 315 316 def __init__( 317 self, 318 connection: Connection, 319 store: OutboxStore, 320 *, 321 interval: timedelta = DEFAULT_INTERVAL, 322 batch: int = DEFAULT_BATCH, 323 ) -> None: 324 if interval <= timedelta(0): 325 raise ValueError(f"acemq: a relay interval must be positive, got {interval}") 326 if batch < 1: 327 raise ValueError(f"acemq: a relay batch must be at least 1, got {batch}") 328 self._connection = connection 329 self._store = store 330 self._interval = interval 331 self._batch = batch 332 self._stopping = asyncio.Event() 333 self._task: asyncio.Task[None] | None = None 334 335 def start(self) -> None: 336 """Sweeps the outbox until :meth:`close`. 337 338 Starting twice is a no-op rather than a second sweeper: two relays on one 339 store publish everything twice, and the shape of that mistake is a 340 service that starts one relay per worker. 341 """ 342 if self._task is None: 343 self._task = asyncio.create_task(self._run(), name="acemq-outbox-relay") 344 345 async def sweep(self) -> int: 346 """Publishes one batch and says how many went out. 347 348 Public so an application can flush the outbox on demand — at the end of 349 a request, say, rather than up to a second later — and so a test can 350 drive the relay without waiting for a tick. 351 352 **What it reports, and what it cannot.** Every record is counted through 353 the connection's :class:`~acemq_amqp.telemetry.Observer` on 354 ``acemq.outbox.total``, published or failed, and a published one records 355 how long it waited on ``acemq.outbox.lag``. That is the whole of the 356 relay's telemetry, and deliberately so: the tracing adapter has an 357 ``outbox.publish_failed`` event and an ``outbox_lag_ms`` attribute, and 358 neither can be written from here. A sweep started by :meth:`start` runs 359 on a task of its own with no span current, and 360 :meth:`~acemq_amqp.connection.Connection.publish_raw` is beneath the 361 interceptor chain and so opens no ``publish`` span either — so an 362 attribute written here would land on nothing. Metrics need no span, 363 which is why the numbers go out this way and the trace side stays where 364 an application can reach it: call ``sweep()`` from inside a span you are 365 holding and the tracing adapter's methods work as documented. 366 367 :returns: how many records were published 368 :raises Exception: whatever the store or the broker raised. The records 369 are still in the outbox, which is the entire point: a relay that 370 fails loses nothing 371 """ 372 waiting = await self._store.pending(self._batch) 373 observer = self._connection.observer 374 375 published = 0 376 for entry in waiting: 377 labels = { 378 TAG_EXCHANGE: entry.exchange, 379 TAG_ROUTING_KEY: entry.routing_key, 380 } 381 try: 382 # Mandatory, whatever an ordinary publisher defaults to. A confirm 383 # says the broker has the message, not that it reached a queue: a 384 # record whose binding nobody made is confirmed and dropped in the 385 # same breath, and reading that confirm as success would mark it 386 # published with the message gone and nothing anywhere saying so. 387 # Unroutable is therefore a failed publish like any other -- the 388 # record stays, the store counts the attempt -- which is what 389 # Java's, .NET's and Ruby's relays do. 390 result = await self._connection.publish_raw( 391 entry.exchange, 392 entry.routing_key, 393 Outbound( 394 body=entry.body, 395 content_type=entry.content_type, 396 message_id=entry.id, 397 headers=entry.headers, 398 persistent=True, 399 mandatory=True, 400 ), 401 ) 402 if not result.routed: 403 raise PublishError( 404 entry.id, 405 entry.exchange, 406 entry.routing_key, 407 result.return_reason or "no queue is bound to receive it", 408 unroutable=True, 409 ) 410 except Exception as failure: 411 # Counted and re-raised. The record is still in the outbox, and 412 # stopping here rather than carrying on to the next one keeps 413 # the order the records were written in, which is what the 414 # writer intended by writing them in that order. 415 observer.count( 416 METRIC_OUTBOX_TOTAL, 1, {**labels, TAG_OUTCOME: OUTCOME_FAILED} 417 ) 418 # Counted against the record too, so stopping here is bounded. 419 # Without this the sweep stops at the same record on every tick and 420 # everything written after it waits for ever -- which is what a 421 # record the broker will never take used to cause, once a nack 422 # started raising instead of being silently swallowed. 423 # 424 # Optional on the store, so a custom one written against the older 425 # shape keeps working rather than breaking on an attribute it has 426 # never heard of. It also keeps the old blocking behaviour, which is 427 # stated in the protocol's own comment. 428 recorder = getattr(self._store, "record_failure", None) 429 if recorder is not None: 430 await recorder(entry.id, str(failure) or failure.__class__.__name__) 431 raise 432 433 # Counted here rather than after the record has been removed, 434 # because the message is at the broker either way: a store that 435 # cannot mark it published sends it a second time, and a counter 436 # that skipped it would report fewer messages than went out. 437 # 438 # The lag is measured from when the record was committed rather than 439 # from when this sweep started. A relay that has been down for an 440 # hour is publishing hour-old messages, and timing from the sweep 441 # would report the same handful of milliseconds as one keeping up. 442 observer.count( 443 METRIC_OUTBOX_TOTAL, 1, {**labels, TAG_OUTCOME: OUTCOME_PUBLISHED} 444 ) 445 observer.observe( 446 METRIC_OUTBOX_LAG, 447 _lag_seconds(entry.created_at), 448 {**labels, TAG_OUTCOME: OUTCOME_PUBLISHED}, 449 ) 450 451 await self._store.mark_published(entry.id) 452 published += 1 453 return published 454 455 async def _run(self) -> None: 456 while True: 457 try: 458 await asyncio.wait_for(self._stopping.wait(), self._interval.total_seconds()) 459 except asyncio.TimeoutError: 460 # The ordinary case: nobody asked it to stop, so it is time to 461 # sweep. Waiting on the event rather than sleeping is what makes 462 # close() return in the time it takes to stop rather than in the 463 # time left on the tick. 464 pass 465 else: 466 return 467 try: 468 await self.sweep() 469 except Exception: 470 # Not fatal, and not even unusual: the broker being down is one 471 # of the things an outbox exists to survive. The records are 472 # still there and the next tick tries again. 473 log.exception( 474 "acemq: a sweep of the outbox failed; every record is still in it" 475 ) 476 477 async def close(self) -> None: 478 """Stops the relay and waits for the sweep in progress. 479 480 Waited for rather than cancelled, because a sweep interrupted between 481 publishing a record and marking it published is exactly the case that 482 sends a message twice — and it costs nothing to not do it on the way out. 483 """ 484 self._stopping.set() 485 if self._task is not None: 486 await self._task 487 self._task = None 488 489 async def __aenter__(self) -> OutboxRelay: 490 self.start() 491 return self 492 493 async def __aexit__(self, *_: object) -> None: 494 await self.close()
Publishes what the outbox holds, and removes what the broker confirmed.
Deliberately at-least-once. A record is removed only after the publish has returned, so a crash in between sends it a second time; removing it first would lose it instead, and a repeated message is a problem a consumer can solve while a lost one is not.
Every record it handles is counted on acemq.outbox.total and every
record it publishes is timed on acemq.outbox.lag, both through the
connection's observer — see sweep() for why those are the only two
signals it can raise, and sweep() again for what an application can
add on top by calling it inside a span of its own.
Parameters
- connection: where to publish
- store: what to publish from
- interval: how often to sweep
- batch: how many records one sweep publishes. A bound rather than a tuning knob: without one, a relay starting up against a backlog holds every waiting record in memory at once
316 def __init__( 317 self, 318 connection: Connection, 319 store: OutboxStore, 320 *, 321 interval: timedelta = DEFAULT_INTERVAL, 322 batch: int = DEFAULT_BATCH, 323 ) -> None: 324 if interval <= timedelta(0): 325 raise ValueError(f"acemq: a relay interval must be positive, got {interval}") 326 if batch < 1: 327 raise ValueError(f"acemq: a relay batch must be at least 1, got {batch}") 328 self._connection = connection 329 self._store = store 330 self._interval = interval 331 self._batch = batch 332 self._stopping = asyncio.Event() 333 self._task: asyncio.Task[None] | None = None
335 def start(self) -> None: 336 """Sweeps the outbox until :meth:`close`. 337 338 Starting twice is a no-op rather than a second sweeper: two relays on one 339 store publish everything twice, and the shape of that mistake is a 340 service that starts one relay per worker. 341 """ 342 if self._task is None: 343 self._task = asyncio.create_task(self._run(), name="acemq-outbox-relay")
Sweeps the outbox until close().
Starting twice is a no-op rather than a second sweeper: two relays on one store publish everything twice, and the shape of that mistake is a service that starts one relay per worker.
345 async def sweep(self) -> int: 346 """Publishes one batch and says how many went out. 347 348 Public so an application can flush the outbox on demand — at the end of 349 a request, say, rather than up to a second later — and so a test can 350 drive the relay without waiting for a tick. 351 352 **What it reports, and what it cannot.** Every record is counted through 353 the connection's :class:`~acemq_amqp.telemetry.Observer` on 354 ``acemq.outbox.total``, published or failed, and a published one records 355 how long it waited on ``acemq.outbox.lag``. That is the whole of the 356 relay's telemetry, and deliberately so: the tracing adapter has an 357 ``outbox.publish_failed`` event and an ``outbox_lag_ms`` attribute, and 358 neither can be written from here. A sweep started by :meth:`start` runs 359 on a task of its own with no span current, and 360 :meth:`~acemq_amqp.connection.Connection.publish_raw` is beneath the 361 interceptor chain and so opens no ``publish`` span either — so an 362 attribute written here would land on nothing. Metrics need no span, 363 which is why the numbers go out this way and the trace side stays where 364 an application can reach it: call ``sweep()`` from inside a span you are 365 holding and the tracing adapter's methods work as documented. 366 367 :returns: how many records were published 368 :raises Exception: whatever the store or the broker raised. The records 369 are still in the outbox, which is the entire point: a relay that 370 fails loses nothing 371 """ 372 waiting = await self._store.pending(self._batch) 373 observer = self._connection.observer 374 375 published = 0 376 for entry in waiting: 377 labels = { 378 TAG_EXCHANGE: entry.exchange, 379 TAG_ROUTING_KEY: entry.routing_key, 380 } 381 try: 382 # Mandatory, whatever an ordinary publisher defaults to. A confirm 383 # says the broker has the message, not that it reached a queue: a 384 # record whose binding nobody made is confirmed and dropped in the 385 # same breath, and reading that confirm as success would mark it 386 # published with the message gone and nothing anywhere saying so. 387 # Unroutable is therefore a failed publish like any other -- the 388 # record stays, the store counts the attempt -- which is what 389 # Java's, .NET's and Ruby's relays do. 390 result = await self._connection.publish_raw( 391 entry.exchange, 392 entry.routing_key, 393 Outbound( 394 body=entry.body, 395 content_type=entry.content_type, 396 message_id=entry.id, 397 headers=entry.headers, 398 persistent=True, 399 mandatory=True, 400 ), 401 ) 402 if not result.routed: 403 raise PublishError( 404 entry.id, 405 entry.exchange, 406 entry.routing_key, 407 result.return_reason or "no queue is bound to receive it", 408 unroutable=True, 409 ) 410 except Exception as failure: 411 # Counted and re-raised. The record is still in the outbox, and 412 # stopping here rather than carrying on to the next one keeps 413 # the order the records were written in, which is what the 414 # writer intended by writing them in that order. 415 observer.count( 416 METRIC_OUTBOX_TOTAL, 1, {**labels, TAG_OUTCOME: OUTCOME_FAILED} 417 ) 418 # Counted against the record too, so stopping here is bounded. 419 # Without this the sweep stops at the same record on every tick and 420 # everything written after it waits for ever -- which is what a 421 # record the broker will never take used to cause, once a nack 422 # started raising instead of being silently swallowed. 423 # 424 # Optional on the store, so a custom one written against the older 425 # shape keeps working rather than breaking on an attribute it has 426 # never heard of. It also keeps the old blocking behaviour, which is 427 # stated in the protocol's own comment. 428 recorder = getattr(self._store, "record_failure", None) 429 if recorder is not None: 430 await recorder(entry.id, str(failure) or failure.__class__.__name__) 431 raise 432 433 # Counted here rather than after the record has been removed, 434 # because the message is at the broker either way: a store that 435 # cannot mark it published sends it a second time, and a counter 436 # that skipped it would report fewer messages than went out. 437 # 438 # The lag is measured from when the record was committed rather than 439 # from when this sweep started. A relay that has been down for an 440 # hour is publishing hour-old messages, and timing from the sweep 441 # would report the same handful of milliseconds as one keeping up. 442 observer.count( 443 METRIC_OUTBOX_TOTAL, 1, {**labels, TAG_OUTCOME: OUTCOME_PUBLISHED} 444 ) 445 observer.observe( 446 METRIC_OUTBOX_LAG, 447 _lag_seconds(entry.created_at), 448 {**labels, TAG_OUTCOME: OUTCOME_PUBLISHED}, 449 ) 450 451 await self._store.mark_published(entry.id) 452 published += 1 453 return published
Publishes one batch and says how many went out.
Public so an application can flush the outbox on demand — at the end of a request, say, rather than up to a second later — and so a test can drive the relay without waiting for a tick.
What it reports, and what it cannot. Every record is counted through
the connection's ~acemq_amqp.telemetry.Observer on
acemq.outbox.total, published or failed, and a published one records
how long it waited on acemq.outbox.lag. That is the whole of the
relay's telemetry, and deliberately so: the tracing adapter has an
outbox.publish_failed event and an outbox_lag_ms attribute, and
neither can be written from here. A sweep started by start() runs
on a task of its own with no span current, and
~acemq_amqp.connection.Connection.publish_raw() is beneath the
interceptor chain and so opens no publish span either — so an
attribute written here would land on nothing. Metrics need no span,
which is why the numbers go out this way and the trace side stays where
an application can reach it: call sweep() from inside a span you are
holding and the tracing adapter's methods work as documented.
:returns: how many records were published
Raises
- Exception: whatever the store or the broker raised. The records are still in the outbox, which is the entire point: a relay that fails loses nothing
477 async def close(self) -> None: 478 """Stops the relay and waits for the sweep in progress. 479 480 Waited for rather than cancelled, because a sweep interrupted between 481 publishing a record and marking it published is exactly the case that 482 sends a message twice — and it costs nothing to not do it on the way out. 483 """ 484 self._stopping.set() 485 if self._task is not None: 486 await self._task 487 self._task = None
Stops the relay and waits for the sweep in progress.
Waited for rather than cancelled, because a sweep interrupted between publishing a record and marking it published is exactly the case that sends a message twice — and it costs nothing to not do it on the way out.
109@runtime_checkable 110class OutboxStore(Protocol): 111 """Holds messages that have been decided but not yet published. 112 113 An implementation is only worth having if :meth:`add` can join the caller's 114 transaction. A store that opens a connection of its own has the gap back, and 115 has bought nothing but a second place for messages to go missing. 116 """ 117 118 async def add(self, entry: OutboxRecord) -> None: 119 """Records a message to be published. 120 121 Adding the same identifier twice is not an error — a caller may be 122 retrying its own transaction — but it must not become two messages. 123 """ 124 125 async def pending(self, limit: int) -> Sequence[OutboxRecord]: 126 """Records waiting to be published, oldest first. 127 128 :param limit: at most this many, or zero for all of them 129 """ 130 131 async def mark_published(self, entry_id: str) -> None: 132 """Removes a record, once the broker has confirmed the message.""" 133 134 # record_failure is deliberately not part of this Protocol. 135 # 136 # A store that does not implement it keeps the behaviour every store had 137 # before: a record the broker will never take is retried on every sweep and 138 # holds up everything behind it. That is not good, but it is what a custom 139 # store written against the old shape already does, and breaking every one of 140 # them to fix it is the more expensive answer. The relay calls it when it is 141 # there, and both stores shipped here have it. 142 # 143 # A custom store should implement it: 144 # 145 # async def record_failure(self, entry_id: str, error: str) -> None: 146 # """Counts a failed publish against the record, and says why.""" 147 # 148 # and exclude records whose attempts have reached the limit from `pending`.
Holds messages that have been decided but not yet published.
An implementation is only worth having if add() can join the caller's
transaction. A store that opens a connection of its own has the gap back, and
has bought nothing but a second place for messages to go missing.
1771def _no_init_or_replace_init(self, *args, **kwargs): 1772 cls = type(self) 1773 1774 if cls._is_protocol: 1775 raise TypeError('Protocols cannot be instantiated') 1776 1777 # Already using a custom `__init__`. No need to calculate correct 1778 # `__init__` to call. This can lead to RecursionError. See bpo-45121. 1779 if cls.__init__ is not _no_init_or_replace_init: 1780 return 1781 1782 # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`. 1783 # The first instantiation of the subclass will call `_no_init_or_replace_init` which 1784 # searches for a proper new `__init__` in the MRO. The new `__init__` 1785 # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent 1786 # instantiation of the protocol subclass will thus use the new 1787 # `__init__` and no longer call `_no_init_or_replace_init`. 1788 for base in cls.__mro__: 1789 init = base.__dict__.get('__init__', _no_init_or_replace_init) 1790 if init is not _no_init_or_replace_init: 1791 cls.__init__ = init 1792 break 1793 else: 1794 # should not happen 1795 cls.__init__ = object.__init__ 1796 1797 cls.__init__(self, *args, **kwargs)
118 async def add(self, entry: OutboxRecord) -> None: 119 """Records a message to be published. 120 121 Adding the same identifier twice is not an error — a caller may be 122 retrying its own transaction — but it must not become two messages. 123 """
Records a message to be published.
Adding the same identifier twice is not an error — a caller may be retrying its own transaction — but it must not become two messages.
125 async def pending(self, limit: int) -> Sequence[OutboxRecord]: 126 """Records waiting to be published, oldest first. 127 128 :param limit: at most this many, or zero for all of them 129 """
Records waiting to be published, oldest first.
Parameters
- limit: at most this many, or zero for all of them
75class ReplayError(AceMQError): 76 """A replay stopped because something failed. 77 78 It carries the :class:`ReplayResult` it had reached, because "it failed" and 79 "it failed after moving four hundred" are different things to be told at 80 three in the morning. 81 """ 82 83 def __init__(self, result: ReplayResult, what: str) -> None: 84 self.result = result 85 super().__init__(f"acemq: {what} (after {result})")
A replay stopped because something failed.
It carries the ReplayResult it had reached, because "it failed" and
"it failed after moving four hundred" are different things to be told at
three in the morning.
58@dataclass(frozen=True, slots=True) 59class ReplayResult: 60 """What a replay did, and why it stopped. 61 62 :param moved: how many messages were republished 63 :param skipped: how many the filter declined, and so left where they were 64 :param reason: ``drained``, ``limit`` or ``deadline`` 65 """ 66 67 moved: int 68 skipped: int 69 reason: str 70 71 def __str__(self) -> str: 72 return f"moved {self.moved}, skipped {self.skipped} ({self.reason})"
What a replay did, and why it stopped.
Parameters
- moved: how many messages were republished
- skipped: how many the filter declined, and so left where they were
- reason:
drained,limitordeadline
91class RequestTimeoutError(AceMQError, TimeoutError): 92 """No reply arrived before the deadline. 93 94 It says nothing about whether the request was handled. A timeout is the 95 absence of an answer, not evidence that nothing happened, which is why a 96 request that changes anything should be idempotent at the other end. 97 98 Also a :class:`TimeoutError`, so ``except TimeoutError`` catches it — which 99 is what a Python caller writes, and how 100 :meth:`~acemq_amqp.tracing.OpenTelemetryTracing.request_span` tells a 101 deadline apart from a responder that failed without having to import this 102 module. ``except AceMQError`` still catches it too; the extra base only adds 103 a way to be caught. 104 """
No reply arrived before the deadline.
It says nothing about whether the request was handled. A timeout is the absence of an answer, not evidence that nothing happened, which is why a request that changes anything should be idempotent at the other end.
Also a TimeoutError, so except TimeoutError catches it — which
is what a Python caller writes, and how
~acemq_amqp.tracing.OpenTelemetryTracing.request_span() tells a
deadline apart from a responder that failed without having to import this
module. except AceMQError still catches it too; the extra base only adds
a way to be caught.
115class Requester: 116 """Sends requests and waits for their replies. 117 118 Built by :meth:`open`, because it has a queue and a consumer of its own and 119 both have to exist before it is any use:: 120 121 async with await Requester.open(mq, "pricing", "price.request") as ask: 122 price = await ask.ask({"sku": "A-1"}) 123 124 Close it when done: it holds a queue, a consumer and whatever is still 125 waiting. 126 """ 127 128 def __init__( 129 self, 130 connection: Connection, 131 exchange: str, 132 routing_key: str, 133 reply_queue: str, 134 timeout: timedelta, 135 codec: Codec | None, 136 ) -> None: 137 self._connection = connection 138 self._exchange = exchange 139 self._routing_key = routing_key 140 self._reply_queue = reply_queue 141 self._timeout = timeout 142 self._codec = codec 143 self._publisher = connection.publisher(exchange, routing_key, codec=codec) 144 self._waiting: dict[str, asyncio.Future[Message]] = {} 145 self._consumer: Consumer | None = None 146 self._observer: Observer = connection.observer 147 # The destination as one word, which is what Java tags the round trip 148 # with: the routing key, or the exchange when publishing without one. 149 self._destination = routing_key or exchange 150 self._timed_out = 0 151 self._unmatched = 0 152 153 @classmethod 154 async def open( 155 cls, 156 connection: Connection, 157 exchange: str = "", 158 routing_key: str = "", 159 *, 160 reply_queue: str | None = None, 161 timeout: timedelta = DEFAULT_TIMEOUT, 162 codec: Codec | None = None, 163 ) -> Requester: 164 """Declares the reply queue and starts consuming it. 165 166 :param connection: where to send and where to listen 167 :param exchange: where requests go, empty for the default exchange 168 :param routing_key: what they are published under, or a queue name 169 :param reply_queue: where replies come back. One is generated when this 170 is not given: transient, exclusive, auto-deleting and classic, 171 belonging to this process. Name one only when replies must survive a 172 restart — a named reply queue that outlives its requester collects 173 answers nobody is waiting for 174 :param timeout: how long :meth:`ask` waits 175 :param codec: a codec other than the connection's, for both directions 176 :returns: the requester, already consuming 177 """ 178 if timeout <= timedelta(0): 179 raise ValueError(f"acemq: a request timeout must be positive, got {timeout}") 180 181 generated = not reply_queue 182 queue = reply_queue or f"acemq-reply-{uuid.uuid4().hex}" 183 # A generated reply queue is classic, and has to be: it is exclusive and 184 # auto-deleting so that it goes when this process does, and RabbitMQ 185 # refuses a quorum queue that is either. Said here rather than left to 186 # the default, because it is the one queue in the library where turning 187 # it quorum would stop request/reply working at all rather than merely 188 # declare something different. A named one is an ordinary durable queue 189 # a responder may also declare, so it is quorum like any other. 190 await connection.declare( 191 Topology().queue( 192 queue, 193 durable=not generated, 194 auto_delete=generated, 195 exclusive=generated, 196 quorum=not generated, 197 ) 198 ) 199 200 requester = cls(connection, exchange, routing_key, queue, timeout, codec) 201 # The one consumer in this library that declares nothing. A reply queue 202 # is a mailbox for one process, and a generated one is a different name 203 # every restart, so the dead-letter queues a consumer usually declares 204 # would be two durable queues per process that nothing ever reads and 205 # nothing ever deletes. There is nothing for them to catch either: 206 # :meth:`_receive` accepts every reply, including one nobody is waiting 207 # for, so no message on this queue is ever dead-lettered or parked. 208 # 209 # Consumed as bytes, and decoded by :meth:`ask`, for the same reason: a 210 # consumer parks a body its codec cannot read, and this queue has no 211 # parked queue. The reply would vanish and its caller would wait out the 212 # whole timeout instead of hearing why. Java and .NET do the same. 213 requester._consumer = await connection.consume( 214 queue, requester._receive, codec=BytesCodec(), declare=False 215 ) 216 return requester 217 218 @property 219 def reply_queue(self) -> str: 220 """The queue replies arrive on.""" 221 return self._reply_queue 222 223 @property 224 def timed_out(self) -> int: 225 """How many calls reached their deadline with no reply. 226 227 Java's ``requester.timedOut()`` and .NET's ``TimedOut``, and the same 228 number: a caller that gave up, counted whether or not the responder 229 eventually answered. 230 """ 231 return self._timed_out 232 233 @property 234 def unmatched(self) -> int: 235 """How many replies arrived with nobody waiting for them. 236 237 Java's ``requester.unmatched()``. Almost always the timeout being too 238 short rather than anything broken: this rising alongside 239 :attr:`timed_out` is the signature of a responder slower than its 240 callers expect. 241 """ 242 return self._unmatched 243 244 async def ask( 245 self, 246 request: Any, 247 *, 248 envelope: Envelope | None = None, 249 timeout: timedelta | None = None, 250 ) -> Any: 251 """Sends a request and waits for its reply. 252 253 :param request: what to send 254 :param envelope: metadata to send instead of a fresh one. Its 255 correlation identifier is replaced: that is what pairs a reply with 256 its request, so it belongs to this call rather than to the caller 257 :param timeout: how long to wait, overriding this requester's 258 :returns: the reply's payload 259 :raises RequestTimeoutError: when nothing came back in time 260 :raises ResponderError: when the responder said it could not do it 261 :raises Exception: whatever the codec raises for a reply it cannot read 262 """ 263 deadline = timeout or self._timeout 264 correlation = str(uuid.uuid4()) 265 outgoing = envelope or Envelope(origin=self._connection.origin) 266 outgoing = outgoing.with_( 267 correlation_id=correlation, 268 headers={**outgoing.headers, HEADER_REPLY_TO: self._reply_queue}, 269 ) 270 271 waiter: asyncio.Future[Message] = asyncio.get_running_loop().create_future() 272 # Registered before the request goes out, because a fast responder can 273 # reply before send() has returned. 274 self._waiting[correlation] = waiter 275 # Started before the publish, because the round trip is what the *caller* 276 # experienced and the caller's wait begins here. A timer started after 277 # the send would leave the publish out of the one number whose whole job 278 # is to include it. 279 started = time.monotonic() 280 outcome = OUTCOME_FAILED 281 try: 282 # Both addresses, the same value. The header is what a Python, Go or 283 # Ruby responder reads first; the native property is what a Java or 284 # .NET one reads, and without it those two would answer nowhere. 285 await self._publisher.send( 286 request, envelope=outgoing, reply_to=self._reply_queue 287 ) 288 answer = await asyncio.wait_for(waiter, deadline.total_seconds()) 289 except RequestTimeoutError: 290 # Already one of ours, from :meth:`close` failing what was still 291 # waiting. Caught first because this class is a ``TimeoutError`` 292 # too, and the clause below would otherwise re-describe "the 293 # requester was closed" as "nothing came back in time" — the same 294 # exception type carrying the wrong reason. 295 self._timed_out += 1 296 outcome = OUTCOME_TIMED_OUT 297 raise 298 except asyncio.TimeoutError as expired: 299 self._timed_out += 1 300 outcome = OUTCOME_TIMED_OUT 301 raise RequestTimeoutError( 302 f"acemq: no reply to {correlation} arrived within {deadline}" 303 ) from expired 304 else: 305 outcome = OUTCOME_ANSWERED 306 finally: 307 self._waiting.pop(correlation, None) 308 # Recorded on every path out, including the ones that raise. A 309 # duration that only covers the calls that worked is a duration that 310 # hides exactly the calls somebody is looking for, and a timed-out 311 # request is the slowest data point there is. 312 self._record(time.monotonic() - started, outcome) 313 314 failure = answer.envelope.headers.get(HEADER_ERROR) 315 if failure: 316 raise ResponderError( 317 f"acemq: the responder could not answer {correlation}: {failure}" 318 ) 319 codec = self._codec or self._connection.codec 320 return codec.decode(answer.body, answer.content_type) 321 322 def _record(self, elapsed: float, outcome: str) -> None: 323 """One round trip, on the two metrics Java writes for the same thing. 324 325 ``answered`` counts a reply that came back, whatever it said: a 326 responder that answered "I could not do it" answered, and the failure is 327 in the reply rather than in the round trip. ``failed`` is for the publish 328 itself not getting out, which is the one case where there was never a 329 question to be slow about. 330 """ 331 labels = {TAG_ROUTING_KEY: self._destination, TAG_OUTCOME: outcome} 332 self._observer.observe(METRIC_REQUEST_DURATION, elapsed, labels) 333 self._observer.count(METRIC_REQUEST_TOTAL, 1, labels) 334 335 async def _receive(self, reply: Message) -> Ack: 336 """Hands a reply to whoever is waiting for it. 337 338 A reply nobody is waiting for is accepted and dropped, which is what a 339 reply to a request that already timed out is. It is not an error and it 340 is not worth dead-lettering: the caller has gone. It is counted, though, 341 because :attr:`unmatched` rising alongside :attr:`timed_out` is the one 342 signature that separates "the responder is broken" from "the timeout is 343 too short", and from outside they look the same. 344 """ 345 waiter = self._waiting.pop(reply.envelope.correlation_id, None) 346 if waiter is None or waiter.done(): 347 self._unmatched += 1 348 else: 349 waiter.set_result(reply) 350 return accept() 351 352 async def close(self) -> None: 353 """Stops consuming replies and fails anything still waiting. 354 355 Failed rather than left, because a caller awaiting a reply on a 356 requester somebody else closed would otherwise wait out its whole 357 timeout for an answer that can no longer arrive. 358 """ 359 if self._consumer is not None: 360 await self._consumer.close() 361 self._consumer = None 362 for correlation, waiter in list(self._waiting.items()): 363 if not waiter.done(): 364 waiter.set_exception( 365 RequestTimeoutError( 366 f"acemq: the requester was closed while {correlation} was outstanding" 367 ) 368 ) 369 self._waiting.clear() 370 371 async def __aenter__(self) -> Requester: 372 return self 373 374 async def __aexit__(self, *_: object) -> None: 375 await self.close()
Sends requests and waits for their replies.
Built by open(), because it has a queue and a consumer of its own and
both have to exist before it is any use::
async with await Requester.open(mq, "pricing", "price.request") as ask:
price = await ask.ask({"sku": "A-1"})
Close it when done: it holds a queue, a consumer and whatever is still waiting.
128 def __init__( 129 self, 130 connection: Connection, 131 exchange: str, 132 routing_key: str, 133 reply_queue: str, 134 timeout: timedelta, 135 codec: Codec | None, 136 ) -> None: 137 self._connection = connection 138 self._exchange = exchange 139 self._routing_key = routing_key 140 self._reply_queue = reply_queue 141 self._timeout = timeout 142 self._codec = codec 143 self._publisher = connection.publisher(exchange, routing_key, codec=codec) 144 self._waiting: dict[str, asyncio.Future[Message]] = {} 145 self._consumer: Consumer | None = None 146 self._observer: Observer = connection.observer 147 # The destination as one word, which is what Java tags the round trip 148 # with: the routing key, or the exchange when publishing without one. 149 self._destination = routing_key or exchange 150 self._timed_out = 0 151 self._unmatched = 0
153 @classmethod 154 async def open( 155 cls, 156 connection: Connection, 157 exchange: str = "", 158 routing_key: str = "", 159 *, 160 reply_queue: str | None = None, 161 timeout: timedelta = DEFAULT_TIMEOUT, 162 codec: Codec | None = None, 163 ) -> Requester: 164 """Declares the reply queue and starts consuming it. 165 166 :param connection: where to send and where to listen 167 :param exchange: where requests go, empty for the default exchange 168 :param routing_key: what they are published under, or a queue name 169 :param reply_queue: where replies come back. One is generated when this 170 is not given: transient, exclusive, auto-deleting and classic, 171 belonging to this process. Name one only when replies must survive a 172 restart — a named reply queue that outlives its requester collects 173 answers nobody is waiting for 174 :param timeout: how long :meth:`ask` waits 175 :param codec: a codec other than the connection's, for both directions 176 :returns: the requester, already consuming 177 """ 178 if timeout <= timedelta(0): 179 raise ValueError(f"acemq: a request timeout must be positive, got {timeout}") 180 181 generated = not reply_queue 182 queue = reply_queue or f"acemq-reply-{uuid.uuid4().hex}" 183 # A generated reply queue is classic, and has to be: it is exclusive and 184 # auto-deleting so that it goes when this process does, and RabbitMQ 185 # refuses a quorum queue that is either. Said here rather than left to 186 # the default, because it is the one queue in the library where turning 187 # it quorum would stop request/reply working at all rather than merely 188 # declare something different. A named one is an ordinary durable queue 189 # a responder may also declare, so it is quorum like any other. 190 await connection.declare( 191 Topology().queue( 192 queue, 193 durable=not generated, 194 auto_delete=generated, 195 exclusive=generated, 196 quorum=not generated, 197 ) 198 ) 199 200 requester = cls(connection, exchange, routing_key, queue, timeout, codec) 201 # The one consumer in this library that declares nothing. A reply queue 202 # is a mailbox for one process, and a generated one is a different name 203 # every restart, so the dead-letter queues a consumer usually declares 204 # would be two durable queues per process that nothing ever reads and 205 # nothing ever deletes. There is nothing for them to catch either: 206 # :meth:`_receive` accepts every reply, including one nobody is waiting 207 # for, so no message on this queue is ever dead-lettered or parked. 208 # 209 # Consumed as bytes, and decoded by :meth:`ask`, for the same reason: a 210 # consumer parks a body its codec cannot read, and this queue has no 211 # parked queue. The reply would vanish and its caller would wait out the 212 # whole timeout instead of hearing why. Java and .NET do the same. 213 requester._consumer = await connection.consume( 214 queue, requester._receive, codec=BytesCodec(), declare=False 215 ) 216 return requester
Declares the reply queue and starts consuming it.
Parameters
- connection: where to send and where to listen
- exchange: where requests go, empty for the default exchange
- routing_key: what they are published under, or a queue name
- reply_queue: where replies come back. One is generated when this is not given: transient, exclusive, auto-deleting and classic, belonging to this process. Name one only when replies must survive a restart — a named reply queue that outlives its requester collects answers nobody is waiting for
- timeout: how long
ask()waits - codec: a codec other than the connection's, for both directions :returns: the requester, already consuming
218 @property 219 def reply_queue(self) -> str: 220 """The queue replies arrive on.""" 221 return self._reply_queue
The queue replies arrive on.
223 @property 224 def timed_out(self) -> int: 225 """How many calls reached their deadline with no reply. 226 227 Java's ``requester.timedOut()`` and .NET's ``TimedOut``, and the same 228 number: a caller that gave up, counted whether or not the responder 229 eventually answered. 230 """ 231 return self._timed_out
How many calls reached their deadline with no reply.
Java's requester.timedOut() and .NET's TimedOut, and the same
number: a caller that gave up, counted whether or not the responder
eventually answered.
233 @property 234 def unmatched(self) -> int: 235 """How many replies arrived with nobody waiting for them. 236 237 Java's ``requester.unmatched()``. Almost always the timeout being too 238 short rather than anything broken: this rising alongside 239 :attr:`timed_out` is the signature of a responder slower than its 240 callers expect. 241 """ 242 return self._unmatched
How many replies arrived with nobody waiting for them.
Java's requester.unmatched(). Almost always the timeout being too
short rather than anything broken: this rising alongside
timed_out is the signature of a responder slower than its
callers expect.
244 async def ask( 245 self, 246 request: Any, 247 *, 248 envelope: Envelope | None = None, 249 timeout: timedelta | None = None, 250 ) -> Any: 251 """Sends a request and waits for its reply. 252 253 :param request: what to send 254 :param envelope: metadata to send instead of a fresh one. Its 255 correlation identifier is replaced: that is what pairs a reply with 256 its request, so it belongs to this call rather than to the caller 257 :param timeout: how long to wait, overriding this requester's 258 :returns: the reply's payload 259 :raises RequestTimeoutError: when nothing came back in time 260 :raises ResponderError: when the responder said it could not do it 261 :raises Exception: whatever the codec raises for a reply it cannot read 262 """ 263 deadline = timeout or self._timeout 264 correlation = str(uuid.uuid4()) 265 outgoing = envelope or Envelope(origin=self._connection.origin) 266 outgoing = outgoing.with_( 267 correlation_id=correlation, 268 headers={**outgoing.headers, HEADER_REPLY_TO: self._reply_queue}, 269 ) 270 271 waiter: asyncio.Future[Message] = asyncio.get_running_loop().create_future() 272 # Registered before the request goes out, because a fast responder can 273 # reply before send() has returned. 274 self._waiting[correlation] = waiter 275 # Started before the publish, because the round trip is what the *caller* 276 # experienced and the caller's wait begins here. A timer started after 277 # the send would leave the publish out of the one number whose whole job 278 # is to include it. 279 started = time.monotonic() 280 outcome = OUTCOME_FAILED 281 try: 282 # Both addresses, the same value. The header is what a Python, Go or 283 # Ruby responder reads first; the native property is what a Java or 284 # .NET one reads, and without it those two would answer nowhere. 285 await self._publisher.send( 286 request, envelope=outgoing, reply_to=self._reply_queue 287 ) 288 answer = await asyncio.wait_for(waiter, deadline.total_seconds()) 289 except RequestTimeoutError: 290 # Already one of ours, from :meth:`close` failing what was still 291 # waiting. Caught first because this class is a ``TimeoutError`` 292 # too, and the clause below would otherwise re-describe "the 293 # requester was closed" as "nothing came back in time" — the same 294 # exception type carrying the wrong reason. 295 self._timed_out += 1 296 outcome = OUTCOME_TIMED_OUT 297 raise 298 except asyncio.TimeoutError as expired: 299 self._timed_out += 1 300 outcome = OUTCOME_TIMED_OUT 301 raise RequestTimeoutError( 302 f"acemq: no reply to {correlation} arrived within {deadline}" 303 ) from expired 304 else: 305 outcome = OUTCOME_ANSWERED 306 finally: 307 self._waiting.pop(correlation, None) 308 # Recorded on every path out, including the ones that raise. A 309 # duration that only covers the calls that worked is a duration that 310 # hides exactly the calls somebody is looking for, and a timed-out 311 # request is the slowest data point there is. 312 self._record(time.monotonic() - started, outcome) 313 314 failure = answer.envelope.headers.get(HEADER_ERROR) 315 if failure: 316 raise ResponderError( 317 f"acemq: the responder could not answer {correlation}: {failure}" 318 ) 319 codec = self._codec or self._connection.codec 320 return codec.decode(answer.body, answer.content_type)
Sends a request and waits for its reply.
Parameters
- request: what to send
- envelope: metadata to send instead of a fresh one. Its correlation identifier is replaced: that is what pairs a reply with its request, so it belongs to this call rather than to the caller
- timeout: how long to wait, overriding this requester's :returns: the reply's payload
Raises
- RequestTimeoutError: when nothing came back in time
- ResponderError: when the responder said it could not do it
- Exception: whatever the codec raises for a reply it cannot read
352 async def close(self) -> None: 353 """Stops consuming replies and fails anything still waiting. 354 355 Failed rather than left, because a caller awaiting a reply on a 356 requester somebody else closed would otherwise wait out its whole 357 timeout for an answer that can no longer arrive. 358 """ 359 if self._consumer is not None: 360 await self._consumer.close() 361 self._consumer = None 362 for correlation, waiter in list(self._waiting.items()): 363 if not waiter.done(): 364 waiter.set_exception( 365 RequestTimeoutError( 366 f"acemq: the requester was closed while {correlation} was outstanding" 367 ) 368 ) 369 self._waiting.clear()
Stops consuming replies and fails anything still waiting.
Failed rather than left, because a caller awaiting a reply on a requester somebody else closed would otherwise wait out its whole timeout for an answer that can no longer arrive.
107class ResponderError(AceMQError): 108 """The responder answered, and the answer was that it could not do it. 109 110 Better than a timeout in every way: the caller learns in milliseconds rather 111 than at the deadline, and learns why. 112 """
The responder answered, and the answer was that it could not do it.
Better than a timeout in every way: the caller learns in milliseconds rather than at the deadline, and learns why.
378class ResponderHandle: 379 """A running responder, and the two numbers it keeps. 380 381 Built by :func:`serve`. It is a :class:`~acemq_amqp.Consumer` in every way 382 that matters — closed the same way, usable as an ``async with``, reporting 383 the same ``queue``, ``running`` and ``in_flight`` — with 384 :attr:`answered` and :attr:`unanswerable` added, which is what Java's 385 ``Responder`` and .NET's ``Responder`` report and what nothing here reported 386 before. 387 388 The counters are built before the subscription is, and that ordering is the 389 guarantee rather than an implementation detail: a broker may hand the first 390 request over from inside the subscribe — which is what a queue with a backlog 391 looks like from in here — and the handler reads these on that very delivery. 392 Java had to say the same thing about field initialisation and .NET had to 393 lift its counters out of the responder to get it. Neither number needs a wait 394 before it can be trusted, and code that sleeps before reading one is working 395 around a defect that is not here. 396 """ 397 398 def __init__(self, queue: str) -> None: 399 self._queue = queue 400 self._answered = 0 401 self._unanswerable = 0 402 self._consumer: Consumer | None = None 403 404 @property 405 def answered(self) -> int: 406 """How many requests were answered, counted before each reply left. 407 408 A caller holding a reply can rely on this having counted it: the 409 increment happens before the publish, so there is no interleaving in 410 which the answer is visible and the number is not. The other order looks 411 more natural and is wrong — it leaves a window where the reply is in the 412 caller's hands and the responder still says nothing has been answered, 413 which is a dashboard reporting an idle service that is demonstrably 414 working. 415 416 **A publish that fails hands the increment back**, so this counts replies 417 that were sent rather than replies that were attempted. 418 """ 419 return self._answered 420 421 @property 422 def unanswerable(self) -> int: 423 """How many requests arrived naming nowhere to reply. 424 425 Anything above zero means a caller is publishing where it means to 426 request. Counted before the delivery is settled. 427 """ 428 return self._unanswerable 429 430 @property 431 def consumer(self) -> Consumer: 432 """The consumer underneath, for anything this class does not forward.""" 433 if self._consumer is None: # pragma: no cover - serve always sets it 434 raise AceMQError("acemq: this responder has not been started") 435 return self._consumer 436 437 @property 438 def queue(self) -> str: 439 """The queue requests arrive on.""" 440 return self._queue 441 442 @property 443 def running(self) -> bool: 444 """Whether this responder is still serving.""" 445 return self._consumer is not None and self._consumer.running 446 447 @property 448 def closed(self) -> bool: 449 """Whether this responder has been stopped.""" 450 return self._consumer is None or self._consumer.closed 451 452 @property 453 def in_flight(self) -> int: 454 """How many requests are being answered right now.""" 455 return 0 if self._consumer is None else self._consumer.in_flight 456 457 async def close(self) -> None: 458 """Stops serving, letting the request being answered finish. 459 460 A request being answered right now has a caller blocked on the other 461 side, and dropping it turns their call into a timeout. 462 """ 463 if self._consumer is not None: 464 await self._consumer.close() 465 466 async def __aenter__(self) -> ResponderHandle: 467 return self 468 469 async def __aexit__(self, *_: object) -> None: 470 await self.close()
A running responder, and the two numbers it keeps.
Built by serve(). It is a ~acemq_amqp.Consumer in every way
that matters — closed the same way, usable as an async with, reporting
the same queue, running and in_flight — with
answered and unanswerable added, which is what Java's
Responder and .NET's Responder report and what nothing here reported
before.
The counters are built before the subscription is, and that ordering is the guarantee rather than an implementation detail: a broker may hand the first request over from inside the subscribe — which is what a queue with a backlog looks like from in here — and the handler reads these on that very delivery. Java had to say the same thing about field initialisation and .NET had to lift its counters out of the responder to get it. Neither number needs a wait before it can be trusted, and code that sleeps before reading one is working around a defect that is not here.
404 @property 405 def answered(self) -> int: 406 """How many requests were answered, counted before each reply left. 407 408 A caller holding a reply can rely on this having counted it: the 409 increment happens before the publish, so there is no interleaving in 410 which the answer is visible and the number is not. The other order looks 411 more natural and is wrong — it leaves a window where the reply is in the 412 caller's hands and the responder still says nothing has been answered, 413 which is a dashboard reporting an idle service that is demonstrably 414 working. 415 416 **A publish that fails hands the increment back**, so this counts replies 417 that were sent rather than replies that were attempted. 418 """ 419 return self._answered
How many requests were answered, counted before each reply left.
A caller holding a reply can rely on this having counted it: the increment happens before the publish, so there is no interleaving in which the answer is visible and the number is not. The other order looks more natural and is wrong — it leaves a window where the reply is in the caller's hands and the responder still says nothing has been answered, which is a dashboard reporting an idle service that is demonstrably working.
A publish that fails hands the increment back, so this counts replies that were sent rather than replies that were attempted.
421 @property 422 def unanswerable(self) -> int: 423 """How many requests arrived naming nowhere to reply. 424 425 Anything above zero means a caller is publishing where it means to 426 request. Counted before the delivery is settled. 427 """ 428 return self._unanswerable
How many requests arrived naming nowhere to reply.
Anything above zero means a caller is publishing where it means to request. Counted before the delivery is settled.
430 @property 431 def consumer(self) -> Consumer: 432 """The consumer underneath, for anything this class does not forward.""" 433 if self._consumer is None: # pragma: no cover - serve always sets it 434 raise AceMQError("acemq: this responder has not been started") 435 return self._consumer
The consumer underneath, for anything this class does not forward.
437 @property 438 def queue(self) -> str: 439 """The queue requests arrive on.""" 440 return self._queue
The queue requests arrive on.
442 @property 443 def running(self) -> bool: 444 """Whether this responder is still serving.""" 445 return self._consumer is not None and self._consumer.running
Whether this responder is still serving.
447 @property 448 def closed(self) -> bool: 449 """Whether this responder has been stopped.""" 450 return self._consumer is None or self._consumer.closed
Whether this responder has been stopped.
452 @property 453 def in_flight(self) -> int: 454 """How many requests are being answered right now.""" 455 return 0 if self._consumer is None else self._consumer.in_flight
How many requests are being answered right now.
457 async def close(self) -> None: 458 """Stops serving, letting the request being answered finish. 459 460 A request being answered right now has a caller blocked on the other 461 side, and dropping it turns their call into a timeout. 462 """ 463 if self._consumer is not None: 464 await self._consumer.close()
Stops serving, letting the request being answered finish.
A request being answered right now has a caller blocked on the other side, and dropping it turns their call into a timeout.
161@dataclass(frozen=True, slots=True) 162class RoutingSlip: 163 """Where a message still has to go, and where it has been:: 164 165 slip = ( 166 RoutingSlip() 167 .then("orders-events", "order.validate", name="validate") 168 .then("orders-events", "order.charge", name="charge") 169 .then("orders-events", "order.ship", name="ship") 170 ) 171 await start(mq, slip, order) 172 173 Immutable, and :meth:`advance` returns a new one. A slip is on a message 174 that has already been published by the time anybody reads it, so changing 175 one in place would describe a journey that did not happen. 176 177 :param steps: what is still to do, in order 178 :param done: what has already happened, oldest first, so a slip that fails 179 halfway says how far it got 180 :param run_id: identifies one run through the route across every hop. Only 181 the declared form carries it on the wire; the JSON form has no field for 182 it, and inventing one would be a change to a header three other 183 libraries already read 184 :param form: which wire form :meth:`onto` writes. A slip read off a message 185 remembers the one it arrived in 186 """ 187 188 steps: tuple[Step, ...] = () 189 done: tuple[Step, ...] = () 190 run_id: str = "" 191 form: SlipForm = SlipForm.JSON 192 193 def then(self, exchange: str, routing_key: str, *, name: str = "") -> RoutingSlip: 194 """A slip with one more stop on the end. 195 196 :param exchange: where that step's message goes 197 :param routing_key: what it is published under 198 :param name: what to call it in a log 199 :returns: a new slip 200 """ 201 return replace( 202 self, steps=(*self.steps, Step(exchange, routing_key, name=name)) 203 ) 204 205 @property 206 def next(self) -> Step | None: 207 """The stop this message is going to, or ``None`` at the end.""" 208 return self.steps[0] if self.steps else None 209 210 @property 211 def finished(self) -> bool: 212 """Whether every step has been done.""" 213 return not self.steps 214 215 def advance(self) -> RoutingSlip: 216 """A copy with the first step moved to :attr:`done`, stamped with now.""" 217 if not self.steps: 218 return self 219 completed = replace( 220 self.steps[0], completed_at=datetime.now(timezone.utc).isoformat() 221 ) 222 return replace(self, steps=self.steps[1:], done=(*self.done, completed)) 223 224 def to_header(self) -> str: 225 """The slip rendered as JSON, whatever form it is in. 226 227 The value of :data:`HEADER_ROUTING_SLIP`. :meth:`onto` is what a caller 228 usually wants, because the declared form is not one header and cannot be 229 returned as a string. 230 """ 231 return json.dumps( 232 { 233 "steps": [step.to_json() for step in self.steps], 234 "done": [step.to_json() for step in self.done], 235 } 236 ) 237 238 def written_as(self, form: SlipForm) -> RoutingSlip: 239 """A copy that will be written in ``form``. 240 241 Switching to :attr:`SlipForm.STEP_NAMES` gives the run an identifier if 242 it has none, because that form has a header for one and a run without an 243 identifier cannot be followed across its hops. 244 245 :param form: which wire form to write 246 :returns: a new slip 247 :raises ValueError: when the steps cannot be written in that form. Asked 248 here rather than at publish time, so a route that can never be 249 written the way it was asked for fails where it was built 250 """ 251 if form is SlipForm.JSON: 252 return replace(self, form=form) 253 switched = replace(self, form=form, run_id=self.run_id or str(uuid.uuid4())) 254 switched.step_names() 255 return switched 256 257 def step_names(self) -> tuple[str, ...]: 258 """The name of every step, done and still to do, in order. 259 260 What the declared form puts on the wire. A step's name is its routing 261 key on the route's exchange, which is how Java resolves one: the 262 pipeline's name is the exchange, the step's name is the binding, and the 263 queue behind it is ``{pipeline}.{step}``. 264 265 :returns: the names, oldest first 266 :raises ValueError: when the steps do not describe a declared route — 267 when they do not all share one exchange, when one has no name, when 268 a name carries the comma the wire form separates on, or when a step 269 has a name and a routing key that disagree. The last of those is a 270 slip that means two different things depending on which form it is 271 written in, and picking one silently is how a message ends up on a 272 queue nobody expected 273 """ 274 every = (*self.done, *self.steps) 275 if not every: 276 raise ValueError("acemq: this routing slip has no steps in it") 277 278 exchanges = {step.exchange for step in every} 279 if len(exchanges) > 1: 280 raise ValueError( 281 "acemq: a declared route is one exchange with a queue per step, but " 282 f"these steps span {sorted(exchanges)}; write this slip as JSON, " 283 "which carries an exchange per step" 284 ) 285 286 names: list[str] = [] 287 for step in every: 288 name = step.name or step.routing_key 289 if not name: 290 raise ValueError( 291 "acemq: a declared route carries step names and nothing else, " 292 "and one of these steps has neither a name nor a routing key" 293 ) 294 if step.name and step.routing_key and step.name != step.routing_key: 295 raise ValueError( 296 f"acemq: step {step.name!r} is published under {step.routing_key!r}, " 297 "and a declared route has one word for both — the name is the " 298 "routing key. Name the step after its routing key, or write this " 299 "slip as JSON" 300 ) 301 if "," in name: 302 raise ValueError( 303 f"acemq: step name {name!r} contains a comma, which is what the " 304 "declared route separates its steps with" 305 ) 306 names.append(name) 307 return tuple(names) 308 309 def onto(self, envelope: Envelope) -> Envelope: 310 """The envelope with this slip written on it, in this slip's form. 311 312 :param envelope: what the message is going out with 313 :returns: a copy carrying the slip 314 :raises ValueError: when the steps cannot be written in this slip's form 315 """ 316 if self.form is SlipForm.JSON: 317 return envelope.with_( 318 headers={**envelope.headers, HEADER_ROUTING_SLIP: self.to_header()} 319 ) 320 return envelope.with_( 321 route=",".join(self.step_names()), 322 route_position=len(self.done), 323 route_id=self.run_id or str(uuid.uuid4()), 324 ) 325 326 def __str__(self) -> str: 327 done = " -> ".join(str(step) for step in self.done) 328 todo = " -> ".join(str(step) for step in self.steps) 329 return f"RoutingSlip[done: {done} | next: {todo}]"
Where a message still has to go, and where it has been::
slip = (
RoutingSlip()
.then("orders-events", "order.validate", name="validate")
.then("orders-events", "order.charge", name="charge")
.then("orders-events", "order.ship", name="ship")
)
await start(mq, slip, order)
Immutable, and advance() returns a new one. A slip is on a message
that has already been published by the time anybody reads it, so changing
one in place would describe a journey that did not happen.
Parameters
- steps: what is still to do, in order
- done: what has already happened, oldest first, so a slip that fails halfway says how far it got
- run_id: identifies one run through the route across every hop. Only the declared form carries it on the wire; the JSON form has no field for it, and inventing one would be a change to a header three other libraries already read
- form: which wire form
onto()writes. A slip read off a message remembers the one it arrived in
193 def then(self, exchange: str, routing_key: str, *, name: str = "") -> RoutingSlip: 194 """A slip with one more stop on the end. 195 196 :param exchange: where that step's message goes 197 :param routing_key: what it is published under 198 :param name: what to call it in a log 199 :returns: a new slip 200 """ 201 return replace( 202 self, steps=(*self.steps, Step(exchange, routing_key, name=name)) 203 )
A slip with one more stop on the end.
Parameters
- exchange: where that step's message goes
- routing_key: what it is published under
- name: what to call it in a log :returns: a new slip
205 @property 206 def next(self) -> Step | None: 207 """The stop this message is going to, or ``None`` at the end.""" 208 return self.steps[0] if self.steps else None
The stop this message is going to, or None at the end.
210 @property 211 def finished(self) -> bool: 212 """Whether every step has been done.""" 213 return not self.steps
Whether every step has been done.
215 def advance(self) -> RoutingSlip: 216 """A copy with the first step moved to :attr:`done`, stamped with now.""" 217 if not self.steps: 218 return self 219 completed = replace( 220 self.steps[0], completed_at=datetime.now(timezone.utc).isoformat() 221 ) 222 return replace(self, steps=self.steps[1:], done=(*self.done, completed))
A copy with the first step moved to done, stamped with now.
224 def to_header(self) -> str: 225 """The slip rendered as JSON, whatever form it is in. 226 227 The value of :data:`HEADER_ROUTING_SLIP`. :meth:`onto` is what a caller 228 usually wants, because the declared form is not one header and cannot be 229 returned as a string. 230 """ 231 return json.dumps( 232 { 233 "steps": [step.to_json() for step in self.steps], 234 "done": [step.to_json() for step in self.done], 235 } 236 )
The slip rendered as JSON, whatever form it is in.
The value of HEADER_ROUTING_SLIP. onto() is what a caller
usually wants, because the declared form is not one header and cannot be
returned as a string.
238 def written_as(self, form: SlipForm) -> RoutingSlip: 239 """A copy that will be written in ``form``. 240 241 Switching to :attr:`SlipForm.STEP_NAMES` gives the run an identifier if 242 it has none, because that form has a header for one and a run without an 243 identifier cannot be followed across its hops. 244 245 :param form: which wire form to write 246 :returns: a new slip 247 :raises ValueError: when the steps cannot be written in that form. Asked 248 here rather than at publish time, so a route that can never be 249 written the way it was asked for fails where it was built 250 """ 251 if form is SlipForm.JSON: 252 return replace(self, form=form) 253 switched = replace(self, form=form, run_id=self.run_id or str(uuid.uuid4())) 254 switched.step_names() 255 return switched
A copy that will be written in form.
Switching to SlipForm.STEP_NAMES gives the run an identifier if
it has none, because that form has a header for one and a run without an
identifier cannot be followed across its hops.
Parameters
- form: which wire form to write :returns: a new slip
Raises
- ValueError: when the steps cannot be written in that form. Asked here rather than at publish time, so a route that can never be written the way it was asked for fails where it was built
257 def step_names(self) -> tuple[str, ...]: 258 """The name of every step, done and still to do, in order. 259 260 What the declared form puts on the wire. A step's name is its routing 261 key on the route's exchange, which is how Java resolves one: the 262 pipeline's name is the exchange, the step's name is the binding, and the 263 queue behind it is ``{pipeline}.{step}``. 264 265 :returns: the names, oldest first 266 :raises ValueError: when the steps do not describe a declared route — 267 when they do not all share one exchange, when one has no name, when 268 a name carries the comma the wire form separates on, or when a step 269 has a name and a routing key that disagree. The last of those is a 270 slip that means two different things depending on which form it is 271 written in, and picking one silently is how a message ends up on a 272 queue nobody expected 273 """ 274 every = (*self.done, *self.steps) 275 if not every: 276 raise ValueError("acemq: this routing slip has no steps in it") 277 278 exchanges = {step.exchange for step in every} 279 if len(exchanges) > 1: 280 raise ValueError( 281 "acemq: a declared route is one exchange with a queue per step, but " 282 f"these steps span {sorted(exchanges)}; write this slip as JSON, " 283 "which carries an exchange per step" 284 ) 285 286 names: list[str] = [] 287 for step in every: 288 name = step.name or step.routing_key 289 if not name: 290 raise ValueError( 291 "acemq: a declared route carries step names and nothing else, " 292 "and one of these steps has neither a name nor a routing key" 293 ) 294 if step.name and step.routing_key and step.name != step.routing_key: 295 raise ValueError( 296 f"acemq: step {step.name!r} is published under {step.routing_key!r}, " 297 "and a declared route has one word for both — the name is the " 298 "routing key. Name the step after its routing key, or write this " 299 "slip as JSON" 300 ) 301 if "," in name: 302 raise ValueError( 303 f"acemq: step name {name!r} contains a comma, which is what the " 304 "declared route separates its steps with" 305 ) 306 names.append(name) 307 return tuple(names)
The name of every step, done and still to do, in order.
What the declared form puts on the wire. A step's name is its routing
key on the route's exchange, which is how Java resolves one: the
pipeline's name is the exchange, the step's name is the binding, and the
queue behind it is {pipeline}.{step}.
:returns: the names, oldest first
Raises
- ValueError: when the steps do not describe a declared route — when they do not all share one exchange, when one has no name, when a name carries the comma the wire form separates on, or when a step has a name and a routing key that disagree. The last of those is a slip that means two different things depending on which form it is written in, and picking one silently is how a message ends up on a queue nobody expected
309 def onto(self, envelope: Envelope) -> Envelope: 310 """The envelope with this slip written on it, in this slip's form. 311 312 :param envelope: what the message is going out with 313 :returns: a copy carrying the slip 314 :raises ValueError: when the steps cannot be written in this slip's form 315 """ 316 if self.form is SlipForm.JSON: 317 return envelope.with_( 318 headers={**envelope.headers, HEADER_ROUTING_SLIP: self.to_header()} 319 ) 320 return envelope.with_( 321 route=",".join(self.step_names()), 322 route_position=len(self.done), 323 route_id=self.run_id or str(uuid.uuid4()), 324 )
The envelope with this slip written on it, in this slip's form.
Parameters
- envelope: what the message is going out with :returns: a copy carrying the slip
Raises
- ValueError: when the steps cannot be written in this slip's form
128class Saga(Generic[T]): 129 """An ordered list of steps, and what to do when one of them fails. 130 131 Built by adding steps, each of which returns the saga, so a description 132 reads as one expression:: 133 134 saga = ( 135 Saga("place order") 136 .step("reserve stock", reserve, release) 137 .step("charge card", charge, refund) 138 .step("notify", notify) 139 ) 140 result = await saga.run(order) 141 if result.has_unresolved: 142 alert(result) 143 144 :param name: what this saga is called, in logs and in results 145 """ 146 147 def __init__(self, name: str) -> None: 148 if not name: 149 raise ValueError("acemq: a saga needs a name") 150 self._name = name 151 self._steps: list[SagaStep[T]] = [] 152 153 @property 154 def name(self) -> str: 155 """What this saga is called.""" 156 return self._name 157 158 @property 159 def steps(self) -> tuple[SagaStep[T], ...]: 160 """The steps, in the order they run.""" 161 return tuple(self._steps) 162 163 def step( 164 self, 165 name: str, 166 action: SagaAction[T], 167 compensation: SagaAction[T] | None = None, 168 ) -> Saga[T]: 169 """Adds a step. 170 171 :param name: what the step is called. It identifies the step in the 172 result, so two steps cannot share one: a compensation report naming 173 a step twice tells an operator nothing 174 :param action: the work 175 :param compensation: how to undo it. Leaving it out is legitimate for a 176 step that changed nothing and a mistake for one that did, and there 177 is no warning for the second case because a library cannot tell 178 them apart 179 :returns: this saga 180 :raises ValueError: when the name is empty or already used 181 """ 182 if not name: 183 raise ValueError(f"acemq: a step of saga {self._name!r} needs a name") 184 if any(existing.name == name for existing in self._steps): 185 raise ValueError( 186 f"acemq: saga {self._name!r} already has a step called {name!r}. Names " 187 "identify a step in the compensation report, so two of them would make " 188 "that report ambiguous" 189 ) 190 self._steps.append(SagaStep(name, action, compensation)) 191 return self 192 193 async def run(self, subject: T) -> SagaResult: 194 """Runs the steps in order, compensating in reverse if one fails. 195 196 :param subject: what the steps operate on 197 :returns: what happened. It does not raise for a step failure, because 198 a caller needs the compensation report more than it needs a 199 traceback — the traceback is in :attr:`SagaResult.failure` 200 :raises ValueError: when the saga has no steps 201 """ 202 if not self._steps: 203 raise ValueError(f"acemq: saga {self._name!r} has no steps") 204 205 completed: list[str] = [] 206 for step in self._steps: 207 try: 208 await _call(step.action, subject) 209 except Exception as failure: 210 # Exception rather than BaseException: a cancellation is not a 211 # step failing, it is this task being taken away, and running 212 # compensations on the way out of one would be doing work 213 # nobody is left to hear about. 214 log.warning( 215 "acemq: saga %s failed at step %s: %r", self._name, step.name, failure 216 ) 217 unresolved = await self._compensate(subject, completed) 218 return SagaResult( 219 saga=self._name, 220 completed=tuple(completed), 221 failed_at=step.name, 222 failure=failure, 223 unresolved=tuple(unresolved), 224 ) 225 completed.append(step.name) 226 log.debug("acemq: saga %s completed step %s", self._name, step.name) 227 228 return SagaResult(saga=self._name, completed=tuple(completed)) 229 230 async def _compensate(self, subject: T, completed: list[str]) -> list[str]: 231 """Undoes what was done, most recent first. 232 233 Reverse order because that is the order the world was changed in, and a 234 compensation often depends on state a later step has not yet altered. 235 236 :returns: the steps whose compensation failed, in the order they were 237 attempted, which is what a human has to reconcile 238 """ 239 unresolved: list[str] = [] 240 for name in reversed(completed): 241 step = self._step_named(name) 242 if step.compensation is None: 243 # Legitimate: a step that only read something, or one whose 244 # effect is harmless, needs no undo. 245 continue 246 try: 247 await _call(step.compensation, subject) 248 except Exception as failure: 249 # Logged and carried on. Stopping here would leave more undone 250 # than continuing, and the caller is told exactly which ones 251 # did not come back. 252 log.error( 253 "acemq: saga %s could not compensate step %s: %r", 254 self._name, 255 name, 256 failure, 257 ) 258 unresolved.append(name) 259 continue 260 log.debug("acemq: saga %s compensated step %s", self._name, name) 261 return unresolved 262 263 def _step_named(self, name: str) -> SagaStep[T]: 264 for step in self._steps: 265 if step.name == name: 266 return step 267 raise AssertionError(f"acemq: saga {self._name!r} has no step called {name!r}") 268 269 def __str__(self) -> str: 270 return f"Saga({self._name}: {' -> '.join(step.name for step in self._steps)})"
An ordered list of steps, and what to do when one of them fails.
Built by adding steps, each of which returns the saga, so a description reads as one expression::
saga = (
Saga("place order")
.step("reserve stock", reserve, release)
.step("charge card", charge, refund)
.step("notify", notify)
)
result = await saga.run(order)
if result.has_unresolved:
alert(result)
Parameters
- name: what this saga is called, in logs and in results
158 @property 159 def steps(self) -> tuple[SagaStep[T], ...]: 160 """The steps, in the order they run.""" 161 return tuple(self._steps)
The steps, in the order they run.
163 def step( 164 self, 165 name: str, 166 action: SagaAction[T], 167 compensation: SagaAction[T] | None = None, 168 ) -> Saga[T]: 169 """Adds a step. 170 171 :param name: what the step is called. It identifies the step in the 172 result, so two steps cannot share one: a compensation report naming 173 a step twice tells an operator nothing 174 :param action: the work 175 :param compensation: how to undo it. Leaving it out is legitimate for a 176 step that changed nothing and a mistake for one that did, and there 177 is no warning for the second case because a library cannot tell 178 them apart 179 :returns: this saga 180 :raises ValueError: when the name is empty or already used 181 """ 182 if not name: 183 raise ValueError(f"acemq: a step of saga {self._name!r} needs a name") 184 if any(existing.name == name for existing in self._steps): 185 raise ValueError( 186 f"acemq: saga {self._name!r} already has a step called {name!r}. Names " 187 "identify a step in the compensation report, so two of them would make " 188 "that report ambiguous" 189 ) 190 self._steps.append(SagaStep(name, action, compensation)) 191 return self
Adds a step.
Parameters
- name: what the step is called. It identifies the step in the result, so two steps cannot share one: a compensation report naming a step twice tells an operator nothing
- action: the work
- compensation: how to undo it. Leaving it out is legitimate for a step that changed nothing and a mistake for one that did, and there is no warning for the second case because a library cannot tell them apart :returns: this saga
Raises
- ValueError: when the name is empty or already used
193 async def run(self, subject: T) -> SagaResult: 194 """Runs the steps in order, compensating in reverse if one fails. 195 196 :param subject: what the steps operate on 197 :returns: what happened. It does not raise for a step failure, because 198 a caller needs the compensation report more than it needs a 199 traceback — the traceback is in :attr:`SagaResult.failure` 200 :raises ValueError: when the saga has no steps 201 """ 202 if not self._steps: 203 raise ValueError(f"acemq: saga {self._name!r} has no steps") 204 205 completed: list[str] = [] 206 for step in self._steps: 207 try: 208 await _call(step.action, subject) 209 except Exception as failure: 210 # Exception rather than BaseException: a cancellation is not a 211 # step failing, it is this task being taken away, and running 212 # compensations on the way out of one would be doing work 213 # nobody is left to hear about. 214 log.warning( 215 "acemq: saga %s failed at step %s: %r", self._name, step.name, failure 216 ) 217 unresolved = await self._compensate(subject, completed) 218 return SagaResult( 219 saga=self._name, 220 completed=tuple(completed), 221 failed_at=step.name, 222 failure=failure, 223 unresolved=tuple(unresolved), 224 ) 225 completed.append(step.name) 226 log.debug("acemq: saga %s completed step %s", self._name, step.name) 227 228 return SagaResult(saga=self._name, completed=tuple(completed))
Runs the steps in order, compensating in reverse if one fails.
Parameters
- subject: what the steps operate on
:returns: what happened. It does not raise for a step failure, because
a caller needs the compensation report more than it needs a
traceback — the traceback is in
SagaResult.failure
Raises
- ValueError: when the saga has no steps
80@dataclass(frozen=True) 81class SagaResult: 82 """What a saga did. 83 84 :param saga: the saga's name 85 :param completed: the steps whose action ran, in the order they ran 86 :param failed_at: the step that failed, or ``None`` when none did 87 :param failure: what it failed with 88 :param unresolved: the steps whose compensation failed 89 """ 90 91 saga: str 92 completed: tuple[str, ...] = () 93 failed_at: str | None = None 94 failure: Exception | None = None 95 unresolved: tuple[str, ...] = field(default=()) 96 97 @property 98 def complete(self) -> bool: 99 """Whether every step ran.""" 100 return self.failed_at is None 101 102 @property 103 def compensated(self) -> bool: 104 """Whether a step failed and the earlier ones were undone.""" 105 return self.failed_at is not None 106 107 @property 108 def has_unresolved(self) -> bool: 109 """Whether anything was left half-done. 110 111 **This is the flag to alert on.** Everything else a saga reports is 112 recoverable by construction; these are real-world effects that happened, 113 were meant to be undone, and were not. Nothing else in the system knows 114 about them and no retry resolves them — a person has to. 115 """ 116 return bool(self.unresolved) 117 118 def __str__(self) -> str: 119 if self.complete: 120 return f"{self.saga} completed: {' -> '.join(self.completed)}" 121 undone = "" if not self.unresolved else f", UNRESOLVED {list(self.unresolved)}" 122 return ( 123 f"{self.saga} failed at {self.failed_at}" 124 f", compensated {list(self.completed)}{undone}" 125 )
What a saga did.
Parameters
- saga: the saga's name
- completed: the steps whose action ran, in the order they ran
- failed_at: the step that failed, or
Nonewhen none did - failure: what it failed with
- unresolved: the steps whose compensation failed
97 @property 98 def complete(self) -> bool: 99 """Whether every step ran.""" 100 return self.failed_at is None
Whether every step ran.
102 @property 103 def compensated(self) -> bool: 104 """Whether a step failed and the earlier ones were undone.""" 105 return self.failed_at is not None
Whether a step failed and the earlier ones were undone.
107 @property 108 def has_unresolved(self) -> bool: 109 """Whether anything was left half-done. 110 111 **This is the flag to alert on.** Everything else a saga reports is 112 recoverable by construction; these are real-world effects that happened, 113 were meant to be undone, and were not. Nothing else in the system knows 114 about them and no retry resolves them — a person has to. 115 """ 116 return bool(self.unresolved)
Whether anything was left half-done.
This is the flag to alert on. Everything else a saga reports is recoverable by construction; these are real-world effects that happened, were meant to be undone, and were not. Nothing else in the system knows about them and no retry resolves them — a person has to.
64@dataclass(frozen=True) 65class SagaStep(Generic[T]): 66 """One thing a saga does, and optionally the thing that undoes it. 67 68 :param name: what the step is called, which is how it is identified in the 69 result and therefore in whatever alert reads the result 70 :param action: the work 71 :param compensation: how to undo it, or ``None`` for a step with nothing to 72 undo 73 """ 74 75 name: str 76 action: SagaAction[T] 77 compensation: SagaAction[T] | None = None
One thing a saga does, and optionally the thing that undoes it.
Parameters
- name: what the step is called, which is how it is identified in the result and therefore in whatever alert reads the result
- action: the work
- compensation: how to undo it, or
Nonefor a step with nothing to undo
254class Scheduler: 255 """Delivers messages later, through a ladder of time-to-live queues. 256 257 Built by :meth:`open`, because it declares its topology and starts a 258 consumer, and both have to have happened before it is any use. Close it when 259 done: it holds a consumer. 260 261 One per process is plenty and several are harmless — they are all consuming 262 the same control queue, and the broker gives each expired message to exactly 263 one of them. 264 """ 265 266 def __init__(self, connection: Connection, codec: Codec | None = None) -> None: 267 self._connection = connection 268 self._codec = codec or connection.codec 269 self._consumer: Consumer | None = None 270 self._scheduled = 0 271 self._delivered = 0 272 self._hops = 0 273 274 @classmethod 275 async def open( 276 cls, 277 connection: Connection, 278 *, 279 codec: Codec | None = None, 280 prefetch: int = DEFAULT_SCHEDULE_PREFETCH, 281 ) -> Scheduler: 282 """Declares the topology and starts consuming the control queue. 283 284 :param connection: an open connection, which the scheduler publishes and 285 consumes on 286 :param codec: what to encode scheduled payloads with, instead of the 287 connection's. Whatever it is travels with the message as 288 :data:`HEADER_SCHEDULE_CONTENT_TYPE`, so the eventual consumer can choose the 289 matching one 290 :param prefetch: how many expired messages to hold at once 291 :returns: the scheduler, already consuming 292 """ 293 await connection.declare(schedule_topology()) 294 scheduler = cls(connection, codec) 295 # Raw bytes: this scheduler never looks inside a payload, and decoding 296 # one it has no business understanding is how a scheduler acquires 297 # opinions about message formats. 298 # 299 # ``declare=False`` because a consumer declares its dead-letter queues 300 # at start-up, and the control queue's would be ``acemq.schedule.due.dlq`` 301 # and ``acemq.schedule.due.parked`` — two durable queues per service 302 # running a scheduler, that nothing publishes to, nothing reads and 303 # nobody deletes. Everything this consumer needs is in 304 # :func:`schedule_topology`, which has already been applied above. 305 scheduler._consumer = await connection.consume( 306 SCHEDULE_DUE, 307 scheduler._forward, 308 codec=BytesCodec(), 309 prefetch=prefetch, 310 declare=False, 311 ) 312 log.debug( 313 "acemq: scheduler declared with rungs %s", 314 [_describe(rung) for rung in SCHEDULE_RUNGS], 315 ) 316 return scheduler 317 318 @property 319 def scheduled(self) -> int: 320 """Messages handed to this scheduler.""" 321 return self._scheduled 322 323 @property 324 def delivered(self) -> int: 325 """Messages this scheduler sent on to their destination.""" 326 return self._delivered 327 328 @property 329 def hops(self) -> int: 330 """How many times a message moved between rungs. 331 332 Divided by :attr:`delivered` this is the average number of hops, which 333 is the number to look at if the scheduler is busier than expected: long 334 delays cost hops. 335 """ 336 return self._hops 337 338 async def after( 339 self, delay: timedelta, exchange: str, routing_key: str, payload: Any 340 ) -> None: 341 """Delivers a message after a delay. 342 343 :param delay: how long to wait; zero or negative delivers immediately 344 :param exchange: where it is going, empty for the default exchange 345 :param routing_key: what to publish it under, or a queue name 346 :param payload: what to send 347 """ 348 await self.at(_now() + delay, exchange, routing_key, payload) 349 350 async def at( 351 self, when: datetime, exchange: str, routing_key: str, payload: Any 352 ) -> None: 353 """Delivers a message at a moment. 354 355 :param when: when it is due. It must carry a time zone: a naive datetime 356 would be read as the local time of whichever machine scheduled it, 357 and a scheduler is the last place to discover that two of them 358 disagree 359 :param exchange: where it is going, empty for the default exchange 360 :param routing_key: what to publish it under, or a queue name 361 :param payload: what to send 362 :raises ValueError: when ``when`` has no time zone 363 """ 364 if when.tzinfo is None: 365 raise ValueError( 366 f"acemq: {when} has no time zone, and a scheduled message needs one. " 367 "Use datetime.now(timezone.utc) rather than datetime.now()" 368 ) 369 370 headers: dict[str, Any] = { 371 HEADER_SCHEDULE_EXCHANGE: exchange, 372 HEADER_SCHEDULE_ROUTING_KEY: routing_key, 373 HEADER_SCHEDULE_DUE_AT: _millis(when), 374 # Encoded once, here, and carried as bytes from then on. The content 375 # type goes with them, because that is how the eventual consumer 376 # chooses a codec. 377 HEADER_SCHEDULE_CONTENT_TYPE: self._codec.content_type, 378 } 379 self._scheduled += 1 380 await self._route(self._codec.encode(payload), headers, when) 381 382 async def _forward(self, message: Message) -> Ack: 383 """Handles one message that has come out of a rung.""" 384 headers = dict(message.envelope.headers) 385 due_at = headers.get(HEADER_SCHEDULE_DUE_AT) 386 if ( 387 due_at is None 388 or HEADER_SCHEDULE_EXCHANGE not in headers 389 or HEADER_SCHEDULE_ROUTING_KEY not in headers 390 ): 391 # Rejected rather than retried: no number of attempts adds a header. 392 return reject( 393 AceMQError( 394 f"acemq: a message reached {SCHEDULE_DUE} without the headers a scheduled " 395 "message carries. Something else is publishing into the scheduler's " 396 "queues, which it must not: they are an implementation detail" 397 ) 398 ) 399 try: 400 when = _from_millis(_number(due_at)) 401 except AceMQError as unreadable: 402 return reject(unreadable) 403 try: 404 await self._route(message.body, headers, when) 405 except Exception as failure: 406 # Only reached when even the hop into a rung failed — a rung deleted 407 # under a running scheduler, or the connection going. Put back on the 408 # control queue rather than left to the consumer's retry policy: this 409 # queue has no dead-letter queue, so giving up would reject the 410 # message to nowhere. 411 log.error( 412 "acemq: could not move a scheduled message on, putting it back on %s: %s", 413 SCHEDULE_DUE, 414 failure, 415 ) 416 return in_progress() 417 return accept() 418 419 async def _route(self, body: bytes, headers: dict[str, Any], when: datetime) -> None: 420 """Delivers if it is due, and otherwise puts it in the largest rung that 421 does not overshoot.""" 422 remaining = when - _now() 423 if remaining < SCHEDULE_RUNGS[-1]: 424 # Due, or so nearly due that another hop would cost more than the 425 # accuracy it buys. 426 await self._deliver(body, headers) 427 return 428 429 rung = next( 430 (candidate for candidate in SCHEDULE_RUNGS if candidate <= remaining), 431 SCHEDULE_RUNGS[-1], 432 ) 433 await self._hop(body, headers, rung) 434 435 async def _hop(self, body: bytes, headers: dict[str, Any], rung: timedelta) -> None: 436 """Puts the message in a rung, from which it comes back to 437 :data:`SCHEDULE_DUE` when the rung's time to live runs out.""" 438 self._hops += 1 439 # Mandatory here and in _deliver, because the control message is 440 # acknowledged as soon as this returns: a rung that has been deleted or 441 # a destination nothing is bound to is confirmed and dropped by the 442 # broker, and without the return the scheduled message would be gone 443 # with nothing reporting it. Raised instead: _deliver sends a refused 444 # delivery round a rung, and _forward puts back a message whose hop 445 # failed. 446 await self._connection.publisher( 447 SCHEDULE_EXCHANGE, schedule_rung_name(rung), codec=BytesCodec(), mandatory=True 448 ).send(body, envelope=Envelope(type=SCHEDULED_TYPE, headers=headers)) 449 450 async def _deliver(self, body: bytes, headers: dict[str, Any]) -> None: 451 """Sends the message on to where it was always going.""" 452 exchange = _text(headers.get(HEADER_SCHEDULE_EXCHANGE)) 453 routing_key = _text(headers.get(HEADER_SCHEDULE_ROUTING_KEY)) 454 content_type = _text(headers.get(HEADER_SCHEDULE_CONTENT_TYPE)) or JSON_CONTENT_TYPE 455 456 # The scheduler's own headers are not passed on: they are bookkeeping, 457 # and a consumer that started depending on them would be depending on 458 # how a message got to it. 459 try: 460 await self._connection.publisher( 461 exchange, routing_key, codec=_Verbatim(content_type), mandatory=True 462 ).send(body, envelope=Envelope(type=SCHEDULED_TYPE)) 463 except Exception as refused: 464 # Unroutable, refused or sent to an exchange that is not there. Kept 465 # and tried again after one more trip through a rung, for as long as 466 # it takes, as the other AceMQ libraries do. Left to the control 467 # consumer it would be dead-lettered to a queue that does not exist, 468 # and so rejected to nowhere: "the target is not bound yet" turned 469 # into a message nobody delivers. 470 log.warning( 471 "acemq: a scheduled message for %r/%r could not be delivered; " 472 "trying again in %s: %s", 473 exchange, 474 routing_key, 475 SCHEDULE_RETRY_RUNG, 476 refused, 477 ) 478 await self._hop(body, headers, SCHEDULE_RETRY_RUNG) 479 return 480 self._delivered += 1 481 482 async def close(self) -> None: 483 """Stops consuming the control queue. 484 485 It declares nothing and deletes nothing on the way out. The rungs are 486 shared with every other scheduler on the broker, and a message already 487 in one is delivered by whoever is consuming ``acemq.schedule.due`` when 488 it expires — which may well be another process. 489 """ 490 if self._consumer is not None: 491 await self._consumer.close() 492 self._consumer = None 493 494 async def __aenter__(self) -> Scheduler: 495 return self 496 497 async def __aexit__(self, *_: object) -> None: 498 await self.close() 499 500 def __str__(self) -> str: 501 return ( 502 f"Scheduler(rungs={[_describe(rung) for rung in SCHEDULE_RUNGS]}, " 503 f"scheduled={self._scheduled}, delivered={self._delivered}, hops={self._hops})" 504 )
Delivers messages later, through a ladder of time-to-live queues.
Built by open(), because it declares its topology and starts a
consumer, and both have to have happened before it is any use. Close it when
done: it holds a consumer.
One per process is plenty and several are harmless — they are all consuming the same control queue, and the broker gives each expired message to exactly one of them.
274 @classmethod 275 async def open( 276 cls, 277 connection: Connection, 278 *, 279 codec: Codec | None = None, 280 prefetch: int = DEFAULT_SCHEDULE_PREFETCH, 281 ) -> Scheduler: 282 """Declares the topology and starts consuming the control queue. 283 284 :param connection: an open connection, which the scheduler publishes and 285 consumes on 286 :param codec: what to encode scheduled payloads with, instead of the 287 connection's. Whatever it is travels with the message as 288 :data:`HEADER_SCHEDULE_CONTENT_TYPE`, so the eventual consumer can choose the 289 matching one 290 :param prefetch: how many expired messages to hold at once 291 :returns: the scheduler, already consuming 292 """ 293 await connection.declare(schedule_topology()) 294 scheduler = cls(connection, codec) 295 # Raw bytes: this scheduler never looks inside a payload, and decoding 296 # one it has no business understanding is how a scheduler acquires 297 # opinions about message formats. 298 # 299 # ``declare=False`` because a consumer declares its dead-letter queues 300 # at start-up, and the control queue's would be ``acemq.schedule.due.dlq`` 301 # and ``acemq.schedule.due.parked`` — two durable queues per service 302 # running a scheduler, that nothing publishes to, nothing reads and 303 # nobody deletes. Everything this consumer needs is in 304 # :func:`schedule_topology`, which has already been applied above. 305 scheduler._consumer = await connection.consume( 306 SCHEDULE_DUE, 307 scheduler._forward, 308 codec=BytesCodec(), 309 prefetch=prefetch, 310 declare=False, 311 ) 312 log.debug( 313 "acemq: scheduler declared with rungs %s", 314 [_describe(rung) for rung in SCHEDULE_RUNGS], 315 ) 316 return scheduler
Declares the topology and starts consuming the control queue.
Parameters
- connection: an open connection, which the scheduler publishes and consumes on
- codec: what to encode scheduled payloads with, instead of the
connection's. Whatever it is travels with the message as
HEADER_SCHEDULE_CONTENT_TYPE, so the eventual consumer can choose the matching one - prefetch: how many expired messages to hold at once :returns: the scheduler, already consuming
318 @property 319 def scheduled(self) -> int: 320 """Messages handed to this scheduler.""" 321 return self._scheduled
Messages handed to this scheduler.
323 @property 324 def delivered(self) -> int: 325 """Messages this scheduler sent on to their destination.""" 326 return self._delivered
Messages this scheduler sent on to their destination.
328 @property 329 def hops(self) -> int: 330 """How many times a message moved between rungs. 331 332 Divided by :attr:`delivered` this is the average number of hops, which 333 is the number to look at if the scheduler is busier than expected: long 334 delays cost hops. 335 """ 336 return self._hops
How many times a message moved between rungs.
Divided by delivered this is the average number of hops, which
is the number to look at if the scheduler is busier than expected: long
delays cost hops.
338 async def after( 339 self, delay: timedelta, exchange: str, routing_key: str, payload: Any 340 ) -> None: 341 """Delivers a message after a delay. 342 343 :param delay: how long to wait; zero or negative delivers immediately 344 :param exchange: where it is going, empty for the default exchange 345 :param routing_key: what to publish it under, or a queue name 346 :param payload: what to send 347 """ 348 await self.at(_now() + delay, exchange, routing_key, payload)
Delivers a message after a delay.
Parameters
- delay: how long to wait; zero or negative delivers immediately
- exchange: where it is going, empty for the default exchange
- routing_key: what to publish it under, or a queue name
- payload: what to send
350 async def at( 351 self, when: datetime, exchange: str, routing_key: str, payload: Any 352 ) -> None: 353 """Delivers a message at a moment. 354 355 :param when: when it is due. It must carry a time zone: a naive datetime 356 would be read as the local time of whichever machine scheduled it, 357 and a scheduler is the last place to discover that two of them 358 disagree 359 :param exchange: where it is going, empty for the default exchange 360 :param routing_key: what to publish it under, or a queue name 361 :param payload: what to send 362 :raises ValueError: when ``when`` has no time zone 363 """ 364 if when.tzinfo is None: 365 raise ValueError( 366 f"acemq: {when} has no time zone, and a scheduled message needs one. " 367 "Use datetime.now(timezone.utc) rather than datetime.now()" 368 ) 369 370 headers: dict[str, Any] = { 371 HEADER_SCHEDULE_EXCHANGE: exchange, 372 HEADER_SCHEDULE_ROUTING_KEY: routing_key, 373 HEADER_SCHEDULE_DUE_AT: _millis(when), 374 # Encoded once, here, and carried as bytes from then on. The content 375 # type goes with them, because that is how the eventual consumer 376 # chooses a codec. 377 HEADER_SCHEDULE_CONTENT_TYPE: self._codec.content_type, 378 } 379 self._scheduled += 1 380 await self._route(self._codec.encode(payload), headers, when)
Delivers a message at a moment.
Parameters
- when: when it is due. It must carry a time zone: a naive datetime would be read as the local time of whichever machine scheduled it, and a scheduler is the last place to discover that two of them disagree
- exchange: where it is going, empty for the default exchange
- routing_key: what to publish it under, or a queue name
- payload: what to send
Raises
- ValueError: when
whenhas no time zone
482 async def close(self) -> None: 483 """Stops consuming the control queue. 484 485 It declares nothing and deletes nothing on the way out. The rungs are 486 shared with every other scheduler on the broker, and a message already 487 in one is delivered by whoever is consuming ``acemq.schedule.due`` when 488 it expires — which may well be another process. 489 """ 490 if self._consumer is not None: 491 await self._consumer.close() 492 self._consumer = None
Stops consuming the control queue.
It declares nothing and deletes nothing on the way out. The rungs are
shared with every other scheduler on the broker, and a message already
in one is delivered by whoever is consuming acemq.schedule.due when
it expires — which may well be another process.
55@dataclass(frozen=True, slots=True) 56class SchemaDefinition: 57 """One version of a message's shape. 58 59 :param id: assigned by the registry, and what goes on the wire 60 :param subject: groups the versions of one message type, conventionally the 61 type itself — ``order.placed`` 62 :param version: counts from 1 within a subject 63 :param format: ``avro``, ``protobuf``, ``json-schema`` — whatever it is 64 written in. The registry does not interpret it 65 :param definition: the schema itself 66 :param fingerprint: a hash of the definition, so the same schema registered 67 twice gets the same identifier rather than a second version 68 :param registered_at: when it was first seen 69 """ 70 71 id: int 72 subject: str 73 version: int 74 format: str 75 definition: str 76 fingerprint: str 77 registered_at: datetime = field( 78 default_factory=lambda: datetime.now(timezone.utc) 79 ) 80 81 def __str__(self) -> str: 82 return f"{self.subject} v{self.version} ({self.format}, id {self.id})"
One version of a message's shape.
Parameters
- id: assigned by the registry, and what goes on the wire
- subject: groups the versions of one message type, conventionally the
type itself —
order.placed - version: counts from 1 within a subject
- format:
avro,protobuf,json-schema— whatever it is written in. The registry does not interpret it - definition: the schema itself
- fingerprint: a hash of the definition, so the same schema registered twice gets the same identifier rather than a second version
- registered_at: when it was first seen
45class SchemaNotFoundError(AceMQError): 46 """A lookup found nothing. 47 48 Raised rather than answered with an empty definition, because a consumer 49 reading a message whose schema it cannot find has a real problem — usually a 50 producer registered against a different registry — and carrying on with a 51 blank shape turns that into silent nonsense downstream. 52 """
A lookup found nothing.
Raised rather than answered with an empty definition, because a consumer reading a message whose schema it cannot find has a real problem — usually a producer registered against a different registry — and carrying on with a blank shape turns that into silent nonsense downstream.
85@runtime_checkable 86class SchemaRegistry(Protocol): 87 """Where message shapes live.""" 88 89 async def register( 90 self, subject: str, format: str, definition: str 91 ) -> SchemaDefinition: 92 """Records a schema and returns it with an identifier. 93 94 Registering the same definition twice must return the same identifier 95 rather than making a second version. Without that, a service that 96 registers its schemas on every start adds a version per restart, and the 97 version number stops meaning anything. 98 """ 99 100 async def by_id(self, identifier: int) -> SchemaDefinition: 101 """Looks a schema up by the identifier a message carries. 102 103 :raises SchemaNotFoundError: when there is no such schema 104 """ 105 106 async def latest(self, subject: str) -> SchemaDefinition: 107 """The newest version of a subject. 108 109 :raises SchemaNotFoundError: when the subject has no versions 110 """ 111 112 async def versions(self, subject: str) -> Sequence[SchemaDefinition]: 113 """Every version of a subject, oldest first."""
Where message shapes live.
1771def _no_init_or_replace_init(self, *args, **kwargs): 1772 cls = type(self) 1773 1774 if cls._is_protocol: 1775 raise TypeError('Protocols cannot be instantiated') 1776 1777 # Already using a custom `__init__`. No need to calculate correct 1778 # `__init__` to call. This can lead to RecursionError. See bpo-45121. 1779 if cls.__init__ is not _no_init_or_replace_init: 1780 return 1781 1782 # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`. 1783 # The first instantiation of the subclass will call `_no_init_or_replace_init` which 1784 # searches for a proper new `__init__` in the MRO. The new `__init__` 1785 # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent 1786 # instantiation of the protocol subclass will thus use the new 1787 # `__init__` and no longer call `_no_init_or_replace_init`. 1788 for base in cls.__mro__: 1789 init = base.__dict__.get('__init__', _no_init_or_replace_init) 1790 if init is not _no_init_or_replace_init: 1791 cls.__init__ = init 1792 break 1793 else: 1794 # should not happen 1795 cls.__init__ = object.__init__ 1796 1797 cls.__init__(self, *args, **kwargs)
89 async def register( 90 self, subject: str, format: str, definition: str 91 ) -> SchemaDefinition: 92 """Records a schema and returns it with an identifier. 93 94 Registering the same definition twice must return the same identifier 95 rather than making a second version. Without that, a service that 96 registers its schemas on every start adds a version per restart, and the 97 version number stops meaning anything. 98 """
Records a schema and returns it with an identifier.
Registering the same definition twice must return the same identifier rather than making a second version. Without that, a service that registers its schemas on every start adds a version per restart, and the version number stops meaning anything.
100 async def by_id(self, identifier: int) -> SchemaDefinition: 101 """Looks a schema up by the identifier a message carries. 102 103 :raises SchemaNotFoundError: when there is no such schema 104 """
Looks a schema up by the identifier a message carries.
Raises
- SchemaNotFoundError: when there is no such schema
106 async def latest(self, subject: str) -> SchemaDefinition: 107 """The newest version of a subject. 108 109 :raises SchemaNotFoundError: when the subject has no versions 110 """
The newest version of a subject.
Raises
- SchemaNotFoundError: when the subject has no versions
92class SlipForm(str, Enum): 93 """Which of the two wire forms a slip is written in. 94 95 A slip read off a message remembers the form it arrived in, and 96 :func:`follow_slip` writes the same one back unless it is told otherwise. A 97 step that read a Java pipeline's route and answered with a JSON slip would 98 hand the next Java step a message with no route on it at all, which is a 99 pipeline that stops in the middle for no visible reason. 100 """ 101 102 #: ``acemq-routing-slip``: exchange, routing key and name per step, as JSON. 103 #: What a slip built here starts out as. 104 JSON = "json" 105 106 #: ``x-acemq-route``: the step names, comma-separated, with the position and 107 #: the run identifier beside them. What Java's ``Pipeline`` writes. 108 STEP_NAMES = "step-names"
Which of the two wire forms a slip is written in.
A slip read off a message remembers the form it arrived in, and
follow_slip() writes the same one back unless it is told otherwise. A
step that read a Java pipeline's route and answered with a JSON slip would
hand the next Java step a message with no route on it at all, which is a
pipeline that stops in the middle for no visible reason.
441class SqlIdempotencyStore: 442 """Remembers handled messages in a table every consumer can see. 443 444 This is what :class:`~acemq_amqp.patterns.InMemoryIdempotencyStore` points 445 at when it says the store has to be one the workers share. Two consumers 446 racing on the same message cannot both be told they are first, because the 447 insert *is* the claim and a primary key makes it atomic across every process 448 using the table — whatever else they are doing, and without a lock anybody 449 has to remember to take. 450 451 **A claim is a lease, not a fact.** A row starts ``CLAIMED`` with a deadline 452 ``claim_timeout`` away. :meth:`confirm` turns it into ``CONFIRMED`` with a 453 deadline ``retention`` away, and :func:`~acemq_amqp.patterns.idempotent` 454 calls that for you when a handler accepts. The lease is what a fact cannot 455 do: a consumer that dies holding a message would otherwise have recorded it 456 as handled without handling it, and the redelivery — the thing at-least-once 457 delivery is *for* — would be skipped. So a hold that has run out can be 458 taken over, and the message gets handled by somebody. 459 460 Nothing on the message path deletes anything. Schedule :meth:`purge_expired` 461 instead; hourly is ample. A store that tidies up on the hot path makes every 462 message pay for it. 463 464 :param connections: a callable returning a connection this store may commit 465 and close — a pool checkout, or ``lambda: sqlite3.connect(path)`` 466 :param table: where the rows go 467 :param paramstyle: ``"qmark"`` for :mod:`sqlite3`, ``"format"`` for psycopg 468 :param claim_timeout: how long a claim is held before it can be taken over 469 :param retention: how long a handled message is remembered 470 """ 471 472 def __init__( 473 self, 474 connections: Connections | None = None, 475 *, 476 table: str = DEFAULT_IDEMPOTENCY_TABLE, 477 paramstyle: str = "qmark", 478 claim_timeout: timedelta = DEFAULT_CLAIM_TIMEOUT, 479 retention: timedelta = DEFAULT_RETENTION, 480 ) -> None: 481 if claim_timeout <= timedelta(0): 482 raise ValueError(f"acemq: a claim timeout must be positive, got {claim_timeout}") 483 if retention <= timedelta(0): 484 raise ValueError(f"acemq: a retention window must be positive, got {retention}") 485 self._sql = _Sql(table, paramstyle) 486 self._work = _Work(connections) 487 self._claim_timeout = claim_timeout 488 self._retention = retention 489 # Which process holds a claim. Only this instance may release its own, 490 # because releasing somebody else's would put two consumers on one 491 # message. 492 self._node = uuid.uuid4().hex 493 494 @property 495 def table(self) -> str: 496 """Where the rows are.""" 497 return self._sql.table 498 499 @property 500 def node(self) -> str: 501 """What this instance writes into ``claimed_by``.""" 502 return self._node 503 504 async def first_time( 505 self, key: str, *, connection: DbConnection | None = None 506 ) -> bool: 507 """Claims a message, and says whether the claim was ours to take. 508 509 :param key: what identifies the message 510 :param connection: a connection to work on instead of one of our own. 511 Pass the one your business transaction is using and the claim 512 becomes durable exactly when your work does 513 :returns: whether this is the first time 514 """ 515 return await self.claim(key, connection=connection) is Claim.CLAIMED 516 517 async def claim(self, key: str, *, connection: DbConnection | None = None) -> Claim: 518 """Claims a message, or says why not. 519 520 :attr:`~Claim.CLAIMED` when there was no row or its lease had run out; 521 :attr:`~Claim.DUPLICATE` when the row is confirmed; 522 :attr:`~Claim.IN_PROGRESS` when somebody holds a live, unconfirmed claim 523 — still working, or failed without releasing it. The difference between 524 the last two is the difference between done and maybe-never-done, which 525 is why :func:`~acemq_amqp.patterns.idempotent` accepts one and puts the 526 other back. 527 528 :param key: what identifies the message 529 :param connection: as for :meth:`first_time` 530 """ 531 if not key: 532 raise ValueError("acemq: an idempotency key cannot be empty") 533 now = _now_ms() 534 535 def statements(cursor: DbCursor) -> Claim: 536 # The insert is the claim, and the primary key is what makes it 537 # atomic. ON CONFLICT DO NOTHING rather than catching the driver's 538 # integrity error, because every driver spells that differently and 539 # a failed statement poisons an open PostgreSQL transaction. 540 cursor.execute( 541 self._sql( 542 "INSERT INTO {table} " 543 "(message_id, state, claimed_by, recorded_at, expires_at) " 544 "VALUES (?, ?, ?, ?, ?) ON CONFLICT (message_id) DO NOTHING" 545 ), 546 (key, _CLAIMED, self._node, now, now + _ms(self._claim_timeout)), 547 ) 548 if cursor.rowcount == 1: 549 return Claim.CLAIMED 550 551 # Somebody already has a row. Taking it over is allowed only if 552 # their hold has run out, and the guard lives in the WHERE clause so 553 # the check and the take-over are one statement: a select followed 554 # by an update would let two consumers both pass the check and both 555 # conclude they had won. 556 cursor.execute( 557 self._sql( 558 "UPDATE {table} SET state = ?, claimed_by = ?, recorded_at = ?, " 559 "expires_at = ? WHERE message_id = ? AND expires_at <= ?" 560 ), 561 ( 562 _CLAIMED, 563 self._node, 564 now, 565 now + _ms(self._claim_timeout), 566 key, 567 now, 568 ), 569 ) 570 if cursor.rowcount == 1: 571 return Claim.CLAIMED 572 573 cursor.execute( 574 self._sql("SELECT state FROM {table} WHERE message_id = ?"), (key,) 575 ) 576 row = cursor.fetchone() 577 # No row means it was released or purged between the two 578 # statements: in progress is the answer that cannot lose it. 579 return ( 580 Claim.DUPLICATE 581 if row is not None and row[0] == _CONFIRMED 582 else Claim.IN_PROGRESS 583 ) 584 585 answer: Claim = await _off_loop(self._work, connection, statements) 586 return answer 587 588 async def confirm(self, key: str, *, connection: DbConnection | None = None) -> None: 589 """Records that the message really was handled. 590 591 Called by :func:`~acemq_amqp.patterns.idempotent` when a handler 592 accepts. Until it happens the row is a lease that will expire; after it, 593 the message is remembered for ``retention`` and every redelivery inside 594 that window is a duplicate. 595 596 :param key: what identifies the message 597 :param connection: a connection to work on instead of one of our own 598 """ 599 now = _now_ms() 600 601 def statements(cursor: DbCursor) -> None: 602 cursor.execute( 603 self._sql( 604 "UPDATE {table} SET state = ?, claimed_by = NULL, recorded_at = ?, " 605 "expires_at = ? WHERE message_id = ?" 606 ), 607 (_CONFIRMED, now, now + _ms(self._retention), key), 608 ) 609 if cursor.rowcount: 610 return 611 # The row was purged, or the lease expired and somebody else took 612 # it. The work still happened, so it is recorded either way: an 613 # unrecorded confirmation is how the same charge gets made twice. 614 cursor.execute( 615 self._sql( 616 "INSERT INTO {table} " 617 "(message_id, state, claimed_by, recorded_at, expires_at) " 618 "VALUES (?, ?, NULL, ?, ?) ON CONFLICT (message_id) DO NOTHING" 619 ), 620 (key, _CONFIRMED, now, now + _ms(self._retention)), 621 ) 622 623 await _off_loop(self._work, connection, statements) 624 625 async def forget(self, key: str, *, connection: DbConnection | None = None) -> None: 626 """Releases our own claim, so the message can be tried again. 627 628 Only our own, and never a confirmation: deleting somebody else's claim 629 would put two consumers on one message, and deleting a confirmation 630 would undo it. 631 632 :param key: what identifies the message 633 :param connection: a connection to work on instead of one of our own 634 """ 635 636 def statements(cursor: DbCursor) -> None: 637 cursor.execute( 638 self._sql( 639 "DELETE FROM {table} " 640 "WHERE message_id = ? AND state = ? AND claimed_by = ?" 641 ), 642 (key, _CLAIMED, self._node), 643 ) 644 645 await _off_loop(self._work, connection, statements) 646 647 async def is_confirmed( 648 self, key: str, *, connection: DbConnection | None = None 649 ) -> bool: 650 """Whether a message is recorded as handled and still inside retention. 651 652 For an operator asking why a message did nothing. Expired rows answer 653 ``False`` rather than being deleted: a read that writes turns every 654 duplicate check into a write on a shared table. 655 """ 656 657 def statements(cursor: DbCursor) -> bool: 658 cursor.execute( 659 self._sql("SELECT expires_at FROM {table} WHERE message_id = ? AND state = ?"), 660 (key, _CONFIRMED), 661 ) 662 row = cursor.fetchone() 663 return row is not None and int(row[0]) > _now_ms() 664 665 return bool(await _off_loop(self._work, connection, statements)) 666 667 async def purge_expired(self, *, connection: DbConnection | None = None) -> int: 668 """Removes rows nobody is bound by any more. 669 670 Confirmations past their retention, and claims whose lease has run out. 671 Schedule it; nothing on the message path calls it. 672 673 :returns: how many rows went 674 """ 675 676 def statements(cursor: DbCursor) -> int: 677 cursor.execute( 678 self._sql("DELETE FROM {table} WHERE expires_at <= ?"), (_now_ms(),) 679 ) 680 return max(cursor.rowcount, 0) 681 682 return int(await _off_loop(self._work, connection, statements)) 683 684 async def size(self, *, connection: DbConnection | None = None) -> int: 685 """How many rows the table holds, expired ones included.""" 686 687 def statements(cursor: DbCursor) -> int: 688 cursor.execute(self._sql("SELECT COUNT(*) FROM {table}")) 689 row = cursor.fetchone() 690 return int(row[0]) 691 692 return int(await _off_loop(self._work, connection, statements)) 693 694 def __repr__(self) -> str: 695 return f"SqlIdempotencyStore({self._sql.table})"
Remembers handled messages in a table every consumer can see.
This is what ~acemq_amqp.patterns.InMemoryIdempotencyStore points
at when it says the store has to be one the workers share. Two consumers
racing on the same message cannot both be told they are first, because the
insert is the claim and a primary key makes it atomic across every process
using the table — whatever else they are doing, and without a lock anybody
has to remember to take.
A claim is a lease, not a fact. A row starts CLAIMED with a deadline
claim_timeout away. confirm() turns it into CONFIRMED with a
deadline retention away, and ~acemq_amqp.patterns.idempotent()
calls that for you when a handler accepts. The lease is what a fact cannot
do: a consumer that dies holding a message would otherwise have recorded it
as handled without handling it, and the redelivery — the thing at-least-once
delivery is for — would be skipped. So a hold that has run out can be
taken over, and the message gets handled by somebody.
Nothing on the message path deletes anything. Schedule purge_expired()
instead; hourly is ample. A store that tidies up on the hot path makes every
message pay for it.
Parameters
- connections: a callable returning a connection this store may commit
and close — a pool checkout, or
lambda: sqlite3.connect(path) - table: where the rows go
- paramstyle:
"qmark"forsqlite3,"format"for psycopg - claim_timeout: how long a claim is held before it can be taken over
- retention: how long a handled message is remembered
472 def __init__( 473 self, 474 connections: Connections | None = None, 475 *, 476 table: str = DEFAULT_IDEMPOTENCY_TABLE, 477 paramstyle: str = "qmark", 478 claim_timeout: timedelta = DEFAULT_CLAIM_TIMEOUT, 479 retention: timedelta = DEFAULT_RETENTION, 480 ) -> None: 481 if claim_timeout <= timedelta(0): 482 raise ValueError(f"acemq: a claim timeout must be positive, got {claim_timeout}") 483 if retention <= timedelta(0): 484 raise ValueError(f"acemq: a retention window must be positive, got {retention}") 485 self._sql = _Sql(table, paramstyle) 486 self._work = _Work(connections) 487 self._claim_timeout = claim_timeout 488 self._retention = retention 489 # Which process holds a claim. Only this instance may release its own, 490 # because releasing somebody else's would put two consumers on one 491 # message. 492 self._node = uuid.uuid4().hex
499 @property 500 def node(self) -> str: 501 """What this instance writes into ``claimed_by``.""" 502 return self._node
What this instance writes into claimed_by.
504 async def first_time( 505 self, key: str, *, connection: DbConnection | None = None 506 ) -> bool: 507 """Claims a message, and says whether the claim was ours to take. 508 509 :param key: what identifies the message 510 :param connection: a connection to work on instead of one of our own. 511 Pass the one your business transaction is using and the claim 512 becomes durable exactly when your work does 513 :returns: whether this is the first time 514 """ 515 return await self.claim(key, connection=connection) is Claim.CLAIMED
Claims a message, and says whether the claim was ours to take.
Parameters
- key: what identifies the message
- connection: a connection to work on instead of one of our own. Pass the one your business transaction is using and the claim becomes durable exactly when your work does :returns: whether this is the first time
517 async def claim(self, key: str, *, connection: DbConnection | None = None) -> Claim: 518 """Claims a message, or says why not. 519 520 :attr:`~Claim.CLAIMED` when there was no row or its lease had run out; 521 :attr:`~Claim.DUPLICATE` when the row is confirmed; 522 :attr:`~Claim.IN_PROGRESS` when somebody holds a live, unconfirmed claim 523 — still working, or failed without releasing it. The difference between 524 the last two is the difference between done and maybe-never-done, which 525 is why :func:`~acemq_amqp.patterns.idempotent` accepts one and puts the 526 other back. 527 528 :param key: what identifies the message 529 :param connection: as for :meth:`first_time` 530 """ 531 if not key: 532 raise ValueError("acemq: an idempotency key cannot be empty") 533 now = _now_ms() 534 535 def statements(cursor: DbCursor) -> Claim: 536 # The insert is the claim, and the primary key is what makes it 537 # atomic. ON CONFLICT DO NOTHING rather than catching the driver's 538 # integrity error, because every driver spells that differently and 539 # a failed statement poisons an open PostgreSQL transaction. 540 cursor.execute( 541 self._sql( 542 "INSERT INTO {table} " 543 "(message_id, state, claimed_by, recorded_at, expires_at) " 544 "VALUES (?, ?, ?, ?, ?) ON CONFLICT (message_id) DO NOTHING" 545 ), 546 (key, _CLAIMED, self._node, now, now + _ms(self._claim_timeout)), 547 ) 548 if cursor.rowcount == 1: 549 return Claim.CLAIMED 550 551 # Somebody already has a row. Taking it over is allowed only if 552 # their hold has run out, and the guard lives in the WHERE clause so 553 # the check and the take-over are one statement: a select followed 554 # by an update would let two consumers both pass the check and both 555 # conclude they had won. 556 cursor.execute( 557 self._sql( 558 "UPDATE {table} SET state = ?, claimed_by = ?, recorded_at = ?, " 559 "expires_at = ? WHERE message_id = ? AND expires_at <= ?" 560 ), 561 ( 562 _CLAIMED, 563 self._node, 564 now, 565 now + _ms(self._claim_timeout), 566 key, 567 now, 568 ), 569 ) 570 if cursor.rowcount == 1: 571 return Claim.CLAIMED 572 573 cursor.execute( 574 self._sql("SELECT state FROM {table} WHERE message_id = ?"), (key,) 575 ) 576 row = cursor.fetchone() 577 # No row means it was released or purged between the two 578 # statements: in progress is the answer that cannot lose it. 579 return ( 580 Claim.DUPLICATE 581 if row is not None and row[0] == _CONFIRMED 582 else Claim.IN_PROGRESS 583 ) 584 585 answer: Claim = await _off_loop(self._work, connection, statements) 586 return answer
Claims a message, or says why not.
~Claim.CLAIMED when there was no row or its lease had run out;
~Claim.DUPLICATE when the row is confirmed;
~Claim.IN_PROGRESS when somebody holds a live, unconfirmed claim
— still working, or failed without releasing it. The difference between
the last two is the difference between done and maybe-never-done, which
is why ~acemq_amqp.patterns.idempotent() accepts one and puts the
other back.
Parameters
- key: what identifies the message
- connection: as for
first_time()
588 async def confirm(self, key: str, *, connection: DbConnection | None = None) -> None: 589 """Records that the message really was handled. 590 591 Called by :func:`~acemq_amqp.patterns.idempotent` when a handler 592 accepts. Until it happens the row is a lease that will expire; after it, 593 the message is remembered for ``retention`` and every redelivery inside 594 that window is a duplicate. 595 596 :param key: what identifies the message 597 :param connection: a connection to work on instead of one of our own 598 """ 599 now = _now_ms() 600 601 def statements(cursor: DbCursor) -> None: 602 cursor.execute( 603 self._sql( 604 "UPDATE {table} SET state = ?, claimed_by = NULL, recorded_at = ?, " 605 "expires_at = ? WHERE message_id = ?" 606 ), 607 (_CONFIRMED, now, now + _ms(self._retention), key), 608 ) 609 if cursor.rowcount: 610 return 611 # The row was purged, or the lease expired and somebody else took 612 # it. The work still happened, so it is recorded either way: an 613 # unrecorded confirmation is how the same charge gets made twice. 614 cursor.execute( 615 self._sql( 616 "INSERT INTO {table} " 617 "(message_id, state, claimed_by, recorded_at, expires_at) " 618 "VALUES (?, ?, NULL, ?, ?) ON CONFLICT (message_id) DO NOTHING" 619 ), 620 (key, _CONFIRMED, now, now + _ms(self._retention)), 621 ) 622 623 await _off_loop(self._work, connection, statements)
Records that the message really was handled.
Called by ~acemq_amqp.patterns.idempotent() when a handler
accepts. Until it happens the row is a lease that will expire; after it,
the message is remembered for retention and every redelivery inside
that window is a duplicate.
Parameters
- key: what identifies the message
- connection: a connection to work on instead of one of our own
625 async def forget(self, key: str, *, connection: DbConnection | None = None) -> None: 626 """Releases our own claim, so the message can be tried again. 627 628 Only our own, and never a confirmation: deleting somebody else's claim 629 would put two consumers on one message, and deleting a confirmation 630 would undo it. 631 632 :param key: what identifies the message 633 :param connection: a connection to work on instead of one of our own 634 """ 635 636 def statements(cursor: DbCursor) -> None: 637 cursor.execute( 638 self._sql( 639 "DELETE FROM {table} " 640 "WHERE message_id = ? AND state = ? AND claimed_by = ?" 641 ), 642 (key, _CLAIMED, self._node), 643 ) 644 645 await _off_loop(self._work, connection, statements)
Releases our own claim, so the message can be tried again.
Only our own, and never a confirmation: deleting somebody else's claim would put two consumers on one message, and deleting a confirmation would undo it.
Parameters
- key: what identifies the message
- connection: a connection to work on instead of one of our own
647 async def is_confirmed( 648 self, key: str, *, connection: DbConnection | None = None 649 ) -> bool: 650 """Whether a message is recorded as handled and still inside retention. 651 652 For an operator asking why a message did nothing. Expired rows answer 653 ``False`` rather than being deleted: a read that writes turns every 654 duplicate check into a write on a shared table. 655 """ 656 657 def statements(cursor: DbCursor) -> bool: 658 cursor.execute( 659 self._sql("SELECT expires_at FROM {table} WHERE message_id = ? AND state = ?"), 660 (key, _CONFIRMED), 661 ) 662 row = cursor.fetchone() 663 return row is not None and int(row[0]) > _now_ms() 664 665 return bool(await _off_loop(self._work, connection, statements))
Whether a message is recorded as handled and still inside retention.
For an operator asking why a message did nothing. Expired rows answer
False rather than being deleted: a read that writes turns every
duplicate check into a write on a shared table.
667 async def purge_expired(self, *, connection: DbConnection | None = None) -> int: 668 """Removes rows nobody is bound by any more. 669 670 Confirmations past their retention, and claims whose lease has run out. 671 Schedule it; nothing on the message path calls it. 672 673 :returns: how many rows went 674 """ 675 676 def statements(cursor: DbCursor) -> int: 677 cursor.execute( 678 self._sql("DELETE FROM {table} WHERE expires_at <= ?"), (_now_ms(),) 679 ) 680 return max(cursor.rowcount, 0) 681 682 return int(await _off_loop(self._work, connection, statements))
Removes rows nobody is bound by any more.
Confirmations past their retention, and claims whose lease has run out. Schedule it; nothing on the message path calls it.
:returns: how many rows went
684 async def size(self, *, connection: DbConnection | None = None) -> int: 685 """How many rows the table holds, expired ones included.""" 686 687 def statements(cursor: DbCursor) -> int: 688 cursor.execute(self._sql("SELECT COUNT(*) FROM {table}")) 689 row = cursor.fetchone() 690 return int(row[0]) 691 692 return int(await _off_loop(self._work, connection, statements))
How many rows the table holds, expired ones included.
698class SqlOutboxStore: 699 """An outbox in the database the work is written to. 700 701 The point of the whole pattern, and the thing 702 :class:`~acemq_amqp.patterns.InMemoryOutboxStore` cannot do:: 703 704 async with database.transaction() as tx: 705 await place_order(tx, order) 706 await store.add( 707 record(mq, "orders-events", "order.placed", event), 708 connection=tx.connection, 709 ) 710 # one commit; the order and the message are the same decision 711 712 :meth:`add` writes on the connection you hand it and does not commit, does 713 not roll back and does not close it. That is not an oversight — it is the 714 guarantee. The insert becomes durable exactly when your transaction does, 715 and if your transaction rolls back the message was never in the outbox to 716 begin with. A store that opened a connection of its own would have the gap 717 straight back, and would have bought nothing but a second place for messages 718 to go missing. 719 720 The connection can come from either end. Pass ``connection=`` at the call 721 site, which is clearest where the transaction is a local variable; or give 722 the constructor a ``transaction`` callable and let it fetch the connection 723 bound to the current transaction, which is what a framework with a 724 thread-local or context-local session wants. What it must never be is 725 "open a new connection": a fresh connection with autocommit on inserts the 726 outbox row immediately and independently, so a later rollback of the 727 business work leaves a message queued for something that never happened — 728 the exact fault the pattern was adopted to prevent, now harder to notice 729 because the code looks right. 730 731 The relay's own work — :meth:`pending` and :meth:`mark_published` — is not 732 the caller's transaction and has no business joining it, so it uses 733 ``connections`` and commits for itself. 734 735 **One relay per outbox.** :meth:`pending` takes no lease, because the 736 :class:`~acemq_amqp.patterns.OutboxStore` protocol has nowhere to put one, 737 so two processes sweeping the same table publish everything twice. Java's 738 JDBC store leases rows for this reason; this one does not, and the shape of 739 the mistake is a service that starts a relay per worker. 740 741 :param connections: a callable returning a connection for the relay's own 742 work, which this store may commit and close 743 :param transaction: a callable returning the connection belonging to the 744 caller's current transaction, for :meth:`add` 745 :param table: where the records go 746 :param paramstyle: ``"qmark"`` for :mod:`sqlite3`, ``"format"`` for psycopg 747 """ 748 749 def __init__( 750 self, 751 connections: Connections | None = None, 752 *, 753 transaction: Connections | None = None, 754 table: str = DEFAULT_OUTBOX_TABLE, 755 paramstyle: str = "qmark", 756 max_attempts: int = DEFAULT_OUTBOX_MAX_ATTEMPTS, 757 ) -> None: 758 if max_attempts < 1: 759 raise ValueError("acemq: max_attempts must be at least 1") 760 self._sql = _Sql(table, paramstyle) 761 self._relay = _Work(connections) 762 self._transaction = transaction 763 self._max_attempts = max_attempts 764 765 @property 766 def table(self) -> str: 767 """Where the records are.""" 768 return self._sql.table 769 770 async def add( 771 self, entry: OutboxRecord, *, connection: DbConnection | None = None 772 ) -> None: 773 """Writes a record into the caller's transaction. 774 775 :param entry: what to publish later, already encoded 776 :param connection: the connection your transaction is using. Omit it and 777 the ``transaction`` callable given to the constructor is asked 778 :raises AceMQError: when there is no transactional connection to write 779 into, which is refused rather than worked around: opening one here 780 would silently reintroduce the gap 781 """ 782 if not entry.id: 783 raise ValueError("acemq: an outbox record needs an id") 784 785 joining = connection if connection is not None else self._caller_connection() 786 787 def statements(cursor: DbCursor) -> None: 788 # Kept rather than replaced. A caller retrying its own transaction 789 # is the ordinary case, and the second write is the same message. 790 cursor.execute( 791 self._sql( 792 "INSERT INTO {table} (id, exchange_name, routing_key, body, " 793 "content_type, headers, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) " 794 "ON CONFLICT (id) DO NOTHING" 795 ), 796 ( 797 entry.id, 798 entry.exchange, 799 entry.routing_key, 800 entry.body, 801 entry.content_type, 802 json.dumps(dict(entry.headers)), 803 _as_ms(entry.created_at), 804 ), 805 ) 806 807 # Deliberately inline and deliberately not committed: it is the caller's 808 # connection, on the caller's thread, inside the caller's transaction. 809 cursor = joining.cursor() 810 try: 811 statements(cursor) 812 finally: 813 cursor.close() 814 815 def _caller_connection(self) -> DbConnection: 816 if self._transaction is None: 817 raise AceMQError( 818 "acemq: the outbox has no transactional connection to write into. Pass " 819 "connection= to add(), or transaction= to the constructor. Opening one " 820 "here would put the message outside your transaction, which is the gap " 821 "the outbox exists to close." 822 ) 823 joining = self._transaction() 824 if joining is None: 825 # Typed as never happening, and it happens: a supplier that forgets 826 # to return is a one-character mistake, and the message it would 827 # otherwise produce is an AttributeError inside a cursor() call. 828 raise AceMQError( 829 "acemq: the transaction callable returned None; the outbox needs the " 830 "caller's transaction to write into." 831 ) 832 return joining 833 834 async def pending( 835 self, limit: int, *, connection: DbConnection | None = None 836 ) -> Sequence[OutboxRecord]: 837 """Records waiting to be published, oldest first. 838 839 :param limit: at most this many, or zero for all of them 840 :param connection: a connection to read on instead of one of our own 841 """ 842 843 def statements(cursor: DbCursor) -> list[OutboxRecord]: 844 # `attempts < ?` is what keeps the relay moving. It publishes in the 845 # order records were written and stops at the first failure, so a record 846 # the broker will never take is retried on every sweep and holds up 847 # everything behind it -- for ever, until it stops being offered. 848 statement = ( 849 "SELECT id, exchange_name, routing_key, body, content_type, headers, " 850 "created_at, attempts, last_error FROM {table} " 851 "WHERE attempts < ? ORDER BY created_at, id" 852 ) 853 if limit > 0: 854 cursor.execute(self._sql(statement + " LIMIT ?"), (self._max_attempts, limit)) 855 else: 856 cursor.execute(self._sql(statement), (self._max_attempts,)) 857 return [_record_from(row) for row in cursor.fetchall()] 858 859 waiting = await _off_loop(self._relay, connection, statements) 860 return list(waiting) 861 862 async def record_failure( 863 self, entry_id: str, error: str, *, connection: DbConnection | None = None 864 ) -> None: 865 """Counts a failed publish against the record, and says why. 866 867 The record is kept. One nothing could publish is evidence: somebody has to 868 read it, fix whatever refuses it and release it by resetting ``attempts``, 869 and deleting it would be the silent loss this pattern exists to prevent 870 arrived at from another direction. 871 872 Once ``attempts`` reaches ``max_attempts`` the record stops being offered by 873 :meth:`pending`, which is what lets the relay past it. 874 """ 875 876 def statements(cursor: DbCursor) -> None: 877 cursor.execute( 878 self._sql( 879 "UPDATE {table} SET attempts = attempts + 1, last_error = ? " 880 "WHERE id = ?" 881 ), 882 (error[:1000], entry_id), 883 ) 884 885 await _off_loop(self._relay, connection, statements) 886 887 async def retired( 888 self, *, connection: DbConnection | None = None 889 ) -> Sequence[OutboxRecord]: 890 """Records the relay has stopped trying. 891 892 Nothing else surfaces them: :meth:`pending` exists to skip them, so without 893 this they are invisible to everything but a hand-written query. 894 """ 895 896 def statements(cursor: DbCursor) -> list[OutboxRecord]: 897 cursor.execute( 898 self._sql( 899 "SELECT id, exchange_name, routing_key, body, content_type, " 900 "headers, created_at, attempts, last_error FROM {table} " 901 "WHERE attempts >= ? ORDER BY created_at, id" 902 ), 903 (self._max_attempts,), 904 ) 905 return [_record_from(row) for row in cursor.fetchall()] 906 907 return list(await _off_loop(self._relay, connection, statements)) 908 909 async def mark_published( 910 self, entry_id: str, *, connection: DbConnection | None = None 911 ) -> None: 912 """Removes a record, once the broker has confirmed the message.""" 913 914 def statements(cursor: DbCursor) -> None: 915 cursor.execute(self._sql("DELETE FROM {table} WHERE id = ?"), (entry_id,)) 916 917 await _off_loop(self._relay, connection, statements) 918 919 async def count(self, *, connection: DbConnection | None = None) -> int: 920 """How many records are waiting.""" 921 922 def statements(cursor: DbCursor) -> int: 923 cursor.execute(self._sql("SELECT COUNT(*) FROM {table}")) 924 row = cursor.fetchone() 925 return int(row[0]) 926 927 return int(await _off_loop(self._relay, connection, statements)) 928 929 def __repr__(self) -> str: 930 return f"SqlOutboxStore({self._sql.table})"
An outbox in the database the work is written to.
The point of the whole pattern, and the thing
~acemq_amqp.patterns.InMemoryOutboxStore cannot do::
async with database.transaction() as tx:
await place_order(tx, order)
await store.add(
record(mq, "orders-events", "order.placed", event),
connection=tx.connection,
)
# one commit; the order and the message are the same decision
add() writes on the connection you hand it and does not commit, does
not roll back and does not close it. That is not an oversight — it is the
guarantee. The insert becomes durable exactly when your transaction does,
and if your transaction rolls back the message was never in the outbox to
begin with. A store that opened a connection of its own would have the gap
straight back, and would have bought nothing but a second place for messages
to go missing.
The connection can come from either end. Pass connection= at the call
site, which is clearest where the transaction is a local variable; or give
the constructor a transaction callable and let it fetch the connection
bound to the current transaction, which is what a framework with a
thread-local or context-local session wants. What it must never be is
"open a new connection": a fresh connection with autocommit on inserts the
outbox row immediately and independently, so a later rollback of the
business work leaves a message queued for something that never happened —
the exact fault the pattern was adopted to prevent, now harder to notice
because the code looks right.
The relay's own work — pending() and mark_published() — is not
the caller's transaction and has no business joining it, so it uses
connections and commits for itself.
One relay per outbox. pending() takes no lease, because the
~acemq_amqp.patterns.OutboxStore protocol has nowhere to put one,
so two processes sweeping the same table publish everything twice. Java's
JDBC store leases rows for this reason; this one does not, and the shape of
the mistake is a service that starts a relay per worker.
Parameters
- connections: a callable returning a connection for the relay's own work, which this store may commit and close
- transaction: a callable returning the connection belonging to the
caller's current transaction, for
add() - table: where the records go
- paramstyle:
"qmark"forsqlite3,"format"for psycopg
749 def __init__( 750 self, 751 connections: Connections | None = None, 752 *, 753 transaction: Connections | None = None, 754 table: str = DEFAULT_OUTBOX_TABLE, 755 paramstyle: str = "qmark", 756 max_attempts: int = DEFAULT_OUTBOX_MAX_ATTEMPTS, 757 ) -> None: 758 if max_attempts < 1: 759 raise ValueError("acemq: max_attempts must be at least 1") 760 self._sql = _Sql(table, paramstyle) 761 self._relay = _Work(connections) 762 self._transaction = transaction 763 self._max_attempts = max_attempts
765 @property 766 def table(self) -> str: 767 """Where the records are.""" 768 return self._sql.table
Where the records are.
770 async def add( 771 self, entry: OutboxRecord, *, connection: DbConnection | None = None 772 ) -> None: 773 """Writes a record into the caller's transaction. 774 775 :param entry: what to publish later, already encoded 776 :param connection: the connection your transaction is using. Omit it and 777 the ``transaction`` callable given to the constructor is asked 778 :raises AceMQError: when there is no transactional connection to write 779 into, which is refused rather than worked around: opening one here 780 would silently reintroduce the gap 781 """ 782 if not entry.id: 783 raise ValueError("acemq: an outbox record needs an id") 784 785 joining = connection if connection is not None else self._caller_connection() 786 787 def statements(cursor: DbCursor) -> None: 788 # Kept rather than replaced. A caller retrying its own transaction 789 # is the ordinary case, and the second write is the same message. 790 cursor.execute( 791 self._sql( 792 "INSERT INTO {table} (id, exchange_name, routing_key, body, " 793 "content_type, headers, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) " 794 "ON CONFLICT (id) DO NOTHING" 795 ), 796 ( 797 entry.id, 798 entry.exchange, 799 entry.routing_key, 800 entry.body, 801 entry.content_type, 802 json.dumps(dict(entry.headers)), 803 _as_ms(entry.created_at), 804 ), 805 ) 806 807 # Deliberately inline and deliberately not committed: it is the caller's 808 # connection, on the caller's thread, inside the caller's transaction. 809 cursor = joining.cursor() 810 try: 811 statements(cursor) 812 finally: 813 cursor.close()
Writes a record into the caller's transaction.
Parameters
- entry: what to publish later, already encoded
- connection: the connection your transaction is using. Omit it and
the
transactioncallable given to the constructor is asked
Raises
- AceMQError: when there is no transactional connection to write into, which is refused rather than worked around: opening one here would silently reintroduce the gap
834 async def pending( 835 self, limit: int, *, connection: DbConnection | None = None 836 ) -> Sequence[OutboxRecord]: 837 """Records waiting to be published, oldest first. 838 839 :param limit: at most this many, or zero for all of them 840 :param connection: a connection to read on instead of one of our own 841 """ 842 843 def statements(cursor: DbCursor) -> list[OutboxRecord]: 844 # `attempts < ?` is what keeps the relay moving. It publishes in the 845 # order records were written and stops at the first failure, so a record 846 # the broker will never take is retried on every sweep and holds up 847 # everything behind it -- for ever, until it stops being offered. 848 statement = ( 849 "SELECT id, exchange_name, routing_key, body, content_type, headers, " 850 "created_at, attempts, last_error FROM {table} " 851 "WHERE attempts < ? ORDER BY created_at, id" 852 ) 853 if limit > 0: 854 cursor.execute(self._sql(statement + " LIMIT ?"), (self._max_attempts, limit)) 855 else: 856 cursor.execute(self._sql(statement), (self._max_attempts,)) 857 return [_record_from(row) for row in cursor.fetchall()] 858 859 waiting = await _off_loop(self._relay, connection, statements) 860 return list(waiting)
Records waiting to be published, oldest first.
Parameters
- limit: at most this many, or zero for all of them
- connection: a connection to read on instead of one of our own
862 async def record_failure( 863 self, entry_id: str, error: str, *, connection: DbConnection | None = None 864 ) -> None: 865 """Counts a failed publish against the record, and says why. 866 867 The record is kept. One nothing could publish is evidence: somebody has to 868 read it, fix whatever refuses it and release it by resetting ``attempts``, 869 and deleting it would be the silent loss this pattern exists to prevent 870 arrived at from another direction. 871 872 Once ``attempts`` reaches ``max_attempts`` the record stops being offered by 873 :meth:`pending`, which is what lets the relay past it. 874 """ 875 876 def statements(cursor: DbCursor) -> None: 877 cursor.execute( 878 self._sql( 879 "UPDATE {table} SET attempts = attempts + 1, last_error = ? " 880 "WHERE id = ?" 881 ), 882 (error[:1000], entry_id), 883 ) 884 885 await _off_loop(self._relay, connection, statements)
Counts a failed publish against the record, and says why.
The record is kept. One nothing could publish is evidence: somebody has to
read it, fix whatever refuses it and release it by resetting attempts,
and deleting it would be the silent loss this pattern exists to prevent
arrived at from another direction.
Once attempts reaches max_attempts the record stops being offered by
pending(), which is what lets the relay past it.
887 async def retired( 888 self, *, connection: DbConnection | None = None 889 ) -> Sequence[OutboxRecord]: 890 """Records the relay has stopped trying. 891 892 Nothing else surfaces them: :meth:`pending` exists to skip them, so without 893 this they are invisible to everything but a hand-written query. 894 """ 895 896 def statements(cursor: DbCursor) -> list[OutboxRecord]: 897 cursor.execute( 898 self._sql( 899 "SELECT id, exchange_name, routing_key, body, content_type, " 900 "headers, created_at, attempts, last_error FROM {table} " 901 "WHERE attempts >= ? ORDER BY created_at, id" 902 ), 903 (self._max_attempts,), 904 ) 905 return [_record_from(row) for row in cursor.fetchall()] 906 907 return list(await _off_loop(self._relay, connection, statements))
Records the relay has stopped trying.
Nothing else surfaces them: pending() exists to skip them, so without
this they are invisible to everything but a hand-written query.
909 async def mark_published( 910 self, entry_id: str, *, connection: DbConnection | None = None 911 ) -> None: 912 """Removes a record, once the broker has confirmed the message.""" 913 914 def statements(cursor: DbCursor) -> None: 915 cursor.execute(self._sql("DELETE FROM {table} WHERE id = ?"), (entry_id,)) 916 917 await _off_loop(self._relay, connection, statements)
Removes a record, once the broker has confirmed the message.
919 async def count(self, *, connection: DbConnection | None = None) -> int: 920 """How many records are waiting.""" 921 922 def statements(cursor: DbCursor) -> int: 923 cursor.execute(self._sql("SELECT COUNT(*) FROM {table}")) 924 row = cursor.fetchone() 925 return int(row[0]) 926 927 return int(await _off_loop(self._relay, connection, statements))
How many records are waiting.
948class SqlSchemaRegistry: 949 """Message shapes in a table every service can read. 950 951 What :class:`~acemq_amqp.patterns.InMemorySchemaRegistry` is not: a consumer 952 that meets a version it has never seen looks it up and finds what the 953 producer registered, in another process, on another host, a fortnight ago. 954 955 Registration is idempotent on the fingerprint, enforced by a unique index 956 rather than by a check-then-insert. A service that registers its schemas on 957 every start would otherwise add a version per restart and the version number 958 would stop meaning anything; and two instances starting together would race. 959 The loser of that race re-reads instead of writing a second id for the same 960 bytes. 961 962 Identifiers come from a one-row counter table rather than from ``MAX(id) + 963 1``. A sequence is spelled differently on every database this has to run on; 964 the arithmetic is simply wrong under load, because two writers registering 965 two different schemas compute the same next id and the loser cannot win by 966 retrying. Updating the counter row takes a row lock, so writers queue for an 967 instant and every one of them gets a number. Registration happens once per 968 schema in the lifetime of a system, so serialising it costs nothing. 969 970 :param connections: a callable returning a connection this registry may 971 commit and close 972 :param table: where the schemas go; the counter is the same name with 973 ``_seq`` on the end 974 :param paramstyle: ``"qmark"`` for :mod:`sqlite3`, ``"format"`` for psycopg 975 """ 976 977 #: How many times registration re-reads after losing a race before giving 978 #: up. Losing twice is already unusual; losing five times means something 979 #: other than contention is wrong, and looping forever would hide it. 980 ATTEMPTS = 5 981 982 def __init__( 983 self, 984 connections: Connections | None = None, 985 *, 986 table: str = DEFAULT_REGISTRY_TABLE, 987 paramstyle: str = "qmark", 988 ) -> None: 989 self._sql = _Sql(table, paramstyle) 990 self._work = _Work(connections) 991 992 @property 993 def table(self) -> str: 994 """Where the schemas are.""" 995 return self._sql.table 996 997 async def register( 998 self, 999 subject: str, 1000 format: str, 1001 definition: str, 1002 *, 1003 connection: DbConnection | None = None, 1004 ) -> SchemaDefinition: 1005 """Records a schema and returns it with an identifier. 1006 1007 The same definition under the same subject comes back with the same 1008 identifier however many times it is offered, and from however many 1009 instances at once. 1010 """ 1011 if not subject or not definition: 1012 raise ValueError("acemq: a schema needs a subject and a definition") 1013 print_of = fingerprint(definition) 1014 1015 def statements(cursor: DbCursor) -> SchemaDefinition: 1016 already = self._find(cursor, subject, print_of) 1017 if already is not None: 1018 return already 1019 # Not there a moment ago. Take a number, write the row, and let the 1020 # unique index decide whether somebody else got there first — the 1021 # read and the insert are not one statement and cannot be, so the 1022 # index is what makes the answer correct rather than merely usual. 1023 return self._insert(cursor, subject, format, definition, print_of) 1024 1025 for attempt in range(self.ATTEMPTS): 1026 try: 1027 written = await _off_loop(self._work, connection, statements) 1028 except Exception: 1029 # Somebody registered the same schema between the read and the 1030 # write and the unique index refused ours. Their row is the 1031 # answer, so go round and read it rather than reporting a 1032 # failure for something that succeeded. 1033 if attempt == self.ATTEMPTS - 1: 1034 raise 1035 continue 1036 schema: SchemaDefinition = written 1037 return schema 1038 1039 raise AceMQError( # pragma: no cover — the loop returns or raises 1040 f"acemq: could not register {subject!r} in {self._sql.table!r} after " 1041 f"{self.ATTEMPTS} attempts." 1042 ) 1043 1044 async def by_id( 1045 self, identifier: int, *, connection: DbConnection | None = None 1046 ) -> SchemaDefinition: 1047 """Looks a schema up by the identifier a message carries.""" 1048 1049 def statements(cursor: DbCursor) -> SchemaDefinition | None: 1050 cursor.execute( 1051 self._sql(f"SELECT {_REGISTRY_COLUMNS} FROM {{table}} WHERE id = ?"), 1052 (identifier,), 1053 ) 1054 row = cursor.fetchone() 1055 return None if row is None else _schema_from(row) 1056 1057 found = await _off_loop(self._work, connection, statements) 1058 if found is None: 1059 raise SchemaNotFoundError(f"acemq: no schema with id {identifier}") 1060 schema: SchemaDefinition = found 1061 return schema 1062 1063 async def latest( 1064 self, subject: str, *, connection: DbConnection | None = None 1065 ) -> SchemaDefinition: 1066 """The newest version of a subject.""" 1067 1068 def statements(cursor: DbCursor) -> SchemaDefinition | None: 1069 cursor.execute( 1070 self._sql( 1071 f"SELECT {_REGISTRY_COLUMNS} FROM {{table}} WHERE subject = ? " 1072 "ORDER BY version DESC LIMIT 1" 1073 ), 1074 (subject,), 1075 ) 1076 row = cursor.fetchone() 1077 return None if row is None else _schema_from(row) 1078 1079 found = await _off_loop(self._work, connection, statements) 1080 if found is None: 1081 raise SchemaNotFoundError(f"acemq: no schema for subject {subject!r}") 1082 schema: SchemaDefinition = found 1083 return schema 1084 1085 async def versions( 1086 self, subject: str, *, connection: DbConnection | None = None 1087 ) -> Sequence[SchemaDefinition]: 1088 """Every version of a subject, oldest first.""" 1089 1090 def statements(cursor: DbCursor) -> list[SchemaDefinition]: 1091 cursor.execute( 1092 self._sql( 1093 f"SELECT {_REGISTRY_COLUMNS} FROM {{table}} WHERE subject = ? " 1094 "ORDER BY version" 1095 ), 1096 (subject,), 1097 ) 1098 return [_schema_from(row) for row in cursor.fetchall()] 1099 1100 return list(await _off_loop(self._work, connection, statements)) 1101 1102 def _find(self, cursor: DbCursor, subject: str, print_of: str) -> SchemaDefinition | None: 1103 cursor.execute( 1104 self._sql( 1105 f"SELECT {_REGISTRY_COLUMNS} FROM {{table}} " 1106 "WHERE subject = ? AND fingerprint = ?" 1107 ), 1108 (subject, print_of), 1109 ) 1110 row = cursor.fetchone() 1111 return None if row is None else _schema_from(row) 1112 1113 def _insert( 1114 self, 1115 cursor: DbCursor, 1116 subject: str, 1117 format: str, 1118 definition: str, 1119 print_of: str, 1120 ) -> SchemaDefinition: 1121 cursor.execute(self._sql("UPDATE {table}_seq SET last_id = last_id + 1")) 1122 if cursor.rowcount < 1: 1123 # Nobody seeded the counter. Doing it here rather than at 1124 # construction keeps the constructor from touching the database at 1125 # all, which is what lets a store be built before its schema exists. 1126 cursor.execute( 1127 self._sql("INSERT INTO {table}_seq (only_row, last_id) VALUES (1, 1)") 1128 ) 1129 identifier = 1 1130 else: 1131 cursor.execute(self._sql("SELECT last_id FROM {table}_seq WHERE only_row = 1")) 1132 identifier = int(cursor.fetchone()[0]) 1133 1134 cursor.execute( 1135 self._sql("SELECT COUNT(*) FROM {table} WHERE subject = ?"), (subject,) 1136 ) 1137 version = int(cursor.fetchone()[0]) + 1 1138 registered = _now_ms() 1139 1140 cursor.execute( 1141 self._sql( 1142 "INSERT INTO {table} " 1143 "(id, subject, version, format, definition, fingerprint, registered_at) " 1144 "VALUES (?, ?, ?, ?, ?, ?, ?)" 1145 ), 1146 (identifier, subject, version, format, definition, print_of, registered), 1147 ) 1148 return SchemaDefinition( 1149 id=identifier, 1150 subject=subject, 1151 version=version, 1152 format=format, 1153 definition=definition, 1154 fingerprint=print_of, 1155 registered_at=_from_ms(registered), 1156 ) 1157 1158 def __repr__(self) -> str: 1159 return f"SqlSchemaRegistry({self._sql.table})"
Message shapes in a table every service can read.
What ~acemq_amqp.patterns.InMemorySchemaRegistry is not: a consumer
that meets a version it has never seen looks it up and finds what the
producer registered, in another process, on another host, a fortnight ago.
Registration is idempotent on the fingerprint, enforced by a unique index rather than by a check-then-insert. A service that registers its schemas on every start would otherwise add a version per restart and the version number would stop meaning anything; and two instances starting together would race. The loser of that race re-reads instead of writing a second id for the same bytes.
Identifiers come from a one-row counter table rather than from MAX(id) +
1. A sequence is spelled differently on every database this has to run on;
the arithmetic is simply wrong under load, because two writers registering
two different schemas compute the same next id and the loser cannot win by
retrying. Updating the counter row takes a row lock, so writers queue for an
instant and every one of them gets a number. Registration happens once per
schema in the lifetime of a system, so serialising it costs nothing.
Parameters
- connections: a callable returning a connection this registry may commit and close
- table: where the schemas go; the counter is the same name with
_seqon the end - paramstyle:
"qmark"forsqlite3,"format"for psycopg
992 @property 993 def table(self) -> str: 994 """Where the schemas are.""" 995 return self._sql.table
Where the schemas are.
997 async def register( 998 self, 999 subject: str, 1000 format: str, 1001 definition: str, 1002 *, 1003 connection: DbConnection | None = None, 1004 ) -> SchemaDefinition: 1005 """Records a schema and returns it with an identifier. 1006 1007 The same definition under the same subject comes back with the same 1008 identifier however many times it is offered, and from however many 1009 instances at once. 1010 """ 1011 if not subject or not definition: 1012 raise ValueError("acemq: a schema needs a subject and a definition") 1013 print_of = fingerprint(definition) 1014 1015 def statements(cursor: DbCursor) -> SchemaDefinition: 1016 already = self._find(cursor, subject, print_of) 1017 if already is not None: 1018 return already 1019 # Not there a moment ago. Take a number, write the row, and let the 1020 # unique index decide whether somebody else got there first — the 1021 # read and the insert are not one statement and cannot be, so the 1022 # index is what makes the answer correct rather than merely usual. 1023 return self._insert(cursor, subject, format, definition, print_of) 1024 1025 for attempt in range(self.ATTEMPTS): 1026 try: 1027 written = await _off_loop(self._work, connection, statements) 1028 except Exception: 1029 # Somebody registered the same schema between the read and the 1030 # write and the unique index refused ours. Their row is the 1031 # answer, so go round and read it rather than reporting a 1032 # failure for something that succeeded. 1033 if attempt == self.ATTEMPTS - 1: 1034 raise 1035 continue 1036 schema: SchemaDefinition = written 1037 return schema 1038 1039 raise AceMQError( # pragma: no cover — the loop returns or raises 1040 f"acemq: could not register {subject!r} in {self._sql.table!r} after " 1041 f"{self.ATTEMPTS} attempts." 1042 )
Records a schema and returns it with an identifier.
The same definition under the same subject comes back with the same identifier however many times it is offered, and from however many instances at once.
1044 async def by_id( 1045 self, identifier: int, *, connection: DbConnection | None = None 1046 ) -> SchemaDefinition: 1047 """Looks a schema up by the identifier a message carries.""" 1048 1049 def statements(cursor: DbCursor) -> SchemaDefinition | None: 1050 cursor.execute( 1051 self._sql(f"SELECT {_REGISTRY_COLUMNS} FROM {{table}} WHERE id = ?"), 1052 (identifier,), 1053 ) 1054 row = cursor.fetchone() 1055 return None if row is None else _schema_from(row) 1056 1057 found = await _off_loop(self._work, connection, statements) 1058 if found is None: 1059 raise SchemaNotFoundError(f"acemq: no schema with id {identifier}") 1060 schema: SchemaDefinition = found 1061 return schema
Looks a schema up by the identifier a message carries.
1063 async def latest( 1064 self, subject: str, *, connection: DbConnection | None = None 1065 ) -> SchemaDefinition: 1066 """The newest version of a subject.""" 1067 1068 def statements(cursor: DbCursor) -> SchemaDefinition | None: 1069 cursor.execute( 1070 self._sql( 1071 f"SELECT {_REGISTRY_COLUMNS} FROM {{table}} WHERE subject = ? " 1072 "ORDER BY version DESC LIMIT 1" 1073 ), 1074 (subject,), 1075 ) 1076 row = cursor.fetchone() 1077 return None if row is None else _schema_from(row) 1078 1079 found = await _off_loop(self._work, connection, statements) 1080 if found is None: 1081 raise SchemaNotFoundError(f"acemq: no schema for subject {subject!r}") 1082 schema: SchemaDefinition = found 1083 return schema
The newest version of a subject.
1085 async def versions( 1086 self, subject: str, *, connection: DbConnection | None = None 1087 ) -> Sequence[SchemaDefinition]: 1088 """Every version of a subject, oldest first.""" 1089 1090 def statements(cursor: DbCursor) -> list[SchemaDefinition]: 1091 cursor.execute( 1092 self._sql( 1093 f"SELECT {_REGISTRY_COLUMNS} FROM {{table}} WHERE subject = ? " 1094 "ORDER BY version" 1095 ), 1096 (subject,), 1097 ) 1098 return [_schema_from(row) for row in cursor.fetchall()] 1099 1100 return list(await _off_loop(self._work, connection, statements))
Every version of a subject, oldest first.
116@dataclass(frozen=True, slots=True) 117class Step: 118 """One stop on a routing slip. 119 120 :param exchange: where this step's message goes, empty for the default 121 exchange 122 :param routing_key: what it is published under, or a queue name 123 :param name: what to call this step when a slip is read in a log 124 :param completed_at: when it finished, filled in as the slip advances 125 """ 126 127 exchange: str 128 routing_key: str 129 name: str = "" 130 completed_at: str = "" 131 132 def __str__(self) -> str: 133 return self.name or f"{self.exchange}/{self.routing_key}" 134 135 def to_json(self) -> dict[str, Any]: 136 """The step as it goes on the wire. 137 138 The field names are camelCase and not Python's, because this header is 139 read by services written in four other languages and the wire is where 140 they have to agree. 141 """ 142 written = {"exchange": self.exchange, "routingKey": self.routing_key} 143 if self.name: 144 written["name"] = self.name 145 if self.completed_at: 146 written["completedAt"] = self.completed_at 147 return written 148 149 @classmethod 150 def from_json(cls, raw: Any) -> Step: 151 if not isinstance(raw, dict): 152 raise ValueError(f"acemq: a routing slip step must be an object, not {type(raw)}") 153 return cls( 154 exchange=str(raw.get("exchange") or ""), 155 routing_key=str(raw.get("routingKey") or ""), 156 name=str(raw.get("name") or ""), 157 completed_at=str(raw.get("completedAt") or ""), 158 )
One stop on a routing slip.
Parameters
- exchange: where this step's message goes, empty for the default exchange
- routing_key: what it is published under, or a queue name
- name: what to call this step when a slip is read in a log
- completed_at: when it finished, filled in as the slip advances
135 def to_json(self) -> dict[str, Any]: 136 """The step as it goes on the wire. 137 138 The field names are camelCase and not Python's, because this header is 139 read by services written in four other languages and the wire is where 140 they have to agree. 141 """ 142 written = {"exchange": self.exchange, "routingKey": self.routing_key} 143 if self.name: 144 written["name"] = self.name 145 if self.completed_at: 146 written["completedAt"] = self.completed_at 147 return written
The step as it goes on the wire.
The field names are camelCase and not Python's, because this header is read by services written in four other languages and the wire is where they have to agree.
149 @classmethod 150 def from_json(cls, raw: Any) -> Step: 151 if not isinstance(raw, dict): 152 raise ValueError(f"acemq: a routing slip step must be an object, not {type(raw)}") 153 return cls( 154 exchange=str(raw.get("exchange") or ""), 155 routing_key=str(raw.get("routingKey") or ""), 156 name=str(raw.get("name") or ""), 157 completed_at=str(raw.get("completedAt") or ""), 158 )
158@dataclass(frozen=True, slots=True) 159class StreamOffset: 160 """Where a stream consumer starts reading. 161 162 Built by the functions below rather than directly: the broker takes a 163 different kind of value for each, and a constructor that accepted any of 164 them would be a constructor nobody could read. 165 """ 166 167 kind: str 168 value: Any = None 169 170 def to_arg(self) -> Any: 171 """What the broker is told.""" 172 return self.kind if self.value is None else self.value 173 174 def __str__(self) -> str: 175 return self.kind if self.value is None else f"{self.kind}({self.value})"
Where a stream consumer starts reading.
Built by the functions below rather than directly: the broker takes a different kind of value for each, and a constructor that accepted any of them would be a constructor nobody could read.
216@dataclass(frozen=True, slots=True) 217class StreamRetention: 218 """How much of a stream to keep. 219 220 Unbounded by default, which for a stream means until the disk is full. Set 221 at least one of these on anything that will run for longer than an 222 afternoon. 223 224 :param max_age: discard messages older than this 225 :param max_bytes: discard the oldest once the stream exceeds this 226 :param segment_bytes: how large each file on disk gets. Retention happens a 227 whole segment at a time, so a very large segment makes retention coarse 228 """ 229 230 max_age: timedelta | None = None 231 max_bytes: int | None = None 232 segment_bytes: int | None = None 233 234 def to_args(self) -> dict[str, Any]: 235 """The declaration arguments this retention means.""" 236 args: dict[str, Any] = {} 237 if self.max_age is not None: 238 args[MAX_AGE_ARG] = _duration(self.max_age) 239 if self.max_bytes is not None: 240 args[MAX_LENGTH_BYTES_ARG] = self.max_bytes 241 if self.segment_bytes is not None: 242 args[SEGMENT_BYTES_ARG] = self.segment_bytes 243 return args
How much of a stream to keep.
Unbounded by default, which for a stream means until the disk is full. Set at least one of these on anything that will run for longer than an afternoon.
Parameters
- max_age: discard messages older than this
- max_bytes: discard the oldest once the stream exceeds this
- segment_bytes: how large each file on disk gets. Retention happens a whole segment at a time, so a very large segment makes retention coarse
234 def to_args(self) -> dict[str, Any]: 235 """The declaration arguments this retention means.""" 236 args: dict[str, Any] = {} 237 if self.max_age is not None: 238 args[MAX_AGE_ARG] = _duration(self.max_age) 239 if self.max_bytes is not None: 240 args[MAX_LENGTH_BYTES_ARG] = self.max_bytes 241 if self.segment_bytes is not None: 242 args[SEGMENT_BYTES_ARG] = self.segment_bytes 243 return args
The declaration arguments this retention means.
89class StreamRetryError(AceMQError, FatalError): 90 """A stream handler asked for a retry, which a stream cannot give it. 91 92 Retrying means republishing, and republishing onto a stream appends: a 93 second copy of the message on the log, which every other consumer of that 94 stream reads as a new one and which stays there for the retention. A handler 95 that failed three times would leave three copies behind, and the copies 96 outlive the outage that caused them. 97 98 There is no third outcome that quietly works, so this names the two that do. 99 **Park it** — :func:`~acemq_amqp.park` puts a copy in ``{stream}.parked``, 100 which is a queue beside the stream rather than part of it, and leaves the log 101 untouched. **Or checkpoint and move on** — :func:`~acemq_amqp.accept` 102 advances this consumer's position past the message and the failure becomes 103 the handler's to record, which is the outcome most projections want: the 104 message is still on the log and can be read again from an earlier offset 105 once whatever broke is fixed. 106 107 Both an :class:`~acemq_amqp.AceMQError` and a 108 :class:`~acemq_amqp.FatalError`, because it is both: something this library 109 refused to do, and a request no number of further attempts would make 110 reasonable. The second is what makes the delivery end up in ``{stream}.dlq`` 111 with this sentence on it rather than going round a retry ladder that would 112 do the very thing being refused. 113 """
A stream handler asked for a retry, which a stream cannot give it.
Retrying means republishing, and republishing onto a stream appends: a second copy of the message on the log, which every other consumer of that stream reads as a new one and which stays there for the retention. A handler that failed three times would leave three copies behind, and the copies outlive the outage that caused them.
There is no third outcome that quietly works, so this names the two that do.
Park it — ~acemq_amqp.park() puts a copy in {stream}.parked,
which is a queue beside the stream rather than part of it, and leaves the log
untouched. Or checkpoint and move on — ~acemq_amqp.accept()
advances this consumer's position past the message and the failure becomes
the handler's to record, which is the outcome most projections want: the
message is still on the log and can be read again from an earlier offset
once whatever broke is fixed.
Both an ~acemq_amqp.AceMQError and a
~acemq_amqp.FatalError, because it is both: something this library
refused to do, and a request no number of further attempts would make
reasonable. The second is what makes the delivery end up in {stream}.dlq
with this sentence on it rather than going round a retry ladder that would
do the very thing being refused.
65def by_correlation() -> PartitionKey: 66 """Orders by correlation identifier, which keeps one business action's 67 messages in sequence.""" 68 69 def key(message: Message) -> str: 70 return message.envelope.correlation_id 71 72 return key
Orders by correlation identifier, which keeps one business action's messages in sequence.
51def by_header(name: str) -> PartitionKey: 52 """Orders by an application header — a tenant, a customer, an aggregate. 53 54 :param name: which header carries the entity's identity 55 :returns: a key function 56 """ 57 58 def key(message: Message) -> str: 59 value = message.envelope.headers.get(name) 60 return "" if value is None else str(value) 61 62 return key
Orders by an application header — a tenant, a customer, an aggregate.
Parameters
- name: which header carries the entity's identity :returns: a key function
69def chain(handler: Handler, *middleware: Middleware) -> AsyncHandler: 70 """Wraps a handler in middleware, outermost first:: 71 72 handler = chain( 73 handle, 74 with_logging(), 75 with_timeout(timedelta(seconds=10)), 76 with_idempotency(store), 77 ) 78 79 Logging is outermost, so it records what the deadline and the idempotency 80 guard decided rather than only what the handler did. 81 82 :param handler: what does the work 83 :param middleware: what goes round it, outermost first 84 :returns: the wrapped handler 85 """ 86 # Applied in reverse so the first one named ends up outermost, which is the 87 # order somebody reading the list expects. 88 composed: Handler = handler 89 for outer in reversed(middleware): 90 composed = outer(composed) 91 92 async def piped(message: Message) -> Ack: 93 return await decide(composed, message) 94 95 return piped
Wraps a handler in middleware, outermost first::
handler = chain(
handle,
with_logging(),
with_timeout(timedelta(seconds=10)),
with_idempotency(store),
)
Logging is outermost, so it records what the deadline and the idempotency guard decided rather than only what the handler did.
Parameters
- handler: what does the work
- middleware: what goes round it, outermost first :returns: the wrapped handler
404def claim_key_of(body: bytes) -> str | None: 405 """Reads the key a message refers to, without fetching it. 406 407 For the operator looking at a dead-letter queue: which object does this need, 408 and is it still in the store? That one line is the difference between a 409 five-minute check and restoring a backup. 410 411 :param body: a message body 412 :returns: the key, or ``None`` when the payload travelled inline or this 413 codec did not write the message 414 """ 415 if not _is_framed(body) or body[2] != _CHECKED: 416 return None 417 return body[_HEADER:].decode("utf-8")
Reads the key a message refers to, without fetching it.
For the operator looking at a dead-letter queue: which object does this need, and is it still in the store? That one line is the difference between a five-minute check and restoring a backup.
Parameters
- body: a message body
:returns: the key, or
Nonewhen the payload travelled inline or this codec did not write the message
289def create_schema( 290 connections: Connections | DbConnection, 291 *, 292 dialect: str = "sqlite", 293 idempotency: str | None = DEFAULT_IDEMPOTENCY_TABLE, 294 outbox: str | None = DEFAULT_OUTBOX_TABLE, 295 registry: str | None = DEFAULT_REGISTRY_TABLE, 296) -> None: 297 """Creates whichever of the three tables are asked for, if they are absent. 298 299 For development and for tests. In production these belong in the migration 300 tool that already owns the schema — the outbox table especially, because it 301 has to live in the same database as the business tables it commits with, and 302 that database's shape is not a messaging library's to change at start-up. 303 The DDL is printable with :func:`schema_ddl` for exactly that reason: copy it 304 into a migration and stop calling this. 305 306 :param connections: a connection, or a callable returning one 307 :param dialect: ``"sqlite"`` or ``"postgres"``, which decides one column type 308 :param idempotency: the idempotency table, or ``None`` to skip it 309 :param outbox: the outbox table, or ``None`` to skip it 310 :param registry: the schema registry table, or ``None`` to skip it. Its 311 counter table is created alongside, named with ``_seq`` on the end 312 """ 313 # Asked "is it a connection?" rather than "is it callable?", because a 314 # sqlite3 connection is itself callable — it compiles a statement — and a 315 # test for callability quietly picks the wrong branch for the one driver 316 # everybody starts with. 317 given = isinstance(connections, DbConnection) 318 opened = connections if isinstance(connections, DbConnection) else connections() 319 cursor = opened.cursor() 320 try: 321 for statement in schema_ddl( 322 dialect=dialect, idempotency=idempotency, outbox=outbox, registry=registry 323 ): 324 cursor.execute(statement) 325 finally: 326 cursor.close() 327 opened.commit() 328 if not given: 329 # Ours to close. A connection the caller handed over is not. 330 opened.close()
Creates whichever of the three tables are asked for, if they are absent.
For development and for tests. In production these belong in the migration
tool that already owns the schema — the outbox table especially, because it
has to live in the same database as the business tables it commits with, and
that database's shape is not a messaging library's to change at start-up.
The DDL is printable with schema_ddl() for exactly that reason: copy it
into a migration and stop calling this.
Parameters
- connections: a connection, or a callable returning one
- dialect:
"sqlite"or"postgres", which decides one column type - idempotency: the idempotency table, or
Noneto skip it - outbox: the outbox table, or
Noneto skip it - registry: the schema registry table, or
Noneto skip it. Its counter table is created alongside, named with_seqon the end
263async def declare_stream( 264 connection: Connection, name: str, retention: StreamRetention | None = None 265) -> None: 266 """Declares a stream on the broker:: 267 268 await declare_stream( 269 mq, "events", StreamRetention(max_age=timedelta(days=7)) 270 ) 271 272 :param connection: where to declare it 273 :param name: what to call it 274 :param retention: how much to keep 275 """ 276 await connection.declare(stream(name, retention))
Declares a stream on the broker::
await declare_stream(
mq, "events", StreamRetention(max_age=timedelta(days=7))
)
Parameters
- connection: where to declare it
- name: what to call it
- retention: how much to keep
179def fingerprint(definition: str) -> str: 180 """Hashes a schema definition, the same way in every language. 181 182 SHA-256 of the exact bytes. Two definitions differing only in whitespace 183 hash differently and are therefore two schemas — which looks strict and is 184 the safer half of the trade. Normalising first would need a parser per 185 format, and a registry that treated two definitions as one because it 186 mis-parsed them would be worse than one that is merely fussy. 187 188 :param definition: the schema text 189 :returns: the hash, as lowercase hexadecimal 190 """ 191 return hashlib.sha256(definition.encode("utf-8")).hexdigest()
Hashes a schema definition, the same way in every language.
SHA-256 of the exact bytes. Two definitions differing only in whitespace hash differently and are therefore two schemas — which looks strict and is the safer half of the trade. Normalising first would need a parser per format, and a registry that treated two definitions as one because it mis-parsed them would be worse than one that is merely fussy.
Parameters
- definition: the schema text :returns: the hash, as lowercase hexadecimal
462def follow_slip( 463 connection: Connection, 464 step: Stage, 465 *, 466 codec: Codec | None = None, 467 pipeline: str = "", 468 form: SlipForm | None = None, 469) -> AsyncHandler: 470 """Wraps a handler so the message goes on to its next stop:: 471 472 consumer = await mq.consume("charge-queue", follow_slip(mq, charge)) 473 474 # A step in a pipeline Java declared, whose queues are fulfilment.*: 475 consumer = await mq.consume( 476 "fulfilment.enrich", follow_slip(mq, enrich, pipeline="fulfilment") 477 ) 478 479 The handler returns the payload to send onwards, which may be the one it was 480 given or a changed copy, and raises to fail. When the slip has no steps left 481 the work is finished and nothing more is published. 482 483 Either wire form is followed, and by default the next message carries the 484 same one it arrived in. That is what lets a Python step sit in the middle of 485 a Java-declared pipeline: answering a declared route with a JSON slip would 486 hand the next Java step a message with no route on it, and the run would 487 stop halfway with nothing anywhere saying why. 488 489 The message is accepted only once the next one is out, so a failure to 490 publish retries this step — which is the reason a step that changes anything 491 should be idempotent. 492 493 The step that finds no next stop writes ``acemq.pipeline.run.duration`` and 494 ``acemq.pipeline.run.total`` to the connection's 495 :class:`~acemq_amqp.telemetry.Observer`, tagged ``pipeline``, ``step`` and 496 ``outcome``. That is once per run rather than once per hop, and the duration 497 is the message's own age, so it covers the whole route including the time 498 spent waiting on the queues between steps. See 499 :data:`~acemq_amqp.telemetry.METRIC_PIPELINE_RUN_TOTAL`. 500 501 :param connection: where the next message goes 502 :param step: what this stop does, returning the payload to send on 503 :param codec: a codec other than the connection's 504 :param pipeline: the exchange a declared route's step names are bound to, 505 which is the pipeline's own name. Needed only for the declared form, 506 which does not carry it 507 :param form: the wire form to write, defaulting to the one that arrived 508 :returns: the wrapped handler 509 """ 510 511 async def travel(message: Message) -> Ack: 512 try: 513 slip = slip_from(message.envelope, pipeline=pipeline) 514 except FatalError as unreadable: 515 return reject(unreadable) 516 if slip is None: 517 return reject( 518 FatalError( 519 f"acemq: message {message.envelope.id} has no routing slip, so " 520 "there is nowhere to send it next" 521 ) 522 ) 523 524 try: 525 payload = await decide(step, message) 526 except FatalError as unfixable: 527 return reject(unfixable) 528 except Exception as failure: 529 return retry(failure) 530 531 advanced = slip.advance() 532 onwards = advanced.next 533 if onwards is None: 534 # The end of the itinerary. Nothing to publish, and the work is done. 535 # 536 # The run is counted here and only here: the step that finds no next 537 # stop is the step that finished the run, so the counter goes up once 538 # per run however many services it passed through. The duration is 539 # the envelope's own age rather than a timer, because the envelope 540 # was created when the message entered the pipeline and carried 541 # through every hop — it is the only number that spans services, and 542 # summing each step's consume duration would miss every second the 543 # message spent waiting on a queue between two of them. 544 labels = { 545 TAG_PIPELINE: pipeline or slip.steps[0].exchange, 546 TAG_STEP: str(slip.steps[0]), 547 TAG_OUTCOME: OUTCOME_COMPLETED, 548 } 549 observer = connection.observer 550 observer.observe( 551 METRIC_PIPELINE_RUN_DURATION, 552 message.envelope.age.total_seconds(), 553 labels, 554 ) 555 observer.count(METRIC_PIPELINE_RUN_TOTAL, 1, labels) 556 return accept() 557 558 try: 559 written = advanced if form is None else advanced.written_as(form) 560 outgoing = written.onto( 561 Envelope( 562 correlation_id=message.envelope.correlation_id, 563 causation_id=message.envelope.id, 564 origin=connection.origin, 565 ) 566 ) 567 except ValueError as unwritable: 568 # A route that cannot be written in the form asked for cannot be 569 # written on the next attempt either, so this is the handler's 570 # answer rather than the retry policy's. 571 return reject( 572 FatalError( 573 f"acemq: {slip.steps[0]} is done for message {message.envelope.id} " 574 f"but its slip cannot be written on: {unwritable}" 575 ) 576 ) 577 578 try: 579 # Mandatory, because accepting below is what makes this hop final: 580 # a next stop nothing is bound to is confirmed and dropped by the 581 # broker, and without the return this step would accept a message 582 # that is now nowhere. 583 await connection.publisher( 584 onwards.exchange, onwards.routing_key, codec=codec, mandatory=True 585 ).send(payload, envelope=outgoing) 586 except Exception as failure: 587 return retry( 588 RuntimeError( 589 f"acemq: {slip.steps[0]} is done for message {message.envelope.id} " 590 f"but the next step did not go out: {failure}" 591 ) 592 ) 593 return accept() 594 595 return travel
Wraps a handler so the message goes on to its next stop::
consumer = await mq.consume("charge-queue", follow_slip(mq, charge))
# A step in a pipeline Java declared, whose queues are fulfilment.*:
consumer = await mq.consume(
"fulfilment.enrich", follow_slip(mq, enrich, pipeline="fulfilment")
)
The handler returns the payload to send onwards, which may be the one it was given or a changed copy, and raises to fail. When the slip has no steps left the work is finished and nothing more is published.
Either wire form is followed, and by default the next message carries the same one it arrived in. That is what lets a Python step sit in the middle of a Java-declared pipeline: answering a declared route with a JSON slip would hand the next Java step a message with no route on it, and the run would stop halfway with nothing anywhere saying why.
The message is accepted only once the next one is out, so a failure to publish retries this step — which is the reason a step that changes anything should be idempotent.
The step that finds no next stop writes acemq.pipeline.run.duration and
acemq.pipeline.run.total to the connection's
~acemq_amqp.telemetry.Observer, tagged pipeline, step and
outcome. That is once per run rather than once per hop, and the duration
is the message's own age, so it covers the whole route including the time
spent waiting on the queues between steps. See
~acemq_amqp.telemetry.METRIC_PIPELINE_RUN_TOTAL.
Parameters
- connection: where the next message goes
- step: what this stop does, returning the payload to send on
- codec: a codec other than the connection's
- pipeline: the exchange a declared route's step names are bound to, which is the pipeline's own name. Needed only for the declared form, which does not carry it
- form: the wire form to write, defaulting to the one that arrived :returns: the wrapped handler
178def from_first() -> StreamOffset: 179 """Start at the oldest message the stream still holds. 180 181 For building a projection from nothing, or a new consumer that needs the 182 history. Note *still holds*: a stream has a retention policy, so the oldest 183 message it has is not necessarily the first one ever written to it. 184 """ 185 return StreamOffset("first")
Start at the oldest message the stream still holds.
For building a projection from nothing, or a new consumer that needs the history. Note still holds: a stream has a retention policy, so the oldest message it has is not necessarily the first one ever written to it.
196def from_last() -> StreamOffset: 197 """Start at the last chunk the stream holds. 198 199 Roughly "the recent past" rather than an exact number of messages: a stream 200 is stored in segments and this starts at the beginning of the last one. 201 """ 202 return StreamOffset("last")
Start at the last chunk the stream holds.
Roughly "the recent past" rather than an exact number of messages: a stream is stored in segments and this starts at the beginning of the last one.
188def from_next() -> StreamOffset: 189 """Start at the next message published, ignoring everything already there. 190 191 The default, and what most consumers want. 192 """ 193 return StreamOffset("next")
Start at the next message published, ignoring everything already there.
The default, and what most consumers want.
205def from_offset(offset: int) -> StreamOffset: 206 """Start at an exact position, which is what a consumer that records its own 207 progress uses to carry on where it left off.""" 208 return StreamOffset("offset", offset)
Start at an exact position, which is what a consumer that records its own progress uses to carry on where it left off.
211def from_timestamp(at: datetime) -> StreamOffset: 212 """Start at the first message published at or after a time.""" 213 return StreamOffset("timestamp", at)
Start at the first message published at or after a time.
164def idempotent( 165 store: IdempotencyStore, 166 handler: Handler, 167 *, 168 key: Callable[[Message], str] | None = None, 169 in_progress_delay: timedelta = DEFAULT_IN_PROGRESS_DELAY, 170) -> AsyncHandler: 171 """Wraps a handler so a message already handled is accepted, not redone:: 172 173 consumer = await mq.consume("orders", idempotent(store, place)) 174 175 A duplicate is **accepted** rather than rejected. The work was done, so the 176 message has been handled, and dead-lettering it would raise an alarm about 177 something that went right. 178 179 When the handler does not accept, the key is forgotten so the retry can 180 actually run. When it does accept and the store has a ``confirm`` method, 181 that is called: a store which hands out a *lease* rather than a fact — so a 182 consumer that dies holding a message does not block its redelivery — needs 183 to be told when the lease becomes a fact. A store without one, such as 184 :class:`InMemoryIdempotencyStore`, is unaffected. 185 186 A redelivery that finds the message **in progress** — claimed by a handler 187 that is still running, or by one that failed and could not release its 188 claim — is neither run nor accepted: it is answered with 189 :func:`~acemq_amqp.in_progress`, which puts it back after 190 ``in_progress_delay`` without spending a retry attempt. Accepting it would 191 lose the message if that claim never became a fact. A store with a 192 ``claim`` method (both shipped stores) is asked for this three-way answer; 193 one with only ``first_time`` cannot tell in-progress from done and is 194 treated as before. 195 196 That ordering is what makes this a guard against duplicates 197 rather than a promise of exactly-once: between the handler finishing and the 198 acknowledgement reaching the broker there is still a gap where a crash leaves 199 a message that will be delivered again. Only a store written in the same 200 transaction as the work closes it, which is why :class:`IdempotencyStore` is 201 an interface and not a class. 202 203 :param store: where handled keys are remembered 204 :param handler: what to do with a message that has not been handled yet 205 :param key: what identifies a message, when the envelope's id is not it. 206 Use it where the natural key is in the payload — an order identifier two 207 different messages both carry, where handling either twice is the thing 208 to prevent 209 :param in_progress_delay: how long a message found in progress waits before 210 it is looked at again. Keep it well under the store's claim timeout 211 :returns: the wrapped handler 212 """ 213 214 async def guarded(message: Message) -> Ack: 215 identity = message.envelope.id if key is None else key(message) 216 if not identity: 217 # Refused rather than guessed at. Falling back to the envelope id 218 # would silently stop deduplicating on the thing that was asked for. 219 return reject( 220 FatalError( 221 f"acemq: message {message.envelope.id} produced an empty " 222 "idempotency key" 223 ) 224 ) 225 226 try: 227 claimed = await _claim(store, identity) 228 except Exception as failure: 229 # The store is what is broken, not the message. Retrying is right; 230 # carrying on and risking a duplicate is not. 231 return retry(failure) 232 233 if claimed is Claim.DUPLICATE: 234 return accept() 235 if claimed is Claim.IN_PROGRESS: 236 return in_progress(in_progress_delay) 237 238 try: 239 decision = await decide(handler, message) 240 except BaseException: 241 # Raising is a failure like returning one, and the consumer turns it 242 # into a retry. Holding the claim would answer that retry "in 243 # progress" until the claim aged out, so the handler would not run 244 # again and the message would loop past its retry ladder. Java 245 # releases on an exception for the same reason. 246 await _forget_quietly(store, identity) 247 raise 248 if decision.action is not Action.ACCEPT: 249 # It did not work, so it has not been handled. Forgetting is what 250 # lets the retry do anything at all. 251 await _forget_quietly(store, identity) 252 else: 253 await _confirm_quietly(store, identity) 254 return decision 255 256 return guarded
Wraps a handler so a message already handled is accepted, not redone::
consumer = await mq.consume("orders", idempotent(store, place))
A duplicate is accepted rather than rejected. The work was done, so the message has been handled, and dead-lettering it would raise an alarm about something that went right.
When the handler does not accept, the key is forgotten so the retry can
actually run. When it does accept and the store has a confirm method,
that is called: a store which hands out a lease rather than a fact — so a
consumer that dies holding a message does not block its redelivery — needs
to be told when the lease becomes a fact. A store without one, such as
InMemoryIdempotencyStore, is unaffected.
A redelivery that finds the message in progress — claimed by a handler
that is still running, or by one that failed and could not release its
claim — is neither run nor accepted: it is answered with
~acemq_amqp.in_progress(), which puts it back after
in_progress_delay without spending a retry attempt. Accepting it would
lose the message if that claim never became a fact. A store with a
claim method (both shipped stores) is asked for this three-way answer;
one with only first_time cannot tell in-progress from done and is
treated as before.
That ordering is what makes this a guard against duplicates
rather than a promise of exactly-once: between the handler finishing and the
acknowledgement reaching the broker there is still a gap where a crash leaves
a message that will be delivered again. Only a store written in the same
transaction as the work closes it, which is why IdempotencyStore is
an interface and not a class.
Parameters
- store: where handled keys are remembered
- handler: what to do with a message that has not been handled yet
- key: what identifies a message, when the envelope's id is not it. Use it where the natural key is in the payload — an order identifier two different messages both carry, where handling either twice is the thing to prevent
- in_progress_delay: how long a message found in progress waits before it is looked at again. Keep it well under the store's claim timeout :returns: the wrapped handler
420def is_claim_check(body: bytes) -> bool: 421 """Whether a body is a claim check rather than a payload. 422 423 The question a consumer answers from the body's first three bytes and never 424 from a header, so that a message which lost its headers on the way through a 425 shovel is still read correctly. 426 427 :param body: a message body 428 :returns: whether the payload is in a store rather than in the message 429 """ 430 return claim_key_of(body) is not None
Whether a body is a claim check rather than a payload.
The question a consumer answers from the body's first three bytes and never from a header, so that a message which lost its headers on the way through a shovel is still read correctly.
Parameters
- body: a message body :returns: whether the payload is in a store rather than in the message
75def ordered(key: PartitionKey, handler: Handler) -> AsyncHandler: 76 """Wraps a handler so messages sharing a key are never handled at once:: 77 78 consumer = await mq.consume( 79 "orders", ordered(by_header("order-id"), handle), concurrency=16 80 ) 81 82 **What it does not do.** It orders the handling of messages that have 83 already been delivered. It cannot reorder ones the broker delivered out of 84 order, and with several consumers on one queue it orders only within each 85 process — ordering across processes needs the messages to reach the same 86 process in the first place, which is a routing decision rather than a 87 handler one. :func:`partitioned_routing_key` is for making that decision. 88 89 A message whose key is empty is handled with no ordering at all, because 90 there is nothing to order it against. That is a deliberate choice over 91 treating "no key" as one shared key, which would serialise every message 92 that happens to be missing the header — turning a producer's bug into a 93 consumer's outage. 94 95 :param key: what identifies the entity a message is about 96 :param handler: what to do with the message 97 :returns: the wrapped handler 98 """ 99 locks = _KeyedLocks() 100 101 async def in_order(message: Message) -> Ack: 102 identity = key(message) 103 if not identity: 104 return await decide(handler, message) 105 async with locks.hold(identity): 106 return await decide(handler, message) 107 108 return in_order
Wraps a handler so messages sharing a key are never handled at once::
consumer = await mq.consume(
"orders", ordered(by_header("order-id"), handle), concurrency=16
)
What it does not do. It orders the handling of messages that have
already been delivered. It cannot reorder ones the broker delivered out of
order, and with several consumers on one queue it orders only within each
process — ordering across processes needs the messages to reach the same
process in the first place, which is a routing decision rather than a
handler one. partitioned_routing_key() is for making that decision.
A message whose key is empty is handled with no ordering at all, because there is nothing to order it against. That is a deliberate choice over treating "no key" as one shared key, which would serialise every message that happens to be missing the header — turning a producer's bug into a consumer's outage.
Parameters
- key: what identifies the entity a message is about
- handler: what to do with the message :returns: the wrapped handler
111def partition(key: str, count: int) -> int: 112 """Maps a key onto one of ``count`` slots, the same way in every language. 113 114 For deciding which queue or which worker a message belongs to, when the 115 ordering has to hold across processes rather than within one. FNV-1a rather 116 than :func:`hash`, because Python's string hash is randomised per process by 117 default: two workers in the same deployment would disagree about where a key 118 belongs, which is the one thing a partition function must not do. 119 120 :param key: what identifies the entity 121 :param count: how many slots there are 122 :returns: the slot, from 0 123 """ 124 if count <= 1: 125 return 0 126 digest = _FNV_OFFSET 127 for byte in key.encode("utf-8"): 128 digest ^= byte 129 digest = (digest * _FNV_PRIME) & _FNV_MASK 130 return digest % count
Maps a key onto one of count slots, the same way in every language.
For deciding which queue or which worker a message belongs to, when the
ordering has to hold across processes rather than within one. FNV-1a rather
than hash(), because Python's string hash is randomised per process by
default: two workers in the same deployment would disagree about where a key
belongs, which is the one thing a partition function must not do.
Parameters
- key: what identifies the entity
- count: how many slots there are :returns: the slot, from 0
133def partitioned_routing_key(base: str, key: str, partitions: int) -> str: 134 """``orders`` and an order id become ``orders.3``. 135 136 For publishing into a queue-per-partition arrangement, which is how ordering 137 is made to hold across processes: every message about one entity is routed 138 to one queue, and one queue is consumed in order. 139 140 :param base: the routing key without the partition 141 :param key: what identifies the entity 142 :param partitions: how many there are 143 :returns: the routing key 144 """ 145 return f"{base}.{partition(key, partitions)}"
orders and an order id become orders.3.
For publishing into a queue-per-partition arrangement, which is how ordering is made to hold across processes: every message about one entity is routed to one queue, and one queue is consumed in order.
Parameters
- base: the routing key without the partition
- key: what identifies the entity
- partitions: how many there are :returns: the routing key
279async def read_stream( 280 connection: Connection, 281 name: str, 282 handler: Handler, 283 *, 284 offset: StreamOffset | None = None, 285 prefetch: int = DEFAULT_STREAM_PREFETCH, 286 consumer_name: str = "", 287 codec: Codec | None = None, 288 concurrency: int = 1, 289 declare: bool = True, 290) -> Consumer: 291 """Reads a stream from a chosen position:: 292 293 consumer = await read_stream(mq, "events", project, offset=from_first()) 294 295 **Nothing this consumer does appends to the stream.** There is no retry 296 policy to pass and there is no policy it can inherit: it runs on 297 :func:`~acemq_amqp.no_retry` whatever the connection's own default is, and a 298 handler that returns :func:`~acemq_amqp.retry` is refused with a 299 :class:`StreamRetryError`. Both close the same hole from the two directions 300 a retry could arrive from — a handler asking for one, and an exception 301 escaping a handler onto a connection that has a retry policy — because 302 retrying on a stream means republishing to it, which appends a second copy 303 for every other consumer of that log to read as well. 304 305 What a failing handler has instead is two outcomes, and they are enough. 306 :func:`~acemq_amqp.park` puts a copy in ``{name}.parked``, a queue beside the 307 stream rather than part of it, and the log is untouched. 308 :func:`~acemq_amqp.accept` checkpoints past the message and leaves the 309 failure for the handler to record — which is what most projections want, 310 because the message is still on the log and can be read again from an earlier 311 offset once whatever broke is fixed. :func:`~acemq_amqp.reject` is the third 312 and it works too, putting a copy in ``{name}.dlq``; it is not offered as one 313 of the two because "this message is bad" is rarely what a stream failure is. 314 315 An ordinary :class:`~acemq_amqp.Consumer` comes back rather than a 316 stream-specific class. See :func:`_refusing_retries` for why: every method on 317 it means what it says on a stream, so there is nothing a second type would 318 take away. 319 320 :param connection: where the stream is 321 :param name: which stream 322 :param handler: what to do with each message. It may not ask for a retry 323 :param offset: where to start, the next message by default 324 :param prefetch: how many messages to hold. It cannot be zero: RabbitMQ 325 refuses a stream consumer without one 326 :param consumer_name: what to call this consumer to the broker, which is 327 what makes server-side offset tracking possible 328 :param codec: a codec other than the connection's 329 :param concurrency: how many messages to work on at once. One by default, 330 because a stream's order is usually why it is a stream 331 :param declare: declare ``{name}.dlq`` and ``{name}.parked`` before 332 subscribing. On by default; see :meth:`acemq_amqp.Connection.consume`. 333 No retry rungs are declared, because nothing here retries 334 :returns: the running consumer 335 """ 336 if prefetch < 1: 337 raise ValueError( 338 f"acemq: a stream consumer needs a prefetch of at least 1, got {prefetch}" 339 ) 340 341 return await connection.consume( 342 name, 343 _refusing_retries(name, handler), 344 codec=codec, 345 # Said rather than inherited. A connection carrying a retry policy for 346 # its queues would otherwise turn every exception out of a stream handler 347 # into a copy on the log, which is the one thing a stream consumer must 348 # never do — and it would do it silently, because from outside a stream 349 # that is growing looks like a stream that is busy. 350 retry=no_retry(), 351 prefetch=prefetch, 352 concurrency=concurrency, 353 tag=consumer_name, 354 args={STREAM_OFFSET_ARG: (offset or from_next()).to_arg()}, 355 declare=declare, 356 )
Reads a stream from a chosen position::
consumer = await read_stream(mq, "events", project, offset=from_first())
Nothing this consumer does appends to the stream. There is no retry
policy to pass and there is no policy it can inherit: it runs on
~acemq_amqp.no_retry() whatever the connection's own default is, and a
handler that returns ~acemq_amqp.retry is refused with a
StreamRetryError. Both close the same hole from the two directions
a retry could arrive from — a handler asking for one, and an exception
escaping a handler onto a connection that has a retry policy — because
retrying on a stream means republishing to it, which appends a second copy
for every other consumer of that log to read as well.
What a failing handler has instead is two outcomes, and they are enough.
~acemq_amqp.park() puts a copy in {name}.parked, a queue beside the
stream rather than part of it, and the log is untouched.
~acemq_amqp.accept() checkpoints past the message and leaves the
failure for the handler to record — which is what most projections want,
because the message is still on the log and can be read again from an earlier
offset once whatever broke is fixed. ~acemq_amqp.reject() is the third
and it works too, putting a copy in {name}.dlq; it is not offered as one
of the two because "this message is bad" is rarely what a stream failure is.
An ordinary ~acemq_amqp.Consumer comes back rather than a
stream-specific class. See _refusing_retries() for why: every method on
it means what it says on a stream, so there is nothing a second type would
take away.
Parameters
- connection: where the stream is
- name: which stream
- handler: what to do with each message. It may not ask for a retry
- offset: where to start, the next message by default
- prefetch: how many messages to hold. It cannot be zero: RabbitMQ refuses a stream consumer without one
- consumer_name: what to call this consumer to the broker, which is what makes server-side offset tracking possible
- codec: a codec other than the connection's
- concurrency: how many messages to work on at once. One by default, because a stream's order is usually why it is a stream
- declare: declare
{name}.dlqand{name}.parkedbefore subscribing. On by default; seeacemq_amqp.Connection.consume(). No retry rungs are declared, because nothing here retries :returns: the running consumer
234def record( 235 connection: Connection, 236 exchange: str, 237 routing_key: str, 238 payload: Any, 239 *, 240 envelope: Envelope | None = None, 241 codec: Codec | None = None, 242) -> OutboxRecord: 243 """Encodes a payload into a record, ready for :meth:`OutboxStore.add`. 244 245 Call it inside the transaction that does the work:: 246 247 async with database.transaction() as tx: 248 await place_order(tx, order) 249 await store.add(record(mq, "orders-events", "order.placed", event)) 250 251 Encoding here rather than in the relay is the point of the two steps. The 252 record has to survive a deployment that changes the class the payload was 253 written from, so what is stored is bytes and a content type; the relay is 254 then a thing that moves bytes and needs to know nothing about what they mean. 255 256 :param connection: whose codec and origin the message takes 257 :param exchange: where it is going, empty for the default exchange 258 :param routing_key: what to publish it under 259 :param payload: what to send 260 :param envelope: metadata to use instead of a fresh one 261 :param codec: a codec other than the connection's 262 :returns: the record 263 """ 264 writer = codec or connection.codec 265 outgoing = envelope or Envelope(origin=connection.origin) 266 return OutboxRecord( 267 id=outgoing.id, 268 exchange=exchange, 269 routing_key=routing_key, 270 body=writer.encode(payload), 271 content_type=writer.content_type, 272 headers=outgoing.to_headers(routing_key=routing_key), 273 )
Encodes a payload into a record, ready for OutboxStore.add().
Call it inside the transaction that does the work::
async with database.transaction() as tx:
await place_order(tx, order)
await store.add(record(mq, "orders-events", "order.placed", event))
Encoding here rather than in the relay is the point of the two steps. The record has to survive a deployment that changes the class the payload was written from, so what is stored is bytes and a content type; the relay is then a thing that moves bytes and needs to know nothing about what they mean.
Parameters
- connection: whose codec and origin the message takes
- exchange: where it is going, empty for the default exchange
- routing_key: what to publish it under
- payload: what to send
- envelope: metadata to use instead of a fresh one
- codec: a codec other than the connection's :returns: the record
88async def replay( 89 connection: Connection, 90 queue: str, 91 *, 92 exchange: str = "", 93 routing_key: str | None = None, 94 limit: int = 0, 95 deadline: timedelta | None = None, 96 only: ReplayFilter | None = None, 97 restart: bool = True, 98) -> ReplayResult: 99 """Moves messages from a queue back onto an exchange:: 100 101 result = await replay( 102 mq, 103 dead_letter_queue("orders.new"), 104 limit=500, 105 only=lambda envelope, body: "timeout" in envelope.error, 106 ) 107 print(result) # moved 137, skipped 363 (drained) 108 109 Each message is stamped with :data:`HEADER_REPLAYED_FROM`, 110 :data:`HEADER_REPLAYED_AT` and :data:`HEADER_REPLAY_COUNT`, so a consumer 111 that needs to treat a replayed message differently can, and one that does 112 not is unaffected. 113 114 :param connection: where to read from and publish to 115 :param queue: where the messages are now, usually a dead-letter queue 116 :param exchange: where they go back to, empty to publish to a queue by name 117 :param routing_key: what to publish them under. ``None`` keeps each 118 message's own, so a message goes back where it came from rather than 119 everywhere 120 :param limit: stop after this many. Zero means no limit, which against a 121 queue something is still writing to may mean it does not stop at all — 122 prefer a limit 123 :param deadline: stop after this long. ``None`` means no deadline 124 :param only: which messages to move. ``None`` moves all of them 125 :param restart: give each message a fresh set of attempts, clearing the 126 counter and the error that dead-lettered it. On by default because it is 127 what a replay is for: a message that arrives back on attempt five of a 128 five-attempt policy is dead-lettered again before a handler sees it, and 129 the operator who has just fixed the bug has moved two thousand messages 130 from one queue to the same queue. Turn it off to put back exactly what 131 was there — for an audit, or for a queue read by something that counts 132 attempts itself 133 :returns: what it did and why it stopped 134 :raises ReplayError: when reading or republishing failed. Whatever had 135 already been moved has been moved; the rest is still on the queue 136 """ 137 if not queue: 138 raise ValueError("acemq: a replay needs a queue to read from") 139 140 clock = asyncio.get_running_loop() 141 expires = None if deadline is None else clock.time() + deadline.total_seconds() 142 moved = 0 143 skipped = 0 144 145 # Messages the filter declines are held unsettled until the pass is over 146 # rather than returned one at a time. Returning one immediately does not 147 # work: the broker puts it back where it was, at the head of the queue, so 148 # the next pull hands over the same message and nothing behind it is ever 149 # looked at. Holding them takes them out of the way for the length of the 150 # pass, and because the broker is the one holding them, a crash returns them 151 # rather than losing them. 152 declined: list[Delivery] = [] 153 154 try: 155 while True: 156 if limit > 0 and moved >= limit: 157 return ReplayResult(moved, skipped, "limit") 158 if expires is not None and clock.time() >= expires: 159 return ReplayResult(moved, skipped, "deadline") 160 161 try: 162 message = await connection.pull(queue) 163 except Exception as failure: 164 raise ReplayError( 165 ReplayResult(moved, skipped, "failed"), 166 f"cannot read {queue!r} to replay it: {failure}", 167 ) from failure 168 169 if message is None: 170 # Everything available has been offered. What is left is what 171 # this pass declined, and it goes back as the pass ends. 172 return ReplayResult(moved, skipped, "drained") 173 174 envelope = Envelope.from_headers(message.headers, message.routing_key) 175 if only is not None and not only(envelope, message.body): 176 skipped += 1 177 declined.append(message) 178 continue 179 180 try: 181 await _move( 182 connection, message, envelope, queue, exchange, routing_key, restart 183 ) 184 except Exception as failure: 185 raise ReplayError( 186 ReplayResult(moved, skipped, "failed"), 187 f"cannot republish a message from {queue!r}: {failure}", 188 ) from failure 189 moved += 1 190 finally: 191 for held in declined: 192 # Nothing to report if this fails: the pass is over, and a message 193 # that cannot be returned is one the broker returns itself when this 194 # connection goes. 195 await _return_quietly(held)
Moves messages from a queue back onto an exchange::
result = await replay(
mq,
dead_letter_queue("orders.new"),
limit=500,
only=lambda envelope, body: "timeout" in envelope.error,
)
print(result) # moved 137, skipped 363 (drained)
Each message is stamped with HEADER_REPLAYED_FROM,
HEADER_REPLAYED_AT and HEADER_REPLAY_COUNT, so a consumer
that needs to treat a replayed message differently can, and one that does
not is unaffected.
Parameters
- connection: where to read from and publish to
- queue: where the messages are now, usually a dead-letter queue
- exchange: where they go back to, empty to publish to a queue by name
- routing_key: what to publish them under.
Nonekeeps each message's own, so a message goes back where it came from rather than everywhere - limit: stop after this many. Zero means no limit, which against a queue something is still writing to may mean it does not stop at all — prefer a limit
- deadline: stop after this long.
Nonemeans no deadline - only: which messages to move.
Nonemoves all of them - restart: give each message a fresh set of attempts, clearing the counter and the error that dead-lettered it. On by default because it is what a replay is for: a message that arrives back on attempt five of a five-attempt policy is dead-lettered again before a handler sees it, and the operator who has just fixed the bug has moved two thousand messages from one queue to the same queue. Turn it off to put back exactly what was there — for an audit, or for a queue read by something that counts attempts itself :returns: what it did and why it stopped
Raises
- ReplayError: when reading or republishing failed. Whatever had already been moved has been moved; the rest is still on the queue
332def route_of(pipeline: str, *names: str, run_id: str = "") -> RoutingSlip: 333 """A slip for a route declared in advance:: 334 335 slip = route_of("fulfilment", "validate", "enrich", "dispatch") 336 await start(mq, slip, order) 337 338 The declared form Java's ``Pipeline`` writes: the pipeline's name is the 339 exchange, each step's name is the routing key it is bound on, and the queue 340 behind it is ``{pipeline}.{step}``. A Java step consuming 341 ``fulfilment.enrich`` reads what this publishes. 342 343 :param pipeline: the pipeline's name, which is also its exchange 344 :param names: the step names, in order 345 :param run_id: an identifier for this run, generated when none is given 346 :returns: a slip at the beginning of that route 347 :raises ValueError: when there are no steps 348 """ 349 if not names: 350 raise ValueError("acemq: a declared route needs at least one step") 351 return RoutingSlip( 352 steps=tuple(Step(pipeline, name, name=name) for name in names), 353 run_id=run_id or str(uuid.uuid4()), 354 form=SlipForm.STEP_NAMES, 355 )
A slip for a route declared in advance::
slip = route_of("fulfilment", "validate", "enrich", "dispatch")
await start(mq, slip, order)
The declared form Java's Pipeline writes: the pipeline's name is the
exchange, each step's name is the routing key it is bound on, and the queue
behind it is {pipeline}.{step}. A Java step consuming
fulfilment.enrich reads what this publishes.
Parameters
- pipeline: the pipeline's name, which is also its exchange
- names: the step names, in order
- run_id: an identifier for this run, generated when none is given :returns: a slip at the beginning of that route
Raises
- ValueError: when there are no steps
180def schedule_rung_args(rung: timedelta) -> dict[str, Any]: 181 """The arguments a rung queue must be declared with. 182 183 Exactly three keys, and the same three in every AceMQ library: the rung as 184 ``x-message-ttl``, :data:`SCHEDULE_EXCHANGE` as the dead-letter target and 185 :data:`SCHEDULE_DUE` as the routing key it is sent under. Nothing consumes a 186 rung; the time to live is the only thing that ever takes a message out of 187 one. 188 189 :param rung: how long the rung holds a message 190 :returns: the argument table, which is a contract and is pinned by a test 191 """ 192 return { 193 MESSAGE_TTL_ARG: int(rung.total_seconds() * 1000), 194 DEAD_LETTER_EXCHANGE_ARG: SCHEDULE_EXCHANGE, 195 DEAD_LETTER_ROUTING_KEY_ARG: SCHEDULE_DUE, 196 }
The arguments a rung queue must be declared with.
Exactly three keys, and the same three in every AceMQ library: the rung as
x-message-ttl, SCHEDULE_EXCHANGE as the dead-letter target and
SCHEDULE_DUE as the routing key it is sent under. Nothing consumes a
rung; the time to live is the only thing that ever takes a message out of
one.
Parameters
- rung: how long the rung holds a message :returns: the argument table, which is a contract and is pinned by a test
156def schedule_rung_name(rung: timedelta) -> str: 157 """The queue name for a rung: ``acemq.schedule.1h`` and its four siblings. 158 159 :param rung: how long the rung holds a message 160 :returns: the queue name 161 """ 162 return f"{SCHEDULE_EXCHANGE}.{_describe(rung)}"
The queue name for a rung: acemq.schedule.1h and its four siblings.
Parameters
- rung: how long the rung holds a message :returns: the queue name
199def schedule_topology() -> Topology: 200 """Everything a scheduler needs a broker to have. 201 202 One direct exchange, five rung queues and the control queue, all classic and 203 all bound on their own names. Every queue is classic rather than quorum, and 204 declared by leaving ``x-queue-type`` off entirely, which is the spelling 205 Java sends and therefore the only one a broker finds equivalent to it. 206 207 Applied by :meth:`Scheduler.open`. It is public so that a deployment can 208 declare the topology from a migration and run its services with a login that 209 has no ``configure`` permission at all, and so that anybody wondering what a 210 scheduler puts on their broker can print it. 211 212 :returns: the topology 213 """ 214 topology = Topology().exchange(SCHEDULE_EXCHANGE, "direct") 215 for rung in SCHEDULE_RUNGS: 216 name = schedule_rung_name(rung) 217 topology.queue(name, quorum=False, args=schedule_rung_args(rung)) 218 topology.binding(name, SCHEDULE_EXCHANGE, name) 219 topology.queue(SCHEDULE_DUE, quorum=False) 220 topology.binding(SCHEDULE_DUE, SCHEDULE_EXCHANGE, SCHEDULE_DUE) 221 return topology
Everything a scheduler needs a broker to have.
One direct exchange, five rung queues and the control queue, all classic and
all bound on their own names. Every queue is classic rather than quorum, and
declared by leaving x-queue-type off entirely, which is the spelling
Java sends and therefore the only one a broker finds equivalent to it.
Applied by Scheduler.open(). It is public so that a deployment can
declare the topology from a migration and run its services with a login that
has no configure permission at all, and so that anybody wondering what a
scheduler puts on their broker can print it.
:returns: the topology
333def schema_ddl( 334 *, 335 dialect: str = "sqlite", 336 idempotency: str | None = DEFAULT_IDEMPOTENCY_TABLE, 337 outbox: str | None = DEFAULT_OUTBOX_TABLE, 338 registry: str | None = DEFAULT_REGISTRY_TABLE, 339) -> list[str]: 340 """The statements :func:`create_schema` would run, for a migration to own. 341 342 :param dialect: ``"sqlite"`` or ``"postgres"`` 343 :param idempotency: the idempotency table, or ``None`` to skip it 344 :param outbox: the outbox table, or ``None`` to skip it 345 :param registry: the schema registry table, or ``None`` to skip it 346 :returns: the statements, in the order they must run 347 """ 348 if dialect not in BLOB_TYPES: 349 raise ValueError( 350 f"acemq: dialect must be one of {sorted(BLOB_TYPES)}, got {dialect!r}" 351 ) 352 blob = BLOB_TYPES[dialect] 353 354 statements: list[str] = [] 355 for table, ddl in ( 356 (idempotency, _IDEMPOTENCY_DDL), 357 (outbox, _OUTBOX_DDL), 358 (registry, _REGISTRY_DDL), 359 ): 360 if table is None: 361 continue 362 written = _Sql(table, "qmark") 363 statements += [written(one).replace("%BLOB%", blob).strip() for one in ddl] 364 return statements
The statements create_schema() would run, for a migration to own.
Parameters
- dialect:
"sqlite"or"postgres" - idempotency: the idempotency table, or
Noneto skip it - outbox: the outbox table, or
Noneto skip it - registry: the schema registry table, or
Noneto skip it :returns: the statements, in the order they must run
473async def serve( 474 connection: Connection, 475 queue: str, 476 respond: Responder, 477 *, 478 codec: Codec | None = None, 479 **consume_options: Any, 480) -> ResponderHandle: 481 """Answers requests arriving on a queue, until the responder is closed:: 482 483 async def price(message: Message) -> dict[str, Any]: 484 return {"pence": look_up(message.payload["sku"])} 485 486 async with await serve(mq, "price-requests", price) as responder: 487 ... 488 responder.answered # requests answered 489 responder.unanswerable # requests that named nowhere to reply 490 491 The responder returns the answer and raises to fail. A failure is sent back 492 to the caller rather than swallowed, because a caller blocked on a reply 493 should learn that it failed in milliseconds rather than wait out its whole 494 timeout to learn nothing. 495 496 A :class:`ResponderHandle` comes back rather than the bare 497 :class:`~acemq_amqp.Consumer` this used to return. It is closed the same way 498 and is the same ``async with``; what it adds is the two counters Java and 499 .NET have always reported and this library reported nowhere. ``consumer`` 500 reaches the consumer underneath for anything not forwarded. 501 502 :param connection: where requests arrive and replies go 503 :param queue: what to consume 504 :param respond: what to answer with 505 :param codec: a codec other than the connection's 506 :param consume_options: passed to :meth:`~acemq_amqp.Connection.consume` 507 :returns: the running responder 508 """ 509 # Before the subscribe, and that is the guarantee rather than tidiness: a 510 # broker may deliver the first request from inside connection.consume, and 511 # the handler below reads these fields on that delivery. 512 responder = ResponderHandle(queue) 513 514 async def handle(request: Message) -> Ack: 515 # Header first, native property second — the same order in all five 516 # libraries. A caller written against this library sets both; one 517 # written against Java or .NET sets only the property; and a request 518 # that came through a service which rebuilt the message has only the 519 # header left. Reading either answers all three. 520 reply_to = str(request.envelope.headers.get(HEADER_REPLY_TO) or "") or request.reply_to 521 if not reply_to: 522 # Retrying cannot make a return address appear, so this is 523 # dead-lettered rather than looped. Counted first: the sender is the 524 # thing that is broken, and this number is what says so. 525 responder._unanswerable += 1 526 log.warning( 527 "acemq: a message on %s asked for no reply, so none was sent. It was " 528 "published without a reply-to, which usually means the sender used " 529 "publish where it meant to use request.", 530 queue, 531 ) 532 return reject( 533 FatalError( 534 f"acemq: request {request.envelope.id} carries neither a " 535 f"{HEADER_REPLY_TO} header nor a reply-to property, so there " 536 "is nowhere to reply" 537 ) 538 ) 539 540 try: 541 answered = respond(request) 542 answer = await answered if inspect.isawaitable(answered) else answered 543 except Exception as failure: 544 try: 545 # Not counted as answered. The caller gets a reply, and it is the 546 # reply that says the responder could not do it — counting it 547 # would make a service that fails every request report a 548 # perfectly healthy answered rate. 549 await _reply(connection, reply_to, request, None, codec, failure) 550 except Exception as undeliverable: 551 # The caller has not been told, so it is still waiting. Retrying 552 # is the only path that can still reach it. 553 return retry(undeliverable) 554 # Settled rather than retried: the caller has its answer, and a 555 # retry would answer the same question twice. 556 return reject(failure) 557 558 # Counted before the reply goes out, and that order is the contract. The 559 # reply and the counter are two things one caller can see, and publishing 560 # first leaves a window in which a caller already holding its answer 561 # reads answered as zero. Incrementing first puts the counter ahead of 562 # the reply in every interleaving there is, which is the only ordering a 563 # reader can rely on — and the increment is handed back below when the 564 # publish fails, so the failure incrementing early would otherwise 565 # introduce does not exist either. 566 responder._answered += 1 567 try: 568 await _reply(connection, reply_to, request, answer, codec, None) 569 except Exception as undeliverable: 570 responder._answered -= 1 571 # The work is done but the answer did not get out. Retrying repeats 572 # the work, which is why a responder should be idempotent. 573 return retry(undeliverable) 574 return accept() 575 576 responder._consumer = await connection.consume( 577 queue, handle, codec=codec, **consume_options 578 ) 579 return responder
Answers requests arriving on a queue, until the responder is closed::
async def price(message: Message) -> dict[str, Any]:
return {"pence": look_up(message.payload["sku"])}
async with await serve(mq, "price-requests", price) as responder:
...
responder.answered # requests answered
responder.unanswerable # requests that named nowhere to reply
The responder returns the answer and raises to fail. A failure is sent back to the caller rather than swallowed, because a caller blocked on a reply should learn that it failed in milliseconds rather than wait out its whole timeout to learn nothing.
A ResponderHandle comes back rather than the bare
~acemq_amqp.Consumer this used to return. It is closed the same way
and is the same async with; what it adds is the two counters Java and
.NET have always reported and this library reported nowhere. consumer
reaches the consumer underneath for anything not forwarded.
Parameters
- connection: where requests arrive and replies go
- queue: what to consume
- respond: what to answer with
- codec: a codec other than the connection's
- consume_options: passed to
~acemq_amqp.Connection.consume():returns: the running responder
358def slip_from(envelope: Envelope, *, pipeline: str = "") -> RoutingSlip | None: 359 """Reads the itinerary off a message, whichever form it is in. 360 361 The JSON slip is looked for first, because it is the one that says where its 362 steps go: a message carrying both is carrying one that can be followed and 363 one that has to be resolved, and following the first needs nothing from the 364 caller. 365 366 A declared route needs ``pipeline``, which is the one thing that form does 367 not put on the wire. Without it the steps come back on the default exchange 368 under their own names, which is right for a route Python declared and wrong 369 for one Java did — Java's queues are ``{pipeline}.{step}``, reached through 370 the pipeline's exchange. 371 372 :param envelope: what arrived 373 :param pipeline: the exchange a declared route's step names are bound to 374 :returns: the slip, or ``None`` when the message carries none 375 :raises FatalError: when it carries one that cannot be read. Fatal because a 376 slip that will not parse will not parse on the next attempt either, and 377 spending five retries on it only delays the person who has to look 378 """ 379 raw = envelope.headers.get(HEADER_ROUTING_SLIP) 380 if raw is None: 381 return _declared_route(envelope, pipeline) 382 383 if isinstance(raw, bytes): 384 raw = raw.decode("utf-8", errors="replace") 385 if not isinstance(raw, str): 386 raise FatalError( 387 f"acemq: the routing slip on message {envelope.id} is a " 388 f"{type(raw).__name__}, not text" 389 ) 390 391 try: 392 written = json.loads(raw) 393 return RoutingSlip( 394 steps=tuple(Step.from_json(step) for step in written.get("steps") or ()), 395 done=tuple(Step.from_json(step) for step in written.get("done") or ()), 396 ) 397 except (ValueError, AttributeError, TypeError) as failure: 398 raise FatalError( 399 f"acemq: cannot read the routing slip on message {envelope.id}: {failure}" 400 ) from failure
Reads the itinerary off a message, whichever form it is in.
The JSON slip is looked for first, because it is the one that says where its steps go: a message carrying both is carrying one that can be followed and one that has to be resolved, and following the first needs nothing from the caller.
A declared route needs pipeline, which is the one thing that form does
not put on the wire. Without it the steps come back on the default exchange
under their own names, which is right for a route Python declared and wrong
for one Java did — Java's queues are {pipeline}.{step}, reached through
the pipeline's exchange.
Parameters
- envelope: what arrived
- pipeline: the exchange a declared route's step names are bound to
:returns: the slip, or
Nonewhen the message carries none
Raises
- FatalError: when it carries one that cannot be read. Fatal because a slip that will not parse will not parse on the next attempt either, and spending five retries on it only delays the person who has to look
428async def start( 429 connection: Connection, 430 slip: RoutingSlip, 431 payload: Any, 432 *, 433 envelope: Envelope | None = None, 434 codec: Codec | None = None, 435 form: SlipForm | None = None, 436) -> None: 437 """Sends a payload to the first stop on a slip. 438 439 :param connection: where to publish 440 :param slip: the itinerary 441 :param payload: what to send 442 :param envelope: metadata to send instead of a fresh one 443 :param codec: a codec other than the connection's 444 :param form: which wire form to write, defaulting to the slip's own — 445 :data:`SlipForm.JSON` for one built with :meth:`RoutingSlip.then`, the 446 declared form for one built by :func:`route_of` 447 :raises ValueError: when the slip has no steps, which would publish a 448 message with an itinerary that is already finished and no first stop to 449 send it to, or when it cannot be written in the form asked for 450 """ 451 first = slip.next 452 if first is None: 453 raise ValueError("acemq: this routing slip has no steps in it") 454 455 written = slip if form is None else slip.written_as(form) 456 outgoing = envelope or Envelope(origin=connection.origin) 457 await connection.publisher(first.exchange, first.routing_key, codec=codec).send( 458 payload, envelope=written.onto(outgoing) 459 )
Sends a payload to the first stop on a slip.
Parameters
- connection: where to publish
- slip: the itinerary
- payload: what to send
- envelope: metadata to send instead of a fresh one
- codec: a codec other than the connection's
- form: which wire form to write, defaulting to the slip's own —
SlipForm.JSONfor one built withRoutingSlip.then(), the declared form for one built byroute_of()
Raises
- ValueError: when the slip has no steps, which would publish a message with an itinerary that is already finished and no first stop to send it to, or when it cannot be written in the form asked for
246def stream(name: str, retention: StreamRetention | None = None) -> Topology: 247 """A topology declaring one stream. 248 249 A stream is durable and can be neither exclusive nor auto-deleting. Those 250 are set here rather than left to the caller, because getting one of them 251 wrong is answered by the broker with a message that never mentions streams. 252 253 :param name: what to call it 254 :param retention: how much to keep 255 :returns: a topology, so a stream can be declared alongside everything else 256 rather than through a call of its own 257 """ 258 args = {QUEUE_TYPE_ARG: "stream"} 259 args.update((retention or StreamRetention()).to_args()) 260 return Topology().queue(name, durable=True, args=args)
A topology declaring one stream.
A stream is durable and can be neither exclusive nor auto-deleting. Those are set here rather than left to the caller, because getting one of them wrong is answered by the broker with a message that never mentions streams.
Parameters
- name: what to call it
- retention: how much to keep :returns: a topology, so a stream can be declared alongside everything else rather than through a call of its own
206def then(publisher: Publisher, step: Callable[[Message], Any]) -> AsyncHandler: 207 """Publishes onwards whatever handling a message produced:: 208 209 handler = then( 210 mq.publisher("shipping-events", "shipment.requested"), 211 lambda message: NOTHING if message.payload["digital"] else ship(message), 212 ) 213 214 The step that turns a set of services into a pipeline. The correlation 215 identifier is carried forward and the incoming message becomes the outgoing 216 one's causation, so afterwards the whole run can be reconstructed from any 217 message in it. 218 219 Returning :data:`NOTHING` publishes nothing and accepts the message, which 220 is how a step says "this one does not continue" without inventing an empty 221 message for the next service to handle. 222 223 The message is accepted only once the next one is out, so a publish that 224 fails retries the step and the work runs again — which is why a step that 225 changes anything should be idempotent. 226 227 :param publisher: where the result goes 228 :param step: what to do, returning the payload to send on or :data:`NOTHING` 229 :returns: the handler 230 """ 231 232 async def onwards(message: Message) -> Ack: 233 produced = await decide(step, message) 234 if produced is NOTHING: 235 return accept() 236 237 try: 238 await publisher.send( 239 produced, 240 envelope=Envelope( 241 correlation_id=message.envelope.correlation_id, 242 causation_id=message.envelope.id, 243 ), 244 ) 245 except Exception as failure: 246 return retry( 247 RuntimeError( 248 f"acemq: the work for message {message.envelope.id} is done but " 249 f"the next message did not go out: {failure}" 250 ) 251 ) 252 return accept() 253 254 return onwards
Publishes onwards whatever handling a message produced::
handler = then(
mq.publisher("shipping-events", "shipment.requested"),
lambda message: NOTHING if message.payload["digital"] else ship(message),
)
The step that turns a set of services into a pipeline. The correlation identifier is carried forward and the incoming message becomes the outgoing one's causation, so afterwards the whole run can be reconstructed from any message in it.
Returning NOTHING publishes nothing and accepts the message, which
is how a step says "this one does not continue" without inventing an empty
message for the next service to handle.
The message is accepted only once the next one is out, so a publish that fails retries the step and the work runs again — which is why a step that changes anything should be idempotent.
Parameters
- publisher: where the result goes
- step: what to do, returning the payload to send on or
NOTHING:returns: the handler
185def with_idempotency( 186 store: IdempotencyStore, *, key: Callable[[Message], str] | None = None 187) -> Middleware: 188 """:func:`~acemq_amqp.patterns.idempotent` as middleware, so it can sit in a 189 :func:`chain`.""" 190 191 def wrap(handler: Handler) -> AsyncHandler: 192 return idempotent(store, handler, key=key) 193 194 return wrap
~acemq_amqp.patterns.idempotent() as middleware, so it can sit in a
chain().
138def with_logging(to: logging.Logger | None = None) -> Middleware: 139 """Records what happened to each message, and how long it took. 140 141 It logs the decision rather than the payload. A payload is the one thing on 142 a message that may not be safe to write down, and a log line that is unsafe 143 to keep is one somebody eventually turns off. 144 145 :param to: where to log, defaulting to the ``acemq`` logger. Passing one is 146 how a service files these with the rest of its own logs 147 :returns: the middleware 148 """ 149 writer = to or log 150 151 def wrap(handler: Handler) -> AsyncHandler: 152 async def recorded(message: Message) -> Ack: 153 started = datetime.now(timezone.utc) 154 try: 155 decision = await decide(handler, message) 156 except Exception: 157 # Logged and re-raised, not swallowed: the consumer above turns 158 # an exception into the retry policy, and catching it here would 159 # quietly take that decision away. 160 writer.exception( 161 "acemq %s type=%s attempt=%d took=%s raised", 162 message.envelope.id, 163 message.envelope.type, 164 message.envelope.attempt, 165 _since(started), 166 ) 167 raise 168 169 writer.info( 170 "acemq %s type=%s attempt=%d took=%s %s%s", 171 message.envelope.id, 172 message.envelope.type, 173 message.envelope.attempt, 174 _since(started), 175 decision, 176 f": {decision.error}" if decision.error else "", 177 ) 178 return decision 179 180 return recorded 181 182 return wrap
Records what happened to each message, and how long it took.
It logs the decision rather than the payload. A payload is the one thing on a message that may not be safe to write down, and a log line that is unsafe to keep is one somebody eventually turns off.
Parameters
- to: where to log, defaulting to the
acemqlogger. Passing one is how a service files these with the rest of its own logs :returns: the middleware
197def with_ordering(key: PartitionKey) -> Middleware: 198 """:func:`~acemq_amqp.patterns.ordered` as middleware.""" 199 200 def wrap(handler: Handler) -> AsyncHandler: 201 return ordered(key, handler) 202 203 return wrap
~acemq_amqp.patterns.ordered as middleware.
98def with_timeout(limit: timedelta) -> Middleware: 99 """Gives the handler a deadline. 100 101 A handler that runs past it is reported as a retry whatever it would have 102 said, which is a deliberate choice between two imperfect answers: retrying 103 work that may have succeeded risks doing it twice, and accepting work that 104 may have failed loses it. Duplicates are a problem that can be solved — see 105 :func:`~acemq_amqp.patterns.idempotent` — and a lost message is not. 106 107 An ``async`` handler is really cancelled at the deadline, so it stops. A 108 blocking handler running on a worker thread — which is what the 109 :mod:`acemq_amqp.sync` API gives it — cannot be: the message is settled as a 110 retry on time, but the thread carries on to the end of whatever it was 111 doing. That is worth knowing before putting a short deadline in front of one. 112 113 :param limit: how long the handler gets 114 :returns: the middleware 115 """ 116 if limit <= timedelta(0): 117 raise ValueError(f"acemq: a handler timeout must be positive, got {limit}") 118 119 def wrap(handler: Handler) -> AsyncHandler: 120 async def within(message: Message) -> Ack: 121 try: 122 return await asyncio.wait_for( 123 decide(handler, message), limit.total_seconds() 124 ) 125 except asyncio.TimeoutError: 126 return retry( 127 TimeoutError( 128 f"acemq: handling message {message.envelope.id} did not " 129 f"finish within {limit}" 130 ) 131 ) 132 133 return within 134 135 return wrap
Gives the handler a deadline.
A handler that runs past it is reported as a retry whatever it would have
said, which is a deliberate choice between two imperfect answers: retrying
work that may have succeeded risks doing it twice, and accepting work that
may have failed loses it. Duplicates are a problem that can be solved — see
~acemq_amqp.patterns.idempotent() — and a lost message is not.
An async handler is really cancelled at the deadline, so it stops. A
blocking handler running on a worker thread — which is what the
acemq_amqp.sync API gives it — cannot be: the message is settled as a
retry on time, but the thread carries on to the end of whatever it was
doing. That is worth knowing before putting a short deadline in front of one.
Parameters
- limit: how long the handler gets :returns: the middleware