Performance Tuning¶
Verified by tests
PolicyContextPoolTests, PerspectiveCompletionStrategyTests — library CI run #31657041675 (2026-08-13)
Optimize Whizbang performance with zero-allocation patterns, object pooling, batching, database optimizations, and profiling techniques.
Performance Benchmarks¶
| Metric | Target | Typical |
|---|---|---|
| Dispatcher Latency | < 20ns | 15ns |
| Message Throughput | > 100K msg/sec | 150K msg/sec |
| Memory Allocations | Zero | 0 per dispatch |
| Database Round-Trips | 1 per batch | 1 per 100 messages |
Zero-Allocation Dispatch¶
Whizbang's dispatch pipeline is source-generated: Whizbang.Generators emits a GeneratedDispatcher and a GeneratedReceptorRegistry whose receptor lookup tables are pre-compiled delegates categorized by message type and lifecycle stage - no reflection, no MakeGenericType, no runtime scanning.
Zero-Allocation Dispatch
// Program.cs - the generated extensions wire the zero-reflection pipeline
services.AddWhizbangDispatcher(); // GeneratedDispatcher + GeneratedReceptorRegistry
services.AddReceptors(); // explicit registrations for every discovered receptor
// Hot-path lookup is a pre-compiled table, not reflection:
IReadOnlyList<ReceptorInfo> receptors =
receptorRegistry.GetReceptorsFor(typeof(OrderCreatedEvent), LifecycleStage.PostInboxInline);
// Void receptors use ValueTask so synchronous completions allocate nothing:
public interface IReceptor<in TMessage> {
ValueTask HandleAsync(TMessage message, CancellationToken cancellationToken = default);
}
Why this is fast:
- ✅ No reflection - Pre-compiled delegates emitted at build time
- ✅ ValueTask handlers - Synchronous completions avoid Task allocations
- ✅ Inlineable - JIT/AOT can inline small generated methods
- ✅ Measured, not guessed - Whizbang.Dispatcher meter exposes whizbang.dispatcher.send.duration, whizbang.dispatcher.receptor.duration, and friends
Object Pooling¶
1. Policy Context Pooling¶
Whizbang ships PolicyContextPool (Whizbang.Core.Pooling) - a thread-safe, lock-free pool (max 1024 pooled instances) that reuses PolicyContext objects instead of allocating one per message:
Policy Context Pooling
using Whizbang.Core.Pooling;
var context = PolicyContextPool.Rent(message, envelope, services, "production");
try {
var config = await policyEngine.MatchAsync(context);
} finally {
PolicyContextPool.Return(context); // ALWAYS return - Reset() happens inside
}
Rent re-initializes a pooled instance (or creates one when the pool is empty); Return resets state and discards the instance if the pool is already full. PolicyContext properties are intentionally read-only from user code - initialization happens only through the pool or the constructor.
Effect: steady-state policy evaluation allocates zero PolicyContext objects after warmup.
2. Bulk Processing Pools¶
Pool arrays for bulk operations:
Bulk Processing Pools
public static class ArrayPool {
public static T[] Rent<T>(int minLength) {
return System.Buffers.ArrayPool<T>.Shared.Rent(minLength);
}
public static void Return<T>(T[] array, bool clearArray = false) {
System.Buffers.ArrayPool<T>.Shared.Return(array, clearArray);
}
}
Usage:
Bulk Processing Pools (2)
var buffer = ArrayPool.Rent<OutboxMessage>(100);
try {
var count = await ClaimWorkAsync(buffer);
await ProcessMessagesAsync(buffer.AsSpan(0, count));
} finally {
ArrayPool.Return(buffer, clearArray: true);
}
Batching¶
1. Database Batching (Built In)¶
You don't write outbox SQL yourself - Whizbang funnels all outbox inserts, inbox writes, completions, and failures through a single process_work_batch database call per flush. Instead of every message handler making its own DB round-trip, the inbox batch strategy collects messages inside a sliding window and flushes them together. Tune the window via MessageProcessingOptions:
Database Batching
// Override defaults before AddTransportConsumer():
services.AddSingleton(new MessageProcessingOptions {
MaxConcurrentMessages = 80, // default 40 - caps concurrent handlers (each holds a DB connection)
InboxBatchSize = 50, // default 100 - flush when this many messages collected
InboxBatchSlideMs = 100, // default 50 - flush after this much quiet time
InboxBatchMaxWaitMs = 2000 // default 1000 - hard flush deadline
});
process_work_batch is the hot path on large datasets - watch whizbang.work_coordinator.process_batch.duration and whizbang.work_coordinator.batch.* metrics when tuning these values.
Performance: batching turns ~100 DB round-trips into 1 per flush window.
2. Transport Receive Batching (Built In)¶
Transport delivery is also batched by the library. TransportBatchOptions controls the sliding window the batch subscriber (ITransport.SubscribeBatchAsync) uses when collecting messages received from the transport before handing them to the inbox in a single flush:
Transport Receive Batching
// Register BEFORE the transport consumer builder - the library uses
// TryAddSingleton, so your instance wins if registered first.
services.AddSingleton(new TransportBatchOptions {
BatchSize = 200, // default 200 - max messages per transport batch
SlideMs = 20, // default 20 - quiet-time flush trigger
MaxWaitMs = 1000 // default 1000 - hard flush deadline
});
Outbox draining (the publish side) uses the same sliding-window pattern (SlidingWindowOutboxOptions: MaxSize 100, SlidingWindow 50ms, MaxWait 1s defaults).
Performance: one batch handler invocation (and one inbox flush) carries up to BatchSize received messages instead of one call per message.
Database Optimizations¶
1. Connection Pooling¶
Configure aggressive connection pooling:
appsettings.json:
Connection Pooling
{
"ConnectionStrings": {
"OrdersDb": "Host=localhost;Database=orders;Username=postgres;Password=postgres;Pooling=true;MinPoolSize=10;MaxPoolSize=100;ConnectionIdleLifetime=300"
}
}
2. Prepared Statements¶
Use Dapper with prepared statements:
Prepared Statements
var orders = await _db.QueryAsync<OrderRow>(
"""
SELECT * FROM orders
WHERE customer_id = @CustomerId AND created_at >= @StartDate
""",
new { CustomerId = "cust-123", StartDate = DateTime.UtcNow.AddDays(-30) },
commandType: CommandType.Text,
commandTimeout: 30
);
PostgreSQL caches prepared statements automatically.
3. Indexes¶
Whizbang's internal tables (wh_outbox, wh_inbox, perspective tables) ship with their indexes managed by the library's own migrations - don't add or remove indexes on wh_* tables yourself. Create indexes for your application tables' common queries:
Indexes
-- App-level order lookups by customer
CREATE INDEX idx_orders_customer ON orders(customer_id, created_at DESC);
-- Partial index for active rows only
CREATE INDEX idx_orders_open ON orders(created_at)
WHERE status = 'open';
When experimenting with indexes on large datasets, prefer a copy of the table plus EXPLAIN ANALYZE over experimenting against live traffic.
4. Partitioning¶
Partition large application tables by date:
Partitioning
CREATE TABLE order_events (
event_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL
-- ... other columns
) PARTITION BY RANGE (created_at);
CREATE TABLE order_events_2026_07 PARTITION OF order_events
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
Benefits: - Faster queries (scan only relevant partitions) - Easier maintenance (drop old partitions)
Whizbang's own queue tables stay small by design - completed wh_outbox / wh_inbox rows are deleted on completion (unless debug mode retains them), so they don't normally need partitioning.
Profiling¶
1. BenchmarkDotNet¶
Benchmark critical paths:
CreateOrderBenchmark.cs:
BenchmarkDotNet
using BenchmarkDotNet.Attributes;
[MemoryDiagnoser]
[SimpleJob(warmupCount: 3, iterationCount: 10)]
public class CreateOrderBenchmark {
private CreateOrderReceptor _receptor = null!;
private CreateOrderCommand _command = null!;
[GlobalSetup]
public void Setup() {
_receptor = new CreateOrderReceptor(
new TestDispatcher(),
NullLogger<CreateOrderReceptor>.Instance);
_command = OrderTestData.CreateValidOrder();
}
[Benchmark]
public async Task<OrderCreatedEvent> CreateOrder() {
return await _receptor.HandleAsync(_command);
}
}
Run:
BenchmarkDotNet (2)
Output:
| Method | Mean | Error | StdDev | Allocated |
|------------ |---------:|--------:|--------:|----------:|
| CreateOrder | 125.3 μs | 2.34 μs | 2.19 μs | 512 B |
2. dotnet-trace¶
Profile production workloads:
dotnet-trace
# Start tracing
dotnet-trace collect --process-id 1234 --profile cpu-sampling
# Stop after 30 seconds
# Open trace file in PerfView or Visual Studio
3. Application Insights¶
Monitor performance in production:
Application Insights
builder.Services.AddApplicationInsightsTelemetry();
builder.Services.AddOpenTelemetryMetrics(metrics => {
metrics
.AddMeter("Whizbang.*")
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation();
});
Query in Azure:
requests
| where timestamp > ago(1h)
| summarize avg(duration), percentile(duration, 95) by name
| order by avg_duration desc
Memory Optimizations¶
1. Struct Value Types¶
Use structs for small, immutable data:
Struct Value Types
// ✅ GOOD - Struct (stack-allocated)
public readonly struct MessageId {
private readonly Guid _value;
public MessageId(Guid value) => _value = value;
public override string ToString() => _value.ToString("N");
}
// ❌ BAD - Class (heap-allocated)
public class MessageId {
public Guid Value { get; }
public MessageId(Guid value) => Value = value;
}
2. Span for Slicing¶
Avoid allocations when slicing arrays:
Span
// ❌ BAD - Allocates new array
var subset = array.Skip(10).Take(50).ToArray();
// ✅ GOOD - No allocation
var subset = array.AsSpan(10, 50);
3. ValueTask for Hot Paths¶
Use ValueTask for frequently called async methods:
ValueTask for Hot Paths
// ✅ GOOD - ValueTask avoids allocation if completed synchronously
// (this is why IReceptor.HandleAsync returns ValueTask)
public ValueTask<OrderCreatedEvent> HandleAsync(
CreateOrderCommand command,
CancellationToken cancellationToken = default
) {
// If cached, return synchronously (no allocation)
if (_cache.TryGetValue(command.CustomerId, out var cached)) {
return new ValueTask<OrderCreatedEvent>(cached);
}
// Otherwise, await async operation
return new ValueTask<OrderCreatedEvent>(HandleSlowPathAsync(command, cancellationToken));
}
Concurrency Optimizations¶
1. Parallelism Is Explicit Configuration (No Auto-Tuning)¶
Whizbang's I/O parallelism is controlled by explicit config knobs - the library does not ship adaptive auto-tuning (AIMD, queue-depth feedback, etc.); set the knobs for your workload and validate with the whizbang.* metrics:
| Knob | Default | Controls |
|---|---|---|
MessageProcessingOptions.MaxConcurrentMessages |
40 | Concurrent message handlers across all subscriptions (each holds a DB connection) |
MessageProcessingOptions.InboxBatchSize / InboxBatchSlideMs / InboxBatchMaxWaitMs |
100 / 50 / 1000 | Inbox process_work_batch flush triggers |
TransportBatchOptions.BatchSize / SlideMs / MaxWaitMs |
200 / 20 / 1000 | Transport receive batch flush triggers |
SlidingWindowOutboxOptions.MaxSize / SlidingWindow / MaxWait |
100 / 50ms / 1s | Outbox drain batching |
Inside the perspective worker, per-stream processing is serialized by design (per-stream semaphores plus cross-pod stream pinning) so a stream's events always apply in order - parallelism happens across streams, never within one. That invariant is structural; there is no knob that relaxes it.
2. Parallel Processing in Your Own Code¶
For app-level fan-out (not Whizbang pipeline work), Parallel.ForEachAsync with a bounded degree is the standard pattern:
Parallel Processing
await Parallel.ForEachAsync(
workItems,
new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount, CancellationToken = ct },
async (item, token) => {
await ProcessItemAsync(item, token);
}
);
3. Async Coordination¶
Use SemaphoreSlim for async locking:
Async Coordination
// ✅ GOOD - Async-friendly
private readonly SemaphoreSlim _lock = new(1, 1);
public async Task ProcessAsync() {
await _lock.WaitAsync();
try {
// Critical section
} finally {
_lock.Release();
}
}
// ❌ BAD - Blocks thread
private readonly object _lock = new();
public async Task ProcessAsync() {
lock (_lock) { // Don't use lock() with async
await SomeAsyncOperation(); // Deadlock risk
}
}
Key Takeaways¶
✅ Zero Allocations - Source-generated dispatch, no reflection
✅ Object Pooling - Built-in PolicyContextPool, ArrayPool<T> for buffers
✅ Batching Built In - process_work_batch + sliding-window transport batching, tuned via options
✅ Explicit Parallelism Knobs - MaxConcurrentMessages and batch windows are config; no auto-tuning shipped
✅ Indexes - Optimize your app tables; Whizbang manages its own wh_* indexes
✅ Profiling - BenchmarkDotNet, dotnet-trace, Application Insights, whizbang.* meters
✅ Span
Version 1.0.0 - Foundation Release | Last Updated: 2024-12-12