acemq_amqp.patterns.replay

Putting dead letters back, when the thing that killed them is fixed.

This is the pattern somebody actually needs at three in the morning: a dead-letter queue has two thousand messages in it, the bug is fixed, and they have to go back through — but not all of them, not silently, and not in a way that cannot be stopped half way.

So a replay takes a filter, a limit and a deadline, and reports what it did and why it stopped. "Moved 500" means something quite different when the limit was 500, which is why the reason comes back with the count.

  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"""Putting dead letters back, when the thing that killed them is fixed.
 16
 17This is the pattern somebody actually needs at three in the morning: a
 18dead-letter queue has two thousand messages in it, the bug is fixed, and they
 19have to go back through — but not all of them, not silently, and not in a way
 20that cannot be stopped half way.
 21
 22So a replay takes a filter, a limit and a deadline, and reports what it did and
 23why it stopped. "Moved 500" means something quite different when the limit was
 24500, which is why the reason comes back with the count.
 25"""
 26
 27from __future__ import annotations
 28
 29import asyncio
 30import logging
 31from collections.abc import Callable
 32from dataclasses import dataclass
 33from datetime import datetime, timedelta, timezone
 34from typing import Any, TypeAlias
 35
 36from ..connection import Connection
 37from ..envelope import Envelope
 38from ..errors import AceMQError, PublishError
 39from ..transport import Delivery, Outbound
 40
 41log = logging.getLogger("acemq")
 42
 43#: The queue a message was replayed out of.
 44HEADER_REPLAYED_FROM = "acemq-replayed-from"
 45
 46#: When it was replayed, as RFC 3339.
 47HEADER_REPLAYED_AT = "acemq-replayed-at"
 48
 49#: How many times it has been replayed, from 1.
 50HEADER_REPLAY_COUNT = "acemq-replay-count"
 51
 52#: Decides whether a message goes back. Returning ``False`` leaves it where it
 53#: is, which is what makes a replay something that can be done in stages.
 54ReplayFilter: TypeAlias = Callable[[Envelope, bytes], bool]
 55
 56
 57@dataclass(frozen=True, slots=True)
 58class ReplayResult:
 59    """What a replay did, and why it stopped.
 60
 61    :param moved: how many messages were republished
 62    :param skipped: how many the filter declined, and so left where they were
 63    :param reason: ``drained``, ``limit`` or ``deadline``
 64    """
 65
 66    moved: int
 67    skipped: int
 68    reason: str
 69
 70    def __str__(self) -> str:
 71        return f"moved {self.moved}, skipped {self.skipped} ({self.reason})"
 72
 73
 74class ReplayError(AceMQError):
 75    """A replay stopped because something failed.
 76
 77    It carries the :class:`ReplayResult` it had reached, because "it failed" and
 78    "it failed after moving four hundred" are different things to be told at
 79    three in the morning.
 80    """
 81
 82    def __init__(self, result: ReplayResult, what: str) -> None:
 83        self.result = result
 84        super().__init__(f"acemq: {what} (after {result})")
 85
 86
 87async def replay(
 88    connection: Connection,
 89    queue: str,
 90    *,
 91    exchange: str = "",
 92    routing_key: str | None = None,
 93    limit: int = 0,
 94    deadline: timedelta | None = None,
 95    only: ReplayFilter | None = None,
 96    restart: bool = True,
 97) -> ReplayResult:
 98    """Moves messages from a queue back onto an exchange::
 99
100        result = await replay(
101            mq,
102            dead_letter_queue("orders.new"),
103            limit=500,
104            only=lambda envelope, body: "timeout" in envelope.error,
105        )
106        print(result)   # moved 137, skipped 363 (drained)
107
108    Each message is stamped with :data:`HEADER_REPLAYED_FROM`,
109    :data:`HEADER_REPLAYED_AT` and :data:`HEADER_REPLAY_COUNT`, so a consumer
110    that needs to treat a replayed message differently can, and one that does
111    not is unaffected.
112
113    :param connection: where to read from and publish to
114    :param queue: where the messages are now, usually a dead-letter queue
115    :param exchange: where they go back to, empty to publish to a queue by name
116    :param routing_key: what to publish them under. ``None`` keeps each
117        message's own, so a message goes back where it came from rather than
118        everywhere
119    :param limit: stop after this many. Zero means no limit, which against a
120        queue something is still writing to may mean it does not stop at all —
121        prefer a limit
122    :param deadline: stop after this long. ``None`` means no deadline
123    :param only: which messages to move. ``None`` moves all of them
124    :param restart: give each message a fresh set of attempts, clearing the
125        counter and the error that dead-lettered it. On by default because it is
126        what a replay is for: a message that arrives back on attempt five of a
127        five-attempt policy is dead-lettered again before a handler sees it, and
128        the operator who has just fixed the bug has moved two thousand messages
129        from one queue to the same queue. Turn it off to put back exactly what
130        was there — for an audit, or for a queue read by something that counts
131        attempts itself
132    :returns: what it did and why it stopped
133    :raises ReplayError: when reading or republishing failed. Whatever had
134        already been moved has been moved; the rest is still on the queue
135    """
136    if not queue:
137        raise ValueError("acemq: a replay needs a queue to read from")
138
139    clock = asyncio.get_running_loop()
140    expires = None if deadline is None else clock.time() + deadline.total_seconds()
141    moved = 0
142    skipped = 0
143
144    # Messages the filter declines are held unsettled until the pass is over
145    # rather than returned one at a time. Returning one immediately does not
146    # work: the broker puts it back where it was, at the head of the queue, so
147    # the next pull hands over the same message and nothing behind it is ever
148    # looked at. Holding them takes them out of the way for the length of the
149    # pass, and because the broker is the one holding them, a crash returns them
150    # rather than losing them.
151    declined: list[Delivery] = []
152
153    try:
154        while True:
155            if limit > 0 and moved >= limit:
156                return ReplayResult(moved, skipped, "limit")
157            if expires is not None and clock.time() >= expires:
158                return ReplayResult(moved, skipped, "deadline")
159
160            try:
161                message = await connection.pull(queue)
162            except Exception as failure:
163                raise ReplayError(
164                    ReplayResult(moved, skipped, "failed"),
165                    f"cannot read {queue!r} to replay it: {failure}",
166                ) from failure
167
168            if message is None:
169                # Everything available has been offered. What is left is what
170                # this pass declined, and it goes back as the pass ends.
171                return ReplayResult(moved, skipped, "drained")
172
173            envelope = Envelope.from_headers(message.headers, message.routing_key)
174            if only is not None and not only(envelope, message.body):
175                skipped += 1
176                declined.append(message)
177                continue
178
179            try:
180                await _move(
181                    connection, message, envelope, queue, exchange, routing_key, restart
182                )
183            except Exception as failure:
184                raise ReplayError(
185                    ReplayResult(moved, skipped, "failed"),
186                    f"cannot republish a message from {queue!r}: {failure}",
187                ) from failure
188            moved += 1
189    finally:
190        for held in declined:
191            # Nothing to report if this fails: the pass is over, and a message
192            # that cannot be returned is one the broker returns itself when this
193            # connection goes.
194            await _return_quietly(held)
195
196
197async def _move(
198    connection: Connection,
199    message: Delivery,
200    envelope: Envelope,
201    queue: str,
202    exchange: str,
203    routing_key: str | None,
204    restart: bool,
205) -> None:
206    """Republishes one message and then acknowledges the original.
207
208    That order, and not the other one. Acknowledging first would lose the
209    message whenever the publish failed; this way a failure in the gap replays
210    it twice, which is the right way round for a queue somebody is putting back
211    by hand.
212    """
213    going_back = envelope.with_(attempt=1, error="") if restart else envelope
214    key = message.routing_key if routing_key is None else routing_key
215
216    headers: dict[str, Any] = dict(going_back.to_headers(routing_key=key))
217    headers[HEADER_REPLAYED_FROM] = queue
218    headers[HEADER_REPLAYED_AT] = datetime.now(timezone.utc).isoformat()
219    headers[HEADER_REPLAY_COUNT] = _replays_so_far(envelope) + 1
220
221    try:
222        # Mandatory, because the original is acknowledged as soon as this
223        # returns: a destination nothing is bound to is confirmed and dropped by
224        # the broker, and without the return the replay would delete the last
225        # copy of a message it was asked to recover.
226        result = await connection.publish_raw(
227            exchange,
228            key,
229            Outbound(
230                body=message.body,
231                content_type=message.content_type or "",
232                message_id=going_back.id,
233                headers=headers,
234                persistent=True,
235                mandatory=True,
236            ),
237        )
238        if not result.routed:
239            raise PublishError(
240                going_back.id,
241                exchange,
242                key,
243                result.return_reason or "no queue is bound to receive it",
244                unroutable=True,
245            )
246    except Exception:
247        # Returned rather than dropped, and the replay stops. A replay that
248        # loses messages is worse than one that stops early.
249        await _return_quietly(message)
250        raise
251
252    await message.ack()
253
254
255async def _return_quietly(message: Delivery) -> None:
256    """Puts a message back without letting that failure hide the first one.
257
258    Logged rather than raised: by the time this is called the replay is already
259    stopping, and the message is one the broker returns itself when this
260    connection goes.
261    """
262    try:
263        await message.nack(True)
264    except Exception:
265        log.warning("acemq: could not return a message to the queue it was replayed from")
266
267
268def _replays_so_far(envelope: Envelope) -> int:
269    """How many times this message has been replayed already.
270
271    Read leniently, because the header may have been written by another
272    language's client or by a broker that renders integers as strings.
273    """
274    written = envelope.headers.get(HEADER_REPLAY_COUNT)
275    if isinstance(written, bool):
276        return 0
277    if isinstance(written, int):
278        return written
279    try:
280        return int(str(written))
281    except (TypeError, ValueError):
282        return 0
log = <Logger acemq (WARNING)>
HEADER_REPLAYED_FROM = 'acemq-replayed-from'
HEADER_REPLAYED_AT = 'acemq-replayed-at'
HEADER_REPLAY_COUNT = 'acemq-replay-count'
ReplayFilter: TypeAlias = 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 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
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