Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

SQS — Consumer Groups

Use this when you want to observe how a pool of SQS poll workers drains a queue over time. Because SQS has no broker-side coordinated-group primitive, the registry spawns independent consumers; the example shows how to monitor messages_ready and messages_not_visible as the backlog drains — useful for sizing your worker pool before adding the autoscaler.

Prerequisites

  • Docker with a running daemon
  • LOCALSTACK_AUTH_TOKEN environment variable set to a valid LocalStack Pro token
  • Cargo feature: aws-sns-sqs

Run

LOCALSTACK_AUTH_TOKEN=<token> cargo run --example sqs_consumer_groups --features aws-sns-sqs

Expected output

published 50 tasks

consumer group started (min_consumers=1)

monitoring queue depth — watching backlog drain

[monitor] messages_ready=48 in_flight=2
[monitor] messages_ready=38 in_flight=10
[monitor] messages_ready=10 in_flight=10
[monitor] messages_ready=0 in_flight=4
[monitor] messages_ready=0 in_flight=0
...
shutting down...
done

Exact numbers vary by system speed and LocalStack latency.

Source

//! Consumer group example (SQS backend).
//!
//! Demonstrates: `SqsConsumerGroupRegistry`, `SqsConsumerGroupConfig`,
//! `SqsQueueStatsProvider`, and dynamic queue depth monitoring.
//!
//! Note: SQS has no broker-level coordinated-group primitive — the SQS
//! registry spawns independent poll workers. The generic `Broker<Sqs>`
//! deliberately exposes only a supervisor (see `Sqs`'s doctest), so this
//! example stays on the backend-specific `SqsConsumerGroupRegistry` path.
//!
//! Spins up a LocalStack testcontainer automatically. Requires a running
//! Docker daemon and the `LOCALSTACK_AUTH_TOKEN` environment variable:
//!
//!     LOCALSTACK_AUTH_TOKEN=... cargo run --example sqs_consumer_groups --features aws-sns-sqs
 
use std::sync::Arc;
use std::time::Duration;
 
use serde::{Deserialize, Serialize};
use shove::sns::*;
use shove::*;
use testcontainers::ImageExt;
use testcontainers::runners::AsyncRunner;
use testcontainers_modules::localstack::LocalStack;
use tokio::sync::Mutex;
 
// ─── Message type ───────────────────────────────────────────────────────────
 
#[derive(Debug, Clone, Serialize, Deserialize)]
struct TaskEvent {
    task_id: String,
    payload: String,
}
 
// ─── Topic ──────────────────────────────────────────────────────────────────
 
define_topic!(
    WorkQueue,
    TaskEvent,
    TopologyBuilder::new("sqs-work-queue").dlq().build()
);
 
// ─── Handler ────────────────────────────────────────────────────────────────
 
// Handler must be Clone for SqsConsumerGroup (each spawned consumer gets a clone).
#[derive(Clone)]
struct TaskHandler;
 
impl MessageHandler<WorkQueue> for TaskHandler {
    type Context = ();
    async fn handle(&self, msg: TaskEvent, metadata: MessageMetadata, _: &()) -> Outcome {
        println!(
            "[worker] task={} attempt={}",
            msg.task_id,
            metadata.retry_count + 1,
        );
        // Simulate work
        tokio::time::sleep(Duration::from_millis(200)).await;
        Outcome::Ack
    }
}
 
