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]
DEFAULT_BATCH = 100
DEFAULT_CLAIM_TIMEOUT = datetime.timedelta(seconds=300)
DEFAULT_IDEMPOTENCY_TABLE = 'acemq_idempotency'
DEFAULT_INTERVAL = datetime.timedelta(seconds=1)
DEFAULT_MEMORY = datetime.timedelta(seconds=3600)
DEFAULT_OUTBOX_MAX_ATTEMPTS = 10
DEFAULT_OUTBOX_TABLE = 'acemq_outbox'
DEFAULT_REGISTRY_TABLE = 'acemq_schema_registry'
DEFAULT_RETENTION = datetime.timedelta(days=1)
DEFAULT_SCHEDULE_PREFETCH = 50
DEFAULT_STREAM_PREFETCH = 10
DEFAULT_THRESHOLD = 65536
DEFAULT_TIMEOUT = datetime.timedelta(seconds=30)
HEADER_ERROR = 'acemq-error'
HEADER_REPLAYED_AT = 'acemq-replayed-at'
HEADER_REPLAYED_FROM = 'acemq-replayed-from'
HEADER_REPLAY_COUNT = 'acemq-replay-count'
HEADER_REPLY_TO = 'acemq-reply-to'
HEADER_ROUTE = 'x-acemq-route'
HEADER_ROUTING_SLIP = 'acemq-routing-slip'
HEADER_SCHEDULE_CONTENT_TYPE = 'x-schedule-content-type'
HEADER_SCHEDULE_DUE_AT = 'x-schedule-due-at'
HEADER_SCHEDULE_EXCHANGE = 'x-schedule-exchange'
HEADER_SCHEDULE_ROUTING_KEY = 'x-schedule-routing-key'
NOTHING = NOTHING
SCHEDULED_TYPE = 'ScheduledMessage'
SCHEDULE_DUE = 'acemq.schedule.due'
SCHEDULE_EXCHANGE = 'acemq.schedule'
SCHEDULE_RUNGS = (datetime.timedelta(seconds=3600), datetime.timedelta(seconds=600), datetime.timedelta(seconds=60), datetime.timedelta(seconds=10), datetime.timedelta(seconds=1))
class Claim(enum.Enum):
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.

CLAIMED = <Claim.CLAIMED: 'claimed'>
DUPLICATE = <Claim.DUPLICATE: 'duplicate'>
IN_PROGRESS = <Claim.IN_PROGRESS: 'in_progress'>
class ClaimCheckCodec:
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
ClaimCheckCodec( delegate: acemq_amqp.Codec, store: ClaimCheckStore, *, threshold: int = 65536)
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
delegate: acemq_amqp.Codec
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.

store: ClaimCheckStore
348    @property
349    def store(self) -> ClaimCheckStore:
350        """Where large payloads go."""
351        return self._store

Where large payloads go.

threshold: int
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.

content_type: str
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.

def encode(self, payload: Any) -> bytes:
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"))
def decode(self, body: bytes, content_type: str | None = None) -> Any:
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)
def can_decode(self, content_type: str | None) -> bool:
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.

@runtime_checkable
class ClaimCheckStore(typing.Protocol):
 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.

ClaimCheckStore(*args, **kwargs)
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)
def put(self, content: bytes) -> str:
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
def get(self, key: str) -> bytes | None:
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 None when the store no longer holds it — which is retention having expired underneath a message that outlived it
def delete(self, key: str) -> None:
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
class ConsumerGroup:
 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):
    ...
ConsumerGroup(queue: str, consumers: list[acemq_amqp.Consumer])
51    def __init__(self, queue: str, consumers: list[Consumer]) -> None:
52        self._queue = queue
53        self._consumers = consumers
@classmethod
async def start( cls, connection: acemq_amqp.Connection, queue: str, size: int, handler: 'Handler', *, codec: acemq_amqp.Codec | None = None, retry: acemq_amqp.RetryPolicy | None = None, prefetch: int | None = None, concurrency: int = 1, tag: str = '', args: Any = None, declare: bool = True) -> ConsumerGroup:
 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
queue: str
124    @property
125    def queue(self) -> str:
126        """What the group is reading."""
127        return self._queue

What the group is reading.

size: int
129    @property
130    def size(self) -> int:
131        """How many consumers are running."""
132        return len(self._consumers)

How many consumers are running.

async def close(self) -> None:
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.

@runtime_checkable
class DbConnection(typing.Protocol):
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.

