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; /// /// PostgreSQL-based implementation of IConsumerOffsetStore. /// Provides durable storage for consumer group offsets and registrations. /// public class PostgresConsumerOffsetStore : IConsumerOffsetStore { private readonly PostgresConsumerGroupOptions _options; private readonly ILogger _logger; private readonly JsonSerializerOptions _jsonOptions; public PostgresConsumerOffsetStore( IOptions options, ILogger 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; } } /// 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); } /// public async Task 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); } /// public async Task> 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(); 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; } /// 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); } /// 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); } /// public async Task> 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(); await using var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) { var metadataJson = reader.IsDBNull(4) ? null : reader.GetString(4); Dictionary? metadata = null; if (!string.IsNullOrWhiteSpace(metadataJson)) { metadata = JsonSerializer.Deserialize>(metadataJson, _jsonOptions); } consumers.Add(new ConsumerInfo { ConsumerId = reader.GetString(0), GroupId = reader.GetString(1), RegisteredAt = reader.GetFieldValue(2), LastHeartbeat = reader.GetFieldValue(3), Metadata = metadata }); } return consumers; } /// public async Task> 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(); await using var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) { groups.Add(reader.GetString(0)); } return groups; } /// public async Task> 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(); 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; } }