dotnet-cqrs/Svrnty.CQRS.Events/Storage/InMemoryReadReceiptStore.cs

210 lines
7.0 KiB
C#

using System;
using Svrnty.CQRS.Events.Abstractions.Storage;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Svrnty.CQRS.Events.Abstractions;
namespace Svrnty.CQRS.Events.Storage;
/// <summary>
/// In-memory implementation of <see cref="IReadReceiptStore"/>.
/// </summary>
/// <remarks>
/// <para>
/// <strong>Scope:</strong>
/// - Single application instance only (not distributed)
/// - Lost on application restart
/// - Suitable for development and testing
/// </para>
/// <para>
/// <strong>Thread Safety:</strong>
/// Uses ConcurrentDictionary for thread-safe operations.
/// </para>
/// </remarks>
public sealed class InMemoryReadReceiptStore : IReadReceiptStore
{
private readonly ILogger<InMemoryReadReceiptStore> _logger;
// Key: "{consumerId}:{streamName}"
private readonly ConcurrentDictionary<string, ConsumerStreamState> _consumerStates = new();
public InMemoryReadReceiptStore(ILogger<InMemoryReadReceiptStore> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public Task AcknowledgeEventAsync(
string consumerId,
string streamName,
string eventId,
long offset,
DateTimeOffset acknowledgedAt,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(consumerId))
throw new ArgumentException("Consumer ID cannot be null or whitespace.", nameof(consumerId));
if (string.IsNullOrWhiteSpace(streamName))
throw new ArgumentException("Stream name cannot be null or whitespace.", nameof(streamName));
if (string.IsNullOrWhiteSpace(eventId))
throw new ArgumentException("Event ID cannot be null or whitespace.", nameof(eventId));
var key = GetKey(consumerId, streamName);
_consumerStates.AddOrUpdate(
key,
// Add new state
_ => new ConsumerStreamState
{
ConsumerId = consumerId,
StreamName = streamName,
LastEventId = eventId,
LastOffset = offset,
LastAcknowledgedAt = acknowledgedAt,
FirstAcknowledgedAt = acknowledgedAt,
TotalAcknowledged = 1
},
// Update existing state
(_, existing) =>
{
// Only update if this offset is newer
if (offset > existing.LastOffset)
{
existing.LastEventId = eventId;
existing.LastOffset = offset;
existing.LastAcknowledgedAt = acknowledgedAt;
}
existing.TotalAcknowledged++;
return existing;
});
_logger.LogDebug(
"Acknowledged event {EventId} at offset {Offset} for consumer {ConsumerId} on stream {StreamName}",
eventId,
offset,
consumerId,
streamName);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<long?> GetLastAcknowledgedOffsetAsync(
string consumerId,
string streamName,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(consumerId))
throw new ArgumentException("Consumer ID cannot be null or whitespace.", nameof(consumerId));
if (string.IsNullOrWhiteSpace(streamName))
throw new ArgumentException("Stream name cannot be null or whitespace.", nameof(streamName));
var key = GetKey(consumerId, streamName);
if (_consumerStates.TryGetValue(key, out var state))
{
return Task.FromResult<long?>(state.LastOffset);
}
return Task.FromResult<long?>(null);
}
/// <inheritdoc />
public Task<ConsumerProgress?> GetConsumerProgressAsync(
string consumerId,
string streamName,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(consumerId))
throw new ArgumentException("Consumer ID cannot be null or whitespace.", nameof(consumerId));
if (string.IsNullOrWhiteSpace(streamName))
throw new ArgumentException("Stream name cannot be null or whitespace.", nameof(streamName));
var key = GetKey(consumerId, streamName);
if (_consumerStates.TryGetValue(key, out var state))
{
var progress = new ConsumerProgress
{
ConsumerId = state.ConsumerId,
StreamName = state.StreamName,
LastOffset = state.LastOffset,
LastAcknowledgedAt = state.LastAcknowledgedAt,
TotalAcknowledged = state.TotalAcknowledged,
FirstAcknowledgedAt = state.FirstAcknowledgedAt
};
return Task.FromResult<ConsumerProgress?>(progress);
}
return Task.FromResult<ConsumerProgress?>(null);
}
/// <inheritdoc />
public Task<IReadOnlyList<string>> GetConsumersForStreamAsync(
string streamName,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(streamName))
throw new ArgumentException("Stream name cannot be null or whitespace.", nameof(streamName));
var consumers = _consumerStates.Values
.Where(state => state.StreamName == streamName)
.Select(state => state.ConsumerId)
.Distinct()
.ToList();
return Task.FromResult<IReadOnlyList<string>>(consumers);
}
/// <inheritdoc />
public Task<int> CleanupAsync(
DateTimeOffset olderThan,
CancellationToken cancellationToken = default)
{
var keysToRemove = _consumerStates
.Where(kvp => kvp.Value.LastAcknowledgedAt < olderThan)
.Select(kvp => kvp.Key)
.ToList();
var removedCount = 0;
foreach (var key in keysToRemove)
{
if (_consumerStates.TryRemove(key, out _))
{
removedCount++;
}
}
if (removedCount > 0)
{
_logger.LogInformation(
"Cleaned up {RemovedCount} read receipt records older than {OlderThan}",
removedCount,
olderThan);
}
return Task.FromResult(removedCount);
}
private static string GetKey(string consumerId, string streamName)
{
return $"{consumerId}:{streamName}";
}
private sealed class ConsumerStreamState
{
public required string ConsumerId { get; init; }
public required string StreamName { get; init; }
public string LastEventId { get; set; } = string.Empty;
public long LastOffset { get; set; }
public DateTimeOffset LastAcknowledgedAt { get; set; }
public DateTimeOffset FirstAcknowledgedAt { get; init; }
public long TotalAcknowledged { get; set; }
}
}