DbConnection(*args, **kwargs)
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)
def cursor(self) -> DbCursor:
152    def cursor(self) -> DbCursor:
153        """A cursor on this connection."""

A cursor on this connection.

def commit(self) -> None:
155    def commit(self) -> None:
156        """Commits the open transaction."""

Commits the open transaction.

def rollback(self) -> None:
158    def rollback(self) -> None:
159        """Abandons the open transaction."""

Abandons the open transaction.

def close(self) -> None:
161    def close(self) -> None:
162        """Releases the connection."""

Releases the connection.

class DbCursor(typing.Protocol):
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.

DbCursor(*args, **kwargs)
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)
rowcount: int
126    @property
127    def rowcount(self) -> int:
128        """How many rows the last statement affected."""

How many rows the last statement affected.

def execute(self, operation: str, parameters: Any = Ellipsis, /) -> Any:
130    def execute(self, operation: str, parameters: Any = ..., /) -> Any:
131        """Runs one statement."""

Runs one statement.

def fetchone(self) -> Any:
133    def fetchone(self) -> Any:
134        """The next row, or ``None``."""

The next row, or None.

def fetchall(self) -> Any:
136    def fetchall(self) -> Any:
137        """Every remaining row."""

Every remaining row.

def close(self) -> None:
139    def close(self) -> None:
140        """Releases the cursor."""

Releases the cursor.

class FilesystemClaimCheckStore:
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
FilesystemClaimCheckStore(directory: str | os.PathLike[str])
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
directory: pathlib.Path
238    @property
239    def directory(self) -> Path:
240        """Where the payloads are."""
241        return self._directory

Where the payloads are.

def put(self, content: bytes) -> str:
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
def get(self, key: str) -> bytes | None:
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
def delete(self, key: str) -> None:
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
@runtime_checkable
class IdempotencyStore(typing.Protocol):
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.

IdempotencyStore(*args, **kwargs)
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)
async def first_time(self, key: str) -> bool:
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
async def forget(self, key: str) -> None:
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
class InMemoryClaimCheckStore:
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.

def put(self, content: bytes) -> str:
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
def get(self, key: str) -> bytes | None:
178    def get(self, key: str) -> bytes | None:
179        with self._lock:
180            return self._contents.get(key)
def delete(self, key: str) -> None:
182    def delete(self, key: str) -> None:
183        with self._lock:
184            self._contents.pop(key, None)
def clear(self) -> None:
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()

Empties the store, which is what a test between cases wants.

class InMemoryIdempotencyStore:
 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
InMemoryIdempotencyStore(ttl: datetime.timedelta = datetime.timedelta(seconds=3600))
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()
async def claim(self, key: str) -> Claim:
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.

async def first_time(self, key: str) -> bool:
127    async def first_time(self, key: str) -> bool:
128        return await self.claim(key) is Claim.CLAIMED
async def confirm(self, key: str) -> None:
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.

async def forget(self, key: str) -> None:
138    async def forget(self, key: str) -> None:
139        with self._lock:
140            self._seen.pop(key, None)
class InMemoryOutboxStore:
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.

InMemoryOutboxStore(max_attempts: int = 10)
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()
DEFAULT_MAX_ATTEMPTS = 10
async def add(self, entry: OutboxRecord) -> None:
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)
async def pending(self, limit: int) -> Sequence[OutboxRecord]:
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
async def mark_published(self, entry_id: str) -> None:
195    async def mark_published(self, entry_id: str) -> None:
196        with self._lock:
197            self._records.pop(entry_id, None)
async def record_failure(self, entry_id: str, error: str) -> None:
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.

def retired(self) -> Sequence[OutboxRecord]:
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.

class InMemorySchemaRegistry:
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.

async def register( self, subject: str, format: str, definition: str) -> SchemaDefinition:
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
async def by_id(self, identifier: int) -> SchemaDefinition:
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
async def latest(self, subject: str) -> SchemaDefinition:
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]
async def versions( self, subject: str) -> Sequence[SchemaDefinition]:
174    async def versions(self, subject: str) -> Sequence[SchemaDefinition]:
175        with self._lock:
176            return list(self._by_subject.get(subject) or [])
Middleware = collections.abc.Callable[[collections.abc.Callable[[acemq_amqp.Message], 'Ack | Awaitable[Ack]']], collections.abc.Callable[[acemq_amqp.Message], collections.abc.Awaitable[acemq_amqp.Ack]]]
@dataclass(frozen=True, slots=True)
class OutboxRecord:
 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
