Streams

A RabbitMQ stream is an append-only log. Reading does not remove anything, several readers can be at different places in it at once, and a reader can start again from the beginning. That is a different data structure from a queue, and it solves different problems:

A queue A stream
A message is delivered once and gone A message stays until it ages out
Two consumers share the work Two consumers each see everything
"Handle this" "Here is what happened"
No history The history is the queue

Use a stream for an event log several services project from, for a late-joining consumer that needs the last hour rather than only what arrives next, and for anything you want to be able to re-read after a bug. Use a queue for work.

What the starter does and does not do

Be clear about this before writing any code, because two of the starter's conveniences do not apply.

acemq.topology.queues[].type quorum or classic only. There is no stream
@AceListener Consumes a queue. It cannot read a stream
The AceMq bean, Telemetry, Codec, health All apply normally

Neither omission is an oversight, and both have the same cause: a stream has no useful default offset. A queue consumer starts where the queue is, because there is nowhere else to start. A stream reader has to say whether it wants the whole history, only what arrives next, or wherever it got to last time — and that answer is the application's, is usually read from a database, and does not belong in a properties file. An annotation with a mandatory attribute that can only be answered in code is worse than no annotation.

So a stream is declared and read in a @Bean. Everything else the starter provides still holds: the connection, its codec, its telemetry, its health indicator and its shutdown ordering.

Declaring one

@Configuration
class OrderLog {

    static final String LOG = "orders.log";

    /**
     * Declared once, at startup, alongside whatever acemq.topology declares. Retention is
     * mandatory in the sense that a stream without it grows until the disk is full.
     */
    @Bean
    InitializingBean orderLogStream(AceMq mq) {
        return () -> mq.declareStream(LOG, Duration.ofDays(7), 20L * 1024 * 1024 * 1024);
    }
}

declareStream(name, maxAge, maxLengthBytes) — either bound may be null, and at least one of them should not be. A stream keeps everything until one of its limits is reached and then drops from the oldest end; with neither limit set it keeps everything for ever, which is a disk alarm with a delay on it.

There is a four-argument form that also sets the segment size. Leave it alone unless you have measured a reason: segments are the unit the broker deletes in, so a very large segment means retention is enforced in very large steps.

InitializingBean rather than @PostConstruct on a @Component so that the declaration is ordered after the AceMq bean by the dependency rather than by luck. AceMqTopologyInitializer does the same thing for acemq.topology.

Reading one

@Configuration
class OrderProjections {

    @Bean(destroyMethod = "close")
    StreamConsumer statementProjection(AceMq mq, Statements statements) {
        return mq.stream(OrderLog.LOG, OrderPlaced.class)
                 .fromFirst()
                 .prefetch(200)
                 .consume(message -> statements.apply(message.payload()));
    }
}

mq.stream(name, type) returns a StreamReader<T>, which is a builder: choose where to start, then consume(handler) to start reading. The handler is the same MessageHandler<T> a queue consumer takes, so it sees a Message<OrderPlaced> with the payload and the envelope.

destroyMethod = "close" stops the reader when the context is destroyed. A stream reader is not in AceListenerRegistry, so it is not part of the registry's ordered drain — see shutdown below.

Where to start

.fromFirst() The beginning of the stream, whatever is still retained
.fromLast() The last message already in the stream, then forwards
.fromNext() Only messages that arrive after this reader attaches
.fromOffset(long) An exact offset. This is the resume case
.from(Instant) The first message at or after a timestamp
.fromLast(Duration) Everything from the last hour, say

.from(StreamOffset) takes the same thing as a value, which is what to use when the choice is computed rather than written: StreamOffset.first(), .last(), .next(), .at(offset), .from(instant), .lastly(age).

The default, if you call none of them, is next() — only what arrives after the reader attaches. It is the one default that cannot lose data by re-reading, and it is very rarely what a projection wants. Say which you mean.

