dotnet-cqrs/Svrnty.CQRS.Events.ConsumerGroups/PostgreSQL/PostgresConsumerOffsetStore.cs

350 lines
14 KiB
C#

using System;
using System.Collections.Generic;
using System.Data;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Npgsql;
using Svrnty.CQRS.Events.ConsumerGroups.Abstractions;
namespace Svrnty.CQRS.Events.ConsumerGroups.PostgreSQL;
/// <summary>
/// PostgreSQL-based implementation of IConsumerOffsetStore.
/// Provides durable storage for consumer group offsets and registrations.
/// </summary>
public class PostgresConsumerOffsetStore : IConsumerOffsetStore
{
private readonly PostgresConsumerGroupOptions _options;
private readonly ILogger<PostgresConsumerOffsetStore> _logger;
private readonly JsonSerializerOptions _jsonOptions;
public PostgresConsumerOffsetStore(
IOptions<PostgresConsumerGroupOptions> options,
ILogger<PostgresConsumerOffsetStore> logger)
{
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
if (_options.AutoMigrate)
{
InitializeDatabaseAsync().GetAwaiter().GetResult();
}
}
private string SchemaQualifiedTable(string tableName) => $"{_options.SchemaName}.{tableName}";
private async Task InitializeDatabaseAsync()
{
try
{
await using var connection = new NpgsqlConnection(_options.ConnectionString);
await connection.OpenAsync();
var migrationPath = Path.Combine(AppContext.BaseDirectory, "Migrations", "002_ConsumerGroups.sql");
if (File.Exists(migrationPath))
{
var sql = await File.ReadAllTextAsync(migrationPath);
await using var command = new NpgsqlCommand(sql, connection);
await command.ExecuteNonQueryAsync();
_logger.LogInformation("Consumer groups database schema initialized successfully");
}
else
{
_logger.LogWarning("Migration file not found: {MigrationPath}", migrationPath);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to initialize consumer groups database schema");
throw;
}
}
/// <inheritdoc />
public async Task CommitOffsetAsync(
string groupId,
string consumerId,
string streamName,
long offset,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(groupId))
throw new ArgumentException("Group ID cannot be null or whitespace", nameof(groupId));
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));
await using var connection = new NpgsqlConnection(_options.ConnectionString);
await connection.OpenAsync(cancellationToken);
var sql = $@"
INSERT INTO {SchemaQualifiedTable("consumer_offsets")}
(group_id, consumer_id, stream_name, offset, committed_at)
VALUES (@groupId, @consumerId, @streamName, @offset, NOW())
ON CONFLICT (group_id, consumer_id, stream_name)
DO UPDATE SET offset = @offset, committed_at = NOW()";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("groupId", groupId);
command.Parameters.AddWithValue("consumerId", consumerId);
command.Parameters.AddWithValue("streamName", streamName);
command.Parameters.AddWithValue("offset", offset);
await command.ExecuteNonQueryAsync(cancellationToken);
_logger.LogDebug(
"Committed offset {Offset} for consumer {ConsumerId} in group {GroupId} on stream {StreamName}",
offset, consumerId, groupId, streamName);
}
/// <inheritdoc />
public async Task<long?> GetCommittedOffsetAsync(
string groupId,
string streamName,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(groupId))
throw new ArgumentException("Group ID cannot be null or whitespace", nameof(groupId));
if (string.IsNullOrWhiteSpace(streamName))
throw new ArgumentException("Stream name cannot be null or whitespace", nameof(streamName));
await using var connection = new NpgsqlConnection(_options.ConnectionString);
await connection.OpenAsync(cancellationToken);
// Get the minimum offset across all consumers in the group (safe point)
var sql = $@"
SELECT MIN(offset)
FROM {SchemaQualifiedTable("consumer_offsets")}
WHERE group_id = @groupId AND stream_name = @streamName";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("groupId", groupId);
command.Parameters.AddWithValue("streamName", streamName);
var result = await command.ExecuteScalarAsync(cancellationToken);
return result == DBNull.Value ? null : Convert.ToInt64(result);
}
/// <inheritdoc />
public async Task<IReadOnlyDictionary<string, long>> GetGroupOffsetsAsync(
string groupId,
string streamName,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(groupId))
throw new ArgumentException("Group ID cannot be null or whitespace", nameof(groupId));
if (string.IsNullOrWhiteSpace(streamName))
throw new ArgumentException("Stream name cannot be null or whitespace", nameof(streamName));
await using var connection = new NpgsqlConnection(_options.ConnectionString);
await connection.OpenAsync(cancellationToken);
var sql = $@"
SELECT consumer_id, offset
FROM {SchemaQualifiedTable("consumer_offsets")}
WHERE group_id = @groupId AND stream_name = @streamName";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("groupId", groupId);
command.Parameters.AddWithValue("streamName", streamName);
var offsets = new Dictionary<string, long>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var consumerId = reader.GetString(0);
var offset = reader.GetInt64(1);
offsets[consumerId] = offset;
}
return offsets;
}
/// <inheritdoc />
public async Task RegisterConsumerAsync(
string groupId,
string consumerId,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(groupId))
throw new ArgumentException("Group ID cannot be null or whitespace", nameof(groupId));
if (string.IsNullOrWhiteSpace(consumerId))
throw new ArgumentException("Consumer ID cannot be null or whitespace", nameof(consumerId));
await using var connection = new NpgsqlConnection(_options.ConnectionString);
await connection.OpenAsync(cancellationToken);
var sql = $@"
INSERT INTO {SchemaQualifiedTable("consumer_registrations")}
(group_id, consumer_id, registered_at, last_heartbeat)
VALUES (@groupId, @consumerId, NOW(), NOW())
ON CONFLICT (group_id, consumer_id)
DO UPDATE SET last_heartbeat = NOW()";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("groupId", groupId);
command.Parameters.AddWithValue("consumerId", consumerId);
await command.ExecuteNonQueryAsync(cancellationToken);
_logger.LogDebug(
"Registered consumer {ConsumerId} in group {GroupId}",
consumerId, groupId);
}
/// <inheritdoc />
public async Task UnregisterConsumerAsync(
string groupId,
string consumerId,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(groupId))
throw new ArgumentException("Group ID cannot be null or whitespace", nameof(groupId));
if (string.IsNullOrWhiteSpace(consumerId))
throw new ArgumentException("Consumer ID cannot be null or whitespace", nameof(consumerId));
await using var connection = new NpgsqlConnection(_options.ConnectionString);
await connection.OpenAsync(cancellationToken);
var sql = $@"
DELETE FROM {SchemaQualifiedTable("consumer_registrations")}
WHERE group_id = @groupId AND consumer_id = @consumerId";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("groupId", groupId);
command.Parameters.AddWithValue("consumerId", consumerId);
await command.ExecuteNonQueryAsync(cancellationToken);
_logger.LogInformation(
"Unregistered consumer {ConsumerId} from group {GroupId}",
consumerId, groupId);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ConsumerInfo>> GetActiveConsumersAsync(
string groupId,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(groupId))
throw new ArgumentException("Group ID cannot be null or whitespace", nameof(groupId));
await using var connection = new NpgsqlConnection(_options.ConnectionString);
await connection.OpenAsync(cancellationToken);
var sql = $@"
SELECT consumer_id, group_id, registered_at, last_heartbeat, metadata
FROM {SchemaQualifiedTable("consumer_registrations")}
WHERE group_id = @groupId
ORDER BY registered_at";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("groupId", groupId);
var consumers = new List<ConsumerInfo>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var metadataJson = reader.IsDBNull(4) ? null : reader.GetString(4);
Dictionary<string, string>? metadata = null;
if (!string.IsNullOrWhiteSpace(metadataJson))
{
metadata = JsonSerializer.Deserialize<Dictionary<string, string>>(metadataJson, _jsonOptions);
}
consumers.Add(new ConsumerInfo
{
ConsumerId = reader.GetString(0),
GroupId = reader.GetString(1),
RegisteredAt = reader.GetFieldValue<DateTimeOffset>(2),
LastHeartbeat = reader.GetFieldValue<DateTimeOffset>(3),
Metadata = metadata
});
}
return consumers;
}
/// <inheritdoc />
public async Task<IReadOnlyList<string>> GetAllGroupsAsync(
CancellationToken cancellationToken = default)
{
await using var connection = new NpgsqlConnection(_options.ConnectionString);
await connection.OpenAsync(cancellationToken);
var sql = $@"
SELECT DISTINCT group_id
FROM {SchemaQualifiedTable("consumer_registrations")}
ORDER BY group_id";
await using var command = new NpgsqlCommand(sql, connection);
var groups = new List<string>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
groups.Add(reader.GetString(0));
}
return groups;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ConsumerInfo>> CleanupStaleConsumersAsync(
TimeSpan sessionTimeout,
CancellationToken cancellationToken = default)
{
await using var connection = new NpgsqlConnection(_options.ConnectionString);
await connection.OpenAsync(cancellationToken);
var sql = $@"
SELECT group_id, consumer_id
FROM event_streaming.cleanup_stale_consumers(@timeoutSeconds)";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("timeoutSeconds", (int)sessionTimeout.TotalSeconds);
var removedConsumers = new List<ConsumerInfo>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var groupId = reader.GetString(0);
var consumerId = reader.GetString(1);
removedConsumers.Add(new ConsumerInfo
{
GroupId = groupId,
ConsumerId = consumerId,
RegisteredAt = DateTimeOffset.UtcNow, // Not available from cleanup
LastHeartbeat = DateTimeOffset.UtcNow.Subtract(sessionTimeout)
});
_logger.LogWarning(
"Cleaned up stale consumer {ConsumerId} from group {GroupId}",
consumerId, groupId);
}
if (removedConsumers.Count > 0)
{
_logger.LogInformation(
"Cleaned up {Count} stale consumers",
removedConsumers.Count);
}
return removedConsumers;
}
}