350 lines
14 KiB
C#
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;
|
|
}
|
|
}
|