These are not interchangeable and the wrong one is a quiet bug. A projection that must be correct wants fromFirst() on its first run and fromOffset(...) thereafter — fromNext() would silently skip whatever arrived while it was being deployed. An alerting consumer that only cares about now wants fromNext(), and fromFirst() would replay a week of history through it on every restart and page somebody about last Tuesday.

Resuming: offsets are yours to keep

This is the part that catches people. The library does not store your offset. There is no consumer-group concept doing it for you, and a reader restarted without being told where to start does not resume — it takes the default, next(), and silently skips everything that arrived while it was down.

There are two places to read the offset from, and they suit different checkpointing strategies.

StreamConsumer.lastHandledOffset() is the offset of the last message the handler returned normally for. It is an OptionalLong, empty until the first message. This is the supported accessor, and it is read from outside the handler — by a scheduled flush, or at shutdown.

The x-stream-offset header, for a handler that wants its own offset while it is running. The broker sets it on every delivery from a stream, and it reaches the handler as an ordinary application header:

static long offsetOf(Message<?> message) {
    Object offset = message.headers().get("x-stream-offset");
    if (!(offset instanceof Number)) {
        throw new IllegalStateException(
                "no x-stream-offset on a delivery from " + message.queue()
                        + "; this is not a stream");
    }
    return ((Number) offset).longValue();
}

It is a Number rather than a Long because the AMQP client chooses the narrowest integer that fits, so the cast has to go through Number. It is not part of the library's own API — it is RabbitMQ's header, passed through untouched — so a version that stops setting it would not be a break in this library. lastHandledOffset() is the safer of the two if either will do.

With the header, a projection can checkpoint inside its own transaction:

@Configuration
class OrderProjections {

    /**
     * Resumes from the offset the last run got to, and reads the whole stream the first time.
     * The checkpoint goes into the same database as the projection, in the same transaction —
     * two stores mean a crash between them replays or skips.
     */
    @Bean(destroyMethod = "close")
    StreamConsumer statementProjection(AceMq mq, Statements statements, Checkpoints checkpoints) {
        StreamReader<OrderPlaced> reader = mq.stream(OrderLog.LOG, OrderPlaced.class);

        StreamReader<OrderPlaced> positioned = checkpoints.offsetOf("statements")
                .map(last -> reader.fromOffset(last + 1))
                .orElseGet(reader::fromFirst);

        return positioned.consume(message ->
                statements.applyAndCheckpoint(message.payload(), offsetOf(message)));
    }
}

last + 1, not last. The offset you stored is the one you finished; starting there again hands the handler that message a second time.

applyAndCheckpoint is a single @Transactional method on Statements — that is the whole point of it being one method rather than two calls from here.

Checkpoint in the same transaction as the work. A projection that applies a row and then writes its offset in a second transaction will, when it crashes between them, redo the row on restart. That is fine if the projection is idempotent and a duplicated statement line if it is not. If the two genuinely cannot be one transaction, make the handler idempotent instead — see Idempotency — and checkpoint afterwards.

How often to checkpoint is a throughput decision. Every message is one write per message; every thousand means re-reading up to a thousand after a crash. Both are correct; only the second is fast.

When a handler throws

By default the reader stops. StreamConsumer.isRunning() goes false and stoppedBy() holds the exception.

That is the opposite of a queue consumer's default, and it is right for a log: a stream is ordered, and a projection that skips the message it could not handle and carries on is a projection that is now wrong, silently, for ever. Stopping keeps the offset at the last message that did work, so the run can be resumed after a fix.

Which means you have to watch it, because a stopped reader is not an exception anywhere:

@Bean
HealthIndicator statementProjectionHealth(StreamConsumer statementProjection) {
    return () -> statementProjection.isRunning()
            ? Health.up()
                    .withDetail("handled", statementProjection.handled())
                    .withDetail("offset", statementProjection.lastHandledOffset().orElse(-1))
                    .build()
            : Health.down()
                    .withDetail("stoppedBy", statementProjection.stoppedBy()
                            .map(Throwable::toString).orElse("unknown"))
                    .withDetail("offset", statementProjection.lastHandledOffset().orElse(-1))
                    .build();
}

