544 lines
14 KiB
Markdown
544 lines
14 KiB
Markdown
# Subscriptions
|
|
|
|
Subscription modes and patterns for consuming event streams.
|
|
|
|
## Overview
|
|
|
|
Subscriptions define how consumers receive events from streams. The framework supports two primary subscription modes: **Broadcast** (all consumers receive all events) and **Queue** (load-balanced delivery to consumer groups).
|
|
|
|
**Key Features:**
|
|
|
|
- ✅ **Broadcast Mode** - All subscribers receive all events
|
|
- ✅ **Queue Mode** - Events load-balanced across consumer group
|
|
- ✅ **Offset Tracking** - Resume from last processed position
|
|
- ✅ **At-Least-Once** - Guaranteed delivery with retries
|
|
- ✅ **Exactly-Once per Group** - No duplicate processing within group
|
|
|
|
## Subscription Modes
|
|
|
|
### Broadcast Subscriptions
|
|
|
|
All consumers receive all events independently:
|
|
|
|
```
|
|
┌───────────┐
|
|
│ Stream │
|
|
│ [Events] │
|
|
└─────┬─────┘
|
|
│
|
|
├─────────▶ Consumer A (all events)
|
|
├─────────▶ Consumer B (all events)
|
|
└─────────▶ Consumer C (all events)
|
|
```
|
|
|
|
**Use Cases:**
|
|
- Analytics and reporting
|
|
- Audit logging
|
|
- Multiple independent projections
|
|
- Notifications to different channels
|
|
|
|
### Queue Subscriptions
|
|
|
|
Events distributed across consumer group (load balanced):
|
|
|
|
```
|
|
┌───────────┐
|
|
│ Stream │
|
|
│ [Events] │
|
|
└─────┬─────┘
|
|
│
|
|
├─────────▶ Consumer A (event 1, 4, 7...)
|
|
├─────────▶ Consumer B (event 2, 5, 8...)
|
|
└─────────▶ Consumer C (event 3, 6, 9...)
|
|
```
|
|
|
|
**Use Cases:**
|
|
- Parallel processing for scalability
|
|
- Background job processing
|
|
- Horizontal scaling of event handlers
|
|
|
|
## Broadcast Mode
|
|
|
|
### Creating Broadcast Subscription
|
|
|
|
```csharp
|
|
public class AnalyticsProjection : BackgroundService
|
|
{
|
|
private readonly IEventStreamStore _eventStore;
|
|
private readonly ILogger<AnalyticsProjection> _logger;
|
|
private long _lastProcessedOffset;
|
|
|
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
{
|
|
// Load checkpoint
|
|
_lastProcessedOffset = await LoadCheckpointAsync();
|
|
|
|
_logger.LogInformation(
|
|
"Starting analytics projection from offset {Offset}",
|
|
_lastProcessedOffset);
|
|
|
|
// Subscribe to stream
|
|
await foreach (var @event in _eventStore.ReadStreamAsync(
|
|
streamName: "orders",
|
|
fromOffset: _lastProcessedOffset + 1,
|
|
cancellationToken: stoppingToken))
|
|
{
|
|
try
|
|
{
|
|
// Process event
|
|
await ProcessEventAsync(@event);
|
|
|
|
// Update checkpoint
|
|
_lastProcessedOffset = @event.Offset;
|
|
await SaveCheckpointAsync(_lastProcessedOffset);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error processing event {Offset}", @event.Offset);
|
|
// Continue processing or implement retry logic
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task ProcessEventAsync(StoredEvent @event)
|
|
{
|
|
var eventData = JsonSerializer.Deserialize(
|
|
@event.Data,
|
|
Type.GetType(@event.EventType));
|
|
|
|
switch (eventData)
|
|
{
|
|
case OrderPlacedEvent placed:
|
|
await _analytics.RecordOrderAsync(placed);
|
|
break;
|
|
|
|
case OrderShippedEvent shipped:
|
|
await _analytics.RecordShipmentAsync(shipped);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Multiple Independent Projections
|
|
|
|
Each projection maintains its own offset:
|
|
|
|
```csharp
|
|
// Projection 1: Order summary
|
|
public class OrderSummaryProjection : BackgroundService
|
|
{
|
|
protected override async Task ExecuteAsync(CancellationToken ct)
|
|
{
|
|
var offset = await LoadCheckpointAsync("order-summary");
|
|
|
|
await foreach (var evt in _eventStore.ReadStreamAsync("orders", offset + 1, ct))
|
|
{
|
|
await UpdateOrderSummaryAsync(evt);
|
|
await SaveCheckpointAsync("order-summary", evt.Offset);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Projection 2: Customer analytics (independent)
|
|
public class CustomerAnalyticsProjection : BackgroundService
|
|
{
|
|
protected override async Task ExecuteAsync(CancellationToken ct)
|
|
{
|
|
var offset = await LoadCheckpointAsync("customer-analytics");
|
|
|
|
await foreach (var evt in _eventStore.ReadStreamAsync("orders", offset + 1, ct))
|
|
{
|
|
await UpdateCustomerAnalyticsAsync(evt);
|
|
await SaveCheckpointAsync("customer-analytics", evt.Offset);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Queue Mode (Consumer Groups)
|
|
|
|
### Creating Queue Subscription
|
|
|
|
```csharp
|
|
public class OrderProcessingWorker : BackgroundService
|
|
{
|
|
private readonly IConsumerGroupReader _consumerGroup;
|
|
private readonly string _consumerId;
|
|
|
|
public OrderProcessingWorker(IConsumerGroupReader consumerGroup)
|
|
{
|
|
_consumerGroup = consumerGroup;
|
|
_consumerId = $"worker-{Environment.MachineName}-{Guid.NewGuid():N}";
|
|
}
|
|
|
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
{
|
|
_logger.LogInformation("Worker {ConsumerId} started", _consumerId);
|
|
|
|
// Join consumer group
|
|
await foreach (var @event in _consumerGroup.ConsumeAsync(
|
|
streamName: "orders",
|
|
groupId: "order-processing",
|
|
consumerId: _consumerId,
|
|
options: new ConsumerGroupOptions
|
|
{
|
|
BatchSize = 100,
|
|
CommitStrategy = OffsetCommitStrategy.AfterBatch,
|
|
HeartbeatInterval = TimeSpan.FromSeconds(10),
|
|
SessionTimeout = TimeSpan.FromSeconds(30)
|
|
},
|
|
cancellationToken: stoppingToken))
|
|
{
|
|
try
|
|
{
|
|
await ProcessOrderEventAsync(@event);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error processing event");
|
|
// Offset will not be committed, event will be reprocessed
|
|
}
|
|
}
|
|
|
|
_logger.LogInformation("Worker {ConsumerId} stopped", _consumerId);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Scaling with Multiple Workers
|
|
|
|
Run multiple instances of the same worker:
|
|
|
|
```bash
|
|
# Start 3 workers in same consumer group
|
|
dotnet run --WorkerId=1 &
|
|
dotnet run --WorkerId=2 &
|
|
dotnet run --WorkerId=3 &
|
|
|
|
# Events automatically load balanced across workers
|
|
# - Worker 1 processes events 1, 4, 7, 10...
|
|
# - Worker 2 processes events 2, 5, 8, 11...
|
|
# - Worker 3 processes events 3, 6, 9, 12...
|
|
```
|
|
|
|
## Offset Management
|
|
|
|
### Checkpoint Strategies
|
|
|
|
**Manual Checkpoint:**
|
|
```csharp
|
|
await foreach (var @event in _eventStore.ReadStreamAsync("orders", offset))
|
|
{
|
|
await ProcessEventAsync(@event);
|
|
|
|
// Manual checkpoint after each event
|
|
offset = @event.Offset;
|
|
await SaveCheckpointAsync(offset);
|
|
}
|
|
```
|
|
|
|
**Batch Checkpoint:**
|
|
```csharp
|
|
const int batchSize = 100;
|
|
var batch = new List<StoredEvent>();
|
|
|
|
await foreach (var @event in _eventStore.ReadStreamAsync("orders", offset))
|
|
{
|
|
batch.Add(@event);
|
|
|
|
if (batch.Count >= batchSize)
|
|
{
|
|
// Process batch
|
|
await ProcessBatchAsync(batch);
|
|
|
|
// Checkpoint after batch
|
|
await SaveCheckpointAsync(batch.Max(e => e.Offset));
|
|
batch.Clear();
|
|
}
|
|
}
|
|
```
|
|
|
|
**Periodic Checkpoint:**
|
|
```csharp
|
|
var lastCheckpoint = DateTimeOffset.UtcNow;
|
|
var checkpointInterval = TimeSpan.FromSeconds(30);
|
|
|
|
await foreach (var @event in _eventStore.ReadStreamAsync("orders", offset))
|
|
{
|
|
await ProcessEventAsync(@event);
|
|
offset = @event.Offset;
|
|
|
|
// Checkpoint every 30 seconds
|
|
if (DateTimeOffset.UtcNow - lastCheckpoint > checkpointInterval)
|
|
{
|
|
await SaveCheckpointAsync(offset);
|
|
lastCheckpoint = DateTimeOffset.UtcNow;
|
|
}
|
|
}
|
|
```
|
|
|
|
### Consumer Group Offset Tracking
|
|
|
|
With consumer groups, offsets are tracked automatically:
|
|
|
|
```csharp
|
|
await foreach (var @event in _consumerGroup.ConsumeAsync(
|
|
streamName: "orders",
|
|
groupId: "order-processing",
|
|
consumerId: "worker-1",
|
|
options: new ConsumerGroupOptions
|
|
{
|
|
// Offset committed after each event
|
|
CommitStrategy = OffsetCommitStrategy.AfterEach
|
|
}))
|
|
{
|
|
await ProcessEventAsync(@event);
|
|
// Offset committed automatically
|
|
}
|
|
```
|
|
|
|
## Subscription Lifecycle
|
|
|
|
### Starting Subscription
|
|
|
|
```csharp
|
|
public class EventSubscriptionService : IHostedService
|
|
{
|
|
private Task? _subscriptionTask;
|
|
private CancellationTokenSource? _cts;
|
|
|
|
public Task StartAsync(CancellationToken cancellationToken)
|
|
{
|
|
_cts = new CancellationTokenSource();
|
|
|
|
_subscriptionTask = Task.Run(async () =>
|
|
{
|
|
await SubscribeAsync(_cts.Token);
|
|
}, cancellationToken);
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public async Task StopAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (_subscriptionTask == null)
|
|
return;
|
|
|
|
// Signal cancellation
|
|
_cts?.Cancel();
|
|
|
|
// Wait for graceful shutdown
|
|
await Task.WhenAny(
|
|
_subscriptionTask,
|
|
Task.Delay(Timeout.Infinite, cancellationToken));
|
|
}
|
|
|
|
private async Task SubscribeAsync(CancellationToken ct)
|
|
{
|
|
var offset = await LoadCheckpointAsync();
|
|
|
|
await foreach (var @event in _eventStore.ReadStreamAsync("orders", offset, ct))
|
|
{
|
|
await ProcessEventAsync(@event);
|
|
await SaveCheckpointAsync(@event.Offset);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Graceful Shutdown
|
|
|
|
```csharp
|
|
public class GracefulShutdownWorker : BackgroundService
|
|
{
|
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
{
|
|
try
|
|
{
|
|
await foreach (var @event in _consumerGroup.ConsumeAsync(
|
|
"orders",
|
|
"workers",
|
|
"worker-1",
|
|
cancellationToken: stoppingToken))
|
|
{
|
|
// Process event
|
|
await ProcessEventAsync(@event);
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
_logger.LogInformation("Shutdown requested, finishing current batch...");
|
|
|
|
// Finish processing current batch before exit
|
|
// Offset will be committed by consumer group
|
|
}
|
|
finally
|
|
{
|
|
_logger.LogInformation("Worker stopped gracefully");
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Error Handling
|
|
|
|
### Retry on Failure
|
|
|
|
```csharp
|
|
public async Task ProcessWithRetryAsync(StoredEvent @event)
|
|
{
|
|
const int maxRetries = 3;
|
|
int attempt = 0;
|
|
|
|
while (attempt < maxRetries)
|
|
{
|
|
try
|
|
{
|
|
await ProcessEventAsync(@event);
|
|
return; // Success
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
attempt++;
|
|
|
|
if (attempt >= maxRetries)
|
|
{
|
|
_logger.LogError(ex,
|
|
"Failed to process event {EventId} after {Attempts} attempts",
|
|
@event.EventId,
|
|
attempt);
|
|
|
|
// Move to dead letter queue
|
|
await MoveToDLQAsync(@event);
|
|
throw;
|
|
}
|
|
|
|
// Exponential backoff
|
|
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt));
|
|
_logger.LogWarning(ex,
|
|
"Retry {Attempt}/{MaxRetries} after {Delay}",
|
|
attempt,
|
|
maxRetries,
|
|
delay);
|
|
|
|
await Task.Delay(delay);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Dead Letter Queue
|
|
|
|
```csharp
|
|
public async Task ProcessWithDLQAsync(StoredEvent @event)
|
|
{
|
|
try
|
|
{
|
|
await ProcessEventAsync(@event);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Processing failed, moving to DLQ");
|
|
|
|
// Move to dead letter queue for manual investigation
|
|
await _eventStore.EnqueueAsync("dlq-orders", new DeadLetterMessage
|
|
{
|
|
OriginalEventId = @event.EventId,
|
|
OriginalStreamName = @event.StreamName,
|
|
OriginalOffset = @event.Offset,
|
|
ErrorMessage = ex.Message,
|
|
ErrorStackTrace = ex.StackTrace,
|
|
FailedAt = DateTimeOffset.UtcNow
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
## Monitoring Subscriptions
|
|
|
|
### Lag Monitoring
|
|
|
|
```csharp
|
|
public class SubscriptionMonitor : BackgroundService
|
|
{
|
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
{
|
|
while (!stoppingToken.IsCancellationRequested)
|
|
{
|
|
var streamLength = await GetStreamLengthAsync("orders");
|
|
var lastProcessedOffset = await LoadCheckpointAsync("order-processing");
|
|
|
|
var lag = streamLength - lastProcessedOffset;
|
|
|
|
if (lag > 1000)
|
|
{
|
|
_logger.LogWarning(
|
|
"Subscription lagging: {Lag} events behind",
|
|
lag);
|
|
}
|
|
|
|
_metrics.RecordConsumerLag("order-processing", lag);
|
|
|
|
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Health Checks
|
|
|
|
```csharp
|
|
public class SubscriptionHealthCheck : IHealthCheck
|
|
{
|
|
private readonly IConsumerOffsetStore _offsetStore;
|
|
|
|
public async Task<HealthCheckResult> CheckHealthAsync(
|
|
HealthCheckContext context,
|
|
CancellationToken ct)
|
|
{
|
|
var consumers = await _offsetStore.GetConsumersAsync("orders", "order-processing");
|
|
|
|
var staleConsumers = consumers.Where(c =>
|
|
DateTimeOffset.UtcNow - c.LastHeartbeat > TimeSpan.FromMinutes(1));
|
|
|
|
if (staleConsumers.Any())
|
|
{
|
|
return HealthCheckResult.Degraded(
|
|
$"Stale consumers: {string.Join(", ", staleConsumers.Select(c => c.ConsumerId))}");
|
|
}
|
|
|
|
return HealthCheckResult.Healthy("All consumers active");
|
|
}
|
|
}
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
### ✅ DO
|
|
|
|
- Use broadcast for independent projections
|
|
- Use queue mode for scalable processing
|
|
- Track offsets reliably
|
|
- Implement idempotent handlers
|
|
- Monitor consumer lag
|
|
- Handle errors gracefully
|
|
- Implement graceful shutdown
|
|
|
|
### ❌ DON'T
|
|
|
|
- Don't lose checkpoint data
|
|
- Don't process events without idempotency
|
|
- Don't ignore consumer lag
|
|
- Don't skip error handling
|
|
- Don't block event processing
|
|
- Don't commit offsets before processing
|
|
|
|
## See Also
|
|
|
|
- [Getting Started](getting-started.md)
|
|
- [Consumer Groups](../consumer-groups/README.md)
|
|
- [Projections](../projections/README.md)
|
|
- [Health Checks](../observability/health-checks/README.md)
|
|
- [Metrics](../observability/metrics/README.md)
|