OutboxRecord( id: str, exchange: str, routing_key: str, body: bytes, content_type: str = '', headers: Mapping[str, typing.Any] = <factory>, created_at: datetime.datetime = <factory>, attempts: int = 0, last_error: str = '')
id: str
exchange: str
routing_key: str
body: bytes
content_type: str
headers: Mapping[str, typing.Any]
created_at: datetime.datetime
attempts: int
last_error: str
class OutboxRelay:
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
OutboxRelay( connection: acemq_amqp.Connection, store: OutboxStore, *, interval: datetime.timedelta = datetime.timedelta(seconds=1), batch: int = 100)
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
def start(self) -> 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.

async def sweep(self) -> int:
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
async def close(self) -> None:
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.

@runtime_checkable
class OutboxStore(typing.Protocol):
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.

OutboxStore(*args, **kwargs)
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)
async def add(self, entry: OutboxRecord) -> None:
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.

async def pending(self, limit: int) -> Sequence[OutboxRecord]:
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
async def mark_published(self, entry_id: str) -> None:
131    async def mark_published(self, entry_id: str) -> None:
132        """Removes a record, once the broker has confirmed the message."""

Removes a record, once the broker has confirmed the message.

PartitionKey = collections.abc.Callable[[acemq_amqp.Message], str]
class ReplayError(acemq_amqp.errors.AceMQError):
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.

ReplayError(result: ReplayResult, what: str)
83    def __init__(self, result: ReplayResult, what: str) -> None:
84        self.result = result
85        super().__init__(f"acemq: {what} (after {result})")
result
ReplayFilter = collections.abc.Callable[[acemq_amqp.Envelope, bytes], bool]
@dataclass(frozen=True, slots=True)
class ReplayResult:
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, limit or deadline
ReplayResult(moved: int, skipped: int, reason: str)
moved: int
skipped: int
reason: str
class RequestTimeoutError(acemq_amqp.errors.AceMQError, builtins.TimeoutError):
 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.

class Requester:
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.

Requester( connection: acemq_amqp.Connection, exchange: str, routing_key: str, reply_queue: str, timeout: datetime.timedelta, codec: acemq_amqp.Codec | None)
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
@classmethod
async def open( cls, connection: acemq_amqp.Connection, exchange: str = '', routing_key: str = '', *, reply_queue: str | None = None, timeout: datetime.timedelta = datetime.timedelta(seconds=30), codec: acemq_amqp.Codec | None = None) -> Requester:
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
reply_queue: str
218    @property
219    def reply_queue(self) -> str:
220        """The queue replies arrive on."""
221        return self._reply_queue

The queue replies arrive on.

timed_out: int
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.

unmatched: int
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.

async def ask( self, request: Any, *, envelope: acemq_amqp.Envelope | None = None, timeout: datetime.timedelta | None = None) -> Any:
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
async def close(self) -> None:
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.

Responder = collections.abc.Callable[[acemq_amqp.Message], typing.Any]
class ResponderError(acemq_amqp.errors.AceMQError):
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.

class ResponderHandle:
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.

ResponderHandle(queue: str)
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
answered: int
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.

unanswerable: int
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.

consumer: acemq_amqp.Consumer
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.

queue: str
437    @property
438    def queue(self) -> str:
439        """The queue requests arrive on."""
440        return self._queue

The queue requests arrive on.

running: bool
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.

closed: bool
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.

in_flight: int
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.

async def close(self) -> None:
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.

@dataclass(frozen=True, slots=True)
class RoutingSlip:
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
RoutingSlip( steps: tuple[Step, ...] = (), done: tuple[Step, ...] = (), run_id: str = '', form: SlipForm = <SlipForm.JSON: 'json'>)
steps: tuple[Step, ...]
done: tuple[Step, ...]
run_id: str
form: SlipForm
def then( self, exchange: str, routing_key: str, *, name: str = '') -> RoutingSlip:
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
next: Step | None
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.

finished: bool
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.

def advance(self) -> RoutingSlip:
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.

def to_header(self) -> str:
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.

def written_as( self, form: SlipForm) -> RoutingSlip:
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
def step_names(self) -> tuple[str, ...]:
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
def onto( self, envelope: acemq_amqp.Envelope) -> acemq_amqp.Envelope:
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
class Saga(typing.Generic[~T]):
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
Saga(name: str)
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]] = []
name: str
153    @property
154    def name(self) -> str:
155        """What this saga is called."""
156        return self._name