A HealthIndicator bean is picked up by Actuator and appears under /actuator/health alongside the starter's own — and a reader that stopped at three in the morning is then an alert rather than a projection that is four hours stale for reasons nobody can see. Note the import: org.springframework.boot.actuate.health.HealthIndicator on Boot 3 and org.springframework.boot.health.contributor.HealthIndicator on Boot 4. That split is the reason the starter ships two health modules, and an application only ever compiles against its own line.

.skipFailures() is the other behaviour: log the failure, count it in skipped(), and carry on. Correct for a consumer where a bad message is genuinely skippable — a metrics feed, a best-effort cache warm — and wrong for anything where the log is the source of truth.

mq.stream(LOG, OrderPlaced.class).fromNext().skipFailures().consume(handler);

What a reader reports

consumer.queue();              // the stream
consumer.isRunning();
consumer.handled();            // handled without throwing
consumer.failed();
consumer.skipped();            // only ever non-zero with skipFailures()
consumer.lastHandledOffset();  // OptionalLong; empty before the first message
consumer.stoppedBy();          // Optional<Throwable>

One subtlety under skipFailures(): a skipped offset advances lastHandledOffset() too. That is what makes the checkpoint usable — a resume must not come back to a message the reader has already decided to skip — but it means the name is "last offset dealt with" rather than "last offset handled successfully". skipped() is how you find out there is a gap, and the reader logs each one at WARN because nothing else will ever report it.

handled() and lastHandledOffset() together answer "is this projection keeping up", which is the question a stream consumer's dashboard exists for. There is no built-in lag metric: the stream's head offset is not something a reader is told, so lag has to be derived from a publisher-side counter or from message timestamps.

Broker support

Streams need RabbitMQ 3.9 or later with the stream plugin enabled. Ask rather than assume:

if (!mq.supports(Capability.STREAMS)) {
    throw new IllegalStateException(
            "this broker has no stream support; enable rabbitmq_stream on it");
}

mq.capabilities() is the whole set. Worth an assertion at startup in an application whose brokers differ between environments — the failure otherwise arrives as a declaration error partway through a deployment.

Shutdown

A stream reader declared as a bean with destroyMethod = "close" is closed during context destruction, which is after every SmartLifecycle has stopped and therefore after the annotated listeners have drained. For most readers that is fine: the handler either finished or it did not, and the offset says which.

When a reader must stop before the rest of the application does — a projection writing through a DataSource that Spring is about to close, say — make it a SmartLifecycle instead, and give it a phase below AceListenerRegistry's:

@Component
class StatementProjection implements SmartLifecycle {

    private final AceMq mq;
    private final Statements statements;
    private volatile StreamConsumer consumer;

    StatementProjection(AceMq mq, Statements statements) {
        this.mq = mq;
        this.statements = statements;
    }

    @Override
    public void start() {
        consumer = mq.stream(OrderLog.LOG, OrderPlaced.class)
                     .fromFirst()
                     .consume(message -> statements.apply(message.payload()));
    }

    @Override
    public void stop() {
        StreamConsumer running = consumer;
        if (running != null) {
            running.close();
            consumer = null;
        }
    }

    @Override
    public boolean isRunning() {
        StreamConsumer running = consumer;
        return running != null && running.isRunning();
    }

    /** Below the registry's DEFAULT_PHASE, so this stops before the queue listeners do. */
    @Override
    public int getPhase() {
        return Integer.MAX_VALUE - 1;
    }
}

A higher phase stops earlier, which is why AceListenerRegistry sits at DEFAULT_PHASE — Integer.MAX_VALUE — and stops first.

Testing

InMemoryTransport does not implement streams, so a stream test needs a broker. That is a Testcontainers test rather than a context test; Testing has the shape, and the Testcontainers version this family pins.