Streams
A queue forgets a message when it is acknowledged, so exactly one consumer ever sees it and nobody can look at it again. A stream keeps everything until its retention policy discards it, so several consumers read the same stream independently and a new one can start from the beginning.
from datetime import timedelta
from acemq_amqp.patterns import (
StreamRetention, declare_stream, from_first, read_stream,
)
await declare_stream(mq, "events", StreamRetention(max_age=timedelta(days=7)))
consumer = await read_stream(mq, "events", project, offset=from_first())That sounds like a queue with better retention. It is not, and the differences are quiet rather than loud. This page is mostly about the quiet ones.
Declaring
await declare_stream(mq, "events", StreamRetention(max_age=timedelta(days=7)))A stream is an ordinary queue declared with
x-queue-type: stream, so it goes through the same topology machinery as everything else.
declare_stream is the short way;
stream(name, retention) returns a Topology
instead, so a stream can be described as a value and applied alongside
the rest of a service's shape rather than through a call of its own:
from acemq_amqp.patterns import stream
shape = stream("orders.log", StreamRetention(max_bytes=10_000_000_000))
shape.validate() # before anything is declared
await mq.declare(shape)There is no operator for joining two topologies — apply each in turn, or add the stream's arguments to a topology you are already building:
from acemq_amqp import QUEUE_TYPE_ARG
await mq.declare(
Topology()
.exchange("orders-events", "topic")
.queue("orders.log", args={QUEUE_TYPE_ARG: "stream"})
.binding("orders.log", "orders-events", "order.#")
)Durable, never exclusive, never auto-deleting. Those three 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.
Retention
StreamRetention(
max_age=timedelta(days=7), # x-max-age
max_bytes=10_000_000_000, # x-max-length-bytes
segment_bytes=500_000_000, # x-stream-max-segment-size-bytes
)StreamRetention() is unbounded, which
for a stream means until the disk is full — and a full disk is a
broker-wide alarm that blocks every publisher on the node, not a problem
confined to this stream. Set at least one of these on anything that will
run for longer than an afternoon.
max_age is written the way RabbitMQ wants it, which is a
number and a unit suffix rather than a count of seconds, and the units
are the broker's own: D for days but lowercase for the
rest.
timedelta(...) |
on the wire |
|---|---|
days=2 |
2D |
hours=3 |
3h |
minutes=90 |
90m |
seconds=45 |
45s |
segment_bytes is how large each of the stream's files on
disk gets. Retention happens a whole segment at a time
— nothing is discarded until an entire segment can be — so a very large
segment makes every other retention setting coarse: a stream told to
keep an hour, in segments large enough to hold a day, keeps a day.
There is no default segment size, on purpose. The
broker has one and it is tuned for the broker's storage. More than
tidiness is at stake: a queue redeclared with an argument the first
declaration did not carry is a PRECONDITION_FAILED rather
than an adjustment, so a segment size invented here would make a stream
declared from Python impossible to redeclare from Java, Go, .NET or
Ruby. All five leave it out the same way.
Where to start reading
from acemq_amqp.patterns import (
from_first, from_last, from_next, from_offset, from_timestamp,
)from_next() |
the next message published, ignoring everything already there. The default |
from_first() |
the oldest message the stream still holds — a retention policy means that is not necessarily the first one ever written |
from_last() |
the last chunk. 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 |
from_offset(n) |
an exact position, inclusive |
from_timestamp(when) |
the first message published at or after a time |
The offset is a consumer setting, not a queue
setting. Two readers of the same stream sit at different places, so it
cannot belong to the queue — which is also why these are functions
returning a StreamOffset rather than an enum: 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.
from_next() is the default because it is the only one
that does not replay history the first time a reader starts. That is
right for a consumer joining a live system and wrong for a
projection: one built without from_first()
silently skips its own history and looks perfectly healthy while being
wrong. State the position rather than inheriting it.
Reading
consumer = await read_stream(
mq,
"events",
project,
offset=from_first(),
prefetch=100,
consumer_name="projection-a",
concurrency=1,
)An ordinary Consumer comes
back — the same object mq.consume returns, closed the same
way, usable as an async with. The handler takes a
Message and returns an Ack, exactly as
anywhere else, with one thing it may not say: see what an acknowledgement means
here.
There is no retry parameter and no policy to inherit. A
stream consumer runs on no_retry() whatever the
connection's default is, because retrying on a stream appends to it.
prefetch cannot be zero: RabbitMQ
refuses a stream consumer without one, because it is the only
backpressure a stream has, and the message it gives back does not
mention streams. Ten by default — enough to keep a handler busy, small
enough that a slow one is not holding a batch nobody else can have. A
projection catching up on history wants rather more than ten.
consumer_name is the consumer tag the broker is told,
and it is what makes server-side offset tracking possible.
concurrency is 1 by default, because a stream's order is
usually why it is a stream. Raising it trades that order for
throughput.
What an acknowledgement means here
This is the part to understand before using one.
Acknowledging does not remove the message. It
advances this consumer's position. The message stays on the
stream for its retention, and another consumer reading from
from_first() tomorrow sees everything you consumed.
Everything else follows from that. A copy of a refused message can
still be put somewhere a person will look, because the queues beside a
stream are ordinary queues and nothing that happens in them touches the
log. A retry cannot be, and read_stream
refuses one:
| the handler returns | what happens |
|---|---|
accept() |
acknowledged. The position advances; the message is still on the stream |
park(e), or a body the codec refuses |
a copy is published to {stream}.parked, then the
delivery is acknowledged. The original is still on the stream |
reject(e) |
a copy goes to {stream}.dlq, same shape |
retry(e) |
refused. StreamRetryError naming the
two alternatives, and a copy of the message goes to
{stream}.dlq carrying that sentence. Nothing is
appended to the stream |
| the handler raises | the same: nothing is retried, and a copy goes to
{stream}.dlq |
The fourth row used to be the one that bit. A retry on a queue puts a
message back on that queue; a retry on a stream appends
to it, so every other consumer of that stream read the retry as a new
message and a handler that failed three times left three copies on the
log for ever. With
fixed_retry(3, timedelta(milliseconds=200)) on the
connection, one published message and a handler that always raised
produced exactly that: attempts 1, 2 and 3 all sitting on the stream,
and one copy in {stream}.dlq.
Both doors are now shut. A handler returning retry(...)
gets:
acemq: a handler reading the stream 'events' asked to retry message 0f9c….
A stream never removes a message, so retrying one means appending a second
copy of it to the log for every other consumer to read as well. Park it
instead — park(...) puts a copy in events.parked and leaves the log alone —
or accept() to checkpoint past it and record the failure yourself.
and a handler that raises gets no retry either, because
read_stream runs its consumer on no_retry() whatever the
connection's own policy is. That is the second door and it mattered more
than the first: a connection carrying a retry policy for its queues used
to turn every exception out of a stream handler into a copy on the log,
silently — from outside, a stream that is growing looks like a stream
that is busy.
StreamRetryError is both an AceMQError and
a FatalError. The first is how a caller catches what this
library refused; the second is what keeps the delivery off a retry
ladder that would do the very thing being refused.
So a failing handler has two honest outcomes, and they are
enough. Park it, and the message is in a queue somebody drains.
Or accept it, checkpoint past it, and record the failure yourself —
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. reject(...) works too and is the same shape as
parking; it is not offered as one of the two because "this message is
bad" is rarely what a stream failure is.
Java models this with a separate StreamConsumer whose
failure policy is STOP or SKIP and nothing
else. Python constrains the consumer instead of replacing it, and the
reason is that the two libraries are removing different things. Java's
MessageConsumer exposes the retry ladder as
configuration, so a type offering a dead-letter policy for a
stream would be a type where half the settings are wrong. Python's
Consumer exposes queue, running,
in_flight and close, every one of which means
exactly what it says on a stream. The three-outcome settle path comes
from the Ack a handler returns, not from the consumer, so
the refusal belongs where the Ack is read — and a second
consumer class would be a copy of the first with nothing taken away.
The queues a stream consumer declares
read_stream declares {stream}.dlq and
{stream}.parked before subscribing, the same as any other
consumer, because the table above means it really can publish to
both. Those are ordinary quorum queues, and a message in one is a
message a person has to look at.
No retry rungs are declared, because nothing here
retries. An ordinary consumer on a connection with a long-delay policy
declares {queue}.retry.5m and its neighbours; a stream
consumer declares none of them, and a rung queue for a consumer that
will never use one is a durable queue nothing ever reads.
Pass declare=False to stop it — for a login without
configure permission, or because you would rather a
stream's failures went somewhere you chose. A consumer started that way
still publishes to those names if a handler parks or rejects, so
somebody else has to have declared them.
Depth does not mean anything on a stream
await mq.message_count("events") # 0, whatever is on the streamThe broker reports zero, because a message on a stream is not waiting for anybody: depth is the count of messages nobody has taken yet, and on a stream nobody ever takes one. It is not a bug and there is nothing to work around — it is the number being meaningless rather than wrong. To know how far behind a reader is, compare its offset with the newest one, which means recording the offset yourself.
Resuming where you stopped
A reconnection is different, and needs nothing from
you. When the connection drops and the library brings it back, a stream
reader carries on from the oldest entry it was handed and had not yet
acknowledged, or from just after the newest it had — not from the offset
it was started with. A reader started at from_first() does
not replay the stream, and one started at from_next() does
not skip what was appended while it was away. Entries a handler was
still working on when the connection went arrive again, so a handler
sees at most a prefetch's worth of repeats.
What follows is about a restart, where the process and everything it held are gone.
The broker does not remember your position between runs. That is what
makes streams cheap, and it is what makes checkpointing your job. A
reader restarted with from_first() reprocesses everything;
one restarted with from_next() misses whatever arrived
while it was down.
The offset of each delivery reaches the handler as an ordinary header:
async def project(message: Message) -> Ack:
offset = message.envelope.headers["x-stream-offset"]
await apply(message.payload)
await checkpoints.save("projection-a", offset)
return accept()
consumer = await read_stream(
mq, "events", project,
offset=from_offset(await checkpoints.load("projection-a") + 1),
consumer_name="projection-a",
)It survives into Envelope.headers because the engine
only strips the reserved x-acemq- namespace, and
x-stream-offset is the broker's rather than this library's.
Resume from one more than the last offset handled;
from_offset(n) is inclusive.
Saving the checkpoint after handling means a crash between the two replays the last message. Saving it before means a crash loses one. Neither is avoidable without a transaction spanning the broker and your store, so write the checkpoint in the same transaction as the projection's own writes and the pair is exactly once — anywhere else is at-least-once, which is fine if the handler is idempotent.
There is no last_handled_offset on the consumer, and no
handled, failed or skipped
counters. .NET reports all four and Java reports the first two; here the
offset is on the message and the counts are on acemq.consume.total,
tagged with the queue and the outcome.
Testing
The fake transport declares a stream and accepts a subscription, so the declaration arguments, the offset argument and the prefetch are all assertable without a broker:
consumer = await read_stream(mq, "events", handler, offset=from_first())
assert transport.specs["events"].args == {"x-stream-offset": "first"}What it does not have is stream semantics: nothing is retained, no second reader sees what the first one read, and no offset comes back on a delivery. Anything that turns on replay needs a real broker — which is the same position Java takes, for the same reason.
When to use one
Use a stream when more than one consumer needs the same messages, when history has to be re-readable, or when a projection must be rebuildable from scratch: event sourcing, audit logs, analytics fan-out.
Stay with a queue for work that is done once and finished. Consuming removes nothing, so two workers reading one stream both do the same job — a stream is the wrong shape for distributing work. And as the table above shows, the retry ladder does not exist on a stream at all, and the dead-letter queue and the parking lot hold copies rather than the message itself. Rebuilding those on top of one is how a simple job becomes a distributed systems project.
Requirements
Streams need RabbitMQ 3.9 or later. There is nothing to install: the
x-queue-type argument is all it takes, and this library
sends it through the ordinary AMQP connection rather than over the
dedicated stream protocol — so the per-message performance of the stream
plugin's own client is not what you get, and the replay, the retention
and the independent positions are.
Related
- Patterns — the same thing in the pattern tour
- Exchanges, queues and bindings
- Consuming — the
Consumerread_streamreturns - Retries, redelivery and shutdown — what the table above is describing a stream's version of