InMemory — Stress
Use this to establish your framework performance baseline before adding a real broker. Everything runs in-process, so results here isolate shove's scheduling and dispatch overhead from network and broker costs — the ceiling against which all durable backends are measured.
Prerequisites
No external services required. The in-memory backend runs entirely in-process.
- Cargo feature:
inmemory
Run
cargo run --example inmemory_stress --features inmemoryYou can narrow the run with CLI flags:
# Single tier, single handler profile
cargo run --example inmemory_stress --features inmemory -- --tier moderate --handler fast
# JSON output
cargo run --example inmemory_stress --features inmemory -- --output jsonRun in release mode for representative numbers:
cargo run -q --release --example inmemory_stress --features inmemoryExpected output
Output is non-deterministic and machine-dependent. Look for the scenario progress lines on stderr and the final table on stdout:
shove stress benchmarks — inmemory
scenarios: 60
[1/60] moderate | 50000msg | 1c | zero (no-op) ...
-> 285000.4 msg/s | dispatch p50=0.0ms p99=0.1ms | e2e p50=0.0ms p99=0.1ms | cpu=98% rss=12.3MB | 0.2s
[2/60] moderate | 50000msg | 4c | zero (no-op) ...
-> 512000.1 msg/s | ...
...
Backend: inmemory
TIER MSGS C HANDLER MSG/SEC disp p50 disp p95 disp p99 e2e p50 e2e p95 e2e p99 SCALE RSS(MB) CPU%
...Characteristic markers to look for:
zero (no-op)handler throughput in the hundreds of thousands of msg/s — this is the raw scheduling cost.scaling_efficiencycolumn approaching1.0xas consumer count grows for CPU-bound profiles.peak_rss_mbstaying well under 100 MB even at extreme tiers.
Source
//! Stress benchmarks for the in-memory backend.
//!
//! cargo run -q --release --example inmemory_stress --features inmemory
//! cargo run -q --release --example inmemory_stress --features inmemory -- --tier moderate
//! cargo run -q --release --example inmemory_stress --features inmemory -- --handler fast
//!
//! The sampled core matrix that feeds `benches/results/bench-results.json` is
//! pinned in `scripts/bench.sh` (runbook: `benches/README.md`):
//!
//! scripts/bench.sh inmemory
//!
//! No containers, no external deps — useful as a ceiling for framework
//! overhead under different handler profiles.
#[path = "../common/stress_test.rs"]
mod harness;
use std::num::NonZeroUsize;
use std::time::Duration;
use shove::batch_consumer::BatchConsumerOptions;
use shove::inmemory::{InMemoryConfig, InMemoryConsumer, InMemoryConsumerGroupConfig};
use shove::{Backend, Broker, InMemory};
use harness::{BatchConsumeFn, DlqDrainFn, HarnessConfig, StressTestTopic, run_all_scenarios};
/// Per-queue capacity for the benchmark broker.
///
/// The default is 10 000, and publishers *block* when a queue is full. The
/// publish-only flows have no consumer draining behind them, and a drain
/// scenario publishes its whole corpus before any consumer starts, so
/// anything past the default would wedge on backpressure rather than measure.
/// This is a bound, not a preallocation, so raising it costs nothing until the
/// messages actually exist. It covers the pinned matrix's largest drain corpus
/// (`--drain-messages` in `scripts/bench.sh`) with room to spare, and the
/// harness is told the bound so a corpus above it is refused before the sweep
/// rather than blocking the fill forever.
const QUEUE_CAPACITY: usize = 8_000_000;
#[tokio::main]
async fn main() {
// The drain runs on the client the fill phase used. For this backend that
// is not an optimisation but a correctness requirement: InMemory's queues
// live inside the client, so a second client would drain an empty DLQ.
let dlq_drain: DlqDrainFn<InMemory> = Box::new(|client, handler, _stop| {
// This backend's `run_dlq` exits when the teardown closes the client;
// the stop token is for backends without that path (see `DlqDrainFn`).
Box::pin(async move {
let consumer = InMemoryConsumer::new(client);
consumer
.run_dlq::<StressTestTopic, _>(handler, ())
.await
.map_err(|e| format!("run_dlq: {e}"))
})
});
// Invoked once per scenario consumer; every loop pops from the same
// in-process queue, so N invocations compete like N group members.
let batch_consume: BatchConsumeFn<InMemory> = Box::new(|client, handler, opts, stop| {
Box::pin(async move {
Broker::<InMemory>::from_client(client)
.batch_consumer()
.run::<StressTestTopic, _>(
handler,
(),
batch_consumer_options(opts).with_shutdown(stop),
)
.await
.map_err(|e| format!("run_batch: {e}"))
})
});
let hcfg = HarnessConfig::<InMemory>::new("inmemory")
.with_broker("shove in-process", env!("CARGO_PKG_VERSION"), "in-process")
.with_prefill_capacity(QUEUE_CAPACITY as u64)
.with_dlq_drain(dlq_drain)
.with_batch_consume(batch_consume);
run_all_scenarios(
hcfg,
|| async {
let capacity =
NonZeroUsize::new(QUEUE_CAPACITY).expect("QUEUE_CAPACITY is a non-zero constant");
<InMemory as Backend>::connect(
InMemoryConfig::default().with_default_capacity(capacity),
)
.await
.expect("connect InMemory")
},
|consumers, prefetch, _concurrent| {
InMemoryConsumerGroupConfig::new(consumers..=consumers).with_prefetch_count(prefetch)
},
)
.await;
}
/// Map the scenario's batch knobs onto `BatchConsumerOptions`; everything
/// else stays at shove's defaults.
fn batch_consumer_options(opts: harness::BatchOptions) -> BatchConsumerOptions<InMemory> {
BatchConsumerOptions::new()
.with_max_batch_size(opts.max_batch_size.get())
.with_max_batch_age(Duration::from_millis(opts.max_batch_age_ms.get()))
}Walkthrough
Thin backend wrapper
inmemory/stress.rs is intentionally minimal — it constructs a HarnessConfig::<InMemory> with the backend name "inmemory", then calls run_all_scenarios from the shared harness in examples/common/stress_test.rs. The make_cfg closure maps (consumers, prefetch, _concurrent) directly to:
InMemoryConsumerGroupConfig::new(consumers..=consumers)
.with_prefetch_count(prefetch)Because in-memory has no concurrent-mode distinction, the _concurrent parameter is intentionally ignored.
Harness configuration
HarnessConfig::<InMemory>::new("inmemory") leaves all defaults: prefetch_cap of 100, publish_chunk_size of 1000, and a no-op purge function. The in-memory backend does not need purging between scenarios because a fresh Broker\<InMemory\> is created for each scenario — previous messages cannot bleed across.
Scenario matrix
The harness defines three tiers (moderate, high, extreme) and four handler profiles (zero, fast, slow, heavy). The moderate tier uses consumer counts [1, 4, 8, 16, 32]; the extreme tier goes up to 256 consumers. Handler latencies range from 0 ms (zero) to 1–5 s (heavy). Each combination is one scenario, run sequentially — a full --tier all --handler all sweep is 60 scenarios on the moderate tier alone.
Metrics collected
For each scenario the harness records per-message dispatch latency (published_at_ns to handler entry) and end-to-end latency (published_at_ns to handler completion), then reports p50/p95/p99 percentiles. It also samples peak RSS and CPU% via platform-specific APIs (mach_task_basic_info on macOS, /proc/self/statm on Linux). The scaling_efficiency column divides each throughput by the baseline (lowest consumer count for the same tier/handler combination) — a value of 4.0x with 4 consumers indicates linear scaling.
What to try next
- Run with
--tier extreme --handler zeroto stress-test the scheduler at 1 M messages and 256 consumers. - Add
--concurrentto enable pipeline processing within each consumer and compare throughput against the default sequential mode. - Pipe output through
--output jsonand import into a spreadsheet to plot scaling curves. - Compare numbers against the RabbitMQ or Kafka stress examples to quantify the network + broker overhead relative to this in-process baseline.