Problem
AmqpActor's default is autoAcknowledge: true, and on that path the channel is acked before the delivery is handed to the target actor — not after, and not even at the same time. The ack sits in a try { … } catch { /* ignore */ }, so an ack that fails is swallowed while the tell below it still runs. Three consequences, all in the default configuration:
- Loss. A process that dies between the ack and the target actor draining its mailbox has already told RabbitMQ the message is settled. The message is gone.
- Duplication on a failing ack. When
channel.ack throws (a closing channel, a stale delivery tag after a reconnect), the catch swallows it and the code falls through to target.tell(...). The actor processes the message and the broker, never having seen the ack, redelivers it. The one path that produces a duplicate is the one that is silent.
- No flow control.
prefetch defaults to 1, which reads like "one unacked message at a time". Because the ack is emitted on delivery, the unacked count returns to zero immediately, the prefetch window never fills, and RabbitMQ pushes as fast as it can into an actor mailbox that drops on overflow. The knob that looks like backpressure does nothing.
The class doc does say the ack happens on delivery rather than after processing, so this is not a doc-versus-code contradiction. It is a default that reads as safe (prefetch: 1, noAck: false, an ack call on every message) and is not.
Evidence
src/io/broker/AmqpActor.ts:83-85 — the defaults:
src/io/broker/AmqpActor.ts:83-85
protected builtInDefaultOptions(): Partial<AmqpOptionsType> {
return { prefetch: 1, autoAcknowledge: true };
}
src/io/broker/AmqpActor.ts:112-126 — ack at :116, the swallow, and the tell at :120 that runs regardless:
src/io/broker/AmqpActor.ts:112-126
await this.channel.consume(queueName, (message) => {
if (!message) return;
const ackToken = this.nextAcknowledgmentToken++;
if (this.options.autoAcknowledge) {
try { this.channel?.ack(message); } catch { /* ignore */ }
} else {
this.pendingAcks.set(ackToken, message);
}
target.tell({
queue: queueName,
content: message.content,
properties: message.properties ?? {},
ackToken,
});
}, { noAck: false });
For contrast, the manual path settles from the actor thread and treats an unknown token as benign because the broker will redeliver — the reasoning that does not hold for the auto path, where the broker will not:
src/io/broker/AmqpActor.ts:187-193
private onAcknowledgment(command: AcknowledgmentCommand): void {
const raw = this.pendingAcks.get(command.delivery.ackToken);
if (raw && this.channel) {
try { this.channel.ack(raw); } catch { /* ignore */ }
this.pendingAcks.delete(command.delivery.ackToken);
}
}
Proposal
- Flip the default to
autoAcknowledge: false. The framework already has the manual path, the ack token, and the commands; the safe mode is one boolean away and the unsafe one is the default. Pre-1.0, a hard cut is in scope.
- If the auto path stays, ack after the
tell, not before, and let an ack failure fall through to not delivering rather than delivering anyway — a redelivery is recoverable, a duplicate side effect is not.
- Do not swallow the ack failure. Log it at
warn with the queue and delivery tag; it is the signal that the channel is dying.
- Document that
prefetch only bounds anything when autoAcknowledge is false, or refuse the combination autoAcknowledge: true + an explicit prefetch in AmqpOptionsValidator.
Acceptance sketch
Verification status
Found in the ten-lens production-readiness review of 2026-08-05 (v0.13.0) and re-verified before filing: confirmed by reading. Reproduction needs a live RabbitMQ (amqplib is not installed in this tree and AmqpActor has no module-injection seam of the kind KafkaActor.createKafkaInstance provides), and the review does not start brokers. The mechanism needs no runtime: channel.ack at :116 precedes target.tell at :120 in the same synchronous callback, and the catch { /* ignore */ } between them has no return.
Adjacent issues: #743 (no TLS material reaches the AMQP driver) and #708 (stop during a detached reconnect) are the same file, different defects. Nothing in the tracker covers the ack ordering or the prefetch no-op.
Part of the production-readiness review batch — tracked in #913.
Problem
AmqpActor's default isautoAcknowledge: true, and on that path the channel is acked before the delivery is handed to the target actor — not after, and not even at the same time. The ack sits in atry { … } catch { /* ignore */ }, so an ack that fails is swallowed while thetellbelow it still runs. Three consequences, all in the default configuration:channel.ackthrows (a closing channel, a stale delivery tag after a reconnect), the catch swallows it and the code falls through totarget.tell(...). The actor processes the message and the broker, never having seen the ack, redelivers it. The one path that produces a duplicate is the one that is silent.prefetchdefaults to1, which reads like "one unacked message at a time". Because the ack is emitted on delivery, the unacked count returns to zero immediately, the prefetch window never fills, and RabbitMQ pushes as fast as it can into an actor mailbox that drops on overflow. The knob that looks like backpressure does nothing.The class doc does say the ack happens on delivery rather than after processing, so this is not a doc-versus-code contradiction. It is a default that reads as safe (
prefetch: 1,noAck: false, an ack call on every message) and is not.Evidence
src/io/broker/AmqpActor.ts:83-85— the defaults:src/io/broker/AmqpActor.ts:112-126— ack at:116, the swallow, and thetellat:120that runs regardless:For contrast, the manual path settles from the actor thread and treats an unknown token as benign because the broker will redeliver — the reasoning that does not hold for the auto path, where the broker will not:
Proposal
autoAcknowledge: false. The framework already has the manual path, the ack token, and the commands; the safe mode is one boolean away and the unsafe one is the default. Pre-1.0, a hard cut is in scope.tell, not before, and let an ack failure fall through to not delivering rather than delivering anyway — a redelivery is recoverable, a duplicate side effect is not.warnwith the queue and delivery tag; it is the signal that the channel is dying.prefetchonly bounds anything whenautoAcknowledgeis false, or refuse the combinationautoAcknowledge: true+ an explicitprefetchinAmqpOptionsValidator.Acceptance sketch
channel.ackthat throws does not alsotellthe target.prefetch: 1actually limits in-flight deliveries in the shipped default mode, or the docs state that it does not.Verification status
Found in the ten-lens production-readiness review of 2026-08-05 (
v0.13.0) and re-verified before filing: confirmed by reading. Reproduction needs a live RabbitMQ (amqplibis not installed in this tree andAmqpActorhas no module-injection seam of the kindKafkaActor.createKafkaInstanceprovides), and the review does not start brokers. The mechanism needs no runtime:channel.ackat:116precedestarget.tellat:120in the same synchronous callback, and thecatch { /* ignore */ }between them has noreturn.Adjacent issues: #743 (no TLS material reaches the AMQP driver) and #708 (stop during a detached reconnect) are the same file, different defects. Nothing in the tracker covers the ack ordering or the prefetch no-op.
Part of the production-readiness review batch — tracked in #913.