What this saga is called.

steps: tuple[SagaStep[~T], ...]
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.

def step( self, name: str, action: Callable[~T, object | Awaitable[object]], compensation: Callable[~T, object | Awaitable[object]] | None = None) -> Saga[~T]:
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
async def run(self, subject: ~T) -> SagaResult:
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
SagaAction = collections.abc.Callable[[~T], 'object | Awaitable[object]']
@dataclass(frozen=True)
class SagaResult:
 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 None when none did
  • failure: what it failed with
  • unresolved: the steps whose compensation failed
SagaResult( saga: str, completed: tuple[str, ...] = (), failed_at: str | None = None, failure: Exception | None = None, unresolved: tuple[str, ...] = ())
saga: str
completed: tuple[str, ...] = ()
failed_at: str | None = None
failure: Exception | None = None
unresolved: tuple[str, ...] = ()
complete: bool
 97    @property
 98    def complete(self) -> bool:
 99        """Whether every step ran."""
100        return self.failed_at is None

Whether every step ran.

compensated: bool
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.

has_unresolved: bool
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.

@dataclass(frozen=True)
class SagaStep(typing.Generic[~T]):
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 None for a step with nothing to undo
SagaStep( name: str, action: Callable[~T, object | Awaitable[object]], compensation: Callable[~T, object | Awaitable[object]] | None = None)
name: str
action: Callable[~T, object | Awaitable[object]]
compensation: Callable[~T, object | Awaitable[object]] | None = None
class Scheduler:
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.

Scheduler( connection: acemq_amqp.Connection, codec: acemq_amqp.Codec | None = None)
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
@classmethod
async def open( cls, connection: acemq_amqp.Connection, *, codec: acemq_amqp.Codec | None = None, prefetch: int = 50) -> Scheduler:
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
scheduled: int
318    @property
319    def scheduled(self) -> int:
320        """Messages handed to this scheduler."""
321        return self._scheduled

Messages handed to this scheduler.

delivered: int
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.

hops: int
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.

async def after( self, delay: datetime.timedelta, exchange: str, routing_key: str, payload: Any) -> None:
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
async def at( self, when: datetime.datetime, exchange: str, routing_key: str, payload: Any) -> None:
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 when has no time zone
async def close(self) -> None:
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.

@dataclass(frozen=True, slots=True)
class SchemaDefinition:
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
SchemaDefinition( id: int, subject: str, version: int, format: str, definition: str, fingerprint: str, registered_at: datetime.datetime = <factory>)
id: int
subject: str
version: int
format: str
definition: str
fingerprint: str
registered_at: datetime.datetime
class SchemaNotFoundError(acemq_amqp.errors.AceMQError):
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.

@runtime_checkable
class SchemaRegistry(typing.Protocol):
 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.

SchemaRegistry(*args, **kwargs)
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)
async def register( self, subject: str, format: str, definition: str) -> SchemaDefinition:
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.

async def by_id(self, identifier: int) -> SchemaDefinition:
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
async def latest(self, subject: str) -> SchemaDefinition:
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
async def versions( self, subject: str) -> Sequence[SchemaDefinition]:
112    async def versions(self, subject: str) -> Sequence[SchemaDefinition]:
113        """Every version of a subject, oldest first."""

Every version of a subject, oldest first.

class SlipForm(builtins.str, enum.Enum):
 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.

JSON = <SlipForm.JSON: 'json'>
STEP_NAMES = <SlipForm.STEP_NAMES: 'step-names'>
class SqlIdempotencyStore:
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" for sqlite3, "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
SqlIdempotencyStore( connections: Callable[[], DbConnection] | None = None, *, table: str = 'acemq_idempotency', paramstyle: str = 'qmark', claim_timeout: datetime.timedelta = datetime.timedelta(seconds=300), retention: datetime.timedelta = datetime.timedelta(days=1))
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
table: str
494    @property
495    def table(self) -> str:
496        """Where the rows are."""
497        return self._sql.table

Where the rows are.

node: str
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.

async def first_time( self, key: str, *, connection: DbConnection | None = None) -> bool:
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
async def claim( self, key: str, *, connection: DbConnection | None = None) -> Claim:
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()
async def confirm( self, key: str, *, connection: DbConnection | None = None) -> None:
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
async def forget( self, key: str, *, connection: DbConnection | None = None) -> None:
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
async def is_confirmed( self, key: str, *, connection: DbConnection | None = None) -> bool:
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.

