210 lines
7.0 KiB
C#
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; }
|
|
}
|
|
}
|