dotnet-cqrs/Svrnty.CQRS.Events/Projections/ProjectionEngine.cs

304 lines
12 KiB
C#

using System;
using Svrnty.CQRS.Events.Abstractions.EventStore;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Svrnty.CQRS.Events.Abstractions;
using Svrnty.CQRS.Events.Abstractions.Projections;
namespace Svrnty.CQRS.Events.Projections;
/// <summary>
/// Manages execution of event stream projections.
/// </summary>
public sealed class ProjectionEngine : IProjectionEngine
{
private readonly IProjectionRegistry _registry;
private readonly IProjectionCheckpointStore _checkpointStore;
private readonly IEventStreamStore _streamStore;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<ProjectionEngine> _logger;
public ProjectionEngine(
IProjectionRegistry registry,
IProjectionCheckpointStore checkpointStore,
IEventStreamStore streamStore,
IServiceProvider serviceProvider,
ILogger<ProjectionEngine> logger)
{
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
_checkpointStore = checkpointStore ?? throw new ArgumentNullException(nameof(checkpointStore));
_streamStore = streamStore ?? throw new ArgumentNullException(nameof(streamStore));
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task RunAsync(
string projectionName,
string streamName,
CancellationToken cancellationToken = default)
{
var definition = _registry.GetProjection(projectionName);
if (definition == null)
{
throw new InvalidOperationException($"Projection '{projectionName}' is not registered");
}
if (definition.StreamName != streamName)
{
throw new InvalidOperationException(
$"Projection '{projectionName}' is registered for stream '{definition.StreamName}', not '{streamName}'");
}
_logger.LogInformation(
"Starting projection: {ProjectionName} on stream {StreamName}",
projectionName, streamName);
try
{
await RunProjectionLoopAsync(definition, cancellationToken);
}
catch (OperationCanceledException)
{
_logger.LogInformation(
"Projection stopped: {ProjectionName} on stream {StreamName}",
projectionName, streamName);
throw;
}
catch (Exception ex)
{
_logger.LogError(ex,
"Projection failed: {ProjectionName} on stream {StreamName}",
projectionName, streamName);
throw;
}
}
/// <inheritdoc />
public async Task RebuildAsync(
string projectionName,
string streamName,
CancellationToken cancellationToken = default)
{
var definition = _registry.GetProjection(projectionName);
if (definition == null)
{
throw new InvalidOperationException($"Projection '{projectionName}' is not registered");
}
if (!definition.Options.AllowRebuild)
{
throw new InvalidOperationException(
$"Projection '{projectionName}' does not allow rebuilding (AllowRebuild=false)");
}
_logger.LogWarning(
"Rebuilding projection: {ProjectionName} on stream {StreamName}",
projectionName, streamName);
// Reset the projection if it implements IResettableProjection
using (var scope = _serviceProvider.CreateScope())
{
var projection = scope.ServiceProvider.GetRequiredService(definition.ProjectionType);
if (projection is IResettableProjection resettable)
{
_logger.LogInformation("Resetting projection read model: {ProjectionName}", projectionName);
await resettable.ResetAsync(cancellationToken);
}
}
// Reset checkpoint
await _checkpointStore.ResetCheckpointAsync(projectionName, streamName, cancellationToken);
_logger.LogInformation("Projection reset complete: {ProjectionName}", projectionName);
// Replay all events
await RunProjectionLoopAsync(definition, cancellationToken);
}
/// <inheritdoc />
public async Task<ProjectionStatus> GetStatusAsync(
string projectionName,
string streamName,
CancellationToken cancellationToken = default)
{
var checkpoint = await _checkpointStore.GetCheckpointAsync(projectionName, streamName, cancellationToken);
var streamLength = await _streamStore.GetStreamLengthAsync(streamName, cancellationToken);
return new ProjectionStatus
{
ProjectionName = projectionName,
StreamName = streamName,
IsRunning = false, // This is a simple implementation; full tracking would require more state
State = checkpoint == null ? ProjectionState.NotStarted : ProjectionState.Running,
LastProcessedOffset = checkpoint?.LastProcessedOffset ?? -1,
StreamLength = streamLength,
LastUpdated = checkpoint?.LastUpdated ?? DateTimeOffset.MinValue,
EventsProcessed = checkpoint?.EventsProcessed ?? 0,
LastError = checkpoint?.LastError,
LastErrorAt = checkpoint?.LastErrorAt
};
}
private async Task RunProjectionLoopAsync(
ProjectionDefinition definition,
CancellationToken cancellationToken)
{
var checkpoint = await _checkpointStore.GetCheckpointAsync(
definition.ProjectionName,
definition.StreamName,
cancellationToken);
long currentOffset = checkpoint?.LastProcessedOffset + 1 ?? 0;
_logger.LogInformation(
"Projection starting from offset {Offset}: {ProjectionName}",
currentOffset, definition.ProjectionName);
while (!cancellationToken.IsCancellationRequested)
{
var events = await _streamStore.ReadStreamAsync(
definition.StreamName,
currentOffset,
definition.Options.BatchSize,
cancellationToken);
if (events.Count == 0)
{
// Caught up, wait before polling again
await Task.Delay(definition.Options.PollingInterval, cancellationToken);
continue;
}
_logger.LogDebug(
"Processing {Count} events from offset {Offset}: {ProjectionName}",
events.Count, currentOffset, definition.ProjectionName);
// Process batch
foreach (var @event in events)
{
var success = await ProcessEventAsync(definition, @event, cancellationToken);
if (!success)
{
// Failed after retries, update checkpoint with error and stop
var errorCheckpoint = new ProjectionCheckpoint
{
ProjectionName = definition.ProjectionName,
StreamName = definition.StreamName,
LastProcessedOffset = currentOffset - 1,
LastUpdated = DateTimeOffset.UtcNow,
EventsProcessed = checkpoint?.EventsProcessed ?? 0,
LastError = $"Failed to process event at offset {currentOffset}",
LastErrorAt = DateTimeOffset.UtcNow
};
await _checkpointStore.SaveCheckpointAsync(errorCheckpoint, cancellationToken);
throw new InvalidOperationException(
$"Projection '{definition.ProjectionName}' failed after max retries at offset {currentOffset}");
}
currentOffset++;
// Checkpoint per event if configured
if (definition.Options.CheckpointPerEvent)
{
checkpoint = new ProjectionCheckpoint
{
ProjectionName = definition.ProjectionName,
StreamName = definition.StreamName,
LastProcessedOffset = currentOffset - 1,
LastUpdated = DateTimeOffset.UtcNow,
EventsProcessed = (checkpoint?.EventsProcessed ?? 0) + 1
};
await _checkpointStore.SaveCheckpointAsync(checkpoint, cancellationToken);
}
}
// Checkpoint after batch if not checkpointing per event
if (!definition.Options.CheckpointPerEvent)
{
checkpoint = new ProjectionCheckpoint
{
ProjectionName = definition.ProjectionName,
StreamName = definition.StreamName,
LastProcessedOffset = currentOffset - 1,
LastUpdated = DateTimeOffset.UtcNow,
EventsProcessed = (checkpoint?.EventsProcessed ?? 0) + events.Count
};
await _checkpointStore.SaveCheckpointAsync(checkpoint, cancellationToken);
}
_logger.LogDebug(
"Processed batch up to offset {Offset}: {ProjectionName}",
currentOffset - 1, definition.ProjectionName);
}
}
private async Task<bool> ProcessEventAsync(
ProjectionDefinition definition,
ICorrelatedEvent @event,
CancellationToken cancellationToken)
{
using var scope = _serviceProvider.CreateScope();
var projection = scope.ServiceProvider.GetRequiredService(definition.ProjectionType);
for (int attempt = 0; attempt <= definition.Options.MaxRetries; attempt++)
{
try
{
if (projection is IDynamicProjection dynamicProjection)
{
await dynamicProjection.HandleAsync(@event, cancellationToken);
}
else
{
// Use reflection to call HandleAsync with the correct event type
var handleMethod = definition.ProjectionType.GetMethod(
nameof(IProjection<ICorrelatedEvent>.HandleAsync));
if (handleMethod != null)
{
var task = (Task?)handleMethod.Invoke(
projection,
new object[] { @event, cancellationToken });
if (task != null)
{
await task;
}
}
}
return true; // Success
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Projection failed (attempt {Attempt}/{MaxRetries}): {ProjectionName}, Event: {@Event}",
attempt + 1, definition.Options.MaxRetries + 1, definition.ProjectionName, @event);
if (attempt < definition.Options.MaxRetries)
{
var delaySeconds = definition.Options.BaseRetryDelay.TotalSeconds * Math.Pow(2, attempt);
await Task.Delay(TimeSpan.FromSeconds(delaySeconds), cancellationToken);
}
else
{
_logger.LogError(ex,
"Projection failed after {MaxRetries} retries: {ProjectionName}, Event: {@Event}",
definition.Options.MaxRetries + 1, definition.ProjectionName, @event);
return false; // Failed after all retries
}
}
}
return false;
}
}