async def purge_expired( self, *, connection: DbConnection | None = None) -> int:
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

async def size( self, *, connection: DbConnection | None = None) -> int:
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.

class SqlOutboxStore:
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" for sqlite3, "format" for psycopg
SqlOutboxStore( connections: Callable[[], DbConnection] | None = None, *, transaction: Callable[[], DbConnection] | None = None, table: str = 'acemq_outbox', paramstyle: str = 'qmark', max_attempts: int = 10)
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
table: str
765    @property
766    def table(self) -> str:
767        """Where the records are."""
768        return self._sql.table

Where the records are.

async def add( self, entry: OutboxRecord, *, connection: DbConnection | None = None) -> None:
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 transaction callable 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
async def pending( self, limit: int, *, connection: DbConnection | None = None) -> Sequence[OutboxRecord]:
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
async def record_failure( self, entry_id: str, error: str, *, connection: DbConnection | None = None) -> None:
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.

async def retired( self, *, connection: DbConnection | None = None) -> Sequence[OutboxRecord]:
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.

async def mark_published( self, entry_id: str, *, connection: DbConnection | None = None) -> None:
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.

async def count( self, *, connection: DbConnection | None = None) -> int:
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.

class SqlSchemaRegistry:
 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 _seq on the end
  • paramstyle: "qmark" for sqlite3, "format" for psycopg
SqlSchemaRegistry( connections: Callable[[], DbConnection] | None = None, *, table: str = 'acemq_schema_registry', paramstyle: str = 'qmark')
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)
ATTEMPTS = 5
table: str
992    @property
993    def table(self) -> str:
994        """Where the schemas are."""
995        return self._sql.table

Where the schemas are.

async def register( self, subject: str, format: str, definition: str, *, connection: DbConnection | None = None) -> SchemaDefinition:
 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.

async def by_id( self, identifier: int, *, connection: DbConnection | None = None) -> SchemaDefinition:
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.

async def latest( self, subject: str, *, connection: DbConnection | None = None) -> SchemaDefinition:
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.

async def versions( self, subject: str, *, connection: DbConnection | None = None) -> Sequence[SchemaDefinition]:
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.

Stage = collections.abc.Callable[[acemq_amqp.Message], typing.Any]
@dataclass(frozen=True, slots=True)
class Step:
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
Step( exchange: str, routing_key: str, name: str = '', completed_at: str = '')
exchange: str
routing_key: str
name: str
completed_at: str
def to_json(self) -> dict[str, typing.Any]:
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.

@classmethod
def from_json(cls, raw: Any) -> Step:
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        )
@dataclass(frozen=True, slots=True)
class StreamOffset:
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.

StreamOffset(kind: str, value: Any = None)
kind: str
value: Any
def to_arg(self) -> Any:
170    def to_arg(self) -> Any:
171        """What the broker is told."""
172        return self.kind if self.value is None else self.value

What the broker is told.

@dataclass(frozen=True, slots=True)
class StreamRetention:
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
StreamRetention( max_age: datetime.timedelta | None = None, max_bytes: int | None = None, segment_bytes: int | None = None)
max_age: datetime.timedelta | None
max_bytes: int | None
segment_bytes: int | None
def to_args(self) -> dict[str, typing.Any]:
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.

class StreamRetryError(acemq_amqp.errors.AceMQError, acemq_amqp.ack.FatalError):
 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.

def by_correlation() -> Callable[[acemq_amqp.Message], str]:
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.

def by_header(name: str) -> Callable[[acemq_amqp.Message], str]:
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
def chain( handler: 'Handler', *middleware: 'Middleware') -> Callable[[acemq_amqp.Message], Awaitable[acemq_amqp.Ack]]:
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
def claim_key_of(body: bytes) -> str | None:
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 None when the payload travelled inline or this codec did not write the message
def create_schema( connections: Callable[[], DbConnection] | DbConnection, *, dialect: str = 'sqlite', idempotency: str | None = 'acemq_idempotency', outbox: str | None = 'acemq_outbox', registry: str | None = 'acemq_schema_registry') -> None:
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 None to skip it
  • outbox: the outbox table, or None to skip it
  • registry: the schema registry table, or None to skip it. Its counter table is created alongside, named with _seq on the end
