Snail.Toolkit.RabbitMQ¶
Extension for the framework RabbitMQ.Client
Concepts¶
-
Release immutability
- The topology for each release must be strictly defined and not changed during its existence
- Until application is running you can't:
- Cancel consumers
- Remove queues and exchanges
- Change bindings
- Until application is running you can't:
- The topology for each release must be strictly defined and not changed during its existence
-
Declarativeness and simplicity
- Intuitive RabbitMQ-close fluent interfaces
- Generic type checks
- Connection, Exchange, Queue, Consumer and Producer declaration classes
-
Reliability by default
- Publisher confirmations:
PublishAsynccompletes only after the broker confirms the message and throws when it is rejected or unroutable (Mandatory) - Poison messages (payload that cannot be deserialized) never stall a consumer and never cycle through the retry queue — they are parked, dropped or rejected depending on the queue setup
- Retry with backoff via a companion retry queue:
WithRetryon a queue, with optional parking of exhausted messages (parkTo) for manual inspection - A dead producer channel is evicted and replaced automatically — one broker-side channel error never disables publishing until restart
- A publish that finds its connection lost waits for the client to recover it (30 s unless
UseRecoveryWaitsays otherwise) and is then published once more, instead of failing - Contradictory consumer configuration (
AutoAck+WithRetry/Requeue) fails the application start with a clear message instead of silently not retrying - Graceful shutdown: consumers are cancelled and channels and connections closed when the host stops
- Traces out of the box: publish and delivery share one trace, and the delivery span records how the message was settled (see Tracing)
- Publisher confirmations:
Installation¶
dotnet add package Snail.Toolkit.RabbitMQ
Usage¶
services.AddRabbit(options =>
options.UseConnectionFactory(factory => ...),
connections => connections
.AddConnection(connection =>
{
// Enable type checks with .AddDirectExchange<TPayload>() extension
var exchange = connection.AddDirectExchange("exchange")
// .NoWait()
// .Deleted()
.AutoDelete()
.Durable();
var queue = connection.AddQueue<Payload>("queue")
// .Exclusive()
// .Deleted()
// .NoWait()
// .Lazy()
// .MaxPriority(10)
// .TimeToLive(10000)
// .MaxMessageSize(1000)
// .MaxMessageCount(1000)
// .MessageTimeToLive(1000)
// .RejectPublish()
// .Quorum()
// .SingleActiveConsumer()
// Failed messages wait in "queue.retry" for 30s, then are redelivered;
// after 5 attempts the message moves to "queue.failed" (or is dropped without parkTo)
// .WithRetry(TimeSpan.FromSeconds(30), maxAttempts: 5, parkTo: "queue.failed")
.AutoDelete()
.Durable()
// Type checks
.BoundTo(exchange);
// Type checks
connection.AddConsumer(queue)
// .Tagged("tag")
// .Prefetch(1) // defaults to 10; 0 means unlimited
// .Count(1)
// .Exclusive()
// .NoLocal()
// .AutoAck()
// .Requeue()
// .Priority(2)
.Count(2)
.Requeue()
.Subscribe<Payload, Consumer>();
// Type checks
connection.AddProducer<Payload>(exchange)
// .Transactional()
// .Mandatory()
// .MessageTimeToLive(TimeSpan.FromSeconds(10))
// .MessageId("...")
// .CorrelationId("...")
.Persistent();
}));
public class Consumer : IConsumer<Payload>
{
public ValueTask HandleAsync(Payload message, CancellationToken cancellationToken)
{
Console.WriteLine($"Received: {message}");
return ValueTask.CompletedTask;
}
}
// Get or inject
var producer = serviceProvider.GetRequiredService<IRabbitProducer>();
// Use; completes after the broker confirms the message
await producer.PublishAsync(new Payload(), overrides => ...);
// Per-message identifiers via overrides
await producer.PublishAsync(payload, b => b
.MessageId(eventId)
.CorrelationId(taskId));
Sample configuration appsettings.json for using Vault¶
{
"RabbitMQ": {
"HostName": "localhost",
"Port": 5672,
"VirtualHost": "/",
"UserName": "guest",
"Password": "guest",
"ClientProvidedName": "default",
"Ssl": {
"Enabled": false,
"ServerName": null,
"CertPath": null,
"CertPassphrase": null
}
}
}
Every key is optional. Port left out means the client default — 5672 plain, 5671 once
Ssl:Enabled is set — and Ssl:ServerName left out falls back to HostName, which is what
certificate validation compares against. CertPath is only needed for mutual TLS.
Supported¶
- Durable, internal, dead letter, bound and alternate exchanges
- Lazy, durable, quorum and exclusive queues
- Publisher confirmations (on by default for non-transactional producers) and transactional channel mode
- Poison message handling: undeserializable payloads are parked, dropped or rejected — never retried, never stalling
- Retry with backoff through a companion retry queue (
WithRetry), with optional parking (parkTo) - Automatic eviction and replacement of broker-closed producer channels
- Consumer configuration validation at startup and graceful consumer shutdown
- Message id and correlation id declaration
- Persistent producers
- Consumers priority
- Queue and message time to live
- Max message count and size limitations
- Batch produce
- App id declaration
- Reject-publish when queue is full
- Deleted operations
- NoWait operations
Queues¶
- Declare durable queue with
Durableextension - Declare exclusive queue with
Exclusiveextension - Declare queue without waiting with
NoWaitextension - Bind exchange to exchange with
BoundToextension - Declare lazy queue with
Lazyextension - Set queue max message count with
MaxMessageCountextension - Set queue max message size in bytes with
MaxMessageSizeextension - Set dead letter exchange with
DeadLetterToextension - Enable retry with backoff with
WithRetryextension: failed messages wait in a companion{queue}.retryqueue for the given delay and are redelivered; aftermaxAttemptsthe message moves to theparkToqueue for manual inspection, or is acknowledged, logged and dropped when no parking queue is configured. Cannot be combined withDeadLetterTo,MessageTimeToLive,MaxMessageCountorMaxMessageSize: the broker takes the queue's dead letter road for every reason, and an expired or overflowed message would cycle through the retry queue forever. Companion queues mirror the main queue's durability - Set queue ttl with
TimeToLiveextension - Set message ttl with
MessageTimeToLiveextension (both at mostint.MaxValuemilliseconds, 24.85 days, which is what the broker reads) - Set queue max priority with
MaxPriorityextension - Explicitly delete queue with
Deletedextension - Delete queue automatically with
AutoDeleteextension - Add custom arguments with
Argumentextension
Exchanges¶
- Declare durable exchange with
Durableextension - Declare exchange without waiting with
NoWaitextension - Delete exchange automatically with
AutoDeleteextension - Explicitly delete exchange with
Deletedextension - Bind exchange to exchange with
BoundToextension - Declare alternate exchange with
AlternateToextension - Add custom arguments with
Argumentextension - Declare exchange with
AddDirectExchange(...),AddFanoutExchange(...),AddHeadersExchange(...),AddTopicExchange(...)extensions
Consumers¶
- Tag consumers using
Taggedextension - Limit prefetch count with
Prefetchextension (defaults to 10; pass 0 for an unlimited prefetch) - Scale consumers by using
Countextension: the consumers of one declaration share a channel whose dispatcher gets one worker per consumer, soCount(4)handles four deliveries at once - Declare exclusive consume with
Exclusiveextension - Forbid to consume own producers with
NoLocalextension - When no need to ack explicitly use
AutoAckextension - Requeue messages on fail with
Requeueextension (single immediate requeue; preferWithRetryon the queue). Messages are always settled one by one — batch ack/nack is not offered, because the consumers of one declaration share a channel and would settle each other's in-flight deliveries - Set consumer priority with
Priorityextension - Add custom arguments with
Argumentextension - All consumers start in
IHostedServiceand are cancelled cleanly when the host stops - A message whose payload cannot be deserialized never stalls the consumer and never enters
the retry cycle (the failure is deterministic): it is parked when the queue has a
parkToqueue, acknowledged and dropped when the queue retries without one, and rejected without requeue otherwise (dead-lettered when the queue has a dead letter exchange) AutoAckcombined withRequeueor aWithRetryqueue fails the application start: an auto-acknowledged message can never be requeued or retried- A handler cancelled by the host stopping has its message returned to the queue (nack with requeue) for the next consumer: a shutdown is neither an attempt of the retry budget nor a rejection
- A consumer that implements
IExhaustibleConsumer<T>is told, throughExhaustedAsync(message, failure), when one of its messages will not be delivered again — its retry attempts exhausted, rejected without a requeue, or lost toAutoAck— before the message is settled. A consumer that throws cannot tell its last attempt from the others; this is where it reports the failure upstream. A shutdown or a poison message tells nobody, and a failure inExhaustedAsyncis logged and settles the message all the same RabbitOptions.Consumerslists the declared consumers, andConsumerTypesthe types subscribed withSubscribe<T, TConsumer>, so a library built on this one can check at startup what it relies on
Producers¶
- Set routing key
RoutingKeyextension - Set mandatory with
Mandatoryextension; combined with publisher confirmations an unroutable message makesPublishAsyncthrowPublishException - Set message priority with
Priorityextension - Set message ttl with
MessageTimeToLiveextension - Set message id with
MessageIdextension (per message viaPublishAsyncoverrides) - Set correlation id with
CorrelationIdextension (per message viaPublishAsyncoverrides) - Use channel transactional mode with
Transactionalextension; transactional publishes are serialized per channel, because an AMQP transaction is channel-wide state - Use message persistence with
Persistentextension - Configure headers with
Headerextension — a publish carries no argument table of its own, so per-message key/value data goes here - Configure properties with
Propertyextension - Non-transactional producers publish with confirmations:
PublishAsynccompletes after the broker confirms the message and throws when it is rejected — it returns a plainValueTask, success is the absence of an exception - A publish that finds its connection lost — the socket gone, or the broker closing the connection
(a restart, an operator) — waits for the client's automatic recovery and publishes once more.
options.UseRecoveryWait(TimeSpan.FromSeconds(10))shortens the wait,TimeSpan.Zerofails at once. A channel the broker closed for a protocol error (an unknown exchange, a failed precondition) is not waited on: the same publish would fail again. Delivery stays at least once — a message the broker received just before the drop may arrive twice, so set aMessageIdwhere duplicates matter
Tracing¶
RabbitMQ.Client already emits a producer span per publish and a consumer span per delivery, and
carries the trace context between them in the message headers — a handler runs inside the span of
the publish that caused it. This library adds no span of its own; it records on the delivery span
the one thing the client cannot know, how the message was settled:
services.AddOpenTelemetry().WithTracing(tracing => tracing.AddSource(RabbitTracing.SourceNames));
snail.messaging.settlement—acked,requeued,rejected,retry_scheduled,parked,dropped, orlostwhen an auto-acknowledged message failed after the broker had settled itsnail.messaging.attempt— the 1-based attempt a retried delivery is onsnail.messaging.poison— set when the payload could not be deserialized
A failed delivery also carries the exception and an error status, so a retry cycle is visible as a chain of traces rather than as silence. With no collector listening the client starts no activity and the whole thing costs one null check per delivery.
Limitations¶
- No dynamic topology declaration by design, but you can use
IRabbitConnectionProviderfor that
Tips¶
RoutingKeyisQueueName/ExchangeNameby default- Do not use same connection for consumers and producers because of tcp backpressure
- RabbitMQ 4 rejects transient non-exclusive queues by default — declare consumer queues
Durable - Pass
parkTotoWithRetryto keep exhausted and poison messages for manual inspection; without it they are logged and dropped
Upgrading from 1.x¶
2.0 tightens the public API; what a 1.x caller has to change:
IRabbitConnectionProvider.FromDeclarationandIRabbitChannelProvider.FromDeclarationareFromDeclarationAsyncRabbitConsumerDeclaration.QueueDeclarationsand.Subscriptions,RabbitProducerDeclaration.Properties, and theBindingDeclarationsof queues and exchanges areIReadOnlyList<T>: add subscriptions, property actions and bindings through the builders (Subscribe,Property,Header,BoundTo), not through the lists;RabbitProducerDeclaration.Propertieshas no setterRabbitClientOptions,RabbitClientSslOptions,RabbitConnectionDeclarationandRabbitMissingDeclarationExceptionare sealed- New, nothing to change:
UseConnectionUrl(Uri)beside the string overload, andUseRecoveryWait
Behaviour that changed with 2.0, nothing to change in code:
Count(n)handles n deliveries at once (the consumer channel gets one dispatcher worker per consumer)- a handler cancelled by the host stopping has its message returned to the queue, not rejected or counted as an attempt
WithRetryrefusesMessageTimeToLive,MaxMessageCountandMaxMessageSizeon the same queue, and durations above 24.85 days are refused instead of being cut short- a publish on a lost connection waits for the recovery, see Producers
License¶
Snail.Toolkit.RabbitMQ is a free and open source project, released under the permissible MIT license.