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; /// /// In-memory implementation of . /// /// /// /// Scope: /// - Single application instance only (not distributed) /// - Lost on application restart /// - Suitable for development and testing /// /// /// Thread Safety: /// Uses ConcurrentDictionary for thread-safe operations. /// /// public sealed class InMemoryReadReceiptStore : IReadReceiptStore { private readonly ILogger _logger; // Key: "{consumerId}:{streamName}" private readonly ConcurrentDictionary _consumerStates = new(); public InMemoryReadReceiptStore(ILogger logger) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// 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; } /// public Task 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(state.LastOffset); } return Task.FromResult(null); } /// public Task 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(progress); } return Task.FromResult(null); } /// public Task> 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>(consumers); } /// public Task 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; } } }