async def declare_stream( connection: acemq_amqp.Connection, name: str, retention: StreamRetention | None = None) -> None:
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
def fingerprint(definition: str) -> str:
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
def follow_slip( connection: acemq_amqp.Connection, step: Callable[[acemq_amqp.Message], typing.Any], *, codec: acemq_amqp.Codec | None = None, pipeline: str = '', form: SlipForm | None = None) -> Callable[[acemq_amqp.Message], Awaitable[acemq_amqp.Ack]]:
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
def from_first() -> StreamOffset:
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.

def from_last() -> StreamOffset:
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.

def from_next() -> StreamOffset:
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.

def from_offset(offset: int) -> StreamOffset:
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.

def from_timestamp(at: datetime.datetime) -> StreamOffset:
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.

def idempotent( store: IdempotencyStore, handler: 'Handler', *, key: Callable[[acemq_amqp.Message], str] | None = None, in_progress_delay: datetime.timedelta = datetime.timedelta(seconds=5)) -> Callable[[acemq_amqp.Message], Awaitable[acemq_amqp.Ack]]:
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
def is_claim_check(body: bytes) -> bool:
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
def ordered( key: Callable[[acemq_amqp.Message], str], handler: 'Handler') -> Callable[[acemq_amqp.Message], Awaitable[acemq_amqp.Ack]]:
 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
def partition(key: str, count: int) -> int:
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
def partitioned_routing_key(base: str, key: str, partitions: int) -> str:
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
async def read_stream( connection: acemq_amqp.Connection, name: str, handler: 'Handler', *, offset: StreamOffset | None = None, prefetch: int = 10, consumer_name: str = '', codec: acemq_amqp.Codec | None = None, concurrency: int = 1, declare: bool = True) -> acemq_amqp.Consumer:
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}.dlq and {name}.parked before subscribing. On by default; see acemq_amqp.Connection.consume(). No retry rungs are declared, because nothing here retries :returns: the running consumer
def record( connection: acemq_amqp.Connection, exchange: str, routing_key: str, payload: Any, *, envelope: acemq_amqp.Envelope | None = None, codec: acemq_amqp.Codec | None = None) -> OutboxRecord:
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
async def replay( connection: acemq_amqp.Connection, queue: str, *, exchange: str = '', routing_key: str | None = None, limit: int = 0, deadline: datetime.timedelta | None = None, only: Callable[[acemq_amqp.Envelope, bytes], bool] | None = None, restart: bool = True) -> ReplayResult:
 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. None keeps 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. None means no deadline
  • only: which messages to move. None moves 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
def route_of( pipeline: str, *names: str, run_id: str = '') -> RoutingSlip:
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
def schedule_rung_args(rung: datetime.timedelta) -> dict[str, typing.Any]:
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
def schedule_rung_name(rung: datetime.timedelta) -> str:
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
def schedule_topology() -> acemq_amqp.Topology:
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

def schema_ddl( *, dialect: str = 'sqlite', idempotency: str | None = 'acemq_idempotency', outbox: str | None = 'acemq_outbox', registry: str | None = 'acemq_schema_registry') -> list[str]:
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 None to skip it
  • outbox: the outbox table, or None to skip it
  • registry: the schema registry table, or None to skip it :returns: the statements, in the order they must run
async def serve( connection: acemq_amqp.Connection, queue: str, respond: Callable[[acemq_amqp.Message], typing.Any], *, codec: acemq_amqp.Codec | None = None, **consume_options: Any) -> ResponderHandle:
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
def slip_from( envelope: acemq_amqp.Envelope, *, pipeline: str = '') -> RoutingSlip | None:
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 None when 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
async def start( connection: acemq_amqp.Connection, slip: RoutingSlip, payload: Any, *, envelope: acemq_amqp.Envelope | None = None, codec: acemq_amqp.Codec | None = None, form: SlipForm | None = None) -> None:
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.JSON for one built with RoutingSlip.then(), the declared form for one built by route_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
def stream( name: str, retention: StreamRetention | None = None) -> acemq_amqp.Topology:
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
def then( publisher: acemq_amqp.Publisher, step: Callable[[acemq_amqp.Message], typing.Any]) -> Callable[[acemq_amqp.Message], Awaitable[acemq_amqp.Ack]]:
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
def with_idempotency( store: IdempotencyStore, *, key: Callable[[acemq_amqp.Message], str] | None = None) -> 'Middleware':
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().

def with_logging(to: logging.Logger | None = None) -> 'Middleware':
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 acemq logger. Passing one is how a service files these with the rest of its own logs :returns: the middleware
def with_ordering(key: Callable[[acemq_amqp.Message], str]) -> '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
def with_timeout(limit: datetime.timedelta) -> '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