RabbitMQ — Sequenced Pubsub
Use this when you need to decide between SequenceFailure::Skip and SequenceFailure::FailAll for your ordering policy. Ledger entries for two accounts are published, one entry is deliberately rejected, and the example asserts the resulting totals — making the behavioral difference between the two policies concrete and verifiable against a real RabbitMQ broker.
Prerequisites
- Docker (a RabbitMQ testcontainer is spun up automatically with the
rabbitmq_consistent_hash_exchangeplugin enabled) - Cargo feature:
rabbitmq
Run
cargo run --example rabbitmq_sequenced_pubsub --features rabbitmqExpected output
sequenced topologies declared
published 5 entries per topic for ACC-A
published 3 entries per topic for ACC-B
[skip error and continue] account=ACC-A seq=1 amount=100 attempt=1
[skip error and continue] account=ACC-B seq=1 amount=50 attempt=1
...
[skip error and continue] account=ACC-A seq=3 amount=300 attempt=1
[skip error and continue] → Reject (seq=3 is poison for ACC-A)
...
[fail all after error] account=ACC-A seq=3 amount=300 attempt=1
[fail all after error] → Reject (FailAll: seq=3 + all subsequent for ACC-A will DLQ)
...
── Results (elapsed: 5.2s) ──
skip ACC-A = 1200 (expected 1200)
skip ACC-B = 300 (expected 300)
strict ACC-A = 300 (expected 300)
strict ACC-B = 300 (expected 300)Source
//! Sequenced topic examples demonstrating strict per-key ordering.
//!
//! Demonstrates: `define_sequenced_topic!`, `SequenceFailure::Skip` vs
//! `SequenceFailure::FailAll`, `run_fifo`, and custom `routing_shards`.
//!
//! Note: per-key FIFO consumption (`run_fifo`) isn't yet surfaced on the
//! generic `Broker<B>` / `ConsumerSupervisor<B>` wrappers — this example
//! therefore keeps using the backend-specific `RabbitMqConsumer::run_fifo`
//! directly, same as the stress example.
//!
//! Each handler sums the `amount_cents` of successfully processed messages per
//! account. After shutdown the totals are compared against expected values to
//! verify that the failure policies work correctly.
//!
//! Published data:
//! ACC-A seq 1..5 amounts 100, 200, 300, 400, 500 (seq 3 is poison)
//! ACC-B seq 1..3 amounts 50, 100, 150 (no poison)
//!
//! Expected totals:
//! Skip: ACC-A = 100+200+400+500 = 1200 (seq 3 DLQ'd, rest continues)
//! ACC-B = 50+100+150 = 300
//! FailAll: ACC-A = 100+200 = 300 (seq 3 + subsequent DLQ'd)
//! ACC-B = 50+100+150 = 300 (independent key, unaffected)
//!
//! Spins up a RabbitMQ testcontainer and enables the
//! `rabbitmq_consistent_hash_exchange` plugin automatically. Requires a
//! running Docker daemon.
//!
//! cargo run --example rabbitmq_sequenced_pubsub --features rabbitmq
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use shove::rabbitmq::{
RabbitMqClient, RabbitMqConfig, RabbitMqConsumer, RabbitMqPublisher, RabbitMqTopologyDeclarer,
};
use shove::{
ConsumerOptions, MessageHandler, MessageMetadata, Outcome, RabbitMq, SequenceFailure,
SequencedTopic, Topic, TopologyBuilder, define_sequenced_topic,
};
use testcontainers::core::ExecCommand;
use testcontainers::runners::AsyncRunner;
use testcontainers_modules::rabbitmq::RabbitMq as RabbitMqImage;
use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken;
// ─── Message type ───────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
struct LedgerEntry {
account_id: String,
sequence_num: u64,
amount_cents: i64,
}
// ─── Topic definitions ──────────────────────────────────────────────────────
// Skip policy: if one entry fails, DLQ it and continue the sequence.
// Good for independent events that happen to need ordering (e.g. audit logs).
define_sequenced_topic!(
SkipLedger,
LedgerEntry,
|msg| msg.account_id.clone(),
TopologyBuilder::new("ex-skip-ledger")
.sequenced(SequenceFailure::Skip)
.hold_queue(Duration::from_secs(5))
.dlq()
.build()
);
// FailAll policy: if one entry fails, DLQ it AND all subsequent entries for
// the same key. Good for causally dependent sequences (e.g. financial ledger).
// Uses 4 routing shards instead of the default 8.
define_sequenced_topic!(
StrictLedger,
LedgerEntry,
|msg| msg.account_id.clone(),
TopologyBuilder::new("ex-strict-ledger")
.sequenced(SequenceFailure::FailAll)
.routing_shards(4)
.hold_queue(Duration::from_secs(5))
.dlq()
.build()
);
// ─── Handlers ───────────────────────────────────────────────────────────────
type Totals = Arc<Mutex<HashMap<String, i64>>>;
// Rejects ACC-A seq 3 to demonstrate failure policies.
// Sums amount_cents per account for successfully acked messages.
struct LedgerHandler {
label: &'static str,
totals: Totals,
}
impl MessageHandler<SkipLedger> for LedgerHandler {
type Context = ();
async fn handle(&self, msg: LedgerEntry, metadata: MessageMetadata, _: &()) -> Outcome {
tokio::time::sleep(Duration::from_millis(50)).await;
println!(
"[{}] account={} seq={} amount={} attempt={}",
self.label,
msg.account_id,
msg.sequence_num,
msg.amount_cents,
metadata.retry_count + 1,
);
if msg.account_id == "ACC-A" && msg.sequence_num == 3 {
println!("[{}] → Reject (seq=3 is poison for ACC-A)", self.label);
Outcome::Reject
} else {
*self.totals.lock().await.entry(msg.account_id).or_default() += msg.amount_cents;
Outcome::Ack
}
}
}
impl MessageHandler<StrictLedger> for LedgerHandler {
type Context = ();
async fn handle(&self, msg: LedgerEntry, metadata: MessageMetadata, _: &()) -> Outcome {
tokio::time::sleep(Duration::from_millis(50)).await;
println!(
"[{}] account={} seq={} amount={} attempt={}",
self.label,
msg.account_id,
msg.sequence_num,
msg.amount_cents,
metadata.retry_count + 1,
);
if msg.account_id == "ACC-A" && msg.sequence_num == 3 {
println!(
"[{}] → Reject (FailAll: seq=3 + all subsequent for ACC-A will DLQ)",
self.label,
);
Outcome::Reject
} else {
*self.totals.lock().await.entry(msg.account_id).or_default() += msg.amount_cents;
Outcome::Ack
}
}
}
// ─── Main ───────────────────────────────────────────────────────────────────
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Spin up a RabbitMQ testcontainer and enable the consistent-hash plugin.
let container = RabbitMqImage::default().start().await?;
let port = container.get_host_port_ipv4(5672).await?;
let mut exec_res = container
.exec(ExecCommand::new([
"rabbitmq-plugins",
"enable",
"rabbitmq_consistent_hash_exchange",
]))
.await?;
let _ = exec_res.stdout_to_vec().await;
tokio::time::sleep(Duration::from_secs(2)).await;
let uri = format!("amqp://guest:guest@localhost:{port}/%2f");
let config = RabbitMqConfig::new(&uri);
let client = RabbitMqClient::connect(&config).await?;
// ── Declare topologies ──
let channel = client.create_channel().await?;
let declarer = RabbitMqTopologyDeclarer::new(channel);
declarer.declare(SkipLedger::topology()).await?;
declarer.declare(StrictLedger::topology()).await?;
println!("sequenced topologies declared\n");
// ── Publish ordered entries for account ACC-A ──
let publisher = RabbitMqPublisher::new(client.clone()).await?;
for seq in 1..=5 {
let entry = LedgerEntry {
account_id: "ACC-A".into(),
sequence_num: seq,
amount_cents: (seq as i64) * 100,
};
publisher.publish::<SkipLedger>(&entry).await?;
publisher.publish::<StrictLedger>(&entry).await?;
}
println!("published 5 entries per topic for ACC-A");
// Also publish for an independent account to show cross-key concurrency.
for seq in 1..=3 {
let entry = LedgerEntry {
account_id: "ACC-B".into(),
sequence_num: seq,
amount_cents: (seq as i64) * 50,
};
publisher.publish::<SkipLedger>(&entry).await?;
publisher.publish::<StrictLedger>(&entry).await?;
}
println!("published 3 entries per topic for ACC-B\n");
// ── Start sequenced consumers ──
//
// Expected behavior:
// Skip: ACC-A seq 1,2 ack → seq 3 reject (DLQ) → seq 4,5 ack
// ACC-B seq 1,2,3 all ack
// FailAll: ACC-A seq 1,2 ack → seq 3 reject (DLQ) → seq 4,5 auto-DLQ (poisoned)
// ACC-B seq 1,2,3 all ack (independent key, unaffected)
let skip_totals: Totals = Arc::new(Mutex::new(HashMap::new()));
let strict_totals: Totals = Arc::new(Mutex::new(HashMap::new()));
let shutdown = CancellationToken::new();
let start = Instant::now();
let s = shutdown.clone();
let c = client.clone();
let t = skip_totals.clone();
let skip_task = tokio::spawn(async move {
let handler = LedgerHandler {
label: "skip error and continue",
totals: t,
};
RabbitMqConsumer::new(c)
.run_fifo::<SkipLedger, _>(
handler,
(),
ConsumerOptions::<RabbitMq>::new()
.with_shutdown(s)
.with_max_retries(2)
.with_prefetch_count(8),
)
.await
});
let s = shutdown.clone();
let c = client.clone();
let t = strict_totals.clone();
let strict_task = tokio::spawn(async move {
let handler = LedgerHandler {
label: "fail all after error",
totals: t,
};
RabbitMqConsumer::new(c)
.run_fifo::<StrictLedger, _>(
handler,
(),
ConsumerOptions::<RabbitMq>::new()
.with_shutdown(s)
.with_max_retries(2)
.with_prefetch_count(8),
)
.await
});
// ── Let consumers process, then shut down ──
tokio::time::sleep(Duration::from_secs(5)).await;
println!("\nshutting down...");
shutdown.cancel();
client.shutdown().await;
let _ = tokio::join!(skip_task, strict_task);
// ── Verify totals ──
let skip = skip_totals.lock().await;
let strict = strict_totals.lock().await;
println!(
"\n── Results (elapsed: {:.1}s) ──",
start.elapsed().as_secs_f64()
);
println!(
"skip ACC-A = {} (expected 1200)",
skip.get("ACC-A").copied().unwrap_or(0)
);
println!(
"skip ACC-B = {} (expected 300)",
skip.get("ACC-B").copied().unwrap_or(0)
);
println!(
"strict ACC-A = {} (expected 300)",
strict.get("ACC-A").copied().unwrap_or(0)
);
println!(
"strict ACC-B = {} (expected 300)",
strict.get("ACC-B").copied().unwrap_or(0)
);
assert_eq!(skip.get("ACC-A").copied().unwrap_or(0), 1200, "skip ACC-A");
assert_eq!(skip.get("ACC-B").copied().unwrap_or(0), 300, "skip ACC-B");
assert_eq!(
strict.get("ACC-A").copied().unwrap_or(0),
300,
"strict ACC-A"
);
assert_eq!(
strict.get("ACC-B").copied().unwrap_or(0),
300,
"strict ACC-B"
);
drop(container);
Ok(())
}Walkthrough
Two topics, two failure policies
SkipLedger uses SequenceFailure::Skip: when ACC-A seq=3 is rejected, it goes to the DLQ and consumption of ACC-A seq=4 and seq=5 resumes normally. The total for ACC-A is 100+200+400+500 = 1200. StrictLedger uses SequenceFailure::FailAll: the same rejection causes seq=4 and seq=5 to be automatically DLQ'd without reaching the handler. The total for ACC-A drops to 100+200 = 300. In both cases ACC-B is unaffected — keys are independent.
define_sequenced_topic! and routing shards
Both topics are created with define_sequenced_topic!(Name, MsgType, key_fn, topology). The key function |msg| msg.account_id.clone() is captured inline and used by the broker to route each LedgerEntry to the correct FIFO shard. StrictLedger adds .routing_shards(4) to reduce the default of 8 shards — useful when the number of distinct keys is small and you want to pack them into fewer exchanges. The consistent-hash exchange plugin (enabled at container start) is required for shard routing to work on RabbitMQ.
Backend-specific run_fifo
Per-key FIFO is not yet available through the generic Broker\<B\> API, so the example uses RabbitMqConsumer::new(client).run_fifo::<Topic, _>(handler, ctx, opts) directly. with_prefetch_count(8) means up to 8 messages may be in-flight at once, but only one per key at a time — the consumer holds back messages for a key while the current one is being processed.
Assertion-based verification
After a 5-second processing window the example reads the totals maps collected by LedgerHandler and asserts exact values. This pattern (accumulate totals in a shared Arc<Mutex<HashMap>>, assert after shutdown) is the cleanest way to write an integration test that also runs as a runnable example.
What to try next
- Swap
SequenceFailure::FailAllforSequenceFailure::SkiponStrictLedger— theACC-Atotal should rise from 300 to 1200. - Increase
routing_shardsto 16 onSkipLedger— measure whether latency changes for two-key workloads. - Add a third account
ACC-Cto the publish loop with no poison entry — confirm it processes all entries regardless of which policy is active. - Read Guides: Sequenced for the full explanation of shard routing and cross-key concurrency.