// ─── Main ───────────────────────────────────────────────────────────────────
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "shove=debug,sqs_consumer_groups=debug".parse().unwrap()),
        )
        .init();
 
    let auth_token = match std::env::var("LOCALSTACK_AUTH_TOKEN") {
        Ok(t) => t,
        Err(_) => {
            eprintln!(
                "LOCALSTACK_AUTH_TOKEN is not set. This example requires a LocalStack Pro auth \
                 token:\n\n    export LOCALSTACK_AUTH_TOKEN=...\n"
            );
            std::process::exit(1);
        }
    };
 
    // SAFETY: called before any concurrent env access in this process.
    unsafe {
        std::env::set_var("AWS_ACCESS_KEY_ID", "test");
        std::env::set_var("AWS_SECRET_ACCESS_KEY", "test");
        std::env::set_var("AWS_REGION", "us-east-1");
    }
 
    let container = LocalStack::default()
        .with_env_var("LOCALSTACK_AUTH_TOKEN", auth_token)
        .start()
        .await?;
    let port = container.get_host_port_ipv4(4566).await?;
    let endpoint = format!("http://localhost:{port}");
 
    let config = SnsConfig {
        region: Some("us-east-1".into()),
        endpoint_url: Some(endpoint),
    };
    let client = SnsClient::new(&config).await?;
 
    // ── Publish an initial burst of tasks ──
    //
    // We declare topology manually here so the publisher can resolve the SNS ARN.
    // The declarer reads the client-owned topic/queue registries shared with
    // every publisher and consumer group built from the same client.
    let declarer = SnsTopologyDeclarer::new(client.clone());
    declarer.declare(WorkQueue::topology()).await?;
 
    let publisher = SnsPublisher::new(client.clone(), client.topic_registry().clone());
    let burst_size = 50;
    for i in 0..burst_size {
        let event = TaskEvent {
            task_id: format!("TASK-{i:03}"),
            payload: format!("work item {i}"),
        };
        publisher.publish::<WorkQueue>(&event).await?;
    }
    println!("published {burst_size} tasks\n");
 
    // ── Set up consumer group registry ──
    //
    // SqsConsumerGroupRegistry manages named groups of identical consumers.
    // It automatically declares the topology and starts consumers at their
    // minimum count. Each group reads from a single SQS queue and can be
    // scaled up/down manually or via a custom autoscaler.
    let mut registry = SqsConsumerGroupRegistry::new(client.clone());
 
    registry
        .register::<WorkQueue, TaskHandler>(
            SqsConsumerGroupConfig::new(1..=5) // min..=max consumers
                .with_prefetch_count(10) // messages per consumer
                .with_max_retries(3),
            || TaskHandler, // factory — called once per spawned consumer
            (),             // handler context (unit for this example)
        )
        .await?;
 
    // Start all groups at their minimum consumer count.
    registry.start_all();
    println!("consumer group started (min_consumers=1)\n");
 
    let registry = Arc::new(Mutex::new(registry));
 
    // ── Monitor queue depth using SqsQueueStatsProvider ──
    //
    // Poll queue attributes to observe the backlog draining.
    let stats_provider =
        SqsQueueStatsProvider::new(client.clone(), client.queue_registry().clone());
 
    println!("monitoring queue depth — watching backlog drain\n");
 
    for _ in 0..15 {
        tokio::time::sleep(Duration::from_secs(2)).await;
 
        match stats_provider
            .get_queue_stats(WorkQueue::topology().queue())
            .await
        {
            Ok(stats) => println!(
                "[monitor] messages_ready={} in_flight={}",
                stats.messages_ready, stats.messages_not_visible,
            ),
            Err(e) => eprintln!("[monitor] failed to fetch stats: {e}"),
        }
    }
 
    // ── Shutdown ──
    println!("\nshutting down...");
    registry.lock().await.shutdown_all().await;
    client.shutdown().await;
    println!("done");
 
    drop(container);
    Ok(())
}

Walkthrough

SQS supervisors vs consumer groups

SQS has no broker-side queue consumer-group primitive (unlike Kafka consumer groups or RabbitMQ coordinated groups). SqsConsumerGroupRegistry fills this role by spawning independent poll workers that each run their own ReceiveMessage loop. From the application's perspective the API mirrors the RabbitMQ ConsumerGroupRegistry: registry.register(config, factory, ctx) followed by registry.start_all(). The key difference is that each registered consumer is fully independent — there is no broker-side coordination when adding or removing workers.

SqsConsumerGroupConfig and worker range

SqsConsumerGroupConfig::new(1..=5) sets the minimum to 1 and the maximum to 5 consumers. .with_prefetch_count(10) lets each consumer hold up to 10 in-flight messages (capped by SQS's batch size limit of 10). .with_max_retries(3) caps hold-queue retry cycles before DLQ routing. registry.start_all() starts one consumer (the minimum); autoscaling would add more, but this example keeps the count fixed to focus on monitoring.

Shared SNS client and registries

SnsClient::new(&config) returns a client that owns the topic and queue registries. SnsTopologyDeclarer::new(client.clone()) declares the WorkQueue topology and populates these registries. SnsPublisher::new(client.clone(), client.topic_registry().clone()) and SqsConsumerGroupRegistry::new(client.clone()) both share the same underlying registry, so the publisher can resolve the SNS topic ARN and the consumers can resolve the SQS queue URL without a second declaration step.

Queue depth monitoring with SqsQueueStatsProvider

SqsQueueStatsProvider::new(client, queue_registry) wraps the GetQueueAttributes API. stats_provider.get_queue_stats(queue_name) returns messages_ready (visible messages waiting to be consumed) and messages_not_visible (in-flight messages currently held by a consumer). Polling these every 2 seconds gives a live view of the drain rate — a production monitoring setup would feed these values to a metrics system and drive autoscaling decisions.

What to try next

  • Wire SqsAutoscalerBackend to the registry (see the autoscaler example) to scale the consumer count automatically based on messages_ready.
  • Reduce the handler delay to 20 ms — observe how quickly messages_ready drops to 0 with even one consumer.
  • Call registry.lock().await.add_consumers::<WorkQueue>(2) after the initial burst to manually scale up mid-flight.
  • See Guides: Groups for an explanation of how independent SQS workers relate to coordinated consumer groups on other backends.