Files
git.stella-ops.org/src/__Libraries/StellaOps.Messaging.Transport.Postgres/PostgresIdempotencyStore.cs
StellaOps Bot 999e26a48e up
2025-12-13 02:22:15 +02:00

211 lines
7.9 KiB
C#

using Dapper;
using Microsoft.Extensions.Logging;
using StellaOps.Messaging.Abstractions;
namespace StellaOps.Messaging.Transport.Postgres;
/// <summary>
/// PostgreSQL implementation of <see cref="IIdempotencyStore"/>.
/// Uses INSERT ... ON CONFLICT DO NOTHING for atomic claiming.
/// </summary>
public sealed class PostgresIdempotencyStore : IIdempotencyStore
{
private readonly PostgresConnectionFactory _connectionFactory;
private readonly string _name;
private readonly ILogger<PostgresIdempotencyStore>? _logger;
private readonly TimeProvider _timeProvider;
private bool _tableInitialized;
public PostgresIdempotencyStore(
PostgresConnectionFactory connectionFactory,
string name,
ILogger<PostgresIdempotencyStore>? logger = null,
TimeProvider? timeProvider = null)
{
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
_name = name ?? throw new ArgumentNullException(nameof(name));
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public string ProviderName => "postgres";
private string TableName => $"{_connectionFactory.Schema}.idempotency_{_name.ToLowerInvariant().Replace("-", "_")}";
/// <inheritdoc />
public async ValueTask<IdempotencyResult> TryClaimAsync(
string key,
string value,
TimeSpan window,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(key);
ArgumentNullException.ThrowIfNull(value);
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
var now = _timeProvider.GetUtcNow();
var expiresAt = now.Add(window);
// Clean up expired entries first
var cleanupSql = $@"DELETE FROM {TableName} WHERE expires_at < @Now";
await conn.ExecuteAsync(new CommandDefinition(cleanupSql, new { Now = now.UtcDateTime }, cancellationToken: cancellationToken))
.ConfigureAwait(false);
// Try to insert
var sql = $@"
INSERT INTO {TableName} (key, value, expires_at)
VALUES (@Key, @Value, @ExpiresAt)
ON CONFLICT (key) DO NOTHING
RETURNING TRUE";
var result = await conn.ExecuteScalarAsync<bool?>(
new CommandDefinition(sql, new { Key = key, Value = value, ExpiresAt = expiresAt.UtcDateTime }, cancellationToken: cancellationToken))
.ConfigureAwait(false);
if (result == true)
{
return IdempotencyResult.Claimed();
}
// Key already exists, get existing value
var existingSql = $@"SELECT value FROM {TableName} WHERE key = @Key";
var existingValue = await conn.ExecuteScalarAsync<string?>(
new CommandDefinition(existingSql, new { Key = key }, cancellationToken: cancellationToken))
.ConfigureAwait(false);
return IdempotencyResult.Duplicate(existingValue ?? string.Empty);
}
/// <inheritdoc />
public async ValueTask<bool> ExistsAsync(string key, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(key);
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
var now = _timeProvider.GetUtcNow();
var sql = $@"SELECT EXISTS(SELECT 1 FROM {TableName} WHERE key = @Key AND expires_at > @Now)";
return await conn.ExecuteScalarAsync<bool>(
new CommandDefinition(sql, new { Key = key, Now = now.UtcDateTime }, cancellationToken: cancellationToken))
.ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask<string?> GetAsync(string key, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(key);
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
var now = _timeProvider.GetUtcNow();
var sql = $@"SELECT value FROM {TableName} WHERE key = @Key AND expires_at > @Now";
return await conn.ExecuteScalarAsync<string?>(
new CommandDefinition(sql, new { Key = key, Now = now.UtcDateTime }, cancellationToken: cancellationToken))
.ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask<bool> ReleaseAsync(string key, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(key);
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
var sql = $@"DELETE FROM {TableName} WHERE key = @Key";
var deleted = await conn.ExecuteAsync(
new CommandDefinition(sql, new { Key = key }, cancellationToken: cancellationToken))
.ConfigureAwait(false);
return deleted > 0;
}
/// <inheritdoc />
public async ValueTask<bool> ExtendAsync(
string key,
TimeSpan extension,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(key);
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
var sql = $@"
UPDATE {TableName}
SET expires_at = expires_at + @Extension
WHERE key = @Key";
var updated = await conn.ExecuteAsync(
new CommandDefinition(sql, new { Key = key, Extension = extension }, cancellationToken: cancellationToken))
.ConfigureAwait(false);
return updated > 0;
}
private async ValueTask EnsureTableExistsAsync(CancellationToken cancellationToken)
{
if (_tableInitialized) return;
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
var safeName = _name.ToLowerInvariant().Replace("-", "_");
var sql = $@"
CREATE TABLE IF NOT EXISTS {TableName} (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_{safeName}_expires ON {TableName} (expires_at);";
await conn.ExecuteAsync(new CommandDefinition(sql, cancellationToken: cancellationToken)).ConfigureAwait(false);
_tableInitialized = true;
}
}
/// <summary>
/// Factory for creating PostgreSQL idempotency store instances.
/// </summary>
public sealed class PostgresIdempotencyStoreFactory : IIdempotencyStoreFactory
{
private readonly PostgresConnectionFactory _connectionFactory;
private readonly ILoggerFactory? _loggerFactory;
private readonly TimeProvider _timeProvider;
public PostgresIdempotencyStoreFactory(
PostgresConnectionFactory connectionFactory,
ILoggerFactory? loggerFactory = null,
TimeProvider? timeProvider = null)
{
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
_loggerFactory = loggerFactory;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public string ProviderName => "postgres";
/// <inheritdoc />
public IIdempotencyStore Create(string name)
{
ArgumentNullException.ThrowIfNull(name);
return new PostgresIdempotencyStore(
_connectionFactory,
name,
_loggerFactory?.CreateLogger<PostgresIdempotencyStore>(),
_timeProvider);
}
}