up
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
/// <summary>
|
||||
/// Transport-agnostic implementation of <see cref="IDpopNonceStore"/> using StellaOps.Messaging abstractions.
|
||||
/// Works with any configured transport (Valkey, PostgreSQL, InMemory).
|
||||
/// </summary>
|
||||
public sealed class MessagingDpopNonceStore : IDpopNonceStore
|
||||
{
|
||||
private static readonly TimeSpan RateLimitWindow = TimeSpan.FromMinutes(1);
|
||||
|
||||
private readonly IRateLimiter _rateLimiter;
|
||||
private readonly IAtomicTokenStore<DpopNonceMetadata> _tokenStore;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<MessagingDpopNonceStore>? _logger;
|
||||
|
||||
public MessagingDpopNonceStore(
|
||||
IRateLimiter rateLimiter,
|
||||
IAtomicTokenStore<DpopNonceMetadata> tokenStore,
|
||||
TimeProvider? timeProvider = null,
|
||||
ILogger<MessagingDpopNonceStore>? logger = null)
|
||||
{
|
||||
_rateLimiter = rateLimiter ?? throw new ArgumentNullException(nameof(rateLimiter));
|
||||
_tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<DpopNonceIssueResult> IssueAsync(
|
||||
string audience,
|
||||
string clientId,
|
||||
string keyThumbprint,
|
||||
TimeSpan ttl,
|
||||
int maxIssuancePerMinute,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
|
||||
|
||||
if (ttl <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(ttl), "Nonce TTL must be greater than zero.");
|
||||
}
|
||||
|
||||
if (maxIssuancePerMinute < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maxIssuancePerMinute), "Max issuance per minute must be at least 1.");
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var storageKey = DpopNonceUtilities.ComputeStorageKey(audience, clientId, keyThumbprint);
|
||||
var rateKey = $"{storageKey}:rate";
|
||||
|
||||
// Check rate limit
|
||||
var ratePolicy = new RateLimitPolicy(maxIssuancePerMinute, RateLimitWindow);
|
||||
var rateLimitResult = await _rateLimiter.TryAcquireAsync(rateKey, ratePolicy, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!rateLimitResult.IsAllowed)
|
||||
{
|
||||
_logger?.LogDebug(
|
||||
"DPoP nonce issuance rate-limited for key {StorageKey}. Current: {Current}, Max: {Max}",
|
||||
storageKey, rateLimitResult.CurrentCount, maxIssuancePerMinute);
|
||||
return DpopNonceIssueResult.RateLimited("rate_limited");
|
||||
}
|
||||
|
||||
// Generate nonce and compute hash for storage
|
||||
var nonce = DpopNonceUtilities.GenerateNonce();
|
||||
var nonceHash = DpopNonceUtilities.EncodeHash(DpopNonceUtilities.ComputeNonceHash(nonce));
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.Add(ttl);
|
||||
|
||||
var metadata = new DpopNonceMetadata
|
||||
{
|
||||
IssuedAt = now,
|
||||
Ttl = ttl
|
||||
};
|
||||
|
||||
// Store the nonce hash as the token (caller-provided)
|
||||
var storeResult = await _tokenStore.StoreAsync(storageKey, nonceHash, metadata, ttl, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!storeResult.Success)
|
||||
{
|
||||
_logger?.LogWarning("Failed to store DPoP nonce for key {StorageKey}", storageKey);
|
||||
return DpopNonceIssueResult.Failure("storage_error");
|
||||
}
|
||||
|
||||
_logger?.LogDebug("Issued DPoP nonce for key {StorageKey}, expires at {ExpiresAt:o}", storageKey, expiresAt);
|
||||
return DpopNonceIssueResult.Success(nonce, expiresAt);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<DpopNonceConsumeResult> TryConsumeAsync(
|
||||
string nonce,
|
||||
string audience,
|
||||
string clientId,
|
||||
string keyThumbprint,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(nonce);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var storageKey = DpopNonceUtilities.ComputeStorageKey(audience, clientId, keyThumbprint);
|
||||
var nonceHash = DpopNonceUtilities.EncodeHash(DpopNonceUtilities.ComputeNonceHash(nonce));
|
||||
|
||||
// Try to consume the token atomically
|
||||
var consumeResult = await _tokenStore.TryConsumeAsync(storageKey, nonceHash, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
switch (consumeResult.Status)
|
||||
{
|
||||
case TokenConsumeStatus.Success:
|
||||
_logger?.LogDebug("Successfully consumed DPoP nonce for key {StorageKey}", storageKey);
|
||||
return DpopNonceConsumeResult.Success(
|
||||
consumeResult.IssuedAt ?? _timeProvider.GetUtcNow(),
|
||||
consumeResult.ExpiresAt ?? _timeProvider.GetUtcNow());
|
||||
|
||||
case TokenConsumeStatus.Expired:
|
||||
_logger?.LogDebug("DPoP nonce expired for key {StorageKey}", storageKey);
|
||||
return DpopNonceConsumeResult.Expired(
|
||||
consumeResult.IssuedAt,
|
||||
consumeResult.ExpiresAt ?? _timeProvider.GetUtcNow());
|
||||
|
||||
case TokenConsumeStatus.NotFound:
|
||||
_logger?.LogDebug("DPoP nonce not found for key {StorageKey}", storageKey);
|
||||
return DpopNonceConsumeResult.NotFound();
|
||||
|
||||
case TokenConsumeStatus.Mismatch:
|
||||
// Token exists but hash doesn't match - treat as not found
|
||||
_logger?.LogDebug("DPoP nonce hash mismatch for key {StorageKey}", storageKey);
|
||||
return DpopNonceConsumeResult.NotFound();
|
||||
|
||||
default:
|
||||
_logger?.LogWarning("Unknown consume status {Status} for key {StorageKey}", consumeResult.Status, storageKey);
|
||||
return DpopNonceConsumeResult.NotFound();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata stored with DPoP nonces.
|
||||
/// </summary>
|
||||
public sealed class DpopNonceMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// When the nonce was issued.
|
||||
/// </summary>
|
||||
public DateTimeOffset IssuedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The configured TTL for the nonce.
|
||||
/// </summary>
|
||||
public TimeSpan Ttl { get; init; }
|
||||
}
|
||||
@@ -29,10 +29,13 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.15.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.2.0" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.24" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.37" />
|
||||
<PackageReference Include="Microsoft.SourceLink.GitLab" Version="8.0.0" PrivateAssets="All" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="README.md" Pack="true" PackagePath="" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Messaging\StellaOps.Messaging.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -70,7 +70,7 @@ public sealed class StellaOpsAuthorityOptions
|
||||
public IList<AuthorityTenantOptions> Tenants => tenants;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration describing the Authority MongoDB storage.
|
||||
/// Configuration describing the Authority storage layer.
|
||||
/// </summary>
|
||||
public AuthorityStorageOptions Storage { get; } = new();
|
||||
|
||||
@@ -677,7 +677,7 @@ public sealed class AuthorityEndpointRateLimitOptions
|
||||
public sealed class AuthorityStorageOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Mongo connection string used by Authority storage.
|
||||
/// Connection string used by Authority storage.
|
||||
/// </summary>
|
||||
public string ConnectionString { get; set; } = string.Empty;
|
||||
|
||||
@@ -687,7 +687,7 @@ public sealed class AuthorityStorageOptions
|
||||
public string? DatabaseName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Mongo command timeout.
|
||||
/// Command timeout.
|
||||
/// </summary>
|
||||
public TimeSpan CommandTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
@@ -695,7 +695,7 @@ public sealed class AuthorityStorageOptions
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ConnectionString))
|
||||
{
|
||||
throw new InvalidOperationException("Authority storage requires a Mongo connection string.");
|
||||
throw new InvalidOperationException("Authority storage requires a connection string.");
|
||||
}
|
||||
|
||||
if (CommandTimeout <= TimeSpan.Zero)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
namespace StellaOps.Messaging.Testing.Builders;
|
||||
|
||||
/// <summary>
|
||||
/// Builder for creating test messages with customizable properties.
|
||||
/// </summary>
|
||||
/// <typeparam name="TMessage">The message type.</typeparam>
|
||||
public sealed class TestMessageBuilder<TMessage> where TMessage : class, new()
|
||||
{
|
||||
private readonly Dictionary<string, object?> _properties = new();
|
||||
|
||||
/// <summary>
|
||||
/// Sets a property on the message.
|
||||
/// </summary>
|
||||
public TestMessageBuilder<TMessage> With(string propertyName, object? value)
|
||||
{
|
||||
_properties[propertyName] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the message with all configured properties.
|
||||
/// </summary>
|
||||
public TMessage Build()
|
||||
{
|
||||
var message = new TMessage();
|
||||
var type = typeof(TMessage);
|
||||
|
||||
foreach (var (propertyName, value) in _properties)
|
||||
{
|
||||
var property = type.GetProperty(propertyName);
|
||||
if (property is not null && property.CanWrite)
|
||||
{
|
||||
property.SetValue(message, value);
|
||||
}
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple test message for queue testing.
|
||||
/// </summary>
|
||||
public sealed record TestQueueMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the message ID.
|
||||
/// </summary>
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the message content.
|
||||
/// </summary>
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the priority.
|
||||
/// </summary>
|
||||
public int Priority { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the tenant ID.
|
||||
/// </summary>
|
||||
public string? TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when the message was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets custom metadata.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Metadata { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for creating test messages.
|
||||
/// </summary>
|
||||
public static class TestMessageExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new test queue message with the specified content.
|
||||
/// </summary>
|
||||
public static TestQueueMessage CreateTestMessage(this string content, string? tenantId = null)
|
||||
{
|
||||
return new TestQueueMessage
|
||||
{
|
||||
Content = content,
|
||||
TenantId = tenantId
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates multiple test messages for batch testing.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<TestQueueMessage> CreateTestMessages(int count, string? tenantId = null)
|
||||
{
|
||||
return Enumerable.Range(1, count)
|
||||
.Select(i => new TestQueueMessage
|
||||
{
|
||||
Content = $"Test message {i}",
|
||||
TenantId = tenantId,
|
||||
Priority = i % 3
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Messaging.Transport.InMemory;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Messaging.Testing.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// xUnit fixture for in-memory messaging transport.
|
||||
/// Provides fast, isolated test infrastructure without external dependencies.
|
||||
/// </summary>
|
||||
public sealed class InMemoryMessagingFixture : IAsyncLifetime
|
||||
{
|
||||
private InMemoryQueueRegistry _registry = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the shared queue registry for test coordination.
|
||||
/// </summary>
|
||||
public InMemoryQueueRegistry Registry => _registry;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask InitializeAsync()
|
||||
{
|
||||
_registry = new InMemoryQueueRegistry();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_registry.Clear();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a service provider configured with in-memory transport.
|
||||
/// </summary>
|
||||
/// <returns>A configured service provider.</returns>
|
||||
public IServiceProvider CreateServiceProvider()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSingleton(_registry);
|
||||
services.AddSingleton<IMessageQueueFactory, InMemoryMessageQueueFactory>();
|
||||
services.AddSingleton<IDistributedCacheFactory, InMemoryCacheFactory>();
|
||||
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a message queue factory using in-memory transport.
|
||||
/// </summary>
|
||||
public IMessageQueueFactory CreateQueueFactory()
|
||||
{
|
||||
var sp = CreateServiceProvider();
|
||||
return sp.GetRequiredService<IMessageQueueFactory>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a cache factory using in-memory transport.
|
||||
/// </summary>
|
||||
public IDistributedCacheFactory CreateCacheFactory()
|
||||
{
|
||||
var sp = CreateServiceProvider();
|
||||
return sp.GetRequiredService<IDistributedCacheFactory>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all queues and caches in the registry.
|
||||
/// Call this between tests for isolation.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
_registry.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for in-memory messaging fixture sharing across test classes.
|
||||
/// </summary>
|
||||
[CollectionDefinition(nameof(InMemoryMessagingFixtureCollection))]
|
||||
public class InMemoryMessagingFixtureCollection : ICollectionFixture<InMemoryMessagingFixture>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Messaging.Transport.Postgres;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Messaging.Testing.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// xUnit fixture for PostgreSQL testcontainer.
|
||||
/// Provides a containerized PostgreSQL instance for queue integration tests.
|
||||
/// </summary>
|
||||
public sealed class PostgresQueueFixture : IAsyncLifetime
|
||||
{
|
||||
private readonly PostgreSqlContainer _container;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the connection string for the PostgreSQL instance.
|
||||
/// </summary>
|
||||
public string ConnectionString => _container.GetConnectionString();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PostgresQueueFixture"/> class.
|
||||
/// </summary>
|
||||
public PostgresQueueFixture()
|
||||
{
|
||||
_container = new PostgreSqlBuilder()
|
||||
.WithImage("postgres:16-alpine")
|
||||
.WithDatabase("stellaops_messaging_test")
|
||||
.WithUsername("test")
|
||||
.WithPassword("test")
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _container.StartAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _container.StopAsync();
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a service provider configured with PostgreSQL transport.
|
||||
/// </summary>
|
||||
/// <param name="configureOptions">Optional configuration for PostgreSQL options.</param>
|
||||
/// <returns>A configured service provider.</returns>
|
||||
public IServiceProvider CreateServiceProvider(Action<PostgresTransportOptions>? configureOptions = null)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddOptions<PostgresTransportOptions>().Configure(options =>
|
||||
{
|
||||
options.ConnectionString = ConnectionString;
|
||||
options.Schema = "messaging";
|
||||
options.AutoCreateTables = true;
|
||||
configureOptions?.Invoke(options);
|
||||
});
|
||||
|
||||
services.AddSingleton<PostgresConnectionFactory>();
|
||||
services.AddSingleton<IMessageQueueFactory, PostgresMessageQueueFactory>();
|
||||
services.AddSingleton<IDistributedCacheFactory, PostgresCacheFactory>();
|
||||
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a message queue factory using PostgreSQL transport.
|
||||
/// </summary>
|
||||
public IMessageQueueFactory CreateQueueFactory()
|
||||
{
|
||||
var sp = CreateServiceProvider();
|
||||
return sp.GetRequiredService<IMessageQueueFactory>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a cache factory using PostgreSQL transport.
|
||||
/// </summary>
|
||||
public IDistributedCacheFactory CreateCacheFactory()
|
||||
{
|
||||
var sp = CreateServiceProvider();
|
||||
return sp.GetRequiredService<IDistributedCacheFactory>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for PostgreSQL fixture sharing across test classes.
|
||||
/// </summary>
|
||||
[CollectionDefinition(nameof(PostgresQueueFixtureCollection))]
|
||||
public class PostgresQueueFixtureCollection : ICollectionFixture<PostgresQueueFixture>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using DotNet.Testcontainers.Containers;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Messaging.Transport.Valkey;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Messaging.Testing.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// xUnit fixture for Valkey testcontainer.
|
||||
/// Provides a containerized Valkey instance for integration tests.
|
||||
/// </summary>
|
||||
public sealed class ValkeyFixture : IAsyncLifetime
|
||||
{
|
||||
private readonly IContainer _container;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the connection string for the Valkey instance.
|
||||
/// </summary>
|
||||
public string ConnectionString { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the host of the Valkey instance.
|
||||
/// </summary>
|
||||
public string Host => _container.Hostname;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the mapped port for the Valkey instance.
|
||||
/// </summary>
|
||||
public ushort Port { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ValkeyFixture"/> class.
|
||||
/// </summary>
|
||||
public ValkeyFixture()
|
||||
{
|
||||
_container = new ContainerBuilder()
|
||||
.WithImage("valkey/valkey:8-alpine")
|
||||
.WithPortBinding(6379, true)
|
||||
.WithWaitStrategy(Wait.ForUnixContainer()
|
||||
.UntilPortIsAvailable(6379))
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _container.StartAsync();
|
||||
Port = _container.GetMappedPublicPort(6379);
|
||||
ConnectionString = $"{Host}:{Port}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _container.StopAsync();
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a service provider configured with Valkey transport.
|
||||
/// </summary>
|
||||
/// <param name="configureOptions">Optional configuration for Valkey options.</param>
|
||||
/// <returns>A configured service provider.</returns>
|
||||
public IServiceProvider CreateServiceProvider(Action<ValkeyTransportOptions>? configureOptions = null)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddOptions<ValkeyTransportOptions>().Configure(options =>
|
||||
{
|
||||
options.ConnectionString = ConnectionString;
|
||||
configureOptions?.Invoke(options);
|
||||
});
|
||||
|
||||
services.AddSingleton<ValkeyConnectionFactory>();
|
||||
services.AddSingleton<IMessageQueueFactory, ValkeyMessageQueueFactory>();
|
||||
services.AddSingleton<IDistributedCacheFactory, ValkeyCacheFactory>();
|
||||
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a message queue factory using Valkey transport.
|
||||
/// </summary>
|
||||
public IMessageQueueFactory CreateQueueFactory()
|
||||
{
|
||||
var sp = CreateServiceProvider();
|
||||
return sp.GetRequiredService<IMessageQueueFactory>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a cache factory using Valkey transport.
|
||||
/// </summary>
|
||||
public IDistributedCacheFactory CreateCacheFactory()
|
||||
{
|
||||
var sp = CreateServiceProvider();
|
||||
return sp.GetRequiredService<IDistributedCacheFactory>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for Valkey fixture sharing across test classes.
|
||||
/// </summary>
|
||||
[CollectionDefinition(nameof(ValkeyFixtureCollection))]
|
||||
public class ValkeyFixtureCollection : ICollectionFixture<ValkeyFixture>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Messaging.Testing</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Messaging\StellaOps.Messaging.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Messaging.Transport.Valkey\StellaOps.Messaging.Transport.Valkey.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Messaging.Transport.Postgres\StellaOps.Messaging.Transport.Postgres.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Messaging.Transport.InMemory\StellaOps.Messaging.Transport.InMemory.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Testcontainers" Version="4.4.0" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="4.4.0" />
|
||||
<PackageReference Include="xunit.v3.extensibility.core" Version="2.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,192 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IAtomicTokenStore{TPayload}"/>.
|
||||
/// Provides atomic token issuance and consumption.
|
||||
/// </summary>
|
||||
public sealed class InMemoryAtomicTokenStore<TPayload> : IAtomicTokenStore<TPayload>
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, TokenEntry<TPayload>> _store;
|
||||
private readonly string _name;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryAtomicTokenStore(
|
||||
InMemoryQueueRegistry registry,
|
||||
string name,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_store = registry.GetOrCreateTokenStore<TPayload>(name);
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<TokenIssueResult> IssueAsync(
|
||||
string key,
|
||||
TPayload payload,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.Add(ttl);
|
||||
|
||||
// Generate secure random token
|
||||
var tokenBytes = new byte[32];
|
||||
RandomNumberGenerator.Fill(tokenBytes);
|
||||
var token = Convert.ToBase64String(tokenBytes);
|
||||
|
||||
var entry = new TokenEntry<TPayload>
|
||||
{
|
||||
Token = token,
|
||||
Payload = payload,
|
||||
IssuedAt = now,
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
|
||||
// Try to add, or update if already exists
|
||||
_store.AddOrUpdate(fullKey, entry, (_, _) => entry);
|
||||
|
||||
return ValueTask.FromResult(TokenIssueResult.Succeeded(token, expiresAt));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<TokenIssueResult> StoreAsync(
|
||||
string key,
|
||||
string token,
|
||||
TPayload payload,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(token);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.Add(ttl);
|
||||
|
||||
var entry = new TokenEntry<TPayload>
|
||||
{
|
||||
Token = token,
|
||||
Payload = payload,
|
||||
IssuedAt = now,
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
|
||||
_store.AddOrUpdate(fullKey, entry, (_, _) => entry);
|
||||
|
||||
return ValueTask.FromResult(TokenIssueResult.Succeeded(token, expiresAt));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<TokenConsumeResult<TPayload>> TryConsumeAsync(
|
||||
string key,
|
||||
string expectedToken,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(expectedToken);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Try to get and remove atomically
|
||||
if (!_store.TryGetValue(fullKey, out var entry))
|
||||
{
|
||||
return ValueTask.FromResult(TokenConsumeResult<TPayload>.NotFound());
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if (entry.ExpiresAt < now)
|
||||
{
|
||||
_store.TryRemove(fullKey, out _);
|
||||
return ValueTask.FromResult(TokenConsumeResult<TPayload>.Expired(entry.IssuedAt, entry.ExpiresAt));
|
||||
}
|
||||
|
||||
// Check token match
|
||||
if (!string.Equals(entry.Token, expectedToken, StringComparison.Ordinal))
|
||||
{
|
||||
return ValueTask.FromResult(TokenConsumeResult<TPayload>.Mismatch());
|
||||
}
|
||||
|
||||
// Atomically remove if token still matches
|
||||
if (_store.TryRemove(fullKey, out var removed) && string.Equals(removed.Token, expectedToken, StringComparison.Ordinal))
|
||||
{
|
||||
return ValueTask.FromResult(TokenConsumeResult<TPayload>.Success(
|
||||
removed.Payload,
|
||||
removed.IssuedAt,
|
||||
removed.ExpiresAt));
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(TokenConsumeResult<TPayload>.NotFound());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> ExistsAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
if (_store.TryGetValue(fullKey, out var entry))
|
||||
{
|
||||
if (entry.ExpiresAt < now)
|
||||
{
|
||||
_store.TryRemove(fullKey, out _);
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> RevokeAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
return ValueTask.FromResult(_store.TryRemove(fullKey, out _));
|
||||
}
|
||||
|
||||
private string BuildKey(string key) => $"{_name}:{key}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating in-memory atomic token store instances.
|
||||
/// </summary>
|
||||
public sealed class InMemoryAtomicTokenStoreFactory : IAtomicTokenStoreFactory
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryAtomicTokenStoreFactory(
|
||||
InMemoryQueueRegistry registry,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAtomicTokenStore<TPayload> Create<TPayload>(string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new InMemoryAtomicTokenStore<TPayload>(_registry, name, _timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating in-memory distributed cache instances.
|
||||
/// </summary>
|
||||
public sealed class InMemoryCacheFactory : IDistributedCacheFactory
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryCacheFactory(
|
||||
InMemoryQueueRegistry registry,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDistributedCache<TKey, TValue> Create<TKey, TValue>(CacheOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return new InMemoryCacheStore<TKey, TValue>(_registry, options, null, _timeProvider);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDistributedCache<TValue> Create<TValue>(CacheOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return new InMemoryCacheStore<TValue>(_registry, options, _timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IDistributedCache{TKey, TValue}"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TKey">The key type.</typeparam>
|
||||
/// <typeparam name="TValue">The value type.</typeparam>
|
||||
public sealed class InMemoryCacheStore<TKey, TValue> : IDistributedCache<TKey, TValue>
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly CacheOptions _options;
|
||||
private readonly Func<TKey, string> _keySerializer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryCacheStore(
|
||||
InMemoryQueueRegistry registry,
|
||||
CacheOptions options,
|
||||
Func<TKey, string>? keySerializer = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_keySerializer = keySerializer ?? (key => key?.ToString() ?? throw new ArgumentNullException(nameof(key)));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
private string CacheName => _options.KeyPrefix ?? "default";
|
||||
private ConcurrentDictionary<string, object> Cache => _registry.GetOrCreateCache(CacheName);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<CacheResult<TValue>> GetAsync(TKey key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = BuildKey(key);
|
||||
|
||||
if (Cache.TryGetValue(cacheKey, out var obj) && obj is CacheEntry<TValue> entry)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Check expiration
|
||||
if (entry.ExpiresAt.HasValue && entry.ExpiresAt.Value < now)
|
||||
{
|
||||
Cache.TryRemove(cacheKey, out _);
|
||||
return ValueTask.FromResult(CacheResult<TValue>.Miss());
|
||||
}
|
||||
|
||||
// Handle sliding expiration
|
||||
if (_options.SlidingExpiration && _options.DefaultTtl.HasValue)
|
||||
{
|
||||
entry.ExpiresAt = now.Add(_options.DefaultTtl.Value);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(CacheResult<TValue>.Found(entry.Value));
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(CacheResult<TValue>.Miss());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask SetAsync(
|
||||
TKey key,
|
||||
TValue value,
|
||||
CacheEntryOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
DateTimeOffset? expiresAt = null;
|
||||
|
||||
if (options?.TimeToLive.HasValue == true)
|
||||
{
|
||||
expiresAt = now.Add(options.TimeToLive.Value);
|
||||
}
|
||||
else if (options?.AbsoluteExpiration.HasValue == true)
|
||||
{
|
||||
expiresAt = options.AbsoluteExpiration.Value;
|
||||
}
|
||||
else if (_options.DefaultTtl.HasValue)
|
||||
{
|
||||
expiresAt = now.Add(_options.DefaultTtl.Value);
|
||||
}
|
||||
|
||||
var entry = new CacheEntry<TValue> { Value = value, ExpiresAt = expiresAt };
|
||||
Cache[cacheKey] = entry;
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> InvalidateAsync(TKey key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = BuildKey(key);
|
||||
return ValueTask.FromResult(Cache.TryRemove(cacheKey, out _));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<long> InvalidateByPatternAsync(string pattern, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Simple pattern matching - supports * at start or end
|
||||
var prefix = _options.KeyPrefix ?? string.Empty;
|
||||
var fullPattern = $"{prefix}{pattern}";
|
||||
|
||||
long count = 0;
|
||||
foreach (var key in Cache.Keys.ToList())
|
||||
{
|
||||
if (MatchesPattern(key, fullPattern))
|
||||
{
|
||||
if (Cache.TryRemove(key, out _))
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(count);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<TValue> GetOrSetAsync(
|
||||
TKey key,
|
||||
Func<CancellationToken, ValueTask<TValue>> factory,
|
||||
CacheEntryOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await GetAsync(key, cancellationToken).ConfigureAwait(false);
|
||||
if (result.HasValue)
|
||||
{
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
var value = await factory(cancellationToken).ConfigureAwait(false);
|
||||
await SetAsync(key, value, options, cancellationToken).ConfigureAwait(false);
|
||||
return value;
|
||||
}
|
||||
|
||||
private string BuildKey(TKey key)
|
||||
{
|
||||
var keyString = _keySerializer(key);
|
||||
return string.IsNullOrWhiteSpace(_options.KeyPrefix)
|
||||
? keyString
|
||||
: $"{_options.KeyPrefix}{keyString}";
|
||||
}
|
||||
|
||||
private static bool MatchesPattern(string input, string pattern)
|
||||
{
|
||||
if (pattern == "*")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pattern.StartsWith('*') && pattern.EndsWith('*'))
|
||||
{
|
||||
return input.Contains(pattern[1..^1], StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (pattern.StartsWith('*'))
|
||||
{
|
||||
return input.EndsWith(pattern[1..], StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (pattern.EndsWith('*'))
|
||||
{
|
||||
return input.StartsWith(pattern[..^1], StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return string.Equals(input, pattern, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private sealed class CacheEntry<T>
|
||||
{
|
||||
public required T Value { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// String-keyed in-memory cache store.
|
||||
/// </summary>
|
||||
/// <typeparam name="TValue">The value type.</typeparam>
|
||||
public sealed class InMemoryCacheStore<TValue> : IDistributedCache<TValue>
|
||||
{
|
||||
private readonly InMemoryCacheStore<string, TValue> _inner;
|
||||
|
||||
public InMemoryCacheStore(
|
||||
InMemoryQueueRegistry registry,
|
||||
CacheOptions options,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_inner = new InMemoryCacheStore<string, TValue>(registry, options, key => key, timeProvider);
|
||||
}
|
||||
|
||||
public string ProviderName => _inner.ProviderName;
|
||||
|
||||
public ValueTask<CacheResult<TValue>> GetAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> _inner.GetAsync(key, cancellationToken);
|
||||
|
||||
public ValueTask SetAsync(string key, TValue value, CacheEntryOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> _inner.SetAsync(key, value, options, cancellationToken);
|
||||
|
||||
public ValueTask<bool> InvalidateAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> _inner.InvalidateAsync(key, cancellationToken);
|
||||
|
||||
public ValueTask<long> InvalidateByPatternAsync(string pattern, CancellationToken cancellationToken = default)
|
||||
=> _inner.InvalidateByPatternAsync(pattern, cancellationToken);
|
||||
|
||||
public ValueTask<TValue> GetOrSetAsync(string key, Func<CancellationToken, ValueTask<TValue>> factory, CacheEntryOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> _inner.GetOrSetAsync(key, factory, options, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IEventStream{TEvent}"/>.
|
||||
/// Provides fire-and-forget event publishing with subscription support.
|
||||
/// </summary>
|
||||
public sealed class InMemoryEventStream<TEvent> : IEventStream<TEvent>
|
||||
where TEvent : class
|
||||
{
|
||||
private readonly EventStreamStore<TEvent> _store;
|
||||
private readonly EventStreamOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryEventStream(
|
||||
InMemoryQueueRegistry registry,
|
||||
EventStreamOptions options,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
_store = registry.GetOrCreateEventStream<TEvent>(options.StreamName);
|
||||
_options = options;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string StreamName => _options.StreamName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<EventPublishResult> PublishAsync(
|
||||
TEvent @event,
|
||||
EventPublishOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(@event);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var entryId = _store.Add(
|
||||
@event,
|
||||
options?.TenantId,
|
||||
options?.CorrelationId,
|
||||
options?.Headers,
|
||||
now);
|
||||
|
||||
// Auto-trim if configured
|
||||
if (_options.MaxLength.HasValue)
|
||||
{
|
||||
_store.Trim(_options.MaxLength.Value);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(EventPublishResult.Succeeded(entryId));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<IReadOnlyList<EventPublishResult>> PublishBatchAsync(
|
||||
IEnumerable<TEvent> events,
|
||||
EventPublishOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(events);
|
||||
|
||||
var results = new List<EventPublishResult>();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
foreach (var @event in events)
|
||||
{
|
||||
var entryId = _store.Add(
|
||||
@event,
|
||||
options?.TenantId,
|
||||
options?.CorrelationId,
|
||||
options?.Headers,
|
||||
now);
|
||||
results.Add(EventPublishResult.Succeeded(entryId));
|
||||
}
|
||||
|
||||
// Auto-trim if configured
|
||||
if (_options.MaxLength.HasValue)
|
||||
{
|
||||
_store.Trim(_options.MaxLength.Value);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<IReadOnlyList<EventPublishResult>>(results);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<StreamEvent<TEvent>> SubscribeAsync(
|
||||
StreamPosition position,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
string? lastEntryId = position.Value == "$" ? null : position.Value;
|
||||
|
||||
// First, yield existing entries after the position
|
||||
if (position.Value != "$")
|
||||
{
|
||||
var existingEntries = _store.GetEntriesAfter(lastEntryId);
|
||||
foreach (var entry in existingEntries)
|
||||
{
|
||||
yield return new StreamEvent<TEvent>(
|
||||
entry.EntryId,
|
||||
entry.Event,
|
||||
entry.Timestamp,
|
||||
entry.TenantId,
|
||||
entry.CorrelationId);
|
||||
lastEntryId = entry.EntryId;
|
||||
}
|
||||
}
|
||||
|
||||
// Then subscribe to new entries
|
||||
var reader = _store.Reader;
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// WaitToReadAsync will throw OperationCanceledException when cancelled,
|
||||
// which will naturally end the async enumeration
|
||||
if (!await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
while (reader.TryRead(out var entry))
|
||||
{
|
||||
// Skip entries we've already seen
|
||||
if (lastEntryId != null && string.CompareOrdinal(entry.EntryId, lastEntryId) <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return new StreamEvent<TEvent>(
|
||||
entry.EntryId,
|
||||
entry.Event,
|
||||
entry.Timestamp,
|
||||
entry.TenantId,
|
||||
entry.CorrelationId);
|
||||
lastEntryId = entry.EntryId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<StreamInfo> GetInfoAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (length, firstId, lastId, firstTs, lastTs) = _store.GetInfo();
|
||||
return ValueTask.FromResult(new StreamInfo(length, firstId, lastId, firstTs, lastTs));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<long> TrimAsync(
|
||||
long maxLength,
|
||||
bool approximate = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.FromResult(_store.Trim(maxLength));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating in-memory event stream instances.
|
||||
/// </summary>
|
||||
public sealed class InMemoryEventStreamFactory : IEventStreamFactory
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryEventStreamFactory(
|
||||
InMemoryQueueRegistry registry,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEventStream<TEvent> Create<TEvent>(EventStreamOptions options) where TEvent : class
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return new InMemoryEventStream<TEvent>(_registry, options, _timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IIdempotencyStore"/>.
|
||||
/// Provides idempotency key management for deduplication.
|
||||
/// </summary>
|
||||
public sealed class InMemoryIdempotencyStore : IIdempotencyStore
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly string _name;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryIdempotencyStore(
|
||||
InMemoryQueueRegistry registry,
|
||||
string name,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<IdempotencyResult> TryClaimAsync(
|
||||
string key,
|
||||
string value,
|
||||
TimeSpan window,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.Add(window);
|
||||
|
||||
// Cleanup expired keys first
|
||||
_registry.CleanupExpiredIdempotencyKeys(now);
|
||||
|
||||
if (_registry.TryClaimIdempotencyKey(fullKey, value, expiresAt, out var existingValue))
|
||||
{
|
||||
return ValueTask.FromResult(IdempotencyResult.Claimed());
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(IdempotencyResult.Duplicate(existingValue ?? string.Empty));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> ExistsAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Cleanup expired keys first
|
||||
_registry.CleanupExpiredIdempotencyKeys(now);
|
||||
|
||||
return ValueTask.FromResult(_registry.IdempotencyKeyExists(fullKey));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<string?> GetAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Cleanup expired keys first
|
||||
_registry.CleanupExpiredIdempotencyKeys(now);
|
||||
|
||||
return ValueTask.FromResult(_registry.GetIdempotencyKey(fullKey));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> ReleaseAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
return ValueTask.FromResult(_registry.ReleaseIdempotencyKey(fullKey));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> ExtendAsync(
|
||||
string key,
|
||||
TimeSpan extension,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
return ValueTask.FromResult(_registry.ExtendIdempotencyKey(fullKey, extension, _timeProvider));
|
||||
}
|
||||
|
||||
private string BuildKey(string key) => $"{_name}:{key}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating in-memory idempotency store instances.
|
||||
/// </summary>
|
||||
public sealed class InMemoryIdempotencyStoreFactory : IIdempotencyStoreFactory
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryIdempotencyStoreFactory(
|
||||
InMemoryQueueRegistry registry,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IIdempotencyStore Create(string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new InMemoryIdempotencyStore(_registry, name, _timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of a message lease.
|
||||
/// </summary>
|
||||
/// <typeparam name="TMessage">The message type.</typeparam>
|
||||
internal sealed class InMemoryMessageLease<TMessage> : IMessageLease<TMessage> where TMessage : class
|
||||
{
|
||||
private readonly InMemoryMessageQueue<TMessage> _queue;
|
||||
private readonly InMemoryQueueEntry<TMessage> _entry;
|
||||
private int _completed;
|
||||
|
||||
internal InMemoryMessageLease(
|
||||
InMemoryMessageQueue<TMessage> queue,
|
||||
InMemoryQueueEntry<TMessage> entry,
|
||||
DateTimeOffset leaseExpiresAt,
|
||||
string consumer)
|
||||
{
|
||||
_queue = queue;
|
||||
_entry = entry;
|
||||
LeaseExpiresAt = leaseExpiresAt;
|
||||
Consumer = consumer;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string MessageId => _entry.MessageId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public TMessage Message => _entry.Message;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Attempt => _entry.Attempt;
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset EnqueuedAt => _entry.EnqueuedAt;
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset LeaseExpiresAt { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Consumer { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? TenantId => _entry.TenantId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? CorrelationId => _entry.CorrelationId;
|
||||
|
||||
internal InMemoryQueueEntry<TMessage> Entry => _entry;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask AcknowledgeAsync(CancellationToken cancellationToken = default)
|
||||
=> _queue.AcknowledgeAsync(this, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask RenewAsync(TimeSpan extension, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LeaseExpiresAt = DateTimeOffset.UtcNow.Add(extension);
|
||||
_entry.LeaseExpiresAt = LeaseExpiresAt;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask ReleaseAsync(ReleaseDisposition disposition, CancellationToken cancellationToken = default)
|
||||
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
|
||||
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
internal bool TryBeginCompletion()
|
||||
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
|
||||
|
||||
internal void IncrementAttempt()
|
||||
=> _entry.Attempt++;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IMessageQueue{TMessage}"/>.
|
||||
/// Useful for testing and development scenarios.
|
||||
/// </summary>
|
||||
/// <typeparam name="TMessage">The message type.</typeparam>
|
||||
public sealed class InMemoryMessageQueue<TMessage> : IMessageQueue<TMessage>
|
||||
where TMessage : class
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly MessageQueueOptions _options;
|
||||
private readonly ILogger<InMemoryMessageQueue<TMessage>>? _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _idempotencyKeys = new(StringComparer.Ordinal);
|
||||
|
||||
private long _messageIdCounter;
|
||||
|
||||
public InMemoryMessageQueue(
|
||||
InMemoryQueueRegistry registry,
|
||||
MessageQueueOptions options,
|
||||
ILogger<InMemoryMessageQueue<TMessage>>? logger = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string QueueName => _options.QueueName;
|
||||
|
||||
private Channel<InMemoryQueueEntry<TMessage>> Queue => _registry.GetOrCreateQueue<TMessage>(_options.QueueName);
|
||||
private ConcurrentDictionary<string, InMemoryQueueEntry<TMessage>> Pending => _registry.GetOrCreatePending<TMessage>(_options.QueueName);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<EnqueueResult> EnqueueAsync(
|
||||
TMessage message,
|
||||
EnqueueOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
// Check idempotency
|
||||
if (!string.IsNullOrWhiteSpace(options?.IdempotencyKey))
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
if (_idempotencyKeys.TryGetValue(options.IdempotencyKey, out var existingTime))
|
||||
{
|
||||
if (now - existingTime < _options.IdempotencyWindow)
|
||||
{
|
||||
return EnqueueResult.Duplicate($"inmem-{options.IdempotencyKey}");
|
||||
}
|
||||
}
|
||||
_idempotencyKeys[options.IdempotencyKey] = now;
|
||||
}
|
||||
|
||||
var messageId = $"inmem-{Interlocked.Increment(ref _messageIdCounter)}";
|
||||
var entry = new InMemoryQueueEntry<TMessage>
|
||||
{
|
||||
MessageId = messageId,
|
||||
Message = message,
|
||||
Attempt = 1,
|
||||
EnqueuedAt = _timeProvider.GetUtcNow(),
|
||||
TenantId = options?.TenantId,
|
||||
CorrelationId = options?.CorrelationId,
|
||||
IdempotencyKey = options?.IdempotencyKey,
|
||||
Headers = options?.Headers
|
||||
};
|
||||
|
||||
await Queue.Writer.WriteAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
_logger?.LogDebug("Enqueued message {MessageId} to queue {Queue}", messageId, _options.QueueName);
|
||||
|
||||
return EnqueueResult.Succeeded(messageId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlyList<IMessageLease<TMessage>>> LeaseAsync(
|
||||
LeaseRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var consumer = _options.ConsumerName ?? $"{Environment.MachineName}-{Environment.ProcessId}";
|
||||
var leases = new List<IMessageLease<TMessage>>(request.BatchSize);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var leaseDuration = request.LeaseDuration ?? _options.DefaultLeaseDuration;
|
||||
|
||||
// First check pending (for redeliveries)
|
||||
if (request.PendingOnly)
|
||||
{
|
||||
foreach (var kvp in Pending)
|
||||
{
|
||||
if (leases.Count >= request.BatchSize)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var entry = kvp.Value;
|
||||
if (entry.LeaseExpiresAt.HasValue && entry.LeaseExpiresAt.Value < now)
|
||||
{
|
||||
// Expired lease - claim it
|
||||
entry.LeasedBy = consumer;
|
||||
entry.LeaseExpiresAt = now.Add(leaseDuration);
|
||||
entry.Attempt++;
|
||||
|
||||
leases.Add(new InMemoryMessageLease<TMessage>(this, entry, entry.LeaseExpiresAt.Value, consumer));
|
||||
}
|
||||
}
|
||||
return leases;
|
||||
}
|
||||
|
||||
// Try to read new messages
|
||||
for (var i = 0; i < request.BatchSize; i++)
|
||||
{
|
||||
if (Queue.Reader.TryRead(out var entry))
|
||||
{
|
||||
entry.LeasedBy = consumer;
|
||||
entry.LeaseExpiresAt = now.Add(leaseDuration);
|
||||
Pending[entry.MessageId] = entry;
|
||||
|
||||
leases.Add(new InMemoryMessageLease<TMessage>(this, entry, entry.LeaseExpiresAt.Value, consumer));
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return leases;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<IReadOnlyList<IMessageLease<TMessage>>> ClaimExpiredAsync(
|
||||
ClaimRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var consumer = _options.ConsumerName ?? $"{Environment.MachineName}-{Environment.ProcessId}";
|
||||
var leases = new List<IMessageLease<TMessage>>(request.BatchSize);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var leaseDuration = request.LeaseDuration ?? _options.DefaultLeaseDuration;
|
||||
|
||||
foreach (var kvp in Pending)
|
||||
{
|
||||
if (leases.Count >= request.BatchSize)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var entry = kvp.Value;
|
||||
if (entry.LeaseExpiresAt.HasValue &&
|
||||
now - entry.LeaseExpiresAt.Value >= request.MinIdleTime &&
|
||||
entry.Attempt >= request.MinDeliveryAttempts)
|
||||
{
|
||||
// Claim it
|
||||
entry.LeasedBy = consumer;
|
||||
entry.LeaseExpiresAt = now.Add(leaseDuration);
|
||||
entry.Attempt++;
|
||||
|
||||
leases.Add(new InMemoryMessageLease<TMessage>(this, entry, entry.LeaseExpiresAt.Value, consumer));
|
||||
}
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<IReadOnlyList<IMessageLease<TMessage>>>(leases);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<long> GetPendingCountAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.FromResult((long)Pending.Count);
|
||||
}
|
||||
|
||||
internal ValueTask AcknowledgeAsync(InMemoryMessageLease<TMessage> lease, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!lease.TryBeginCompletion())
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
Pending.TryRemove(lease.MessageId, out _);
|
||||
_logger?.LogDebug("Acknowledged message {MessageId} from queue {Queue}", lease.MessageId, _options.QueueName);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
internal async ValueTask ReleaseAsync(
|
||||
InMemoryMessageLease<TMessage> lease,
|
||||
ReleaseDisposition disposition,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (disposition == ReleaseDisposition.Retry && lease.Attempt >= _options.MaxDeliveryAttempts)
|
||||
{
|
||||
await DeadLetterAsync(lease, $"max-delivery-attempts:{lease.Attempt}", cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!lease.TryBeginCompletion())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Pending.TryRemove(lease.MessageId, out _);
|
||||
|
||||
if (disposition == ReleaseDisposition.Retry)
|
||||
{
|
||||
lease.IncrementAttempt();
|
||||
lease.Entry.LeasedBy = null;
|
||||
lease.Entry.LeaseExpiresAt = null;
|
||||
|
||||
// Re-enqueue
|
||||
await Queue.Writer.WriteAsync(lease.Entry, cancellationToken).ConfigureAwait(false);
|
||||
_logger?.LogDebug("Retrying message {MessageId}, attempt {Attempt}", lease.MessageId, lease.Attempt);
|
||||
}
|
||||
}
|
||||
|
||||
internal async ValueTask DeadLetterAsync(InMemoryMessageLease<TMessage> lease, string reason, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!lease.TryBeginCompletion())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Pending.TryRemove(lease.MessageId, out _);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.DeadLetterQueue))
|
||||
{
|
||||
var dlqChannel = _registry.GetOrCreateQueue<TMessage>(_options.DeadLetterQueue);
|
||||
lease.Entry.LeasedBy = null;
|
||||
lease.Entry.LeaseExpiresAt = null;
|
||||
await dlqChannel.Writer.WriteAsync(lease.Entry, cancellationToken).ConfigureAwait(false);
|
||||
_logger?.LogWarning("Dead-lettered message {MessageId}: {Reason}", lease.MessageId, reason);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger?.LogWarning("Dropped message {MessageId} (no DLQ configured): {Reason}", lease.MessageId, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating in-memory message queue instances.
|
||||
/// </summary>
|
||||
public sealed class InMemoryMessageQueueFactory : IMessageQueueFactory
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryMessageQueueFactory(
|
||||
InMemoryQueueRegistry registry,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_loggerFactory = loggerFactory;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IMessageQueue<TMessage> Create<TMessage>(MessageQueueOptions options)
|
||||
where TMessage : class
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
return new InMemoryMessageQueue<TMessage>(
|
||||
_registry,
|
||||
options,
|
||||
_loggerFactory?.CreateLogger<InMemoryMessageQueue<TMessage>>(),
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,741 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// Shared registry for in-memory queues. Enables message passing between
|
||||
/// producers and consumers in the same process (useful for testing).
|
||||
/// </summary>
|
||||
public sealed class InMemoryQueueRegistry
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, object> _queues = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, object> _pendingMessages = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, object>> _caches = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, RateLimitBucket> _rateLimitBuckets = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, object> _tokenStores = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, object> _sortedIndexes = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, object> _setStores = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, object> _eventStreams = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, IdempotencyEntry> _idempotencyKeys = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates a queue channel for the specified queue name.
|
||||
/// </summary>
|
||||
public Channel<InMemoryQueueEntry<TMessage>> GetOrCreateQueue<TMessage>(string queueName)
|
||||
where TMessage : class
|
||||
{
|
||||
return (Channel<InMemoryQueueEntry<TMessage>>)_queues.GetOrAdd(
|
||||
queueName,
|
||||
_ => Channel.CreateUnbounded<InMemoryQueueEntry<TMessage>>(
|
||||
new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = false,
|
||||
SingleWriter = false
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates the pending messages dictionary for a queue.
|
||||
/// </summary>
|
||||
public ConcurrentDictionary<string, InMemoryQueueEntry<TMessage>> GetOrCreatePending<TMessage>(string queueName)
|
||||
where TMessage : class
|
||||
{
|
||||
return (ConcurrentDictionary<string, InMemoryQueueEntry<TMessage>>)_pendingMessages.GetOrAdd(
|
||||
queueName,
|
||||
_ => new ConcurrentDictionary<string, InMemoryQueueEntry<TMessage>>(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates a cache dictionary for the specified cache name.
|
||||
/// </summary>
|
||||
public ConcurrentDictionary<string, object> GetOrCreateCache(string cacheName)
|
||||
{
|
||||
return _caches.GetOrAdd(cacheName, _ => new ConcurrentDictionary<string, object>(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all queues and caches (useful for test cleanup).
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_queues.Clear();
|
||||
_pendingMessages.Clear();
|
||||
_caches.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears a specific queue.
|
||||
/// </summary>
|
||||
public void ClearQueue(string queueName)
|
||||
{
|
||||
_queues.TryRemove(queueName, out _);
|
||||
_pendingMessages.TryRemove(queueName, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears a specific cache.
|
||||
/// </summary>
|
||||
public void ClearCache(string cacheName)
|
||||
{
|
||||
_caches.TryRemove(cacheName, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates a rate limit bucket for the specified key.
|
||||
/// </summary>
|
||||
public RateLimitBucket GetOrCreateRateLimitBucket(string key)
|
||||
{
|
||||
return _rateLimitBuckets.GetOrAdd(key, _ => new RateLimitBucket());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a rate limit bucket.
|
||||
/// </summary>
|
||||
public bool RemoveRateLimitBucket(string key)
|
||||
{
|
||||
return _rateLimitBuckets.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates a token store for the specified name.
|
||||
/// </summary>
|
||||
public ConcurrentDictionary<string, TokenEntry<TPayload>> GetOrCreateTokenStore<TPayload>(string name)
|
||||
{
|
||||
return (ConcurrentDictionary<string, TokenEntry<TPayload>>)_tokenStores.GetOrAdd(
|
||||
name,
|
||||
_ => new ConcurrentDictionary<string, TokenEntry<TPayload>>(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates a sorted index for the specified name.
|
||||
/// </summary>
|
||||
public SortedIndexStore<TKey, TElement> GetOrCreateSortedIndex<TKey, TElement>(string name)
|
||||
where TKey : notnull
|
||||
where TElement : notnull
|
||||
{
|
||||
return (SortedIndexStore<TKey, TElement>)_sortedIndexes.GetOrAdd(
|
||||
name,
|
||||
_ => new SortedIndexStore<TKey, TElement>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates a set store for the specified name.
|
||||
/// </summary>
|
||||
public SetStoreData<TKey, TElement> GetOrCreateSetStore<TKey, TElement>(string name)
|
||||
where TKey : notnull
|
||||
{
|
||||
return (SetStoreData<TKey, TElement>)_setStores.GetOrAdd(
|
||||
name,
|
||||
_ => new SetStoreData<TKey, TElement>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates an event stream for the specified name.
|
||||
/// </summary>
|
||||
public EventStreamStore<TEvent> GetOrCreateEventStream<TEvent>(string name)
|
||||
where TEvent : class
|
||||
{
|
||||
return (EventStreamStore<TEvent>)_eventStreams.GetOrAdd(
|
||||
name,
|
||||
_ => new EventStreamStore<TEvent>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to claim an idempotency key.
|
||||
/// </summary>
|
||||
public bool TryClaimIdempotencyKey(string key, string value, DateTimeOffset expiresAt, out string? existingValue)
|
||||
{
|
||||
var entry = new IdempotencyEntry { Value = value, ExpiresAt = expiresAt };
|
||||
|
||||
if (_idempotencyKeys.TryAdd(key, entry))
|
||||
{
|
||||
existingValue = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_idempotencyKeys.TryGetValue(key, out var existing))
|
||||
{
|
||||
existingValue = existing.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
existingValue = null;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an idempotency key exists.
|
||||
/// </summary>
|
||||
public bool IdempotencyKeyExists(string key)
|
||||
{
|
||||
return _idempotencyKeys.ContainsKey(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an idempotency key value.
|
||||
/// </summary>
|
||||
public string? GetIdempotencyKey(string key)
|
||||
{
|
||||
return _idempotencyKeys.TryGetValue(key, out var entry) ? entry.Value : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases an idempotency key.
|
||||
/// </summary>
|
||||
public bool ReleaseIdempotencyKey(string key)
|
||||
{
|
||||
return _idempotencyKeys.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extends an idempotency key's expiration.
|
||||
/// </summary>
|
||||
public bool ExtendIdempotencyKey(string key, TimeSpan extension, TimeProvider timeProvider)
|
||||
{
|
||||
if (_idempotencyKeys.TryGetValue(key, out var entry))
|
||||
{
|
||||
entry.ExpiresAt = timeProvider.GetUtcNow().Add(extension);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up expired idempotency keys.
|
||||
/// </summary>
|
||||
public void CleanupExpiredIdempotencyKeys(DateTimeOffset now)
|
||||
{
|
||||
var expiredKeys = _idempotencyKeys
|
||||
.Where(kvp => kvp.Value.ExpiresAt < now)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in expiredKeys)
|
||||
{
|
||||
_idempotencyKeys.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rate limit bucket for sliding window tracking.
|
||||
/// </summary>
|
||||
public sealed class RateLimitBucket
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly List<DateTimeOffset> _timestamps = [];
|
||||
|
||||
public int GetCount(DateTimeOffset windowStart)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
CleanupOld(windowStart);
|
||||
return _timestamps.Count;
|
||||
}
|
||||
}
|
||||
|
||||
public int Increment(DateTimeOffset now, DateTimeOffset windowStart)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
CleanupOld(windowStart);
|
||||
_timestamps.Add(now);
|
||||
return _timestamps.Count;
|
||||
}
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_timestamps.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private void CleanupOld(DateTimeOffset windowStart)
|
||||
{
|
||||
_timestamps.RemoveAll(t => t < windowStart);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Token entry for atomic token store.
|
||||
/// </summary>
|
||||
public sealed class TokenEntry<TPayload>
|
||||
{
|
||||
public required string Token { get; init; }
|
||||
public required TPayload Payload { get; init; }
|
||||
public required DateTimeOffset IssuedAt { get; init; }
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Idempotency entry.
|
||||
/// </summary>
|
||||
public sealed class IdempotencyEntry
|
||||
{
|
||||
public required string Value { get; init; }
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sorted index storage with score-based ordering.
|
||||
/// </summary>
|
||||
public sealed class SortedIndexStore<TKey, TElement> where TKey : notnull where TElement : notnull
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly Dictionary<TKey, SortedIndexData<TElement>> _indexes = [];
|
||||
|
||||
public SortedIndexData<TElement> GetOrCreateIndex(TKey key)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_indexes.TryGetValue(key, out var index))
|
||||
{
|
||||
index = new SortedIndexData<TElement>();
|
||||
_indexes[key] = index;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetIndex(TKey key, out SortedIndexData<TElement>? index)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _indexes.TryGetValue(key, out index);
|
||||
}
|
||||
}
|
||||
|
||||
public bool RemoveIndex(TKey key)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _indexes.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetExpiration(TKey key, DateTimeOffset expiresAt)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_indexes.TryGetValue(key, out var index))
|
||||
{
|
||||
index.ExpiresAt = expiresAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Data for a single sorted index.
|
||||
/// </summary>
|
||||
public sealed class SortedIndexData<TElement> where TElement : notnull
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly SortedList<double, List<TElement>> _byScore = [];
|
||||
private readonly Dictionary<TElement, double> _elementScores = [];
|
||||
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
|
||||
public bool Add(TElement element, double score)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var isNew = true;
|
||||
|
||||
// Remove existing entry if present
|
||||
if (_elementScores.TryGetValue(element, out var oldScore))
|
||||
{
|
||||
isNew = false;
|
||||
if (_byScore.TryGetValue(oldScore, out var oldList))
|
||||
{
|
||||
oldList.Remove(element);
|
||||
if (oldList.Count == 0)
|
||||
{
|
||||
_byScore.Remove(oldScore);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add new entry
|
||||
_elementScores[element] = score;
|
||||
if (!_byScore.TryGetValue(score, out var list))
|
||||
{
|
||||
list = [];
|
||||
_byScore[score] = list;
|
||||
}
|
||||
list.Add(element);
|
||||
|
||||
return isNew;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Remove(TElement element)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_elementScores.TryGetValue(element, out var score))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_elementScores.Remove(element);
|
||||
if (_byScore.TryGetValue(score, out var list))
|
||||
{
|
||||
list.Remove(element);
|
||||
if (list.Count == 0)
|
||||
{
|
||||
_byScore.Remove(score);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public long RemoveByScoreRange(double minScore, double maxScore)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var toRemove = _byScore
|
||||
.Where(kvp => kvp.Key >= minScore && kvp.Key <= maxScore)
|
||||
.SelectMany(kvp => kvp.Value)
|
||||
.ToList();
|
||||
|
||||
foreach (var element in toRemove)
|
||||
{
|
||||
Remove(element);
|
||||
}
|
||||
|
||||
return toRemove.Count;
|
||||
}
|
||||
}
|
||||
|
||||
public double? GetScore(TElement element)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _elementScores.TryGetValue(element, out var score) ? score : null;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<(TElement Element, double Score)> GetByRank(long start, long stop, bool ascending)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var all = ascending
|
||||
? _byScore.SelectMany(kvp => kvp.Value.Select(e => (Element: e, Score: kvp.Key))).ToList()
|
||||
: _byScore.Reverse().SelectMany(kvp => kvp.Value.Select(e => (Element: e, Score: kvp.Key))).ToList();
|
||||
|
||||
var count = all.Count;
|
||||
if (start < 0) start = Math.Max(0, count + start);
|
||||
if (stop < 0) stop = count + stop;
|
||||
stop = Math.Min(stop, count - 1);
|
||||
|
||||
if (start > stop || start >= count)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return all.Skip((int)start).Take((int)(stop - start + 1)).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<(TElement Element, double Score)> GetByScoreRange(double minScore, double maxScore, bool ascending, int? limit)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var filtered = _byScore
|
||||
.Where(kvp => kvp.Key >= minScore && kvp.Key <= maxScore)
|
||||
.SelectMany(kvp => kvp.Value.Select(e => (Element: e, Score: kvp.Key)));
|
||||
|
||||
if (!ascending)
|
||||
{
|
||||
filtered = filtered.Reverse();
|
||||
}
|
||||
|
||||
if (limit.HasValue)
|
||||
{
|
||||
filtered = filtered.Take(limit.Value);
|
||||
}
|
||||
|
||||
return filtered.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public long Count()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _elementScores.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set store data with multiple sets.
|
||||
/// </summary>
|
||||
public sealed class SetStoreData<TKey, TElement> where TKey : notnull
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly Dictionary<TKey, SetData<TElement>> _sets = [];
|
||||
|
||||
public SetData<TElement> GetOrCreateSet(TKey key)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_sets.TryGetValue(key, out var set))
|
||||
{
|
||||
set = new SetData<TElement>();
|
||||
_sets[key] = set;
|
||||
}
|
||||
return set;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetSet(TKey key, out SetData<TElement>? set)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _sets.TryGetValue(key, out set);
|
||||
}
|
||||
}
|
||||
|
||||
public bool RemoveSet(TKey key)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _sets.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetExpiration(TKey key, DateTimeOffset expiresAt)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_sets.TryGetValue(key, out var set))
|
||||
{
|
||||
set.ExpiresAt = expiresAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Data for a single set.
|
||||
/// </summary>
|
||||
public sealed class SetData<TElement>
|
||||
{
|
||||
private readonly HashSet<TElement> _elements = [];
|
||||
private readonly object _lock = new();
|
||||
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
|
||||
public bool Add(TElement element)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _elements.Add(element);
|
||||
}
|
||||
}
|
||||
|
||||
public long AddRange(IEnumerable<TElement> elements)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
long added = 0;
|
||||
foreach (var element in elements)
|
||||
{
|
||||
if (_elements.Add(element))
|
||||
{
|
||||
added++;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Contains(TElement element)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _elements.Contains(element);
|
||||
}
|
||||
}
|
||||
|
||||
public bool Remove(TElement element)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _elements.Remove(element);
|
||||
}
|
||||
}
|
||||
|
||||
public long RemoveRange(IEnumerable<TElement> elements)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
long removed = 0;
|
||||
foreach (var element in elements)
|
||||
{
|
||||
if (_elements.Remove(element))
|
||||
{
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlySet<TElement> GetAll()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return new HashSet<TElement>(_elements);
|
||||
}
|
||||
}
|
||||
|
||||
public long Count()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _elements.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event stream storage with ordered entries.
|
||||
/// </summary>
|
||||
public sealed class EventStreamStore<TEvent> where TEvent : class
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly List<EventStreamEntry<TEvent>> _entries = [];
|
||||
private readonly Channel<EventStreamEntry<TEvent>> _channel;
|
||||
private long _nextSequence = 1;
|
||||
|
||||
public EventStreamStore()
|
||||
{
|
||||
_channel = Channel.CreateUnbounded<EventStreamEntry<TEvent>>(
|
||||
new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = false,
|
||||
SingleWriter = false
|
||||
});
|
||||
}
|
||||
|
||||
public string Add(TEvent @event, string? tenantId, string? correlationId, IReadOnlyDictionary<string, string>? headers, DateTimeOffset timestamp)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var sequence = _nextSequence++;
|
||||
var entryId = $"{timestamp.ToUnixTimeMilliseconds()}-{sequence}";
|
||||
var entry = new EventStreamEntry<TEvent>
|
||||
{
|
||||
EntryId = entryId,
|
||||
Sequence = sequence,
|
||||
Event = @event,
|
||||
Timestamp = timestamp,
|
||||
TenantId = tenantId,
|
||||
CorrelationId = correlationId,
|
||||
Headers = headers
|
||||
};
|
||||
_entries.Add(entry);
|
||||
|
||||
// Notify subscribers
|
||||
_channel.Writer.TryWrite(entry);
|
||||
|
||||
return entryId;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<EventStreamEntry<TEvent>> GetEntriesAfter(string? afterEntryId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (string.IsNullOrEmpty(afterEntryId) || afterEntryId == "0")
|
||||
{
|
||||
return _entries.ToList();
|
||||
}
|
||||
|
||||
var startIndex = _entries.FindIndex(e => e.EntryId == afterEntryId);
|
||||
if (startIndex < 0)
|
||||
{
|
||||
return _entries.ToList();
|
||||
}
|
||||
|
||||
return _entries.Skip(startIndex + 1).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public ChannelReader<EventStreamEntry<TEvent>> Reader => _channel.Reader;
|
||||
|
||||
public (long Length, string? FirstEntryId, string? LastEntryId, DateTimeOffset? FirstTimestamp, DateTimeOffset? LastTimestamp) GetInfo()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_entries.Count == 0)
|
||||
{
|
||||
return (0, null, null, null, null);
|
||||
}
|
||||
|
||||
return (
|
||||
_entries.Count,
|
||||
_entries[0].EntryId,
|
||||
_entries[^1].EntryId,
|
||||
_entries[0].Timestamp,
|
||||
_entries[^1].Timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
public long Trim(long maxLength)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_entries.Count <= maxLength)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var toRemove = (int)(_entries.Count - maxLength);
|
||||
_entries.RemoveRange(0, toRemove);
|
||||
return toRemove;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry in an event stream.
|
||||
/// </summary>
|
||||
public sealed class EventStreamEntry<TEvent> where TEvent : class
|
||||
{
|
||||
public required string EntryId { get; init; }
|
||||
public required long Sequence { get; init; }
|
||||
public required TEvent Event { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public string? TenantId { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Headers { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry stored in an in-memory queue.
|
||||
/// </summary>
|
||||
public sealed class InMemoryQueueEntry<TMessage> where TMessage : class
|
||||
{
|
||||
public required string MessageId { get; init; }
|
||||
public required TMessage Message { get; init; }
|
||||
public required int Attempt { get; set; }
|
||||
public required DateTimeOffset EnqueuedAt { get; init; }
|
||||
public string? TenantId { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
public string? IdempotencyKey { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Headers { get; init; }
|
||||
|
||||
// Lease tracking
|
||||
public string? LeasedBy { get; set; }
|
||||
public DateTimeOffset? LeaseExpiresAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IRateLimiter"/>.
|
||||
/// Uses sliding window algorithm for rate limiting.
|
||||
/// </summary>
|
||||
public sealed class InMemoryRateLimiter : IRateLimiter
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly string _name;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryRateLimiter(
|
||||
InMemoryQueueRegistry registry,
|
||||
string name,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<RateLimitResult> TryAcquireAsync(
|
||||
string key,
|
||||
RateLimitPolicy policy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
var bucket = _registry.GetOrCreateRateLimitBucket(fullKey);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var windowStart = now - policy.Window;
|
||||
|
||||
var currentCount = bucket.GetCount(windowStart);
|
||||
|
||||
if (currentCount >= policy.MaxPermits)
|
||||
{
|
||||
// Denied - calculate retry after
|
||||
var retryAfter = policy.Window; // Simplified - actual implementation could track exact timestamps
|
||||
return ValueTask.FromResult(RateLimitResult.Denied(currentCount, retryAfter));
|
||||
}
|
||||
|
||||
// Increment and allow
|
||||
var newCount = bucket.Increment(now, windowStart);
|
||||
var remaining = Math.Max(0, policy.MaxPermits - newCount);
|
||||
return ValueTask.FromResult(RateLimitResult.Allowed(newCount, remaining));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<RateLimitStatus> GetStatusAsync(
|
||||
string key,
|
||||
RateLimitPolicy policy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
var bucket = _registry.GetOrCreateRateLimitBucket(fullKey);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var windowStart = now - policy.Window;
|
||||
|
||||
var currentCount = bucket.GetCount(windowStart);
|
||||
var remaining = Math.Max(0, policy.MaxPermits - currentCount);
|
||||
|
||||
return ValueTask.FromResult(new RateLimitStatus
|
||||
{
|
||||
CurrentCount = currentCount,
|
||||
RemainingPermits = remaining,
|
||||
WindowRemaining = policy.Window, // Simplified
|
||||
Exists = true
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> ResetAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
return ValueTask.FromResult(_registry.RemoveRateLimitBucket(fullKey));
|
||||
}
|
||||
|
||||
private string BuildKey(string key) => $"{_name}:{key}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating in-memory rate limiter instances.
|
||||
/// </summary>
|
||||
public sealed class InMemoryRateLimiterFactory : IRateLimiterFactory
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryRateLimiterFactory(
|
||||
InMemoryQueueRegistry registry,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IRateLimiter Create(string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new InMemoryRateLimiter(_registry, name, _timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="ISetStore{TKey, TElement}"/>.
|
||||
/// Provides unordered set operations.
|
||||
/// </summary>
|
||||
public sealed class InMemorySetStore<TKey, TElement> : ISetStore<TKey, TElement>
|
||||
where TKey : notnull
|
||||
{
|
||||
private readonly SetStoreData<TKey, TElement> _store;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemorySetStore(
|
||||
InMemoryQueueRegistry registry,
|
||||
string name,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
|
||||
_store = registry.GetOrCreateSetStore<TKey, TElement>(name);
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> AddAsync(
|
||||
TKey setKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var set = _store.GetOrCreateSet(setKey);
|
||||
return ValueTask.FromResult(set.Add(element));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<long> AddRangeAsync(
|
||||
TKey setKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
var set = _store.GetOrCreateSet(setKey);
|
||||
return ValueTask.FromResult(set.AddRange(elements));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<IReadOnlySet<TElement>> GetMembersAsync(
|
||||
TKey setKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.TryGetSet(setKey, out var set) || set is null)
|
||||
{
|
||||
return ValueTask.FromResult<IReadOnlySet<TElement>>(new HashSet<TElement>());
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(set.GetAll());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> ContainsAsync(
|
||||
TKey setKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.TryGetSet(setKey, out var set) || set is null)
|
||||
{
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(set.Contains(element));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> RemoveAsync(
|
||||
TKey setKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.TryGetSet(setKey, out var set) || set is null)
|
||||
{
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(set.Remove(element));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<long> RemoveRangeAsync(
|
||||
TKey setKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
if (!_store.TryGetSet(setKey, out var set) || set is null)
|
||||
{
|
||||
return ValueTask.FromResult(0L);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(set.RemoveRange(elements));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> DeleteAsync(
|
||||
TKey setKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.FromResult(_store.RemoveSet(setKey));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<long> CountAsync(
|
||||
TKey setKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.TryGetSet(setKey, out var set) || set is null)
|
||||
{
|
||||
return ValueTask.FromResult(0L);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(set.Count());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask SetExpirationAsync(
|
||||
TKey setKey,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var expiresAt = _timeProvider.GetUtcNow().Add(ttl);
|
||||
_store.SetExpiration(setKey, expiresAt);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating in-memory set store instances.
|
||||
/// </summary>
|
||||
public sealed class InMemorySetStoreFactory : ISetStoreFactory
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemorySetStoreFactory(
|
||||
InMemoryQueueRegistry registry,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISetStore<TKey, TElement> Create<TKey, TElement>(string name) where TKey : notnull
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new InMemorySetStore<TKey, TElement>(_registry, name, _timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="ISortedIndex{TKey, TElement}"/>.
|
||||
/// Provides score-ordered collections with range queries.
|
||||
/// </summary>
|
||||
public sealed class InMemorySortedIndex<TKey, TElement> : ISortedIndex<TKey, TElement>
|
||||
where TKey : notnull
|
||||
where TElement : notnull
|
||||
{
|
||||
private readonly SortedIndexStore<TKey, TElement> _store;
|
||||
private readonly Func<TKey, string> _keySerializer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemorySortedIndex(
|
||||
InMemoryQueueRegistry registry,
|
||||
string name,
|
||||
Func<TKey, string>? keySerializer = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
|
||||
_store = registry.GetOrCreateSortedIndex<TKey, TElement>(name);
|
||||
_keySerializer = keySerializer ?? (key => key?.ToString() ?? throw new ArgumentNullException(nameof(key)));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> AddAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
double score,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var index = _store.GetOrCreateIndex(indexKey);
|
||||
var wasAdded = index.Add(element, score);
|
||||
return ValueTask.FromResult(wasAdded);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<long> AddRangeAsync(
|
||||
TKey indexKey,
|
||||
IEnumerable<ScoredElement<TElement>> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
var index = _store.GetOrCreateIndex(indexKey);
|
||||
long addedCount = 0;
|
||||
foreach (var item in elements)
|
||||
{
|
||||
if (index.Add(item.Element, item.Score))
|
||||
{
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
return ValueTask.FromResult(addedCount);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<IReadOnlyList<ScoredElement<TElement>>> GetByRankAsync(
|
||||
TKey indexKey,
|
||||
long start,
|
||||
long stop,
|
||||
SortOrder order = SortOrder.Ascending,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.TryGetIndex(indexKey, out var index) || index is null)
|
||||
{
|
||||
return ValueTask.FromResult<IReadOnlyList<ScoredElement<TElement>>>([]);
|
||||
}
|
||||
|
||||
var results = index.GetByRank(start, stop, order == SortOrder.Ascending);
|
||||
var mapped = results.Select(r => new ScoredElement<TElement>(r.Element, r.Score)).ToList();
|
||||
return ValueTask.FromResult<IReadOnlyList<ScoredElement<TElement>>>(mapped);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<IReadOnlyList<ScoredElement<TElement>>> GetByScoreAsync(
|
||||
TKey indexKey,
|
||||
double minScore,
|
||||
double maxScore,
|
||||
SortOrder order = SortOrder.Ascending,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.TryGetIndex(indexKey, out var index) || index is null)
|
||||
{
|
||||
return ValueTask.FromResult<IReadOnlyList<ScoredElement<TElement>>>([]);
|
||||
}
|
||||
|
||||
var results = index.GetByScoreRange(minScore, maxScore, order == SortOrder.Ascending, limit);
|
||||
var mapped = results.Select(r => new ScoredElement<TElement>(r.Element, r.Score)).ToList();
|
||||
return ValueTask.FromResult<IReadOnlyList<ScoredElement<TElement>>>(mapped);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<double?> GetScoreAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.TryGetIndex(indexKey, out var index) || index is null)
|
||||
{
|
||||
return ValueTask.FromResult<double?>(null);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(index.GetScore(element));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> RemoveAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.TryGetIndex(indexKey, out var index) || index is null)
|
||||
{
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(index.Remove(element));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<long> RemoveRangeAsync(
|
||||
TKey indexKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
if (!_store.TryGetIndex(indexKey, out var index) || index is null)
|
||||
{
|
||||
return ValueTask.FromResult(0L);
|
||||
}
|
||||
|
||||
long removed = 0;
|
||||
foreach (var element in elements)
|
||||
{
|
||||
if (index.Remove(element))
|
||||
{
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
return ValueTask.FromResult(removed);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<long> RemoveByScoreAsync(
|
||||
TKey indexKey,
|
||||
double minScore,
|
||||
double maxScore,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.TryGetIndex(indexKey, out var index) || index is null)
|
||||
{
|
||||
return ValueTask.FromResult(0L);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(index.RemoveByScoreRange(minScore, maxScore));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<long> CountAsync(
|
||||
TKey indexKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.TryGetIndex(indexKey, out var index) || index is null)
|
||||
{
|
||||
return ValueTask.FromResult(0L);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(index.Count());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> DeleteAsync(
|
||||
TKey indexKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.FromResult(_store.RemoveIndex(indexKey));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask SetExpirationAsync(
|
||||
TKey indexKey,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var expiresAt = _timeProvider.GetUtcNow().Add(ttl);
|
||||
_store.SetExpiration(indexKey, expiresAt);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating in-memory sorted index instances.
|
||||
/// </summary>
|
||||
public sealed class InMemorySortedIndexFactory : ISortedIndexFactory
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemorySortedIndexFactory(
|
||||
InMemoryQueueRegistry registry,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISortedIndex<TKey, TElement> Create<TKey, TElement>(string name)
|
||||
where TKey : notnull
|
||||
where TElement : notnull
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new InMemorySortedIndex<TKey, TElement>(_registry, name, null, _timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Messaging.Plugins;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory transport plugin for StellaOps.Messaging.
|
||||
/// Useful for testing and development scenarios.
|
||||
/// </summary>
|
||||
public sealed class InMemoryTransportPlugin : IMessagingTransportPlugin
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Name => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Register(MessagingTransportRegistrationContext context)
|
||||
{
|
||||
// Register shared registry (singleton for test state sharing)
|
||||
context.Services.AddSingleton<InMemoryQueueRegistry>();
|
||||
|
||||
// Register message queue factory
|
||||
context.Services.AddSingleton<IMessageQueueFactory, InMemoryMessageQueueFactory>();
|
||||
|
||||
// Register cache factory
|
||||
context.Services.AddSingleton<IDistributedCacheFactory, InMemoryCacheFactory>();
|
||||
|
||||
// Register rate limiter factory
|
||||
context.Services.AddSingleton<IRateLimiterFactory, InMemoryRateLimiterFactory>();
|
||||
|
||||
// Register atomic token store factory
|
||||
context.Services.AddSingleton<IAtomicTokenStoreFactory, InMemoryAtomicTokenStoreFactory>();
|
||||
|
||||
// Register sorted index factory
|
||||
context.Services.AddSingleton<ISortedIndexFactory, InMemorySortedIndexFactory>();
|
||||
|
||||
// Register set store factory
|
||||
context.Services.AddSingleton<ISetStoreFactory, InMemorySetStoreFactory>();
|
||||
|
||||
// Register event stream factory
|
||||
context.Services.AddSingleton<IEventStreamFactory, InMemoryEventStreamFactory>();
|
||||
|
||||
// Register idempotency store factory
|
||||
context.Services.AddSingleton<IIdempotencyStoreFactory, InMemoryIdempotencyStoreFactory>();
|
||||
|
||||
context.LoggerFactory?.CreateLogger<InMemoryTransportPlugin>()
|
||||
.LogDebug("Registered in-memory transport plugin");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Messaging.Transport.InMemory</RootNamespace>
|
||||
<AssemblyName>StellaOps.Messaging.Transport.InMemory</AssemblyName>
|
||||
<Description>In-memory transport plugin for StellaOps.Messaging (for testing)</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Messaging/StellaOps.Messaging.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the PostgreSQL transport.
|
||||
/// </summary>
|
||||
public class PostgresTransportOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the connection string.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string ConnectionString { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the schema name for queue tables.
|
||||
/// </summary>
|
||||
public string Schema { get; set; } = "messaging";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to auto-create tables on startup.
|
||||
/// </summary>
|
||||
public bool AutoCreateTables { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the command timeout in seconds.
|
||||
/// </summary>
|
||||
public int CommandTimeoutSeconds { get; set; } = 30;
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IAtomicTokenStore{TPayload}"/>.
|
||||
/// Uses DELETE ... RETURNING for atomic token consumption.
|
||||
/// </summary>
|
||||
public sealed class PostgresAtomicTokenStore<TPayload> : IAtomicTokenStore<TPayload>
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly string _name;
|
||||
private readonly ILogger<PostgresAtomicTokenStore<TPayload>>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private bool _tableInitialized;
|
||||
|
||||
public PostgresAtomicTokenStore(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
string name,
|
||||
ILogger<PostgresAtomicTokenStore<TPayload>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_logger = logger;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
private string TableName => $"{_connectionFactory.Schema}.atomic_token_{_name.ToLowerInvariant().Replace("-", "_")}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<TokenIssueResult> IssueAsync(
|
||||
string key,
|
||||
TPayload payload,
|
||||
TimeSpan ttl,
|
||||
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 expiresAt = now.Add(ttl);
|
||||
|
||||
var tokenBytes = new byte[32];
|
||||
RandomNumberGenerator.Fill(tokenBytes);
|
||||
var token = Convert.ToBase64String(tokenBytes);
|
||||
|
||||
var payloadJson = JsonSerializer.Serialize(payload, _jsonOptions);
|
||||
|
||||
var sql = $@"
|
||||
INSERT INTO {TableName} (key, token, payload, issued_at, expires_at)
|
||||
VALUES (@Key, @Token, @Payload::jsonb, @IssuedAt, @ExpiresAt)
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
token = EXCLUDED.token,
|
||||
payload = EXCLUDED.payload,
|
||||
issued_at = EXCLUDED.issued_at,
|
||||
expires_at = EXCLUDED.expires_at";
|
||||
|
||||
await conn.ExecuteAsync(new CommandDefinition(sql, new
|
||||
{
|
||||
Key = key,
|
||||
Token = token,
|
||||
Payload = payloadJson,
|
||||
IssuedAt = now.UtcDateTime,
|
||||
ExpiresAt = expiresAt.UtcDateTime
|
||||
}, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||
|
||||
return TokenIssueResult.Succeeded(token, expiresAt);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<TokenIssueResult> StoreAsync(
|
||||
string key,
|
||||
string token,
|
||||
TPayload payload,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(token);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.Add(ttl);
|
||||
|
||||
var payloadJson = JsonSerializer.Serialize(payload, _jsonOptions);
|
||||
|
||||
var sql = $@"
|
||||
INSERT INTO {TableName} (key, token, payload, issued_at, expires_at)
|
||||
VALUES (@Key, @Token, @Payload::jsonb, @IssuedAt, @ExpiresAt)
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
token = EXCLUDED.token,
|
||||
payload = EXCLUDED.payload,
|
||||
issued_at = EXCLUDED.issued_at,
|
||||
expires_at = EXCLUDED.expires_at";
|
||||
|
||||
await conn.ExecuteAsync(new CommandDefinition(sql, new
|
||||
{
|
||||
Key = key,
|
||||
Token = token,
|
||||
Payload = payloadJson,
|
||||
IssuedAt = now.UtcDateTime,
|
||||
ExpiresAt = expiresAt.UtcDateTime
|
||||
}, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||
|
||||
return TokenIssueResult.Succeeded(token, expiresAt);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<TokenConsumeResult<TPayload>> TryConsumeAsync(
|
||||
string key,
|
||||
string expectedToken,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(expectedToken);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// First, get the entry to check expiration and mismatch
|
||||
var selectSql = $@"SELECT token, payload, issued_at, expires_at FROM {TableName} WHERE key = @Key";
|
||||
var entry = await conn.QuerySingleOrDefaultAsync<TokenRow>(
|
||||
new CommandDefinition(selectSql, new { Key = key }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
return TokenConsumeResult<TPayload>.NotFound();
|
||||
}
|
||||
|
||||
var issuedAt = new DateTimeOffset(entry.IssuedAt, TimeSpan.Zero);
|
||||
var expiresAt = new DateTimeOffset(entry.ExpiresAt, TimeSpan.Zero);
|
||||
|
||||
if (expiresAt < now)
|
||||
{
|
||||
// Delete expired entry
|
||||
await conn.ExecuteAsync(new CommandDefinition(
|
||||
$"DELETE FROM {TableName} WHERE key = @Key", new { Key = key }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
return TokenConsumeResult<TPayload>.Expired(issuedAt, expiresAt);
|
||||
}
|
||||
|
||||
if (!string.Equals(entry.Token, expectedToken, StringComparison.Ordinal))
|
||||
{
|
||||
return TokenConsumeResult<TPayload>.Mismatch();
|
||||
}
|
||||
|
||||
// Atomic delete with condition
|
||||
var deleteSql = $@"
|
||||
DELETE FROM {TableName}
|
||||
WHERE key = @Key AND token = @Token
|
||||
RETURNING payload";
|
||||
|
||||
var deletedPayload = await conn.ExecuteScalarAsync<string>(
|
||||
new CommandDefinition(deleteSql, new { Key = key, Token = expectedToken }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (deletedPayload is null)
|
||||
{
|
||||
return TokenConsumeResult<TPayload>.NotFound();
|
||||
}
|
||||
|
||||
var payload = JsonSerializer.Deserialize<TPayload>(deletedPayload, _jsonOptions);
|
||||
return TokenConsumeResult<TPayload>.Success(payload!, issuedAt, expiresAt);
|
||||
}
|
||||
|
||||
/// <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<bool> RevokeAsync(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;
|
||||
}
|
||||
|
||||
private async ValueTask EnsureTableExistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized) return;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $@"
|
||||
CREATE TABLE IF NOT EXISTS {TableName} (
|
||||
key TEXT PRIMARY KEY,
|
||||
token TEXT NOT NULL,
|
||||
payload JSONB,
|
||||
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_{_name}_expires ON {TableName} (expires_at);";
|
||||
|
||||
await conn.ExecuteAsync(new CommandDefinition(sql, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||
_tableInitialized = true;
|
||||
}
|
||||
|
||||
private sealed class TokenRow
|
||||
{
|
||||
public string Token { get; init; } = null!;
|
||||
public string Payload { get; init; } = null!;
|
||||
public DateTime IssuedAt { get; init; }
|
||||
public DateTime ExpiresAt { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating PostgreSQL atomic token store instances.
|
||||
/// </summary>
|
||||
public sealed class PostgresAtomicTokenStoreFactory : IAtomicTokenStoreFactory
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly JsonSerializerOptions? _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresAtomicTokenStoreFactory(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_jsonOptions = jsonOptions;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAtomicTokenStore<TPayload> Create<TPayload>(string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new PostgresAtomicTokenStore<TPayload>(
|
||||
_connectionFactory,
|
||||
name,
|
||||
_loggerFactory?.CreateLogger<PostgresAtomicTokenStore<TPayload>>(),
|
||||
_jsonOptions,
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating PostgreSQL distributed cache instances.
|
||||
/// </summary>
|
||||
public sealed class PostgresCacheFactory : IDistributedCacheFactory
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresCacheFactory(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDistributedCache<TKey, TValue> Create<TKey, TValue>(CacheOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return new PostgresCacheStore<TKey, TValue>(
|
||||
_connectionFactory,
|
||||
options,
|
||||
_loggerFactory?.CreateLogger<PostgresCacheStore<TKey, TValue>>(),
|
||||
_jsonOptions,
|
||||
null,
|
||||
_timeProvider);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDistributedCache<TValue> Create<TValue>(CacheOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return new PostgresCacheStore<TValue>(
|
||||
_connectionFactory,
|
||||
options,
|
||||
_loggerFactory?.CreateLogger<PostgresCacheStore<TValue>>(),
|
||||
_jsonOptions,
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IDistributedCache{TKey, TValue}"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TKey">The key type.</typeparam>
|
||||
/// <typeparam name="TValue">The value type.</typeparam>
|
||||
public sealed class PostgresCacheStore<TKey, TValue> : IDistributedCache<TKey, TValue>
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly CacheOptions _cacheOptions;
|
||||
private readonly ILogger<PostgresCacheStore<TKey, TValue>>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly Func<TKey, string> _keySerializer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly string _tableName;
|
||||
private readonly SemaphoreSlim _initLock = new(1, 1);
|
||||
private volatile bool _tableInitialized;
|
||||
|
||||
public PostgresCacheStore(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
CacheOptions cacheOptions,
|
||||
ILogger<PostgresCacheStore<TKey, TValue>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
Func<TKey, string>? keySerializer = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_cacheOptions = cacheOptions ?? throw new ArgumentNullException(nameof(cacheOptions));
|
||||
_logger = logger;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_keySerializer = keySerializer ?? (key => key?.ToString() ?? throw new ArgumentNullException(nameof(key)));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
var cacheName = (_cacheOptions.KeyPrefix ?? "default").Replace(":", "_").Replace("-", "_").ToLowerInvariant();
|
||||
_tableName = $"{_connectionFactory.Schema}.cache_{cacheName}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<CacheResult<TValue>> GetAsync(TKey key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var cacheKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $@"
|
||||
SELECT value, expires_at FROM {_tableName}
|
||||
WHERE key = @Key AND (expires_at IS NULL OR expires_at > @Now)
|
||||
LIMIT 1;";
|
||||
|
||||
var row = await conn.QuerySingleOrDefaultAsync(sql, new { Key = cacheKey, Now = now.UtcDateTime }).ConfigureAwait(false);
|
||||
|
||||
if (row is null)
|
||||
{
|
||||
return CacheResult<TValue>.Miss();
|
||||
}
|
||||
|
||||
// Handle sliding expiration
|
||||
if (_cacheOptions.SlidingExpiration && _cacheOptions.DefaultTtl.HasValue)
|
||||
{
|
||||
var updateSql = $"UPDATE {_tableName} SET expires_at = @ExpiresAt WHERE key = @Key;";
|
||||
await conn.ExecuteAsync(updateSql, new
|
||||
{
|
||||
Key = cacheKey,
|
||||
ExpiresAt = now.Add(_cacheOptions.DefaultTtl.Value).UtcDateTime
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var value = JsonSerializer.Deserialize<TValue>((string)row.value, _jsonOptions);
|
||||
return value is not null ? CacheResult<TValue>.Found(value) : CacheResult<TValue>.Miss();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return CacheResult<TValue>.Miss();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask SetAsync(
|
||||
TKey key,
|
||||
TValue value,
|
||||
CacheEntryOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var cacheKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var serialized = JsonSerializer.Serialize(value, _jsonOptions);
|
||||
|
||||
DateTime? expiresAt = null;
|
||||
if (options?.TimeToLive.HasValue == true)
|
||||
{
|
||||
expiresAt = now.Add(options.TimeToLive.Value).UtcDateTime;
|
||||
}
|
||||
else if (options?.AbsoluteExpiration.HasValue == true)
|
||||
{
|
||||
expiresAt = options.AbsoluteExpiration.Value.UtcDateTime;
|
||||
}
|
||||
else if (_cacheOptions.DefaultTtl.HasValue)
|
||||
{
|
||||
expiresAt = now.Add(_cacheOptions.DefaultTtl.Value).UtcDateTime;
|
||||
}
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $@"
|
||||
INSERT INTO {_tableName} (key, value, expires_at, created_at, updated_at)
|
||||
VALUES (@Key, @Value, @ExpiresAt, @Now, @Now)
|
||||
ON CONFLICT (key) DO UPDATE SET value = @Value, expires_at = @ExpiresAt, updated_at = @Now;";
|
||||
|
||||
await conn.ExecuteAsync(sql, new
|
||||
{
|
||||
Key = cacheKey,
|
||||
Value = serialized,
|
||||
ExpiresAt = expiresAt,
|
||||
Now = now.UtcDateTime
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> InvalidateAsync(TKey key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var cacheKey = BuildKey(key);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $"DELETE FROM {_tableName} WHERE key = @Key;";
|
||||
var affected = await conn.ExecuteAsync(sql, new { Key = cacheKey }).ConfigureAwait(false);
|
||||
return affected > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> InvalidateByPatternAsync(string pattern, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Convert glob pattern to SQL LIKE pattern
|
||||
var likePattern = (_cacheOptions.KeyPrefix ?? "") + pattern.Replace("*", "%");
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $"DELETE FROM {_tableName} WHERE key LIKE @Pattern;";
|
||||
return await conn.ExecuteAsync(sql, new { Pattern = likePattern }).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<TValue> GetOrSetAsync(
|
||||
TKey key,
|
||||
Func<CancellationToken, ValueTask<TValue>> factory,
|
||||
CacheEntryOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await GetAsync(key, cancellationToken).ConfigureAwait(false);
|
||||
if (result.HasValue)
|
||||
{
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
var value = await factory(cancellationToken).ConfigureAwait(false);
|
||||
await SetAsync(key, value, options, cancellationToken).ConfigureAwait(false);
|
||||
return value;
|
||||
}
|
||||
|
||||
private string BuildKey(TKey key)
|
||||
{
|
||||
var keyString = _keySerializer(key);
|
||||
return string.IsNullOrWhiteSpace(_cacheOptions.KeyPrefix)
|
||||
? keyString
|
||||
: $"{_cacheOptions.KeyPrefix}{keyString}";
|
||||
}
|
||||
|
||||
private async Task EnsureTableAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized) return;
|
||||
|
||||
await _initLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_tableInitialized) return;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await conn.ExecuteAsync($"CREATE SCHEMA IF NOT EXISTS {_connectionFactory.Schema};").ConfigureAwait(false);
|
||||
|
||||
var sql = $@"
|
||||
CREATE TABLE IF NOT EXISTS {_tableName} (
|
||||
key TEXT PRIMARY KEY,
|
||||
value JSONB NOT NULL,
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_{_tableName.Replace(".", "_")}_expires
|
||||
ON {_tableName} (expires_at)
|
||||
WHERE expires_at IS NOT NULL;
|
||||
";
|
||||
|
||||
await conn.ExecuteAsync(sql).ConfigureAwait(false);
|
||||
|
||||
_tableInitialized = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_initLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// String-keyed PostgreSQL cache store.
|
||||
/// </summary>
|
||||
public sealed class PostgresCacheStore<TValue> : IDistributedCache<TValue>
|
||||
{
|
||||
private readonly PostgresCacheStore<string, TValue> _inner;
|
||||
|
||||
public PostgresCacheStore(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
CacheOptions options,
|
||||
ILogger<PostgresCacheStore<TValue>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_inner = new PostgresCacheStore<string, TValue>(connectionFactory, options, null, jsonOptions, key => key, timeProvider);
|
||||
}
|
||||
|
||||
public string ProviderName => _inner.ProviderName;
|
||||
|
||||
public ValueTask<CacheResult<TValue>> GetAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> _inner.GetAsync(key, cancellationToken);
|
||||
|
||||
public ValueTask SetAsync(string key, TValue value, CacheEntryOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> _inner.SetAsync(key, value, options, cancellationToken);
|
||||
|
||||
public ValueTask<bool> InvalidateAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> _inner.InvalidateAsync(key, cancellationToken);
|
||||
|
||||
public ValueTask<long> InvalidateByPatternAsync(string pattern, CancellationToken cancellationToken = default)
|
||||
=> _inner.InvalidateByPatternAsync(pattern, cancellationToken);
|
||||
|
||||
public ValueTask<TValue> GetOrSetAsync(string key, Func<CancellationToken, ValueTask<TValue>> factory, CacheEntryOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> _inner.GetOrSetAsync(key, factory, options, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating PostgreSQL connections.
|
||||
/// </summary>
|
||||
public sealed class PostgresConnectionFactory : IAsyncDisposable
|
||||
{
|
||||
private readonly PostgresTransportOptions _options;
|
||||
private readonly ILogger<PostgresConnectionFactory>? _logger;
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private bool _disposed;
|
||||
|
||||
public PostgresConnectionFactory(
|
||||
IOptions<PostgresTransportOptions> options,
|
||||
ILogger<PostgresConnectionFactory>? logger = null)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
|
||||
var builder = new NpgsqlDataSourceBuilder(_options.ConnectionString);
|
||||
_dataSource = builder.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the schema name.
|
||||
/// </summary>
|
||||
public string Schema => _options.Schema;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command timeout.
|
||||
/// </summary>
|
||||
public int CommandTimeoutSeconds => _options.CommandTimeoutSeconds;
|
||||
|
||||
/// <summary>
|
||||
/// Opens a new connection.
|
||||
/// </summary>
|
||||
public async ValueTask<NpgsqlConnection> OpenConnectionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
return connection;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the connection.
|
||||
/// </summary>
|
||||
public async ValueTask PingAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var conn = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT 1";
|
||||
await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
await _dataSource.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IEventStream{TEvent}"/>.
|
||||
/// Uses polling-based subscription with optional LISTEN/NOTIFY.
|
||||
/// </summary>
|
||||
public sealed class PostgresEventStream<TEvent> : IEventStream<TEvent>
|
||||
where TEvent : class
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly EventStreamOptions _options;
|
||||
private readonly ILogger<PostgresEventStream<TEvent>>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private bool _tableInitialized;
|
||||
|
||||
public PostgresEventStream(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
EventStreamOptions options,
|
||||
ILogger<PostgresEventStream<TEvent>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string StreamName => _options.StreamName;
|
||||
|
||||
private string TableName => $"{_connectionFactory.Schema}.event_stream_{_options.StreamName.ToLowerInvariant().Replace("-", "_")}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<EventPublishResult> PublishAsync(
|
||||
TEvent @event,
|
||||
EventPublishOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(@event);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var eventJson = JsonSerializer.Serialize(@event, _jsonOptions);
|
||||
|
||||
var sql = $@"
|
||||
INSERT INTO {TableName} (data, tenant_id, correlation_id, timestamp)
|
||||
VALUES (@Data::jsonb, @TenantId, @CorrelationId, @Timestamp)
|
||||
RETURNING id";
|
||||
|
||||
var id = await conn.ExecuteScalarAsync<long>(
|
||||
new CommandDefinition(sql, new
|
||||
{
|
||||
Data = eventJson,
|
||||
TenantId = options?.TenantId,
|
||||
CorrelationId = options?.CorrelationId,
|
||||
Timestamp = now.UtcDateTime
|
||||
}, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Auto-trim if configured
|
||||
if (_options.MaxLength.HasValue)
|
||||
{
|
||||
await TrimInternalAsync(conn, _options.MaxLength.Value, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var entryId = $"{now.ToUnixTimeMilliseconds()}-{id}";
|
||||
return EventPublishResult.Succeeded(entryId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlyList<EventPublishResult>> PublishBatchAsync(
|
||||
IEnumerable<TEvent> events,
|
||||
EventPublishOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(events);
|
||||
|
||||
var results = new List<EventPublishResult>();
|
||||
foreach (var @event in events)
|
||||
{
|
||||
var result = await PublishAsync(@event, options, cancellationToken).ConfigureAwait(false);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<StreamEvent<TEvent>> SubscribeAsync(
|
||||
StreamPosition position,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
long lastId = position.Value switch
|
||||
{
|
||||
"0" => 0,
|
||||
"$" => long.MaxValue, // Will be resolved to actual max
|
||||
_ => ParseEntryId(position.Value)
|
||||
};
|
||||
|
||||
// If starting from end, get current max ID
|
||||
if (position.Value == "$")
|
||||
{
|
||||
await using var initConn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
var maxIdSql = $@"SELECT COALESCE(MAX(id), 0) FROM {TableName}";
|
||||
lastId = await initConn.ExecuteScalarAsync<long>(
|
||||
new CommandDefinition(maxIdSql, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $@"
|
||||
SELECT id, data, tenant_id, correlation_id, timestamp
|
||||
FROM {TableName}
|
||||
WHERE id > @LastId
|
||||
ORDER BY id
|
||||
LIMIT 100";
|
||||
|
||||
var entries = await conn.QueryAsync<EventRow>(
|
||||
new CommandDefinition(sql, new { LastId = lastId }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var entriesList = entries.ToList();
|
||||
|
||||
if (entriesList.Count > 0)
|
||||
{
|
||||
foreach (var entry in entriesList)
|
||||
{
|
||||
var @event = JsonSerializer.Deserialize<TEvent>(entry.Data, _jsonOptions);
|
||||
if (@event is not null)
|
||||
{
|
||||
var timestamp = new DateTimeOffset(entry.Timestamp, TimeSpan.Zero);
|
||||
var entryId = $"{timestamp.ToUnixTimeMilliseconds()}-{entry.Id}";
|
||||
|
||||
yield return new StreamEvent<TEvent>(
|
||||
entryId,
|
||||
@event,
|
||||
timestamp,
|
||||
entry.TenantId,
|
||||
entry.CorrelationId);
|
||||
}
|
||||
lastId = entry.Id;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No new entries, wait before polling again
|
||||
await Task.Delay(_options.PollInterval, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<StreamInfo> GetInfoAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $@"
|
||||
SELECT
|
||||
COUNT(*) as length,
|
||||
MIN(id) as first_id,
|
||||
MAX(id) as last_id,
|
||||
MIN(timestamp) as first_ts,
|
||||
MAX(timestamp) as last_ts
|
||||
FROM {TableName}";
|
||||
|
||||
var info = await conn.QuerySingleAsync<StreamInfoRow>(
|
||||
new CommandDefinition(sql, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
string? firstEntryId = null;
|
||||
string? lastEntryId = null;
|
||||
DateTimeOffset? firstTs = null;
|
||||
DateTimeOffset? lastTs = null;
|
||||
|
||||
if (info.FirstId.HasValue && info.FirstTs.HasValue)
|
||||
{
|
||||
firstTs = new DateTimeOffset(info.FirstTs.Value, TimeSpan.Zero);
|
||||
firstEntryId = $"{firstTs.Value.ToUnixTimeMilliseconds()}-{info.FirstId.Value}";
|
||||
}
|
||||
|
||||
if (info.LastId.HasValue && info.LastTs.HasValue)
|
||||
{
|
||||
lastTs = new DateTimeOffset(info.LastTs.Value, TimeSpan.Zero);
|
||||
lastEntryId = $"{lastTs.Value.ToUnixTimeMilliseconds()}-{info.LastId.Value}";
|
||||
}
|
||||
|
||||
return new StreamInfo(info.Length, firstEntryId, lastEntryId, firstTs, lastTs);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> TrimAsync(
|
||||
long maxLength,
|
||||
bool approximate = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await TrimInternalAsync(conn, maxLength, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask<long> TrimInternalAsync(Npgsql.NpgsqlConnection conn, long maxLength, CancellationToken cancellationToken)
|
||||
{
|
||||
var sql = $@"
|
||||
WITH to_delete AS (
|
||||
SELECT id FROM {TableName}
|
||||
ORDER BY id DESC
|
||||
OFFSET @MaxLength
|
||||
)
|
||||
DELETE FROM {TableName}
|
||||
WHERE id IN (SELECT id FROM to_delete)";
|
||||
|
||||
return await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { MaxLength = maxLength }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask EnsureTableExistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized) return;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var safeName = _options.StreamName.ToLowerInvariant().Replace("-", "_");
|
||||
var sql = $@"
|
||||
CREATE TABLE IF NOT EXISTS {TableName} (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
data JSONB NOT NULL,
|
||||
tenant_id TEXT,
|
||||
correlation_id TEXT,
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_{safeName}_timestamp ON {TableName} (timestamp);";
|
||||
|
||||
await conn.ExecuteAsync(new CommandDefinition(sql, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||
_tableInitialized = true;
|
||||
}
|
||||
|
||||
private static long ParseEntryId(string entryId)
|
||||
{
|
||||
// Format is "timestamp-id"
|
||||
var dashIndex = entryId.LastIndexOf('-');
|
||||
if (dashIndex > 0 && long.TryParse(entryId.AsSpan(dashIndex + 1), out var id))
|
||||
{
|
||||
return id;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private sealed class EventRow
|
||||
{
|
||||
public long Id { get; init; }
|
||||
public string Data { get; init; } = null!;
|
||||
public string? TenantId { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
public DateTime Timestamp { get; init; }
|
||||
}
|
||||
|
||||
private sealed class StreamInfoRow
|
||||
{
|
||||
public long Length { get; init; }
|
||||
public long? FirstId { get; init; }
|
||||
public long? LastId { get; init; }
|
||||
public DateTime? FirstTs { get; init; }
|
||||
public DateTime? LastTs { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating PostgreSQL event stream instances.
|
||||
/// </summary>
|
||||
public sealed class PostgresEventStreamFactory : IEventStreamFactory
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly JsonSerializerOptions? _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresEventStreamFactory(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_jsonOptions = jsonOptions;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEventStream<TEvent> Create<TEvent>(EventStreamOptions options) where TEvent : class
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return new PostgresEventStream<TEvent>(
|
||||
_connectionFactory,
|
||||
options,
|
||||
_loggerFactory?.CreateLogger<PostgresEventStream<TEvent>>(),
|
||||
_jsonOptions,
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of a message lease.
|
||||
/// </summary>
|
||||
/// <typeparam name="TMessage">The message type.</typeparam>
|
||||
internal sealed class PostgresMessageLease<TMessage> : IMessageLease<TMessage> where TMessage : class
|
||||
{
|
||||
private readonly PostgresMessageQueue<TMessage> _queue;
|
||||
private int _completed;
|
||||
|
||||
internal PostgresMessageLease(
|
||||
PostgresMessageQueue<TMessage> queue,
|
||||
string messageId,
|
||||
TMessage message,
|
||||
int attempt,
|
||||
DateTimeOffset enqueuedAt,
|
||||
DateTimeOffset leaseExpiresAt,
|
||||
string consumer,
|
||||
string? tenantId,
|
||||
string? correlationId)
|
||||
{
|
||||
_queue = queue;
|
||||
MessageId = messageId;
|
||||
Message = message;
|
||||
Attempt = attempt;
|
||||
EnqueuedAt = enqueuedAt;
|
||||
LeaseExpiresAt = leaseExpiresAt;
|
||||
Consumer = consumer;
|
||||
TenantId = tenantId;
|
||||
CorrelationId = correlationId;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string MessageId { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public TMessage Message { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Attempt { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset EnqueuedAt { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset LeaseExpiresAt { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Consumer { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? TenantId { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? CorrelationId { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask AcknowledgeAsync(CancellationToken cancellationToken = default)
|
||||
=> _queue.AcknowledgeAsync(this, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask RenewAsync(TimeSpan extension, CancellationToken cancellationToken = default)
|
||||
=> _queue.RenewLeaseAsync(this, extension, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask ReleaseAsync(ReleaseDisposition disposition, CancellationToken cancellationToken = default)
|
||||
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
|
||||
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
internal bool TryBeginCompletion()
|
||||
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
|
||||
|
||||
internal void RefreshLease(DateTimeOffset expiresAt)
|
||||
=> LeaseExpiresAt = expiresAt;
|
||||
|
||||
internal void IncrementAttempt()
|
||||
=> Attempt++;
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IMessageQueue{TMessage}"/> using FOR UPDATE SKIP LOCKED.
|
||||
/// </summary>
|
||||
/// <typeparam name="TMessage">The message type.</typeparam>
|
||||
public sealed class PostgresMessageQueue<TMessage> : IMessageQueue<TMessage>
|
||||
where TMessage : class
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly MessageQueueOptions _queueOptions;
|
||||
private readonly ILogger<PostgresMessageQueue<TMessage>>? _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly string _tableName;
|
||||
private readonly SemaphoreSlim _initLock = new(1, 1);
|
||||
private volatile bool _tableInitialized;
|
||||
|
||||
public PostgresMessageQueue(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
MessageQueueOptions queueOptions,
|
||||
ILogger<PostgresMessageQueue<TMessage>>? logger = null,
|
||||
TimeProvider? timeProvider = null,
|
||||
JsonSerializerOptions? jsonOptions = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_queueOptions = queueOptions ?? throw new ArgumentNullException(nameof(queueOptions));
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
// Sanitize queue name for table name
|
||||
var sanitizedName = queueOptions.QueueName.Replace(":", "_").Replace("-", "_").ToLowerInvariant();
|
||||
_tableName = $"{_connectionFactory.Schema}.queue_{sanitizedName}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string QueueName => _queueOptions.QueueName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<EnqueueResult> EnqueueAsync(
|
||||
TMessage message,
|
||||
EnqueueOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var messageId = Guid.NewGuid().ToString("N");
|
||||
var payload = JsonSerializer.Serialize(message, _jsonOptions);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Check idempotency if key provided
|
||||
if (!string.IsNullOrWhiteSpace(options?.IdempotencyKey))
|
||||
{
|
||||
var existingSql = $@"
|
||||
SELECT id FROM {_tableName}
|
||||
WHERE idempotency_key = @IdempotencyKey
|
||||
AND enqueued_at > @WindowStart
|
||||
LIMIT 1;";
|
||||
|
||||
var existing = await conn.QuerySingleOrDefaultAsync<string>(existingSql, new
|
||||
{
|
||||
IdempotencyKey = options.IdempotencyKey,
|
||||
WindowStart = now.Subtract(_queueOptions.IdempotencyWindow)
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
_logger?.LogDebug("Duplicate enqueue detected for queue {Queue} with key {Key}", _queueOptions.QueueName, options.IdempotencyKey);
|
||||
return EnqueueResult.Duplicate(existing);
|
||||
}
|
||||
}
|
||||
|
||||
var visibleAt = options?.VisibleAt ?? now;
|
||||
|
||||
var sql = $@"
|
||||
INSERT INTO {_tableName} (
|
||||
id, payload, status, attempt_count, enqueued_at, available_at,
|
||||
tenant_id, correlation_id, idempotency_key, priority
|
||||
) VALUES (
|
||||
@Id, @Payload, 'pending', 1, @EnqueuedAt, @AvailableAt,
|
||||
@TenantId, @CorrelationId, @IdempotencyKey, @Priority
|
||||
);";
|
||||
|
||||
await conn.ExecuteAsync(sql, new
|
||||
{
|
||||
Id = messageId,
|
||||
Payload = payload,
|
||||
EnqueuedAt = now.UtcDateTime,
|
||||
AvailableAt = visibleAt.UtcDateTime,
|
||||
TenantId = options?.TenantId,
|
||||
CorrelationId = options?.CorrelationId,
|
||||
IdempotencyKey = options?.IdempotencyKey,
|
||||
Priority = options?.Priority ?? 0
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
_logger?.LogDebug("Enqueued message {MessageId} to queue {Queue}", messageId, _queueOptions.QueueName);
|
||||
return EnqueueResult.Succeeded(messageId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlyList<IMessageLease<TMessage>>> LeaseAsync(
|
||||
LeaseRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var consumer = _queueOptions.ConsumerName ?? $"{Environment.MachineName}-{Environment.ProcessId}";
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var leaseDuration = request.LeaseDuration ?? _queueOptions.DefaultLeaseDuration;
|
||||
var leaseExpires = now.Add(leaseDuration);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Use FOR UPDATE SKIP LOCKED to atomically claim messages
|
||||
var sql = $@"
|
||||
WITH candidates AS (
|
||||
SELECT id
|
||||
FROM {_tableName}
|
||||
WHERE status IN ('pending', 'retrying')
|
||||
AND available_at <= @Now
|
||||
ORDER BY priority DESC, available_at ASC, enqueued_at ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT @BatchSize
|
||||
)
|
||||
UPDATE {_tableName} q
|
||||
SET status = 'processing',
|
||||
lease_owner = @Consumer,
|
||||
lease_expires_at = @LeaseExpires,
|
||||
attempt_count = attempt_count + 1,
|
||||
last_attempt_at = @Now,
|
||||
updated_at = @Now
|
||||
FROM candidates c
|
||||
WHERE q.id = c.id
|
||||
RETURNING q.*;";
|
||||
|
||||
var rows = await conn.QueryAsync(sql, new
|
||||
{
|
||||
Now = now.UtcDateTime,
|
||||
BatchSize = request.BatchSize,
|
||||
Consumer = consumer,
|
||||
LeaseExpires = leaseExpires.UtcDateTime
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
var leases = new List<IMessageLease<TMessage>>();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var lease = TryMapLease(row, consumer, leaseExpires);
|
||||
if (lease is not null)
|
||||
{
|
||||
leases.Add(lease);
|
||||
}
|
||||
}
|
||||
|
||||
return leases;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlyList<IMessageLease<TMessage>>> ClaimExpiredAsync(
|
||||
ClaimRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var consumer = _queueOptions.ConsumerName ?? $"{Environment.MachineName}-{Environment.ProcessId}";
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var leaseDuration = request.LeaseDuration ?? _queueOptions.DefaultLeaseDuration;
|
||||
var leaseExpires = now.Add(leaseDuration);
|
||||
var minIdleThreshold = now.Subtract(request.MinIdleTime);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Claim messages with expired leases
|
||||
var sql = $@"
|
||||
WITH candidates AS (
|
||||
SELECT id
|
||||
FROM {_tableName}
|
||||
WHERE status = 'processing'
|
||||
AND lease_expires_at < @MinIdleThreshold
|
||||
AND attempt_count >= @MinAttempts
|
||||
ORDER BY lease_expires_at ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT @BatchSize
|
||||
)
|
||||
UPDATE {_tableName} q
|
||||
SET lease_owner = @Consumer,
|
||||
lease_expires_at = @LeaseExpires,
|
||||
attempt_count = attempt_count + 1,
|
||||
last_attempt_at = @Now,
|
||||
updated_at = @Now
|
||||
FROM candidates c
|
||||
WHERE q.id = c.id
|
||||
RETURNING q.*;";
|
||||
|
||||
var rows = await conn.QueryAsync(sql, new
|
||||
{
|
||||
MinIdleThreshold = minIdleThreshold.UtcDateTime,
|
||||
MinAttempts = request.MinDeliveryAttempts,
|
||||
BatchSize = request.BatchSize,
|
||||
Consumer = consumer,
|
||||
LeaseExpires = leaseExpires.UtcDateTime,
|
||||
Now = now.UtcDateTime
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
var leases = new List<IMessageLease<TMessage>>();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var lease = TryMapLease(row, consumer, leaseExpires);
|
||||
if (lease is not null)
|
||||
{
|
||||
leases.Add(lease);
|
||||
}
|
||||
}
|
||||
|
||||
return leases;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> GetPendingCountAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $"SELECT COUNT(*) FROM {_tableName} WHERE status IN ('pending', 'retrying', 'processing');";
|
||||
return await conn.ExecuteScalarAsync<long>(sql).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
internal async ValueTask AcknowledgeAsync(PostgresMessageLease<TMessage> lease, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!lease.TryBeginCompletion()) return;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $"DELETE FROM {_tableName} WHERE id = @Id AND lease_owner = @Consumer;";
|
||||
await conn.ExecuteAsync(sql, new { Id = lease.MessageId, Consumer = lease.Consumer }).ConfigureAwait(false);
|
||||
|
||||
_logger?.LogDebug("Acknowledged message {MessageId} from queue {Queue}", lease.MessageId, _queueOptions.QueueName);
|
||||
}
|
||||
|
||||
internal async ValueTask RenewLeaseAsync(PostgresMessageLease<TMessage> lease, TimeSpan extension, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var newExpiry = now.Add(extension);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $@"
|
||||
UPDATE {_tableName}
|
||||
SET lease_expires_at = @LeaseExpires, updated_at = @Now
|
||||
WHERE id = @Id AND lease_owner = @Consumer;";
|
||||
|
||||
await conn.ExecuteAsync(sql, new
|
||||
{
|
||||
Id = lease.MessageId,
|
||||
Consumer = lease.Consumer,
|
||||
LeaseExpires = newExpiry.UtcDateTime,
|
||||
Now = now.UtcDateTime
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
lease.RefreshLease(newExpiry);
|
||||
}
|
||||
|
||||
internal async ValueTask ReleaseAsync(
|
||||
PostgresMessageLease<TMessage> lease,
|
||||
ReleaseDisposition disposition,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (disposition == ReleaseDisposition.Retry && lease.Attempt >= _queueOptions.MaxDeliveryAttempts)
|
||||
{
|
||||
await DeadLetterAsync(lease, $"max-delivery-attempts:{lease.Attempt}", cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!lease.TryBeginCompletion()) return;
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (disposition == ReleaseDisposition.Retry)
|
||||
{
|
||||
var backoff = CalculateBackoff(lease.Attempt + 1);
|
||||
var availableAt = now.Add(backoff);
|
||||
|
||||
var sql = $@"
|
||||
UPDATE {_tableName}
|
||||
SET status = 'retrying',
|
||||
lease_owner = NULL,
|
||||
lease_expires_at = NULL,
|
||||
available_at = @AvailableAt,
|
||||
updated_at = @Now
|
||||
WHERE id = @Id;";
|
||||
|
||||
await conn.ExecuteAsync(sql, new
|
||||
{
|
||||
Id = lease.MessageId,
|
||||
AvailableAt = availableAt.UtcDateTime,
|
||||
Now = now.UtcDateTime
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
_logger?.LogDebug("Released message {MessageId} for retry, attempt {Attempt}", lease.MessageId, lease.Attempt + 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Abandon - just delete
|
||||
var sql = $"DELETE FROM {_tableName} WHERE id = @Id;";
|
||||
await conn.ExecuteAsync(sql, new { Id = lease.MessageId }).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
internal async ValueTask DeadLetterAsync(PostgresMessageLease<TMessage> lease, string reason, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!lease.TryBeginCompletion()) return;
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_queueOptions.DeadLetterQueue))
|
||||
{
|
||||
// Move to dead-letter status (or separate table)
|
||||
var sql = $@"
|
||||
UPDATE {_tableName}
|
||||
SET status = 'dead_letter',
|
||||
lease_owner = NULL,
|
||||
lease_expires_at = NULL,
|
||||
last_error = @Reason,
|
||||
updated_at = @Now
|
||||
WHERE id = @Id;";
|
||||
|
||||
await conn.ExecuteAsync(sql, new
|
||||
{
|
||||
Id = lease.MessageId,
|
||||
Reason = reason,
|
||||
Now = now.UtcDateTime
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
_logger?.LogWarning("Dead-lettered message {MessageId}: {Reason}", lease.MessageId, reason);
|
||||
}
|
||||
else
|
||||
{
|
||||
var sql = $"DELETE FROM {_tableName} WHERE id = @Id;";
|
||||
await conn.ExecuteAsync(sql, new { Id = lease.MessageId }).ConfigureAwait(false);
|
||||
_logger?.LogWarning("Dropped message {MessageId} (no DLQ): {Reason}", lease.MessageId, reason);
|
||||
}
|
||||
}
|
||||
|
||||
private TimeSpan CalculateBackoff(int attempt)
|
||||
{
|
||||
if (attempt <= 1) return _queueOptions.RetryInitialBackoff;
|
||||
|
||||
var initial = _queueOptions.RetryInitialBackoff;
|
||||
var max = _queueOptions.RetryMaxBackoff;
|
||||
var multiplier = _queueOptions.RetryBackoffMultiplier;
|
||||
|
||||
var scaledTicks = initial.Ticks * Math.Pow(multiplier, attempt - 1);
|
||||
var cappedTicks = Math.Min(max.Ticks, scaledTicks);
|
||||
|
||||
return TimeSpan.FromTicks((long)Math.Max(initial.Ticks, cappedTicks));
|
||||
}
|
||||
|
||||
private PostgresMessageLease<TMessage>? TryMapLease(dynamic row, string consumer, DateTimeOffset leaseExpires)
|
||||
{
|
||||
try
|
||||
{
|
||||
var payload = (string)row.payload;
|
||||
var message = JsonSerializer.Deserialize<TMessage>(payload, _jsonOptions);
|
||||
if (message is null) return null;
|
||||
|
||||
return new PostgresMessageLease<TMessage>(
|
||||
this,
|
||||
(string)row.id,
|
||||
message,
|
||||
(int)row.attempt_count,
|
||||
new DateTimeOffset(DateTime.SpecifyKind((DateTime)row.enqueued_at, DateTimeKind.Utc)),
|
||||
leaseExpires,
|
||||
consumer,
|
||||
(string?)row.tenant_id,
|
||||
(string?)row.correlation_id);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureTableAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized) return;
|
||||
|
||||
await _initLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_tableInitialized) return;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Create schema if not exists
|
||||
await conn.ExecuteAsync($"CREATE SCHEMA IF NOT EXISTS {_connectionFactory.Schema};").ConfigureAwait(false);
|
||||
|
||||
// Create table
|
||||
var createTableSql = $@"
|
||||
CREATE TABLE IF NOT EXISTS {_tableName} (
|
||||
id TEXT PRIMARY KEY,
|
||||
payload JSONB NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
attempt_count INTEGER NOT NULL DEFAULT 1,
|
||||
enqueued_at TIMESTAMPTZ NOT NULL,
|
||||
available_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ,
|
||||
last_attempt_at TIMESTAMPTZ,
|
||||
last_error TEXT,
|
||||
lease_owner TEXT,
|
||||
lease_expires_at TIMESTAMPTZ,
|
||||
tenant_id TEXT,
|
||||
correlation_id TEXT,
|
||||
idempotency_key TEXT,
|
||||
priority INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_{_queueOptions.QueueName.Replace(":", "_").Replace("-", "_")}_status_available
|
||||
ON {_tableName} (status, available_at, priority DESC)
|
||||
WHERE status IN ('pending', 'retrying');
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_{_queueOptions.QueueName.Replace(":", "_").Replace("-", "_")}_lease
|
||||
ON {_tableName} (lease_expires_at)
|
||||
WHERE status = 'processing';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_{_queueOptions.QueueName.Replace(":", "_").Replace("-", "_")}_idempotency
|
||||
ON {_tableName} (idempotency_key, enqueued_at)
|
||||
WHERE idempotency_key IS NOT NULL;
|
||||
";
|
||||
|
||||
await conn.ExecuteAsync(createTableSql).ConfigureAwait(false);
|
||||
|
||||
_tableInitialized = true;
|
||||
_logger?.LogDebug("Initialized queue table {Table}", _tableName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_initLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating PostgreSQL message queue instances.
|
||||
/// </summary>
|
||||
public sealed class PostgresMessageQueueFactory : IMessageQueueFactory
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public PostgresMessageQueueFactory(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
TimeProvider? timeProvider = null,
|
||||
JsonSerializerOptions? jsonOptions = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IMessageQueue<TMessage> Create<TMessage>(MessageQueueOptions options)
|
||||
where TMessage : class
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
return new PostgresMessageQueue<TMessage>(
|
||||
_connectionFactory,
|
||||
options,
|
||||
_loggerFactory?.CreateLogger<PostgresMessageQueue<TMessage>>(),
|
||||
_timeProvider,
|
||||
_jsonOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IRateLimiter"/>.
|
||||
/// Uses sliding window algorithm with INSERT ON CONFLICT UPDATE.
|
||||
/// </summary>
|
||||
public sealed class PostgresRateLimiter : IRateLimiter
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly string _name;
|
||||
private readonly ILogger<PostgresRateLimiter>? _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private bool _tableInitialized;
|
||||
|
||||
public PostgresRateLimiter(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
string name,
|
||||
ILogger<PostgresRateLimiter>? 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}.rate_limit_{_name.ToLowerInvariant().Replace("-", "_")}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<RateLimitResult> TryAcquireAsync(
|
||||
string key,
|
||||
RateLimitPolicy policy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var windowStart = now - policy.Window;
|
||||
|
||||
// Clean up old entries and count + increment
|
||||
var sql = $@"
|
||||
WITH cleaned AS (
|
||||
DELETE FROM {TableName}
|
||||
WHERE key = @Key AND timestamp < @WindowStart
|
||||
),
|
||||
existing AS (
|
||||
SELECT COUNT(*) as cnt FROM {TableName} WHERE key = @Key
|
||||
),
|
||||
inserted AS (
|
||||
INSERT INTO {TableName} (key, timestamp)
|
||||
VALUES (@Key, @Now)
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT (SELECT cnt FROM existing) + 1 as count";
|
||||
|
||||
var currentCount = await conn.ExecuteScalarAsync<int>(
|
||||
new CommandDefinition(sql, new { Key = key, WindowStart = windowStart.UtcDateTime, Now = now.UtcDateTime }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (currentCount > policy.MaxPermits)
|
||||
{
|
||||
var retryAfter = policy.Window;
|
||||
return RateLimitResult.Denied(currentCount, retryAfter);
|
||||
}
|
||||
|
||||
var remaining = Math.Max(0, policy.MaxPermits - currentCount);
|
||||
return RateLimitResult.Allowed(currentCount, remaining);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<RateLimitStatus> GetStatusAsync(
|
||||
string key,
|
||||
RateLimitPolicy policy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var windowStart = now - policy.Window;
|
||||
|
||||
var sql = $@"SELECT COUNT(*) FROM {TableName} WHERE key = @Key AND timestamp >= @WindowStart";
|
||||
var currentCount = await conn.ExecuteScalarAsync<int>(
|
||||
new CommandDefinition(sql, new { Key = key, WindowStart = windowStart.UtcDateTime }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var remaining = Math.Max(0, policy.MaxPermits - currentCount);
|
||||
|
||||
return new RateLimitStatus
|
||||
{
|
||||
CurrentCount = currentCount,
|
||||
RemainingPermits = remaining,
|
||||
WindowRemaining = policy.Window,
|
||||
Exists = currentCount > 0
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ResetAsync(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;
|
||||
}
|
||||
|
||||
private async ValueTask EnsureTableExistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized) return;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $@"
|
||||
CREATE TABLE IF NOT EXISTS {TableName} (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
key TEXT NOT NULL,
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_{_name}_key_ts ON {TableName} (key, timestamp);";
|
||||
|
||||
await conn.ExecuteAsync(new CommandDefinition(sql, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||
_tableInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating PostgreSQL rate limiter instances.
|
||||
/// </summary>
|
||||
public sealed class PostgresRateLimiterFactory : IRateLimiterFactory
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresRateLimiterFactory(
|
||||
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 IRateLimiter Create(string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new PostgresRateLimiter(
|
||||
_connectionFactory,
|
||||
name,
|
||||
_loggerFactory?.CreateLogger<PostgresRateLimiter>(),
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="ISetStore{TKey, TElement}"/>.
|
||||
/// Uses unique constraint for set semantics.
|
||||
/// </summary>
|
||||
public sealed class PostgresSetStore<TKey, TElement> : ISetStore<TKey, TElement>
|
||||
where TKey : notnull
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly string _name;
|
||||
private readonly ILogger<PostgresSetStore<TKey, TElement>>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly Func<TKey, string> _keySerializer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private bool _tableInitialized;
|
||||
|
||||
public PostgresSetStore(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
string name,
|
||||
ILogger<PostgresSetStore<TKey, TElement>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
Func<TKey, string>? keySerializer = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_logger = logger;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_keySerializer = keySerializer ?? (key => key?.ToString() ?? throw new ArgumentNullException(nameof(key)));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
private string TableName => $"{_connectionFactory.Schema}.set_store_{_name.ToLowerInvariant().Replace("-", "_")}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> AddAsync(
|
||||
TKey setKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(setKey);
|
||||
var elementJson = Serialize(element);
|
||||
|
||||
var sql = $@"
|
||||
INSERT INTO {TableName} (set_key, element, element_hash)
|
||||
VALUES (@Key, @Element::jsonb, md5(@Element))
|
||||
ON CONFLICT (set_key, element_hash) DO NOTHING
|
||||
RETURNING TRUE";
|
||||
|
||||
var result = await conn.ExecuteScalarAsync<bool?>(
|
||||
new CommandDefinition(sql, new { Key = keyString, Element = elementJson }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result == true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> AddRangeAsync(
|
||||
TKey setKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var elementsList = elements.ToList();
|
||||
if (elementsList.Count == 0) return 0;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(setKey);
|
||||
long addedCount = 0;
|
||||
|
||||
foreach (var element in elementsList)
|
||||
{
|
||||
var elementJson = Serialize(element);
|
||||
var sql = $@"
|
||||
INSERT INTO {TableName} (set_key, element, element_hash)
|
||||
VALUES (@Key, @Element::jsonb, md5(@Element))
|
||||
ON CONFLICT (set_key, element_hash) DO NOTHING
|
||||
RETURNING TRUE";
|
||||
|
||||
var result = await conn.ExecuteScalarAsync<bool?>(
|
||||
new CommandDefinition(sql, new { Key = keyString, Element = elementJson }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result == true) addedCount++;
|
||||
}
|
||||
|
||||
return addedCount;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlySet<TElement>> GetMembersAsync(
|
||||
TKey setKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(setKey);
|
||||
|
||||
var sql = $@"SELECT element FROM {TableName} WHERE set_key = @Key";
|
||||
var results = await conn.QueryAsync<string>(
|
||||
new CommandDefinition(sql, new { Key = keyString }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var set = new HashSet<TElement>();
|
||||
foreach (var json in results)
|
||||
{
|
||||
var element = Deserialize(json);
|
||||
if (element is not null)
|
||||
{
|
||||
set.Add(element);
|
||||
}
|
||||
}
|
||||
|
||||
return set;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ContainsAsync(
|
||||
TKey setKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(setKey);
|
||||
var elementJson = Serialize(element);
|
||||
|
||||
var sql = $@"SELECT EXISTS(SELECT 1 FROM {TableName} WHERE set_key = @Key AND element_hash = md5(@Element))";
|
||||
return await conn.ExecuteScalarAsync<bool>(
|
||||
new CommandDefinition(sql, new { Key = keyString, Element = elementJson }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> RemoveAsync(
|
||||
TKey setKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(setKey);
|
||||
var elementJson = Serialize(element);
|
||||
|
||||
var sql = $@"DELETE FROM {TableName} WHERE set_key = @Key AND element_hash = md5(@Element)";
|
||||
var deleted = await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = keyString, Element = elementJson }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> RemoveRangeAsync(
|
||||
TKey setKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var elementsList = elements.ToList();
|
||||
if (elementsList.Count == 0) return 0;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(setKey);
|
||||
long removedCount = 0;
|
||||
|
||||
foreach (var element in elementsList)
|
||||
{
|
||||
var elementJson = Serialize(element);
|
||||
var sql = $@"DELETE FROM {TableName} WHERE set_key = @Key AND element_hash = md5(@Element)";
|
||||
var deleted = await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = keyString, Element = elementJson }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
removedCount += deleted;
|
||||
}
|
||||
|
||||
return removedCount;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> DeleteAsync(
|
||||
TKey setKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(setKey);
|
||||
|
||||
var sql = $@"DELETE FROM {TableName} WHERE set_key = @Key";
|
||||
var deleted = await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = keyString }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> CountAsync(
|
||||
TKey setKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(setKey);
|
||||
|
||||
var sql = $@"SELECT COUNT(*) FROM {TableName} WHERE set_key = @Key";
|
||||
return await conn.ExecuteScalarAsync<long>(
|
||||
new CommandDefinition(sql, new { Key = keyString }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask SetExpirationAsync(
|
||||
TKey setKey,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(setKey);
|
||||
var expiresAt = _timeProvider.GetUtcNow().Add(ttl);
|
||||
|
||||
var sql = $@"UPDATE {TableName} SET expires_at = @ExpiresAt WHERE set_key = @Key";
|
||||
await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = keyString, ExpiresAt = expiresAt.UtcDateTime }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
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} (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
set_key TEXT NOT NULL,
|
||||
element JSONB NOT NULL,
|
||||
element_hash TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ,
|
||||
UNIQUE (set_key, element_hash)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_{safeName}_set_key ON {TableName} (set_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_{safeName}_expires ON {TableName} (expires_at) WHERE expires_at IS NOT NULL;";
|
||||
|
||||
await conn.ExecuteAsync(new CommandDefinition(sql, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||
_tableInitialized = true;
|
||||
}
|
||||
|
||||
private string Serialize(TElement element)
|
||||
{
|
||||
if (element is string s) return JsonSerializer.Serialize(s, _jsonOptions);
|
||||
return JsonSerializer.Serialize(element, _jsonOptions);
|
||||
}
|
||||
|
||||
private TElement? Deserialize(string value)
|
||||
{
|
||||
if (typeof(TElement) == typeof(string))
|
||||
{
|
||||
return JsonSerializer.Deserialize<TElement>(value, _jsonOptions);
|
||||
}
|
||||
return JsonSerializer.Deserialize<TElement>(value, _jsonOptions);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating PostgreSQL set store instances.
|
||||
/// </summary>
|
||||
public sealed class PostgresSetStoreFactory : ISetStoreFactory
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly JsonSerializerOptions? _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresSetStoreFactory(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_jsonOptions = jsonOptions;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISetStore<TKey, TElement> Create<TKey, TElement>(string name)
|
||||
where TKey : notnull
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new PostgresSetStore<TKey, TElement>(
|
||||
_connectionFactory,
|
||||
name,
|
||||
_loggerFactory?.CreateLogger<PostgresSetStore<TKey, TElement>>(),
|
||||
_jsonOptions,
|
||||
null,
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="ISortedIndex{TKey, TElement}"/>.
|
||||
/// Uses B-tree indexes for efficient score-based queries.
|
||||
/// </summary>
|
||||
public sealed class PostgresSortedIndex<TKey, TElement> : ISortedIndex<TKey, TElement>
|
||||
where TKey : notnull
|
||||
where TElement : notnull
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly string _name;
|
||||
private readonly ILogger<PostgresSortedIndex<TKey, TElement>>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly Func<TKey, string> _keySerializer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private bool _tableInitialized;
|
||||
|
||||
public PostgresSortedIndex(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
string name,
|
||||
ILogger<PostgresSortedIndex<TKey, TElement>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
Func<TKey, string>? keySerializer = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_logger = logger;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_keySerializer = keySerializer ?? (key => key?.ToString() ?? throw new ArgumentNullException(nameof(key)));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
private string TableName => $"{_connectionFactory.Schema}.sorted_index_{_name.ToLowerInvariant().Replace("-", "_")}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> AddAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
double score,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
var elementJson = JsonSerializer.Serialize(element, _jsonOptions);
|
||||
|
||||
var sql = $@"
|
||||
INSERT INTO {TableName} (index_key, element, element_hash, score)
|
||||
VALUES (@Key, @Element::jsonb, md5(@Element), @Score)
|
||||
ON CONFLICT (index_key, element_hash) DO UPDATE SET score = EXCLUDED.score
|
||||
RETURNING (xmax = 0)";
|
||||
|
||||
var wasInserted = await conn.ExecuteScalarAsync<bool>(
|
||||
new CommandDefinition(sql, new { Key = keyString, Element = elementJson, Score = score }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return wasInserted;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> AddRangeAsync(
|
||||
TKey indexKey,
|
||||
IEnumerable<ScoredElement<TElement>> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var elementsList = elements.ToList();
|
||||
if (elementsList.Count == 0) return 0;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
long addedCount = 0;
|
||||
|
||||
foreach (var item in elementsList)
|
||||
{
|
||||
var elementJson = JsonSerializer.Serialize(item.Element, _jsonOptions);
|
||||
var sql = $@"
|
||||
INSERT INTO {TableName} (index_key, element, element_hash, score)
|
||||
VALUES (@Key, @Element::jsonb, md5(@Element), @Score)
|
||||
ON CONFLICT (index_key, element_hash) DO UPDATE SET score = EXCLUDED.score
|
||||
RETURNING (xmax = 0)";
|
||||
|
||||
var wasInserted = await conn.ExecuteScalarAsync<bool>(
|
||||
new CommandDefinition(sql, new { Key = keyString, Element = elementJson, Score = item.Score }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (wasInserted) addedCount++;
|
||||
}
|
||||
|
||||
return addedCount;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlyList<ScoredElement<TElement>>> GetByRankAsync(
|
||||
TKey indexKey,
|
||||
long start,
|
||||
long stop,
|
||||
SortOrder order = SortOrder.Ascending,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
var orderSql = order == SortOrder.Ascending ? "ASC" : "DESC";
|
||||
|
||||
// Handle negative indices like Redis
|
||||
var sql = $@"
|
||||
WITH ranked AS (
|
||||
SELECT element, score, ROW_NUMBER() OVER (ORDER BY score {orderSql}) - 1 AS rank
|
||||
FROM {TableName}
|
||||
WHERE index_key = @Key
|
||||
)
|
||||
SELECT element, score FROM ranked
|
||||
WHERE rank >= @Start AND rank <= @Stop";
|
||||
|
||||
var results = await conn.QueryAsync<ElementScoreRow>(
|
||||
new CommandDefinition(sql, new { Key = keyString, Start = start, Stop = stop }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return results
|
||||
.Select(r => new ScoredElement<TElement>(
|
||||
JsonSerializer.Deserialize<TElement>(r.Element, _jsonOptions)!,
|
||||
r.Score))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlyList<ScoredElement<TElement>>> GetByScoreAsync(
|
||||
TKey indexKey,
|
||||
double minScore,
|
||||
double maxScore,
|
||||
SortOrder order = SortOrder.Ascending,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
var orderSql = order == SortOrder.Ascending ? "ASC" : "DESC";
|
||||
var limitSql = limit.HasValue ? $"LIMIT {limit.Value}" : "";
|
||||
|
||||
var sql = $@"
|
||||
SELECT element, score FROM {TableName}
|
||||
WHERE index_key = @Key AND score >= @MinScore AND score <= @MaxScore
|
||||
ORDER BY score {orderSql}
|
||||
{limitSql}";
|
||||
|
||||
var results = await conn.QueryAsync<ElementScoreRow>(
|
||||
new CommandDefinition(sql, new { Key = keyString, MinScore = minScore, MaxScore = maxScore }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return results
|
||||
.Select(r => new ScoredElement<TElement>(
|
||||
JsonSerializer.Deserialize<TElement>(r.Element, _jsonOptions)!,
|
||||
r.Score))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<double?> GetScoreAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
var elementJson = JsonSerializer.Serialize(element, _jsonOptions);
|
||||
|
||||
var sql = $@"SELECT score FROM {TableName} WHERE index_key = @Key AND element_hash = md5(@Element)";
|
||||
return await conn.ExecuteScalarAsync<double?>(
|
||||
new CommandDefinition(sql, new { Key = keyString, Element = elementJson }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> RemoveAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
var elementJson = JsonSerializer.Serialize(element, _jsonOptions);
|
||||
|
||||
var sql = $@"DELETE FROM {TableName} WHERE index_key = @Key AND element_hash = md5(@Element)";
|
||||
var deleted = await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = keyString, Element = elementJson }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> RemoveRangeAsync(
|
||||
TKey indexKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var elementsList = elements.ToList();
|
||||
if (elementsList.Count == 0) return 0;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
long removedCount = 0;
|
||||
|
||||
foreach (var element in elementsList)
|
||||
{
|
||||
var elementJson = JsonSerializer.Serialize(element, _jsonOptions);
|
||||
var sql = $@"DELETE FROM {TableName} WHERE index_key = @Key AND element_hash = md5(@Element)";
|
||||
var deleted = await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = keyString, Element = elementJson }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
removedCount += deleted;
|
||||
}
|
||||
|
||||
return removedCount;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> RemoveByScoreAsync(
|
||||
TKey indexKey,
|
||||
double minScore,
|
||||
double maxScore,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
|
||||
var sql = $@"DELETE FROM {TableName} WHERE index_key = @Key AND score >= @MinScore AND score <= @MaxScore";
|
||||
return await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = keyString, MinScore = minScore, MaxScore = maxScore }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> CountAsync(
|
||||
TKey indexKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
|
||||
var sql = $@"SELECT COUNT(*) FROM {TableName} WHERE index_key = @Key";
|
||||
return await conn.ExecuteScalarAsync<long>(
|
||||
new CommandDefinition(sql, new { Key = keyString }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> DeleteAsync(
|
||||
TKey indexKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
|
||||
var sql = $@"DELETE FROM {TableName} WHERE index_key = @Key";
|
||||
var deleted = await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = keyString }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask SetExpirationAsync(
|
||||
TKey indexKey,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
var expiresAt = _timeProvider.GetUtcNow().Add(ttl);
|
||||
|
||||
var sql = $@"UPDATE {TableName} SET expires_at = @ExpiresAt WHERE index_key = @Key";
|
||||
await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = keyString, ExpiresAt = expiresAt.UtcDateTime }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
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} (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
index_key TEXT NOT NULL,
|
||||
element JSONB NOT NULL,
|
||||
element_hash TEXT NOT NULL,
|
||||
score DOUBLE PRECISION NOT NULL,
|
||||
expires_at TIMESTAMPTZ,
|
||||
UNIQUE (index_key, element_hash)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_{safeName}_score ON {TableName} (index_key, score);
|
||||
CREATE INDEX IF NOT EXISTS idx_{safeName}_expires ON {TableName} (expires_at) WHERE expires_at IS NOT NULL;";
|
||||
|
||||
await conn.ExecuteAsync(new CommandDefinition(sql, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||
_tableInitialized = true;
|
||||
}
|
||||
|
||||
private sealed class ElementScoreRow
|
||||
{
|
||||
public string Element { get; init; } = null!;
|
||||
public double Score { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating PostgreSQL sorted index instances.
|
||||
/// </summary>
|
||||
public sealed class PostgresSortedIndexFactory : ISortedIndexFactory
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly JsonSerializerOptions? _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresSortedIndexFactory(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_jsonOptions = jsonOptions;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISortedIndex<TKey, TElement> Create<TKey, TElement>(string name)
|
||||
where TKey : notnull
|
||||
where TElement : notnull
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new PostgresSortedIndex<TKey, TElement>(
|
||||
_connectionFactory,
|
||||
name,
|
||||
_loggerFactory?.CreateLogger<PostgresSortedIndex<TKey, TElement>>(),
|
||||
_jsonOptions,
|
||||
null,
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Messaging.Plugins;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL transport plugin for StellaOps.Messaging.
|
||||
/// Uses FOR UPDATE SKIP LOCKED for reliable queue semantics.
|
||||
/// Ideal for air-gap deployments with PostgreSQL-only infrastructure.
|
||||
/// </summary>
|
||||
public sealed class PostgresTransportPlugin : IMessagingTransportPlugin
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Name => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Register(MessagingTransportRegistrationContext context)
|
||||
{
|
||||
// Register options
|
||||
context.Services.AddOptions<PostgresTransportOptions>()
|
||||
.Bind(context.GetTransportConfiguration())
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
// Register connection factory
|
||||
context.Services.AddSingleton<PostgresConnectionFactory>();
|
||||
|
||||
// Register message queue factory
|
||||
context.Services.AddSingleton<IMessageQueueFactory, PostgresMessageQueueFactory>();
|
||||
|
||||
// Register cache factory
|
||||
context.Services.AddSingleton<IDistributedCacheFactory, PostgresCacheFactory>();
|
||||
|
||||
// Register rate limiter factory
|
||||
context.Services.AddSingleton<IRateLimiterFactory, PostgresRateLimiterFactory>();
|
||||
|
||||
// Register atomic token store factory
|
||||
context.Services.AddSingleton<IAtomicTokenStoreFactory, PostgresAtomicTokenStoreFactory>();
|
||||
|
||||
// Register sorted index factory
|
||||
context.Services.AddSingleton<ISortedIndexFactory, PostgresSortedIndexFactory>();
|
||||
|
||||
// Register set store factory
|
||||
context.Services.AddSingleton<ISetStoreFactory, PostgresSetStoreFactory>();
|
||||
|
||||
// Register event stream factory
|
||||
context.Services.AddSingleton<IEventStreamFactory, PostgresEventStreamFactory>();
|
||||
|
||||
// Register idempotency store factory
|
||||
context.Services.AddSingleton<IIdempotencyStoreFactory, PostgresIdempotencyStoreFactory>();
|
||||
|
||||
context.LoggerFactory?.CreateLogger<PostgresTransportPlugin>()
|
||||
.LogDebug("Registered PostgreSQL transport plugin");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Messaging.Transport.Postgres</RootNamespace>
|
||||
<AssemblyName>StellaOps.Messaging.Transport.Postgres</AssemblyName>
|
||||
<Description>PostgreSQL transport plugin for StellaOps.Messaging (FOR UPDATE SKIP LOCKED pattern)</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapper" Version="2.1.35" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.0" />
|
||||
<PackageReference Include="Npgsql" Version="9.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Messaging/StellaOps.Messaging.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -19,6 +19,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.0" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.37" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Valkey/Redis implementation of <see cref="IAtomicTokenStore{TPayload}"/>.
|
||||
/// Uses Lua scripts for atomic compare-and-delete operations.
|
||||
/// </summary>
|
||||
public sealed class ValkeyAtomicTokenStore<TPayload> : IAtomicTokenStore<TPayload>
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly string _name;
|
||||
private readonly ILogger<ValkeyAtomicTokenStore<TPayload>>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
// Lua script for atomic consume: GET, compare, DELETE if matches
|
||||
private const string ConsumeScript = @"
|
||||
local value = redis.call('GET', KEYS[1])
|
||||
if not value then
|
||||
return {0, nil}
|
||||
end
|
||||
local data = cjson.decode(value)
|
||||
if data.token ~= ARGV[1] then
|
||||
return {2, value}
|
||||
end
|
||||
redis.call('DEL', KEYS[1])
|
||||
return {1, value}
|
||||
";
|
||||
|
||||
public ValkeyAtomicTokenStore(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
string name,
|
||||
ILogger<ValkeyAtomicTokenStore<TPayload>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_logger = logger;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<TokenIssueResult> IssueAsync(
|
||||
string key,
|
||||
TPayload payload,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.Add(ttl);
|
||||
|
||||
// Generate secure random token
|
||||
var tokenBytes = new byte[32];
|
||||
RandomNumberGenerator.Fill(tokenBytes);
|
||||
var token = Convert.ToBase64String(tokenBytes);
|
||||
|
||||
var entry = new TokenData<TPayload>
|
||||
{
|
||||
Token = token,
|
||||
Payload = payload,
|
||||
IssuedAt = now,
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
|
||||
var serialized = JsonSerializer.Serialize(entry, _jsonOptions);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await db.StringSetAsync(redisKey, serialized, ttl).ConfigureAwait(false);
|
||||
|
||||
return TokenIssueResult.Succeeded(token, expiresAt);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<TokenIssueResult> StoreAsync(
|
||||
string key,
|
||||
string token,
|
||||
TPayload payload,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(token);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.Add(ttl);
|
||||
|
||||
var entry = new TokenData<TPayload>
|
||||
{
|
||||
Token = token,
|
||||
Payload = payload,
|
||||
IssuedAt = now,
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
|
||||
var serialized = JsonSerializer.Serialize(entry, _jsonOptions);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await db.StringSetAsync(redisKey, serialized, ttl).ConfigureAwait(false);
|
||||
|
||||
return TokenIssueResult.Succeeded(token, expiresAt);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<TokenConsumeResult<TPayload>> TryConsumeAsync(
|
||||
string key,
|
||||
string expectedToken,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(expectedToken);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
var result = await db.ScriptEvaluateAsync(
|
||||
ConsumeScript,
|
||||
new RedisKey[] { redisKey },
|
||||
new RedisValue[] { expectedToken }).ConfigureAwait(false);
|
||||
|
||||
var results = (RedisResult[])result!;
|
||||
var status = (int)results[0];
|
||||
|
||||
switch (status)
|
||||
{
|
||||
case 0: // Not found
|
||||
return TokenConsumeResult<TPayload>.NotFound();
|
||||
|
||||
case 1: // Success
|
||||
var data = JsonSerializer.Deserialize<TokenData<TPayload>>((string)results[1]!, _jsonOptions);
|
||||
if (data is null)
|
||||
{
|
||||
return TokenConsumeResult<TPayload>.NotFound();
|
||||
}
|
||||
|
||||
if (data.ExpiresAt < now)
|
||||
{
|
||||
return TokenConsumeResult<TPayload>.Expired(data.IssuedAt, data.ExpiresAt);
|
||||
}
|
||||
|
||||
return TokenConsumeResult<TPayload>.Success(data.Payload!, data.IssuedAt, data.ExpiresAt);
|
||||
|
||||
case 2: // Mismatch
|
||||
return TokenConsumeResult<TPayload>.Mismatch();
|
||||
|
||||
default:
|
||||
return TokenConsumeResult<TPayload>.NotFound();
|
||||
}
|
||||
}
|
||||
catch (RedisServerException ex) when (ex.Message.Contains("NOSCRIPT"))
|
||||
{
|
||||
// Fallback: non-atomic approach (less safe but works without Lua)
|
||||
return await TryConsumeNonAtomicAsync(db, redisKey, expectedToken, now).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask<TokenConsumeResult<TPayload>> TryConsumeNonAtomicAsync(
|
||||
IDatabase db,
|
||||
string redisKey,
|
||||
string expectedToken,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
var value = await db.StringGetAsync(redisKey).ConfigureAwait(false);
|
||||
if (value.IsNullOrEmpty)
|
||||
{
|
||||
return TokenConsumeResult<TPayload>.NotFound();
|
||||
}
|
||||
|
||||
var data = JsonSerializer.Deserialize<TokenData<TPayload>>((string)value!, _jsonOptions);
|
||||
if (data is null)
|
||||
{
|
||||
return TokenConsumeResult<TPayload>.NotFound();
|
||||
}
|
||||
|
||||
if (data.ExpiresAt < now)
|
||||
{
|
||||
await db.KeyDeleteAsync(redisKey).ConfigureAwait(false);
|
||||
return TokenConsumeResult<TPayload>.Expired(data.IssuedAt, data.ExpiresAt);
|
||||
}
|
||||
|
||||
if (!string.Equals(data.Token, expectedToken, StringComparison.Ordinal))
|
||||
{
|
||||
return TokenConsumeResult<TPayload>.Mismatch();
|
||||
}
|
||||
|
||||
// Try to delete - if someone else deleted it first, we lost the race
|
||||
if (await db.KeyDeleteAsync(redisKey).ConfigureAwait(false))
|
||||
{
|
||||
return TokenConsumeResult<TPayload>.Success(data.Payload!, data.IssuedAt, data.ExpiresAt);
|
||||
}
|
||||
|
||||
return TokenConsumeResult<TPayload>.NotFound();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ExistsAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await db.KeyExistsAsync(redisKey).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> RevokeAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await db.KeyDeleteAsync(redisKey).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private string BuildKey(string key) => $"token:{_name}:{key}";
|
||||
|
||||
private sealed class TokenData<T>
|
||||
{
|
||||
public required string Token { get; init; }
|
||||
public T? Payload { get; init; }
|
||||
public DateTimeOffset IssuedAt { get; init; }
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating Valkey atomic token store instances.
|
||||
/// </summary>
|
||||
public sealed class ValkeyAtomicTokenStoreFactory : IAtomicTokenStoreFactory
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly JsonSerializerOptions? _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ValkeyAtomicTokenStoreFactory(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_jsonOptions = jsonOptions;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAtomicTokenStore<TPayload> Create<TPayload>(string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new ValkeyAtomicTokenStore<TPayload>(
|
||||
_connectionFactory,
|
||||
name,
|
||||
_loggerFactory?.CreateLogger<ValkeyAtomicTokenStore<TPayload>>(),
|
||||
_jsonOptions,
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating Valkey distributed cache instances.
|
||||
/// </summary>
|
||||
public sealed class ValkeyCacheFactory : IDistributedCacheFactory
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public ValkeyCacheFactory(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
JsonSerializerOptions? jsonOptions = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDistributedCache<TKey, TValue> Create<TKey, TValue>(CacheOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
return new ValkeyCacheStore<TKey, TValue>(
|
||||
_connectionFactory,
|
||||
options,
|
||||
_loggerFactory?.CreateLogger<ValkeyCacheStore<TKey, TValue>>(),
|
||||
_jsonOptions);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDistributedCache<TValue> Create<TValue>(CacheOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
return new ValkeyCacheStore<TValue>(
|
||||
_connectionFactory,
|
||||
options,
|
||||
_loggerFactory?.CreateLogger<ValkeyCacheStore<TValue>>(),
|
||||
_jsonOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Valkey/Redis implementation of <see cref="IDistributedCache{TKey, TValue}"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TKey">The key type.</typeparam>
|
||||
/// <typeparam name="TValue">The value type.</typeparam>
|
||||
public sealed class ValkeyCacheStore<TKey, TValue> : IDistributedCache<TKey, TValue>
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly CacheOptions _cacheOptions;
|
||||
private readonly ILogger<ValkeyCacheStore<TKey, TValue>>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly Func<TKey, string> _keySerializer;
|
||||
|
||||
public ValkeyCacheStore(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
CacheOptions cacheOptions,
|
||||
ILogger<ValkeyCacheStore<TKey, TValue>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
Func<TKey, string>? keySerializer = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_cacheOptions = cacheOptions ?? throw new ArgumentNullException(nameof(cacheOptions));
|
||||
_logger = logger;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_keySerializer = keySerializer ?? (key => key?.ToString() ?? throw new ArgumentNullException(nameof(key)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<CacheResult<TValue>> GetAsync(TKey key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var value = await db.StringGetAsync(redisKey).ConfigureAwait(false);
|
||||
|
||||
if (value.IsNullOrEmpty)
|
||||
{
|
||||
return CacheResult<TValue>.Miss();
|
||||
}
|
||||
|
||||
// Handle sliding expiration by refreshing TTL
|
||||
if (_cacheOptions.SlidingExpiration && _cacheOptions.DefaultTtl.HasValue)
|
||||
{
|
||||
await db.KeyExpireAsync(redisKey, _cacheOptions.DefaultTtl.Value).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = JsonSerializer.Deserialize<TValue>((string)value!, _jsonOptions);
|
||||
return result is not null ? CacheResult<TValue>.Found(result) : CacheResult<TValue>.Miss();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Failed to deserialize cached value for key {Key}", redisKey);
|
||||
return CacheResult<TValue>.Miss();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask SetAsync(
|
||||
TKey key,
|
||||
TValue value,
|
||||
CacheEntryOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(key);
|
||||
var serialized = JsonSerializer.Serialize(value, _jsonOptions);
|
||||
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
TimeSpan? expiry = null;
|
||||
|
||||
if (options?.TimeToLive.HasValue == true)
|
||||
{
|
||||
expiry = options.TimeToLive.Value;
|
||||
}
|
||||
else if (options?.AbsoluteExpiration.HasValue == true)
|
||||
{
|
||||
expiry = options.AbsoluteExpiration.Value - DateTimeOffset.UtcNow;
|
||||
if (expiry.Value < TimeSpan.Zero)
|
||||
{
|
||||
expiry = TimeSpan.Zero;
|
||||
}
|
||||
}
|
||||
else if (_cacheOptions.DefaultTtl.HasValue)
|
||||
{
|
||||
expiry = _cacheOptions.DefaultTtl.Value;
|
||||
}
|
||||
|
||||
await db.StringSetAsync(redisKey, serialized, expiry).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> InvalidateAsync(TKey key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await db.KeyDeleteAsync(redisKey).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> InvalidateByPatternAsync(string pattern, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var fullPattern = string.IsNullOrWhiteSpace(_cacheOptions.KeyPrefix)
|
||||
? pattern
|
||||
: $"{_cacheOptions.KeyPrefix}{pattern}";
|
||||
|
||||
var connection = await _connectionFactory.GetConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
var server = connection.GetServers().First();
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
long count = 0;
|
||||
await foreach (var key in server.KeysAsync(pattern: fullPattern))
|
||||
{
|
||||
if (await db.KeyDeleteAsync(key).ConfigureAwait(false))
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<TValue> GetOrSetAsync(
|
||||
TKey key,
|
||||
Func<CancellationToken, ValueTask<TValue>> factory,
|
||||
CacheEntryOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await GetAsync(key, cancellationToken).ConfigureAwait(false);
|
||||
if (result.HasValue)
|
||||
{
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
var value = await factory(cancellationToken).ConfigureAwait(false);
|
||||
await SetAsync(key, value, options, cancellationToken).ConfigureAwait(false);
|
||||
return value;
|
||||
}
|
||||
|
||||
private string BuildKey(TKey key)
|
||||
{
|
||||
var keyString = _keySerializer(key);
|
||||
return string.IsNullOrWhiteSpace(_cacheOptions.KeyPrefix)
|
||||
? keyString
|
||||
: $"{_cacheOptions.KeyPrefix}{keyString}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// String-keyed Valkey cache store.
|
||||
/// </summary>
|
||||
/// <typeparam name="TValue">The value type.</typeparam>
|
||||
public sealed class ValkeyCacheStore<TValue> : IDistributedCache<TValue>
|
||||
{
|
||||
private readonly ValkeyCacheStore<string, TValue> _inner;
|
||||
|
||||
public ValkeyCacheStore(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
CacheOptions cacheOptions,
|
||||
ILogger<ValkeyCacheStore<TValue>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null)
|
||||
{
|
||||
_inner = new ValkeyCacheStore<string, TValue>(
|
||||
connectionFactory,
|
||||
cacheOptions,
|
||||
null, // Use dedicated logger
|
||||
jsonOptions,
|
||||
key => key);
|
||||
}
|
||||
|
||||
public string ProviderName => _inner.ProviderName;
|
||||
|
||||
public ValueTask<CacheResult<TValue>> GetAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> _inner.GetAsync(key, cancellationToken);
|
||||
|
||||
public ValueTask SetAsync(string key, TValue value, CacheEntryOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> _inner.SetAsync(key, value, options, cancellationToken);
|
||||
|
||||
public ValueTask<bool> InvalidateAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> _inner.InvalidateAsync(key, cancellationToken);
|
||||
|
||||
public ValueTask<long> InvalidateByPatternAsync(string pattern, CancellationToken cancellationToken = default)
|
||||
=> _inner.InvalidateByPatternAsync(pattern, cancellationToken);
|
||||
|
||||
public ValueTask<TValue> GetOrSetAsync(string key, Func<CancellationToken, ValueTask<TValue>> factory, CacheEntryOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> _inner.GetOrSetAsync(key, factory, options, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Valkey/Redis implementation of <see cref="IEventStream{TEvent}"/>.
|
||||
/// Uses stream commands (XADD, XREAD, XINFO, XTRIM) without consumer groups.
|
||||
/// </summary>
|
||||
public sealed class ValkeyEventStream<TEvent> : IEventStream<TEvent>
|
||||
where TEvent : class
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly EventStreamOptions _options;
|
||||
private readonly ILogger<ValkeyEventStream<TEvent>>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private const string DataField = "data";
|
||||
private const string TenantIdField = "tenantId";
|
||||
private const string CorrelationIdField = "correlationId";
|
||||
|
||||
public ValkeyEventStream(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
EventStreamOptions options,
|
||||
ILogger<ValkeyEventStream<TEvent>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string StreamName => _options.StreamName;
|
||||
|
||||
private string RedisKey => $"stream:{_options.StreamName}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<EventPublishResult> PublishAsync(
|
||||
TEvent @event,
|
||||
EventPublishOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(@event);
|
||||
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var entries = new List<NameValueEntry>
|
||||
{
|
||||
new(DataField, JsonSerializer.Serialize(@event, _jsonOptions))
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(options?.TenantId))
|
||||
{
|
||||
entries.Add(new(TenantIdField, options.TenantId));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(options?.CorrelationId))
|
||||
{
|
||||
entries.Add(new(CorrelationIdField, options.CorrelationId));
|
||||
}
|
||||
|
||||
// Add custom headers
|
||||
if (options?.Headers is not null)
|
||||
{
|
||||
foreach (var header in options.Headers)
|
||||
{
|
||||
entries.Add(new($"h:{header.Key}", header.Value));
|
||||
}
|
||||
}
|
||||
|
||||
var entryId = await db.StreamAddAsync(
|
||||
RedisKey,
|
||||
entries.ToArray(),
|
||||
maxLength: _options.MaxLength.HasValue ? (int)_options.MaxLength.Value : null,
|
||||
useApproximateMaxLength: _options.ApproximateTrimming).ConfigureAwait(false);
|
||||
|
||||
return EventPublishResult.Succeeded(entryId!);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlyList<EventPublishResult>> PublishBatchAsync(
|
||||
IEnumerable<TEvent> events,
|
||||
EventPublishOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(events);
|
||||
|
||||
var results = new List<EventPublishResult>();
|
||||
|
||||
foreach (var @event in events)
|
||||
{
|
||||
var result = await PublishAsync(@event, options, cancellationToken).ConfigureAwait(false);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<StreamEvent<TEvent>> SubscribeAsync(
|
||||
StreamPosition position,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Convert position to Redis format
|
||||
var lastId = position.Value == "0" ? "0-0" :
|
||||
position.Value == "$" ? "$" :
|
||||
position.Value;
|
||||
|
||||
// If starting from end, get current last entry ID
|
||||
if (lastId == "$")
|
||||
{
|
||||
var info = await GetInfoAsync(cancellationToken).ConfigureAwait(false);
|
||||
lastId = info.LastEntryId ?? "0-0";
|
||||
}
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var entries = await db.StreamReadAsync(
|
||||
RedisKey,
|
||||
lastId,
|
||||
count: 100).ConfigureAwait(false);
|
||||
|
||||
if (entries.Length > 0)
|
||||
{
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var streamEvent = ParseEntry(entry);
|
||||
if (streamEvent is not null)
|
||||
{
|
||||
yield return streamEvent;
|
||||
}
|
||||
lastId = entry.Id!;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No new entries, wait before polling again
|
||||
await Task.Delay(_options.PollInterval, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<StreamInfo> GetInfoAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var info = await db.StreamInfoAsync(RedisKey).ConfigureAwait(false);
|
||||
|
||||
return new StreamInfo(
|
||||
info.Length,
|
||||
info.FirstEntry.Id,
|
||||
info.LastEntry.Id,
|
||||
ParseTimestamp(info.FirstEntry.Id),
|
||||
ParseTimestamp(info.LastEntry.Id));
|
||||
}
|
||||
catch (RedisServerException ex) when (ex.Message.Contains("no such key"))
|
||||
{
|
||||
return new StreamInfo(0, null, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> TrimAsync(
|
||||
long maxLength,
|
||||
bool approximate = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await db.StreamTrimAsync(RedisKey, (int)maxLength, approximate).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private StreamEvent<TEvent>? ParseEntry(StreamEntry entry)
|
||||
{
|
||||
var data = entry[DataField];
|
||||
if (data.IsNullOrEmpty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var @event = JsonSerializer.Deserialize<TEvent>((string)data!, _jsonOptions);
|
||||
if (@event is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var tenantId = entry[TenantIdField];
|
||||
var correlationId = entry[CorrelationIdField];
|
||||
|
||||
return new StreamEvent<TEvent>(
|
||||
entry.Id!,
|
||||
@event,
|
||||
ParseTimestamp(entry.Id) ?? _timeProvider.GetUtcNow(),
|
||||
tenantId.IsNullOrEmpty ? null : (string)tenantId!,
|
||||
correlationId.IsNullOrEmpty ? null : (string)correlationId!);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
_logger?.LogWarning("Failed to deserialize stream event {EntryId}", entry.Id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ParseTimestamp(string? entryId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(entryId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Redis stream IDs are formatted as "timestamp-sequence"
|
||||
var dashIndex = entryId.IndexOf('-');
|
||||
if (dashIndex <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (long.TryParse(entryId.AsSpan(0, dashIndex), out var timestamp))
|
||||
{
|
||||
return DateTimeOffset.FromUnixTimeMilliseconds(timestamp);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating Valkey event stream instances.
|
||||
/// </summary>
|
||||
public sealed class ValkeyEventStreamFactory : IEventStreamFactory
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly JsonSerializerOptions? _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ValkeyEventStreamFactory(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_jsonOptions = jsonOptions;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEventStream<TEvent> Create<TEvent>(EventStreamOptions options) where TEvent : class
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return new ValkeyEventStream<TEvent>(
|
||||
_connectionFactory,
|
||||
options,
|
||||
_loggerFactory?.CreateLogger<ValkeyEventStream<TEvent>>(),
|
||||
_jsonOptions,
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Valkey/Redis implementation of <see cref="IIdempotencyStore"/>.
|
||||
/// Uses SET NX EX for atomic key claiming.
|
||||
/// </summary>
|
||||
public sealed class ValkeyIdempotencyStore : IIdempotencyStore
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly string _name;
|
||||
private readonly ILogger<ValkeyIdempotencyStore>? _logger;
|
||||
|
||||
public ValkeyIdempotencyStore(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
string name,
|
||||
ILogger<ValkeyIdempotencyStore>? logger = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IdempotencyResult> TryClaimAsync(
|
||||
string key,
|
||||
string value,
|
||||
TimeSpan window,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// SET key value NX EX ttl - only sets if key doesn't exist
|
||||
var wasSet = await db.StringSetAsync(redisKey, value, window, When.NotExists).ConfigureAwait(false);
|
||||
|
||||
if (wasSet)
|
||||
{
|
||||
return IdempotencyResult.Claimed();
|
||||
}
|
||||
|
||||
// Key already exists, get the existing value
|
||||
var existing = await db.StringGetAsync(redisKey).ConfigureAwait(false);
|
||||
return IdempotencyResult.Duplicate(existing.HasValue ? (string)existing! : string.Empty);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ExistsAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await db.KeyExistsAsync(redisKey).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<string?> GetAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var value = await db.StringGetAsync(redisKey).ConfigureAwait(false);
|
||||
return value.HasValue ? (string)value! : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ReleaseAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await db.KeyDeleteAsync(redisKey).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ExtendAsync(
|
||||
string key,
|
||||
TimeSpan extension,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Get current TTL and extend it
|
||||
var currentTtl = await db.KeyTimeToLiveAsync(redisKey).ConfigureAwait(false);
|
||||
if (!currentTtl.HasValue)
|
||||
{
|
||||
return false; // Key doesn't exist or has no TTL
|
||||
}
|
||||
|
||||
var newTtl = currentTtl.Value + extension;
|
||||
return await db.KeyExpireAsync(redisKey, newTtl).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private string BuildKey(string key) => $"idempotency:{_name}:{key}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating Valkey idempotency store instances.
|
||||
/// </summary>
|
||||
public sealed class ValkeyIdempotencyStoreFactory : IIdempotencyStoreFactory
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
|
||||
public ValkeyIdempotencyStoreFactory(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IIdempotencyStore Create(string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new ValkeyIdempotencyStore(
|
||||
_connectionFactory,
|
||||
name,
|
||||
_loggerFactory?.CreateLogger<ValkeyIdempotencyStore>());
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StackExchange.Redis;
|
||||
using RedisStreamPosition = StackExchange.Redis.StreamPosition;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
@@ -444,7 +445,7 @@ public sealed class ValkeyMessageQueue<TMessage> : IMessageQueue<TMessage>, IAsy
|
||||
await database.StreamCreateConsumerGroupAsync(
|
||||
_queueOptions.QueueName,
|
||||
_queueOptions.ConsumerGroup,
|
||||
StreamPosition.Beginning,
|
||||
RedisStreamPosition.Beginning,
|
||||
createStream: true)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating Valkey message queue instances.
|
||||
/// </summary>
|
||||
public sealed class ValkeyMessageQueueFactory : IMessageQueueFactory
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly ValkeyTransportOptions _transportOptions;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public ValkeyMessageQueueFactory(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
IOptions<ValkeyTransportOptions> transportOptions,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
TimeProvider? timeProvider = null,
|
||||
JsonSerializerOptions? jsonOptions = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_transportOptions = transportOptions?.Value ?? throw new ArgumentNullException(nameof(transportOptions));
|
||||
_loggerFactory = loggerFactory;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IMessageQueue<TMessage> Create<TMessage>(MessageQueueOptions options)
|
||||
where TMessage : class
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
return new ValkeyMessageQueue<TMessage>(
|
||||
_connectionFactory,
|
||||
options,
|
||||
_transportOptions,
|
||||
_loggerFactory?.CreateLogger<ValkeyMessageQueue<TMessage>>(),
|
||||
_timeProvider,
|
||||
_jsonOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Valkey/Redis implementation of <see cref="IRateLimiter"/>.
|
||||
/// Uses sliding window algorithm with INCR and EXPIRE commands.
|
||||
/// </summary>
|
||||
public sealed class ValkeyRateLimiter : IRateLimiter
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly string _name;
|
||||
private readonly ILogger<ValkeyRateLimiter>? _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ValkeyRateLimiter(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
string name,
|
||||
ILogger<ValkeyRateLimiter>? 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 => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<RateLimitResult> TryAcquireAsync(
|
||||
string key,
|
||||
RateLimitPolicy policy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Use sliding window with timestamp-based keys
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var windowKey = $"{redisKey}:{now.ToUnixTimeSeconds() / (long)policy.Window.TotalSeconds}";
|
||||
|
||||
var transaction = db.CreateTransaction();
|
||||
var incrTask = transaction.StringIncrementAsync(windowKey);
|
||||
var expireTask = transaction.KeyExpireAsync(windowKey, policy.Window + TimeSpan.FromSeconds(1));
|
||||
|
||||
await transaction.ExecuteAsync().ConfigureAwait(false);
|
||||
|
||||
var currentCount = (int)await incrTask.ConfigureAwait(false);
|
||||
|
||||
if (currentCount > policy.MaxPermits)
|
||||
{
|
||||
// We incremented but exceeded, calculate retry after
|
||||
var retryAfter = policy.Window;
|
||||
return RateLimitResult.Denied(currentCount, retryAfter);
|
||||
}
|
||||
|
||||
var remaining = Math.Max(0, policy.MaxPermits - currentCount);
|
||||
return RateLimitResult.Allowed(currentCount, remaining);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<RateLimitStatus> GetStatusAsync(
|
||||
string key,
|
||||
RateLimitPolicy policy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var windowKey = $"{redisKey}:{now.ToUnixTimeSeconds() / (long)policy.Window.TotalSeconds}";
|
||||
|
||||
var value = await db.StringGetAsync(windowKey).ConfigureAwait(false);
|
||||
var currentCount = value.HasValue ? (int)(long)value : 0;
|
||||
var remaining = Math.Max(0, policy.MaxPermits - currentCount);
|
||||
|
||||
return new RateLimitStatus
|
||||
{
|
||||
CurrentCount = currentCount,
|
||||
RemainingPermits = remaining,
|
||||
WindowRemaining = policy.Window,
|
||||
Exists = value.HasValue
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ResetAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Delete all keys matching the pattern
|
||||
var connection = await _connectionFactory.GetConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
var server = connection.GetServers().First();
|
||||
|
||||
var deleted = false;
|
||||
await foreach (var matchingKey in server.KeysAsync(pattern: $"{redisKey}:*"))
|
||||
{
|
||||
if (await db.KeyDeleteAsync(matchingKey).ConfigureAwait(false))
|
||||
{
|
||||
deleted = true;
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
private string BuildKey(string key) => $"ratelimit:{_name}:{key}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating Valkey rate limiter instances.
|
||||
/// </summary>
|
||||
public sealed class ValkeyRateLimiterFactory : IRateLimiterFactory
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ValkeyRateLimiterFactory(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IRateLimiter Create(string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new ValkeyRateLimiter(
|
||||
_connectionFactory,
|
||||
name,
|
||||
_loggerFactory?.CreateLogger<ValkeyRateLimiter>(),
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Valkey/Redis implementation of <see cref="ISetStore{TKey, TElement}"/>.
|
||||
/// Uses set commands (SADD, SMEMBERS, SISMEMBER, SREM, etc.).
|
||||
/// </summary>
|
||||
public sealed class ValkeySetStore<TKey, TElement> : ISetStore<TKey, TElement>
|
||||
where TKey : notnull
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly string _name;
|
||||
private readonly ILogger<ValkeySetStore<TKey, TElement>>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly Func<TKey, string> _keySerializer;
|
||||
|
||||
public ValkeySetStore(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
string name,
|
||||
ILogger<ValkeySetStore<TKey, TElement>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
Func<TKey, string>? keySerializer = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_logger = logger;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_keySerializer = keySerializer ?? (key => key?.ToString() ?? throw new ArgumentNullException(nameof(key)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> AddAsync(
|
||||
TKey setKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(setKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var serialized = Serialize(element);
|
||||
return await db.SetAddAsync(redisKey, serialized).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> AddRangeAsync(
|
||||
TKey setKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
var redisKey = BuildKey(setKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var values = elements.Select(e => (RedisValue)Serialize(e)).ToArray();
|
||||
if (values.Length == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return await db.SetAddAsync(redisKey, values).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlySet<TElement>> GetMembersAsync(
|
||||
TKey setKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(setKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var members = await db.SetMembersAsync(redisKey).ConfigureAwait(false);
|
||||
var result = new HashSet<TElement>();
|
||||
|
||||
foreach (var member in members)
|
||||
{
|
||||
if (!member.IsNullOrEmpty)
|
||||
{
|
||||
var element = Deserialize((string)member!);
|
||||
if (element is not null)
|
||||
{
|
||||
result.Add(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ContainsAsync(
|
||||
TKey setKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(setKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var serialized = Serialize(element);
|
||||
return await db.SetContainsAsync(redisKey, serialized).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> RemoveAsync(
|
||||
TKey setKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(setKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var serialized = Serialize(element);
|
||||
return await db.SetRemoveAsync(redisKey, serialized).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> RemoveRangeAsync(
|
||||
TKey setKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
var redisKey = BuildKey(setKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var values = elements.Select(e => (RedisValue)Serialize(e)).ToArray();
|
||||
if (values.Length == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return await db.SetRemoveAsync(redisKey, values).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> DeleteAsync(
|
||||
TKey setKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(setKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await db.KeyDeleteAsync(redisKey).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> CountAsync(
|
||||
TKey setKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(setKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await db.SetLengthAsync(redisKey).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask SetExpirationAsync(
|
||||
TKey setKey,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(setKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await db.KeyExpireAsync(redisKey, ttl).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private string BuildKey(TKey setKey)
|
||||
{
|
||||
var keyString = _keySerializer(setKey);
|
||||
return $"set:{_name}:{keyString}";
|
||||
}
|
||||
|
||||
private string Serialize(TElement element)
|
||||
{
|
||||
// For primitive types, use ToString directly
|
||||
if (element is string s)
|
||||
{
|
||||
return s;
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(element, _jsonOptions);
|
||||
}
|
||||
|
||||
private TElement? Deserialize(string value)
|
||||
{
|
||||
// For string types, return directly
|
||||
if (typeof(TElement) == typeof(string))
|
||||
{
|
||||
return (TElement)(object)value;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<TElement>(value, _jsonOptions);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating Valkey set store instances.
|
||||
/// </summary>
|
||||
public sealed class ValkeySetStoreFactory : ISetStoreFactory
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly JsonSerializerOptions? _jsonOptions;
|
||||
|
||||
public ValkeySetStoreFactory(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
JsonSerializerOptions? jsonOptions = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_jsonOptions = jsonOptions;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISetStore<TKey, TElement> Create<TKey, TElement>(string name)
|
||||
where TKey : notnull
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new ValkeySetStore<TKey, TElement>(
|
||||
_connectionFactory,
|
||||
name,
|
||||
_loggerFactory?.CreateLogger<ValkeySetStore<TKey, TElement>>(),
|
||||
_jsonOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Valkey/Redis implementation of <see cref="ISortedIndex{TKey, TElement}"/>.
|
||||
/// Uses sorted set commands (ZADD, ZRANGE, ZRANGEBYSCORE, etc.).
|
||||
/// </summary>
|
||||
public sealed class ValkeySortedIndex<TKey, TElement> : ISortedIndex<TKey, TElement>
|
||||
where TKey : notnull
|
||||
where TElement : notnull
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly string _name;
|
||||
private readonly ILogger<ValkeySortedIndex<TKey, TElement>>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly Func<TKey, string> _keySerializer;
|
||||
|
||||
public ValkeySortedIndex(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
string name,
|
||||
ILogger<ValkeySortedIndex<TKey, TElement>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
Func<TKey, string>? keySerializer = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_logger = logger;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_keySerializer = keySerializer ?? (key => key?.ToString() ?? throw new ArgumentNullException(nameof(key)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> AddAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
double score,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var serialized = JsonSerializer.Serialize(element, _jsonOptions);
|
||||
return await db.SortedSetAddAsync(redisKey, serialized, score).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> AddRangeAsync(
|
||||
TKey indexKey,
|
||||
IEnumerable<ScoredElement<TElement>> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var entries = elements
|
||||
.Select(e => new SortedSetEntry(JsonSerializer.Serialize(e.Element, _jsonOptions), e.Score))
|
||||
.ToArray();
|
||||
|
||||
if (entries.Length == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return await db.SortedSetAddAsync(redisKey, entries).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlyList<ScoredElement<TElement>>> GetByRankAsync(
|
||||
TKey indexKey,
|
||||
long start,
|
||||
long stop,
|
||||
SortOrder order = SortOrder.Ascending,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var redisOrder = order == SortOrder.Ascending ? Order.Ascending : Order.Descending;
|
||||
var entries = await db.SortedSetRangeByRankWithScoresAsync(redisKey, start, stop, redisOrder).ConfigureAwait(false);
|
||||
|
||||
return entries
|
||||
.Select(e => new ScoredElement<TElement>(
|
||||
JsonSerializer.Deserialize<TElement>((string)e.Element!, _jsonOptions)!,
|
||||
e.Score))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlyList<ScoredElement<TElement>>> GetByScoreAsync(
|
||||
TKey indexKey,
|
||||
double minScore,
|
||||
double maxScore,
|
||||
SortOrder order = SortOrder.Ascending,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var redisOrder = order == SortOrder.Ascending ? Order.Ascending : Order.Descending;
|
||||
var take = limit ?? -1;
|
||||
|
||||
var entries = await db.SortedSetRangeByScoreWithScoresAsync(
|
||||
redisKey,
|
||||
minScore,
|
||||
maxScore,
|
||||
order: redisOrder,
|
||||
take: take).ConfigureAwait(false);
|
||||
|
||||
return entries
|
||||
.Select(e => new ScoredElement<TElement>(
|
||||
JsonSerializer.Deserialize<TElement>((string)e.Element!, _jsonOptions)!,
|
||||
e.Score))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<double?> GetScoreAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var serialized = JsonSerializer.Serialize(element, _jsonOptions);
|
||||
return await db.SortedSetScoreAsync(redisKey, serialized).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> RemoveAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var serialized = JsonSerializer.Serialize(element, _jsonOptions);
|
||||
return await db.SortedSetRemoveAsync(redisKey, serialized).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> RemoveRangeAsync(
|
||||
TKey indexKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var values = elements
|
||||
.Select(e => (RedisValue)JsonSerializer.Serialize(e, _jsonOptions))
|
||||
.ToArray();
|
||||
|
||||
if (values.Length == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return await db.SortedSetRemoveAsync(redisKey, values).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> RemoveByScoreAsync(
|
||||
TKey indexKey,
|
||||
double minScore,
|
||||
double maxScore,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await db.SortedSetRemoveRangeByScoreAsync(redisKey, minScore, maxScore).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> CountAsync(
|
||||
TKey indexKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await db.SortedSetLengthAsync(redisKey).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> DeleteAsync(
|
||||
TKey indexKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await db.KeyDeleteAsync(redisKey).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask SetExpirationAsync(
|
||||
TKey indexKey,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await db.KeyExpireAsync(redisKey, ttl).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private string BuildKey(TKey indexKey)
|
||||
{
|
||||
var keyString = _keySerializer(indexKey);
|
||||
return $"sortedindex:{_name}:{keyString}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating Valkey sorted index instances.
|
||||
/// </summary>
|
||||
public sealed class ValkeySortedIndexFactory : ISortedIndexFactory
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly JsonSerializerOptions? _jsonOptions;
|
||||
|
||||
public ValkeySortedIndexFactory(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
JsonSerializerOptions? jsonOptions = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_jsonOptions = jsonOptions;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISortedIndex<TKey, TElement> Create<TKey, TElement>(string name)
|
||||
where TKey : notnull
|
||||
where TElement : notnull
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new ValkeySortedIndex<TKey, TElement>(
|
||||
_connectionFactory,
|
||||
name,
|
||||
_loggerFactory?.CreateLogger<ValkeySortedIndex<TKey, TElement>>(),
|
||||
_jsonOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Messaging.Plugins;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Valkey/Redis transport plugin for StellaOps.Messaging.
|
||||
/// </summary>
|
||||
public sealed class ValkeyTransportPlugin : IMessagingTransportPlugin
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Name => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Register(MessagingTransportRegistrationContext context)
|
||||
{
|
||||
// Register options
|
||||
context.Services.AddOptions<ValkeyTransportOptions>()
|
||||
.Bind(context.GetTransportConfiguration())
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
// Register connection factory
|
||||
context.Services.AddSingleton<ValkeyConnectionFactory>();
|
||||
|
||||
// Register message queue factory
|
||||
context.Services.AddSingleton<IMessageQueueFactory, ValkeyMessageQueueFactory>();
|
||||
|
||||
// Register cache factory
|
||||
context.Services.AddSingleton<IDistributedCacheFactory, ValkeyCacheFactory>();
|
||||
|
||||
// Register rate limiter factory
|
||||
context.Services.AddSingleton<IRateLimiterFactory, ValkeyRateLimiterFactory>();
|
||||
|
||||
// Register atomic token store factory
|
||||
context.Services.AddSingleton<IAtomicTokenStoreFactory, ValkeyAtomicTokenStoreFactory>();
|
||||
|
||||
// Register sorted index factory
|
||||
context.Services.AddSingleton<ISortedIndexFactory, ValkeySortedIndexFactory>();
|
||||
|
||||
// Register set store factory
|
||||
context.Services.AddSingleton<ISetStoreFactory, ValkeySetStoreFactory>();
|
||||
|
||||
// Register event stream factory
|
||||
context.Services.AddSingleton<IEventStreamFactory, ValkeyEventStreamFactory>();
|
||||
|
||||
// Register idempotency store factory
|
||||
context.Services.AddSingleton<IIdempotencyStoreFactory, ValkeyIdempotencyStoreFactory>();
|
||||
|
||||
context.LoggerFactory?.CreateLogger<ValkeyTransportPlugin>()
|
||||
.LogDebug("Registered Valkey transport plugin");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
namespace StellaOps.Messaging.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Transport-agnostic atomic token store for one-time consumable tokens.
|
||||
/// Supports issuing tokens with TTL and atomic consumption (single use).
|
||||
/// </summary>
|
||||
/// <typeparam name="TPayload">The type of metadata payload stored with the token.</typeparam>
|
||||
public interface IAtomicTokenStore<TPayload>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the provider name for diagnostics (e.g., "valkey", "postgres", "inmemory").
|
||||
/// </summary>
|
||||
string ProviderName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Issues a token with the given payload and TTL.
|
||||
/// </summary>
|
||||
/// <param name="key">The storage key for the token.</param>
|
||||
/// <param name="payload">The metadata payload to store with the token.</param>
|
||||
/// <param name="ttl">The time-to-live for the token.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The result containing the generated token.</returns>
|
||||
ValueTask<TokenIssueResult> IssueAsync(
|
||||
string key,
|
||||
TPayload payload,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Stores a caller-provided token with payload and TTL.
|
||||
/// Use when the token must be generated externally (e.g., cryptographic nonces).
|
||||
/// </summary>
|
||||
/// <param name="key">The storage key for the token.</param>
|
||||
/// <param name="token">The caller-provided token value.</param>
|
||||
/// <param name="payload">The metadata payload to store with the token.</param>
|
||||
/// <param name="ttl">The time-to-live for the token.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The result containing the stored token information.</returns>
|
||||
ValueTask<TokenIssueResult> StoreAsync(
|
||||
string key,
|
||||
string token,
|
||||
TPayload payload,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Atomically consumes a token if it exists and matches.
|
||||
/// The token is deleted after successful consumption (single use).
|
||||
/// </summary>
|
||||
/// <param name="key">The storage key for the token.</param>
|
||||
/// <param name="expectedToken">The token value to match.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The result of the consumption attempt.</returns>
|
||||
ValueTask<TokenConsumeResult<TPayload>> TryConsumeAsync(
|
||||
string key,
|
||||
string expectedToken,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a token exists without consuming it.
|
||||
/// </summary>
|
||||
/// <param name="key">The storage key for the token.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the token exists.</returns>
|
||||
ValueTask<bool> ExistsAsync(
|
||||
string key,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Revokes a token before it expires.
|
||||
/// </summary>
|
||||
/// <param name="key">The storage key for the token.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the token existed and was revoked.</returns>
|
||||
ValueTask<bool> RevokeAsync(
|
||||
string key,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
namespace StellaOps.Messaging.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Transport-agnostic event stream interface.
|
||||
/// Provides fire-and-forget event publishing without consumer group semantics.
|
||||
/// Unlike <see cref="IMessageQueue{TMessage}"/>, events are not acknowledged and may be consumed by multiple subscribers.
|
||||
/// </summary>
|
||||
/// <typeparam name="TEvent">The event type.</typeparam>
|
||||
public interface IEventStream<TEvent> where TEvent : class
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the provider name for diagnostics (e.g., "valkey", "postgres", "inmemory").
|
||||
/// </summary>
|
||||
string ProviderName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the stream name.
|
||||
/// </summary>
|
||||
string StreamName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Publishes an event to the stream.
|
||||
/// </summary>
|
||||
/// <param name="event">The event to publish.</param>
|
||||
/// <param name="options">Optional publish options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The result of the publish operation.</returns>
|
||||
ValueTask<EventPublishResult> PublishAsync(
|
||||
TEvent @event,
|
||||
EventPublishOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Publishes multiple events to the stream.
|
||||
/// </summary>
|
||||
/// <param name="events">The events to publish.</param>
|
||||
/// <param name="options">Optional publish options (applied to all events).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The results of the publish operations.</returns>
|
||||
ValueTask<IReadOnlyList<EventPublishResult>> PublishBatchAsync(
|
||||
IEnumerable<TEvent> events,
|
||||
EventPublishOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to events from a position.
|
||||
/// Events are delivered as they become available.
|
||||
/// </summary>
|
||||
/// <param name="position">The stream position to start from.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>An async enumerable of events.</returns>
|
||||
IAsyncEnumerable<StreamEvent<TEvent>> SubscribeAsync(
|
||||
StreamPosition position,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets stream metadata.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Information about the stream.</returns>
|
||||
ValueTask<StreamInfo> GetInfoAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Trims stream to approximate max length.
|
||||
/// </summary>
|
||||
/// <param name="maxLength">The maximum length to retain.</param>
|
||||
/// <param name="approximate">Whether to use approximate trimming (more efficient).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The number of entries removed.</returns>
|
||||
ValueTask<long> TrimAsync(
|
||||
long maxLength,
|
||||
bool approximate = true,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace StellaOps.Messaging.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Transport-agnostic idempotency store interface.
|
||||
/// Provides deduplication keys with configurable time windows.
|
||||
/// </summary>
|
||||
public interface IIdempotencyStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the provider name for diagnostics (e.g., "valkey", "postgres", "inmemory").
|
||||
/// </summary>
|
||||
string ProviderName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to claim an idempotency key.
|
||||
/// If the key doesn't exist, it's claimed for the duration of the window.
|
||||
/// </summary>
|
||||
/// <param name="key">The idempotency key.</param>
|
||||
/// <param name="value">The value to store (e.g., message ID, operation ID).</param>
|
||||
/// <param name="window">The idempotency window duration.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The result indicating whether this was the first claim.</returns>
|
||||
ValueTask<IdempotencyResult> TryClaimAsync(
|
||||
string key,
|
||||
string value,
|
||||
TimeSpan window,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a key was already claimed.
|
||||
/// </summary>
|
||||
/// <param name="key">The idempotency key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the key exists (was previously claimed).</returns>
|
||||
ValueTask<bool> ExistsAsync(
|
||||
string key,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the value for a claimed key.
|
||||
/// </summary>
|
||||
/// <param name="key">The idempotency key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The stored value, or null if the key doesn't exist.</returns>
|
||||
ValueTask<string?> GetAsync(
|
||||
string key,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Releases a claimed key before the window expires.
|
||||
/// </summary>
|
||||
/// <param name="key">The idempotency key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the key existed and was released.</returns>
|
||||
ValueTask<bool> ReleaseAsync(
|
||||
string key,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Extends the window for a claimed key.
|
||||
/// </summary>
|
||||
/// <param name="key">The idempotency key.</param>
|
||||
/// <param name="extension">The time to extend by.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the key existed and was extended.</returns>
|
||||
ValueTask<bool> ExtendAsync(
|
||||
string key,
|
||||
TimeSpan extension,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -46,3 +46,120 @@ public interface IDistributedCacheFactory
|
||||
/// <returns>A configured distributed cache instance.</returns>
|
||||
IDistributedCache<TValue> Create<TValue>(CacheOptions options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating rate limiter instances.
|
||||
/// </summary>
|
||||
public interface IRateLimiterFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the provider name for this factory.
|
||||
/// </summary>
|
||||
string ProviderName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a rate limiter with the specified name.
|
||||
/// </summary>
|
||||
/// <param name="name">The rate limiter name (used as key prefix).</param>
|
||||
/// <returns>A configured rate limiter instance.</returns>
|
||||
IRateLimiter Create(string name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating atomic token store instances.
|
||||
/// </summary>
|
||||
public interface IAtomicTokenStoreFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the provider name for this factory.
|
||||
/// </summary>
|
||||
string ProviderName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates an atomic token store for the specified payload type.
|
||||
/// </summary>
|
||||
/// <typeparam name="TPayload">The payload type.</typeparam>
|
||||
/// <param name="name">The store name (used as key prefix).</param>
|
||||
/// <returns>A configured atomic token store instance.</returns>
|
||||
IAtomicTokenStore<TPayload> Create<TPayload>(string name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating sorted index instances.
|
||||
/// </summary>
|
||||
public interface ISortedIndexFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the provider name for this factory.
|
||||
/// </summary>
|
||||
string ProviderName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a sorted index for the specified key and element types.
|
||||
/// </summary>
|
||||
/// <typeparam name="TKey">The index key type.</typeparam>
|
||||
/// <typeparam name="TElement">The element type.</typeparam>
|
||||
/// <param name="name">The index name (used as key prefix).</param>
|
||||
/// <returns>A configured sorted index instance.</returns>
|
||||
ISortedIndex<TKey, TElement> Create<TKey, TElement>(string name)
|
||||
where TKey : notnull
|
||||
where TElement : notnull;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating set store instances.
|
||||
/// </summary>
|
||||
public interface ISetStoreFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the provider name for this factory.
|
||||
/// </summary>
|
||||
string ProviderName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a set store for the specified key and element types.
|
||||
/// </summary>
|
||||
/// <typeparam name="TKey">The set key type.</typeparam>
|
||||
/// <typeparam name="TElement">The element type.</typeparam>
|
||||
/// <param name="name">The store name (used as key prefix).</param>
|
||||
/// <returns>A configured set store instance.</returns>
|
||||
ISetStore<TKey, TElement> Create<TKey, TElement>(string name)
|
||||
where TKey : notnull;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating event stream instances.
|
||||
/// </summary>
|
||||
public interface IEventStreamFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the provider name for this factory.
|
||||
/// </summary>
|
||||
string ProviderName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates an event stream for the specified event type.
|
||||
/// </summary>
|
||||
/// <typeparam name="TEvent">The event type.</typeparam>
|
||||
/// <param name="options">The event stream options.</param>
|
||||
/// <returns>A configured event stream instance.</returns>
|
||||
IEventStream<TEvent> Create<TEvent>(EventStreamOptions options) where TEvent : class;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating idempotency store instances.
|
||||
/// </summary>
|
||||
public interface IIdempotencyStoreFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the provider name for this factory.
|
||||
/// </summary>
|
||||
string ProviderName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates an idempotency store with the specified name.
|
||||
/// </summary>
|
||||
/// <param name="name">The store name (used as key prefix).</param>
|
||||
/// <returns>A configured idempotency store instance.</returns>
|
||||
IIdempotencyStore Create(string name);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace StellaOps.Messaging.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Transport-agnostic rate limiter interface.
|
||||
/// Implements sliding window rate limiting with configurable policies.
|
||||
/// </summary>
|
||||
public interface IRateLimiter
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the provider name for diagnostics (e.g., "valkey", "postgres", "inmemory").
|
||||
/// </summary>
|
||||
string ProviderName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to acquire a permit from the rate limiter.
|
||||
/// </summary>
|
||||
/// <param name="key">The rate limit key (e.g., user ID, IP address, resource identifier).</param>
|
||||
/// <param name="policy">The rate limit policy defining max permits and window.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The result of the rate limit check.</returns>
|
||||
ValueTask<RateLimitResult> TryAcquireAsync(
|
||||
string key,
|
||||
RateLimitPolicy policy,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets current usage for a key without consuming a permit.
|
||||
/// </summary>
|
||||
/// <param name="key">The rate limit key.</param>
|
||||
/// <param name="policy">The rate limit policy.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The current rate limit status.</returns>
|
||||
ValueTask<RateLimitStatus> GetStatusAsync(
|
||||
string key,
|
||||
RateLimitPolicy policy,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resets the rate limit counter for a key.
|
||||
/// </summary>
|
||||
/// <param name="key">The rate limit key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the key existed and was reset.</returns>
|
||||
ValueTask<bool> ResetAsync(
|
||||
string key,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
116
src/__Libraries/StellaOps.Messaging/Abstractions/ISetStore.cs
Normal file
116
src/__Libraries/StellaOps.Messaging/Abstractions/ISetStore.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
namespace StellaOps.Messaging.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Transport-agnostic set store interface.
|
||||
/// Provides unordered set membership operations.
|
||||
/// </summary>
|
||||
/// <typeparam name="TKey">The set key type.</typeparam>
|
||||
/// <typeparam name="TElement">The element type stored in the set.</typeparam>
|
||||
public interface ISetStore<TKey, TElement>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the provider name for diagnostics (e.g., "valkey", "postgres", "inmemory").
|
||||
/// </summary>
|
||||
string ProviderName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Adds an element to the set.
|
||||
/// </summary>
|
||||
/// <param name="setKey">The set key.</param>
|
||||
/// <param name="element">The element to add.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the element was added (false if already existed).</returns>
|
||||
ValueTask<bool> AddAsync(
|
||||
TKey setKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds multiple elements to the set.
|
||||
/// </summary>
|
||||
/// <param name="setKey">The set key.</param>
|
||||
/// <param name="elements">The elements to add.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The number of elements added (not already present).</returns>
|
||||
ValueTask<long> AddRangeAsync(
|
||||
TKey setKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all members of the set.
|
||||
/// </summary>
|
||||
/// <param name="setKey">The set key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>All elements in the set.</returns>
|
||||
ValueTask<IReadOnlySet<TElement>> GetMembersAsync(
|
||||
TKey setKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an element exists in the set.
|
||||
/// </summary>
|
||||
/// <param name="setKey">The set key.</param>
|
||||
/// <param name="element">The element to check.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the element is a member of the set.</returns>
|
||||
ValueTask<bool> ContainsAsync(
|
||||
TKey setKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes an element from the set.
|
||||
/// </summary>
|
||||
/// <param name="setKey">The set key.</param>
|
||||
/// <param name="element">The element to remove.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the element was removed.</returns>
|
||||
ValueTask<bool> RemoveAsync(
|
||||
TKey setKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes multiple elements from the set.
|
||||
/// </summary>
|
||||
/// <param name="setKey">The set key.</param>
|
||||
/// <param name="elements">The elements to remove.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The number of elements removed.</returns>
|
||||
ValueTask<long> RemoveRangeAsync(
|
||||
TKey setKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the entire set.
|
||||
/// </summary>
|
||||
/// <param name="setKey">The set key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the set existed and was deleted.</returns>
|
||||
ValueTask<bool> DeleteAsync(
|
||||
TKey setKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cardinality (count) of the set.
|
||||
/// </summary>
|
||||
/// <param name="setKey">The set key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The number of elements in the set.</returns>
|
||||
ValueTask<long> CountAsync(
|
||||
TKey setKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sets TTL on the set key.
|
||||
/// </summary>
|
||||
/// <param name="setKey">The set key.</param>
|
||||
/// <param name="ttl">The time-to-live.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
ValueTask SetExpirationAsync(
|
||||
TKey setKey,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
180
src/__Libraries/StellaOps.Messaging/Abstractions/ISortedIndex.cs
Normal file
180
src/__Libraries/StellaOps.Messaging/Abstractions/ISortedIndex.cs
Normal file
@@ -0,0 +1,180 @@
|
||||
namespace StellaOps.Messaging.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Transport-agnostic sorted index interface.
|
||||
/// Provides score-ordered collections with range queries.
|
||||
/// </summary>
|
||||
/// <typeparam name="TKey">The index key type.</typeparam>
|
||||
/// <typeparam name="TElement">The element type stored in the index.</typeparam>
|
||||
public interface ISortedIndex<TKey, TElement>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the provider name for diagnostics (e.g., "valkey", "postgres", "inmemory").
|
||||
/// </summary>
|
||||
string ProviderName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Adds an element with a score.
|
||||
/// </summary>
|
||||
/// <param name="indexKey">The index key.</param>
|
||||
/// <param name="element">The element to add.</param>
|
||||
/// <param name="score">The score for ordering.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the element was added (false if updated).</returns>
|
||||
ValueTask<bool> AddAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
double score,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds multiple elements with scores atomically.
|
||||
/// </summary>
|
||||
/// <param name="indexKey">The index key.</param>
|
||||
/// <param name="elements">The elements with scores to add.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The number of elements added (not updated).</returns>
|
||||
ValueTask<long> AddRangeAsync(
|
||||
TKey indexKey,
|
||||
IEnumerable<ScoredElement<TElement>> elements,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets elements by rank range (0-based, inclusive).
|
||||
/// </summary>
|
||||
/// <param name="indexKey">The index key.</param>
|
||||
/// <param name="start">The start rank (0-based).</param>
|
||||
/// <param name="stop">The stop rank (inclusive, use -1 for last).</param>
|
||||
/// <param name="order">The sort order.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Elements within the rank range.</returns>
|
||||
ValueTask<IReadOnlyList<ScoredElement<TElement>>> GetByRankAsync(
|
||||
TKey indexKey,
|
||||
long start,
|
||||
long stop,
|
||||
SortOrder order = SortOrder.Ascending,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets elements by score range.
|
||||
/// </summary>
|
||||
/// <param name="indexKey">The index key.</param>
|
||||
/// <param name="minScore">The minimum score (inclusive).</param>
|
||||
/// <param name="maxScore">The maximum score (inclusive).</param>
|
||||
/// <param name="order">The sort order.</param>
|
||||
/// <param name="limit">Optional limit on returned elements.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Elements within the score range.</returns>
|
||||
ValueTask<IReadOnlyList<ScoredElement<TElement>>> GetByScoreAsync(
|
||||
TKey indexKey,
|
||||
double minScore,
|
||||
double maxScore,
|
||||
SortOrder order = SortOrder.Ascending,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the score of an element.
|
||||
/// </summary>
|
||||
/// <param name="indexKey">The index key.</param>
|
||||
/// <param name="element">The element to look up.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The score, or null if the element doesn't exist.</returns>
|
||||
ValueTask<double?> GetScoreAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes an element from the index.
|
||||
/// </summary>
|
||||
/// <param name="indexKey">The index key.</param>
|
||||
/// <param name="element">The element to remove.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the element was removed.</returns>
|
||||
ValueTask<bool> RemoveAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes multiple elements from the index.
|
||||
/// </summary>
|
||||
/// <param name="indexKey">The index key.</param>
|
||||
/// <param name="elements">The elements to remove.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The number of elements removed.</returns>
|
||||
ValueTask<long> RemoveRangeAsync(
|
||||
TKey indexKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes elements by score range.
|
||||
/// </summary>
|
||||
/// <param name="indexKey">The index key.</param>
|
||||
/// <param name="minScore">The minimum score (inclusive).</param>
|
||||
/// <param name="maxScore">The maximum score (inclusive).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The number of elements removed.</returns>
|
||||
ValueTask<long> RemoveByScoreAsync(
|
||||
TKey indexKey,
|
||||
double minScore,
|
||||
double maxScore,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total count of elements in the index.
|
||||
/// </summary>
|
||||
/// <param name="indexKey">The index key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The element count.</returns>
|
||||
ValueTask<long> CountAsync(
|
||||
TKey indexKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the entire index.
|
||||
/// </summary>
|
||||
/// <param name="indexKey">The index key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the index existed and was deleted.</returns>
|
||||
ValueTask<bool> DeleteAsync(
|
||||
TKey indexKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sets TTL on the index key.
|
||||
/// </summary>
|
||||
/// <param name="indexKey">The index key.</param>
|
||||
/// <param name="ttl">The time-to-live.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
ValueTask SetExpirationAsync(
|
||||
TKey indexKey,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An element with an associated score.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The element type.</typeparam>
|
||||
/// <param name="Element">The element value.</param>
|
||||
/// <param name="Score">The score for ordering.</param>
|
||||
public readonly record struct ScoredElement<T>(T Element, double Score);
|
||||
|
||||
/// <summary>
|
||||
/// Sort order for index queries.
|
||||
/// </summary>
|
||||
public enum SortOrder
|
||||
{
|
||||
/// <summary>
|
||||
/// Sort by ascending score (lowest first).
|
||||
/// </summary>
|
||||
Ascending,
|
||||
|
||||
/// <summary>
|
||||
/// Sort by descending score (highest first).
|
||||
/// </summary>
|
||||
Descending
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace StellaOps.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for event streams.
|
||||
/// </summary>
|
||||
public sealed class EventStreamOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the stream name.
|
||||
/// </summary>
|
||||
public required string StreamName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum stream length.
|
||||
/// When set, the stream is automatically trimmed to this approximate length.
|
||||
/// </summary>
|
||||
public long? MaxLength { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to use approximate trimming (more efficient).
|
||||
/// Default is true.
|
||||
/// </summary>
|
||||
public bool ApproximateTrimming { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the polling interval for subscription (when applicable).
|
||||
/// Default is 100ms.
|
||||
/// </summary>
|
||||
public TimeSpan PollInterval { get; set; } = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the idempotency window for duplicate detection.
|
||||
/// Default is 5 minutes.
|
||||
/// </summary>
|
||||
public TimeSpan IdempotencyWindow { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether idempotency checking is enabled.
|
||||
/// Default is false.
|
||||
/// </summary>
|
||||
public bool EnableIdempotency { get; set; }
|
||||
}
|
||||
177
src/__Libraries/StellaOps.Messaging/Results/EventStreamResult.cs
Normal file
177
src/__Libraries/StellaOps.Messaging/Results/EventStreamResult.cs
Normal file
@@ -0,0 +1,177 @@
|
||||
namespace StellaOps.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Options for publishing events to a stream.
|
||||
/// </summary>
|
||||
public sealed record EventPublishOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the idempotency key for deduplication.
|
||||
/// </summary>
|
||||
public string? IdempotencyKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the tenant identifier.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the correlation identifier for tracing.
|
||||
/// </summary>
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets additional headers.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Headers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum stream length (triggers trimming).
|
||||
/// </summary>
|
||||
public long? MaxStreamLength { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an event publish operation.
|
||||
/// </summary>
|
||||
public readonly struct EventPublishResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether the publish was successful.
|
||||
/// </summary>
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the entry ID assigned by the stream.
|
||||
/// </summary>
|
||||
public string? EntryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this was a duplicate (based on idempotency key).
|
||||
/// </summary>
|
||||
public bool WasDeduplicated { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error message if the operation failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful publish result.
|
||||
/// </summary>
|
||||
public static EventPublishResult Succeeded(string entryId, bool wasDeduplicated = false) =>
|
||||
new()
|
||||
{
|
||||
Success = true,
|
||||
EntryId = entryId,
|
||||
WasDeduplicated = wasDeduplicated
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed publish result.
|
||||
/// </summary>
|
||||
public static EventPublishResult Failed(string error) =>
|
||||
new()
|
||||
{
|
||||
Success = false,
|
||||
Error = error
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a deduplicated result.
|
||||
/// </summary>
|
||||
public static EventPublishResult Deduplicated(string existingEntryId) =>
|
||||
new()
|
||||
{
|
||||
Success = true,
|
||||
EntryId = existingEntryId,
|
||||
WasDeduplicated = true
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An event from the stream with metadata.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The event type.</typeparam>
|
||||
/// <param name="EntryId">The stream entry identifier.</param>
|
||||
/// <param name="Event">The event payload.</param>
|
||||
/// <param name="Timestamp">When the event was published.</param>
|
||||
/// <param name="TenantId">The tenant identifier, if present.</param>
|
||||
/// <param name="CorrelationId">The correlation identifier, if present.</param>
|
||||
public sealed record StreamEvent<T>(
|
||||
string EntryId,
|
||||
T Event,
|
||||
DateTimeOffset Timestamp,
|
||||
string? TenantId,
|
||||
string? CorrelationId);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a position in the stream.
|
||||
/// </summary>
|
||||
public readonly struct StreamPosition : IEquatable<StreamPosition>
|
||||
{
|
||||
/// <summary>
|
||||
/// Position at the beginning of the stream (read all).
|
||||
/// </summary>
|
||||
public static StreamPosition Beginning => new("0");
|
||||
|
||||
/// <summary>
|
||||
/// Position at the end of the stream (only new entries).
|
||||
/// </summary>
|
||||
public static StreamPosition End => new("$");
|
||||
|
||||
/// <summary>
|
||||
/// Creates a position after a specific entry ID.
|
||||
/// </summary>
|
||||
public static StreamPosition After(string entryId) => new(entryId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the position value.
|
||||
/// </summary>
|
||||
public string Value { get; }
|
||||
|
||||
private StreamPosition(string value) => Value = value;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Equals(StreamPosition other) => Value == other.Value;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Equals(object? obj) => obj is StreamPosition other && Equals(other);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode() => Value?.GetHashCode() ?? 0;
|
||||
|
||||
/// <summary>
|
||||
/// Equality operator.
|
||||
/// </summary>
|
||||
public static bool operator ==(StreamPosition left, StreamPosition right) => left.Equals(right);
|
||||
|
||||
/// <summary>
|
||||
/// Inequality operator.
|
||||
/// </summary>
|
||||
public static bool operator !=(StreamPosition left, StreamPosition right) => !left.Equals(right);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a stream.
|
||||
/// </summary>
|
||||
/// <param name="Length">The number of entries in the stream.</param>
|
||||
/// <param name="FirstEntryId">The ID of the first entry, if any.</param>
|
||||
/// <param name="LastEntryId">The ID of the last entry, if any.</param>
|
||||
/// <param name="FirstEntryTimestamp">The timestamp of the first entry, if available.</param>
|
||||
/// <param name="LastEntryTimestamp">The timestamp of the last entry, if available.</param>
|
||||
public sealed record StreamInfo(
|
||||
long Length,
|
||||
string? FirstEntryId,
|
||||
string? LastEntryId,
|
||||
DateTimeOffset? FirstEntryTimestamp,
|
||||
DateTimeOffset? LastEntryTimestamp)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an empty stream info.
|
||||
/// </summary>
|
||||
public static StreamInfo Empty => new(0, null, null, null, null);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace StellaOps.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Result of an idempotency claim attempt.
|
||||
/// </summary>
|
||||
public readonly struct IdempotencyResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether this was the first claim (not a duplicate).
|
||||
/// </summary>
|
||||
public bool IsFirstClaim { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the existing value if this was a duplicate.
|
||||
/// </summary>
|
||||
public string? ExistingValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this was a duplicate (key already claimed).
|
||||
/// </summary>
|
||||
public bool IsDuplicate => !IsFirstClaim;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a result indicating the key was successfully claimed.
|
||||
/// </summary>
|
||||
public static IdempotencyResult Claimed() =>
|
||||
new()
|
||||
{
|
||||
IsFirstClaim = true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a result indicating the key was already claimed (duplicate).
|
||||
/// </summary>
|
||||
public static IdempotencyResult Duplicate(string existingValue) =>
|
||||
new()
|
||||
{
|
||||
IsFirstClaim = false,
|
||||
ExistingValue = existingValue
|
||||
};
|
||||
}
|
||||
127
src/__Libraries/StellaOps.Messaging/Results/RateLimitResult.cs
Normal file
127
src/__Libraries/StellaOps.Messaging/Results/RateLimitResult.cs
Normal file
@@ -0,0 +1,127 @@
|
||||
namespace StellaOps.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a rate limit policy.
|
||||
/// </summary>
|
||||
/// <param name="MaxPermits">Maximum number of permits allowed within the window.</param>
|
||||
/// <param name="Window">The time window for rate limiting.</param>
|
||||
public sealed record RateLimitPolicy(int MaxPermits, TimeSpan Window)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a per-second rate limit policy.
|
||||
/// </summary>
|
||||
public static RateLimitPolicy PerSecond(int maxPermits) =>
|
||||
new(maxPermits, TimeSpan.FromSeconds(1));
|
||||
|
||||
/// <summary>
|
||||
/// Creates a per-minute rate limit policy.
|
||||
/// </summary>
|
||||
public static RateLimitPolicy PerMinute(int maxPermits) =>
|
||||
new(maxPermits, TimeSpan.FromMinutes(1));
|
||||
|
||||
/// <summary>
|
||||
/// Creates a per-hour rate limit policy.
|
||||
/// </summary>
|
||||
public static RateLimitPolicy PerHour(int maxPermits) =>
|
||||
new(maxPermits, TimeSpan.FromHours(1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a rate limit acquisition attempt.
|
||||
/// </summary>
|
||||
public readonly struct RateLimitResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether the permit was acquired (request allowed).
|
||||
/// </summary>
|
||||
public bool IsAllowed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current count of permits used in the window.
|
||||
/// </summary>
|
||||
public int CurrentCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of remaining permits in the window.
|
||||
/// </summary>
|
||||
public int RemainingPermits { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the suggested time to wait before retrying (when denied).
|
||||
/// </summary>
|
||||
public TimeSpan? RetryAfter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a result indicating the permit was acquired.
|
||||
/// </summary>
|
||||
public static RateLimitResult Allowed(int currentCount, int remainingPermits) =>
|
||||
new()
|
||||
{
|
||||
IsAllowed = true,
|
||||
CurrentCount = currentCount,
|
||||
RemainingPermits = remainingPermits,
|
||||
RetryAfter = null
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a result indicating the request was denied.
|
||||
/// </summary>
|
||||
public static RateLimitResult Denied(int currentCount, TimeSpan retryAfter) =>
|
||||
new()
|
||||
{
|
||||
IsAllowed = false,
|
||||
CurrentCount = currentCount,
|
||||
RemainingPermits = 0,
|
||||
RetryAfter = retryAfter
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Current status of a rate limit key.
|
||||
/// </summary>
|
||||
public readonly struct RateLimitStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current count of permits used in the window.
|
||||
/// </summary>
|
||||
public int CurrentCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of remaining permits in the window.
|
||||
/// </summary>
|
||||
public int RemainingPermits { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the time remaining until the window resets.
|
||||
/// </summary>
|
||||
public TimeSpan WindowRemaining { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the key exists (has any usage).
|
||||
/// </summary>
|
||||
public bool Exists { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a status for an existing key.
|
||||
/// </summary>
|
||||
public static RateLimitStatus WithUsage(int currentCount, int remainingPermits, TimeSpan windowRemaining) =>
|
||||
new()
|
||||
{
|
||||
CurrentCount = currentCount,
|
||||
RemainingPermits = remainingPermits,
|
||||
WindowRemaining = windowRemaining,
|
||||
Exists = true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a status for a key with no usage.
|
||||
/// </summary>
|
||||
public static RateLimitStatus Empty(int maxPermits) =>
|
||||
new()
|
||||
{
|
||||
CurrentCount = 0,
|
||||
RemainingPermits = maxPermits,
|
||||
WindowRemaining = TimeSpan.Zero,
|
||||
Exists = false
|
||||
};
|
||||
}
|
||||
148
src/__Libraries/StellaOps.Messaging/Results/TokenResult.cs
Normal file
148
src/__Libraries/StellaOps.Messaging/Results/TokenResult.cs
Normal file
@@ -0,0 +1,148 @@
|
||||
namespace StellaOps.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Result of a token issuance operation.
|
||||
/// </summary>
|
||||
public readonly struct TokenIssueResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether the token was issued successfully.
|
||||
/// </summary>
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the generated token value.
|
||||
/// </summary>
|
||||
public string Token { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the token expires.
|
||||
/// </summary>
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error message if issuance failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful issuance result.
|
||||
/// </summary>
|
||||
public static TokenIssueResult Succeeded(string token, DateTimeOffset expiresAt) =>
|
||||
new()
|
||||
{
|
||||
Success = true,
|
||||
Token = token,
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed issuance result.
|
||||
/// </summary>
|
||||
public static TokenIssueResult Failed(string error) =>
|
||||
new()
|
||||
{
|
||||
Success = false,
|
||||
Token = string.Empty,
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a token consumption attempt.
|
||||
/// </summary>
|
||||
/// <typeparam name="TPayload">The type of metadata payload stored with the token.</typeparam>
|
||||
public readonly struct TokenConsumeResult<TPayload>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the status of the consumption attempt.
|
||||
/// </summary>
|
||||
public TokenConsumeStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the payload associated with the token (when successful).
|
||||
/// </summary>
|
||||
public TPayload? Payload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the token was issued (when available).
|
||||
/// </summary>
|
||||
public DateTimeOffset? IssuedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the token expires/expired (when available).
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the consumption was successful.
|
||||
/// </summary>
|
||||
public bool IsSuccess => Status == TokenConsumeStatus.Success;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful consumption result.
|
||||
/// </summary>
|
||||
public static TokenConsumeResult<TPayload> Success(TPayload payload, DateTimeOffset issuedAt, DateTimeOffset expiresAt) =>
|
||||
new()
|
||||
{
|
||||
Status = TokenConsumeStatus.Success,
|
||||
Payload = payload,
|
||||
IssuedAt = issuedAt,
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a not found result.
|
||||
/// </summary>
|
||||
public static TokenConsumeResult<TPayload> NotFound() =>
|
||||
new()
|
||||
{
|
||||
Status = TokenConsumeStatus.NotFound
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates an expired result.
|
||||
/// </summary>
|
||||
public static TokenConsumeResult<TPayload> Expired(DateTimeOffset issuedAt, DateTimeOffset expiresAt) =>
|
||||
new()
|
||||
{
|
||||
Status = TokenConsumeStatus.Expired,
|
||||
IssuedAt = issuedAt,
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mismatch result (token exists but value doesn't match).
|
||||
/// </summary>
|
||||
public static TokenConsumeResult<TPayload> Mismatch() =>
|
||||
new()
|
||||
{
|
||||
Status = TokenConsumeStatus.Mismatch
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of a token consumption attempt.
|
||||
/// </summary>
|
||||
public enum TokenConsumeStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Token was consumed successfully.
|
||||
/// </summary>
|
||||
Success,
|
||||
|
||||
/// <summary>
|
||||
/// Token was not found (doesn't exist or already consumed).
|
||||
/// </summary>
|
||||
NotFound,
|
||||
|
||||
/// <summary>
|
||||
/// Token has expired.
|
||||
/// </summary>
|
||||
Expired,
|
||||
|
||||
/// <summary>
|
||||
/// Token exists but the provided value doesn't match.
|
||||
/// </summary>
|
||||
Mismatch
|
||||
}
|
||||
@@ -29,5 +29,10 @@ public enum TransportType
|
||||
/// <summary>
|
||||
/// RabbitMQ transport for queue-based communication.
|
||||
/// </summary>
|
||||
RabbitMq
|
||||
RabbitMq,
|
||||
|
||||
/// <summary>
|
||||
/// Messaging transport using StellaOps.Messaging (Valkey/Postgres/InMemory).
|
||||
/// </summary>
|
||||
Messaging
|
||||
}
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
using System.Buffers;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Transport.Messaging.Options;
|
||||
using StellaOps.Router.Transport.Messaging.Protocol;
|
||||
|
||||
namespace StellaOps.Router.Transport.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Transport client that communicates with the gateway via StellaOps.Messaging.
|
||||
/// Implements both ITransportClient (for sending to microservices) and
|
||||
/// IMicroserviceTransport (for microservices connecting to gateway).
|
||||
/// </summary>
|
||||
public sealed class MessagingTransportClient : ITransportClient, IMicroserviceTransport, IDisposable
|
||||
{
|
||||
private readonly IMessageQueueFactory _queueFactory;
|
||||
private readonly MessagingTransportOptions _options;
|
||||
private readonly ILogger<MessagingTransportClient> _logger;
|
||||
private readonly CorrelationTracker _correlationTracker;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly ConcurrentDictionary<string, CancellationTokenSource> _inflightHandlers = new();
|
||||
private readonly CancellationTokenSource _clientCts = new();
|
||||
private IMessageQueue<RpcRequestMessage>? _requestQueue;
|
||||
private IMessageQueue<RpcResponseMessage>? _responseQueue;
|
||||
private IMessageQueue<RpcResponseMessage>? _serviceIncomingQueue;
|
||||
private Task? _receiveTask;
|
||||
private string? _connectionId;
|
||||
private InstanceDescriptor? _instance;
|
||||
private bool _disposed;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event Func<Frame, CancellationToken, Task<Frame>>? OnRequestReceived;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event Func<Guid, string?, Task>? OnCancelReceived;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MessagingTransportClient"/> class.
|
||||
/// </summary>
|
||||
public MessagingTransportClient(
|
||||
IMessageQueueFactory queueFactory,
|
||||
IOptions<MessagingTransportOptions> options,
|
||||
ILogger<MessagingTransportClient> logger)
|
||||
{
|
||||
_queueFactory = queueFactory;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_correlationTracker = new CorrelationTracker();
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ConnectAsync(
|
||||
InstanceDescriptor instance,
|
||||
IReadOnlyList<EndpointDescriptor> endpoints,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
_connectionId = Guid.NewGuid().ToString("N");
|
||||
_instance = instance;
|
||||
|
||||
// Create request queue (for sending to gateway)
|
||||
_requestQueue = _queueFactory.Create<RpcRequestMessage>(new MessageQueueOptions
|
||||
{
|
||||
QueueName = _options.GetRequestQueueName("gateway"),
|
||||
ConsumerName = $"{instance.ServiceName}-{instance.InstanceId}"
|
||||
});
|
||||
|
||||
// Create response queue (for receiving gateway responses)
|
||||
_responseQueue = _queueFactory.Create<RpcResponseMessage>(new MessageQueueOptions
|
||||
{
|
||||
QueueName = _options.ResponseQueueName,
|
||||
ConsumerName = $"{instance.ServiceName}-{instance.InstanceId}"
|
||||
});
|
||||
|
||||
// Create service-specific incoming queue (for receiving requests from gateway)
|
||||
_serviceIncomingQueue = _queueFactory.Create<RpcResponseMessage>(new MessageQueueOptions
|
||||
{
|
||||
QueueName = _options.GetRequestQueueName(instance.ServiceName),
|
||||
ConsumerName = $"{instance.ServiceName}-{instance.InstanceId}",
|
||||
DefaultLeaseDuration = _options.LeaseDuration
|
||||
});
|
||||
|
||||
// Send HELLO frame
|
||||
var helloPayload = new HelloPayload
|
||||
{
|
||||
Instance = instance,
|
||||
Endpoints = endpoints
|
||||
};
|
||||
|
||||
var helloMessage = new RpcRequestMessage
|
||||
{
|
||||
CorrelationId = Guid.NewGuid().ToString("N"),
|
||||
ConnectionId = _connectionId,
|
||||
TargetService = "gateway",
|
||||
FrameType = FrameType.Hello,
|
||||
PayloadBase64 = Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(helloPayload, _jsonOptions)),
|
||||
SenderInstanceId = instance.InstanceId
|
||||
};
|
||||
|
||||
await _requestQueue.EnqueueAsync(helloMessage, cancellationToken: cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Connected as {ServiceName}/{Version} instance {InstanceId} with {EndpointCount} endpoints via messaging",
|
||||
instance.ServiceName,
|
||||
instance.Version,
|
||||
instance.InstanceId,
|
||||
endpoints.Count);
|
||||
|
||||
// Start receiving responses and requests
|
||||
_receiveTask = Task.Run(() => ReceiveLoopAsync(_clientCts.Token), CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task ReceiveLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Run two loops concurrently: one for responses, one for incoming requests
|
||||
var responseTask = ProcessResponsesAsync(cancellationToken);
|
||||
var incomingTask = ProcessIncomingRequestsAsync(cancellationToken);
|
||||
|
||||
await Task.WhenAll(responseTask, incomingTask);
|
||||
}
|
||||
|
||||
private async Task ProcessResponsesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_responseQueue is null) return;
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var leases = await _responseQueue.LeaseAsync(
|
||||
new LeaseRequest { BatchSize = _options.BatchSize },
|
||||
cancellationToken);
|
||||
|
||||
foreach (var lease in leases)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Check if this response is for us (our connection)
|
||||
if (lease.Message.ConnectionId == _connectionId ||
|
||||
string.IsNullOrEmpty(lease.Message.ConnectionId))
|
||||
{
|
||||
var frame = DecodeFrame(
|
||||
lease.Message.FrameType,
|
||||
lease.Message.CorrelationId,
|
||||
lease.Message.PayloadBase64);
|
||||
|
||||
_correlationTracker.TryCompleteRequest(lease.Message.CorrelationId, frame);
|
||||
}
|
||||
await lease.AcknowledgeAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing response {MessageId}", lease.MessageId);
|
||||
await lease.ReleaseAsync(ReleaseDisposition.Retry, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
if (leases.Count == 0)
|
||||
{
|
||||
await Task.Delay(100, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in response processing loop");
|
||||
await Task.Delay(1000, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessIncomingRequestsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_serviceIncomingQueue is null) return;
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var leases = await _serviceIncomingQueue.LeaseAsync(
|
||||
new LeaseRequest { BatchSize = _options.BatchSize },
|
||||
cancellationToken);
|
||||
|
||||
foreach (var lease in leases)
|
||||
{
|
||||
try
|
||||
{
|
||||
await HandleIncomingRequestAsync(lease, cancellationToken);
|
||||
await lease.AcknowledgeAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error handling request {MessageId}", lease.MessageId);
|
||||
await lease.ReleaseAsync(ReleaseDisposition.Retry, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
if (leases.Count == 0)
|
||||
{
|
||||
await Task.Delay(100, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in incoming request processing loop");
|
||||
await Task.Delay(1000, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleIncomingRequestAsync(
|
||||
IMessageLease<RpcResponseMessage> lease,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var message = lease.Message;
|
||||
|
||||
if (message.FrameType == FrameType.Cancel)
|
||||
{
|
||||
HandleCancelMessage(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.FrameType is not (FrameType.Request or FrameType.RequestStreamData))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (OnRequestReceived is null)
|
||||
{
|
||||
_logger.LogWarning("No request handler registered, discarding request {CorrelationId}",
|
||||
message.CorrelationId);
|
||||
return;
|
||||
}
|
||||
|
||||
using var handlerCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
_inflightHandlers[message.CorrelationId] = handlerCts;
|
||||
|
||||
try
|
||||
{
|
||||
var requestFrame = DecodeFrame(message.FrameType, message.CorrelationId, message.PayloadBase64);
|
||||
var responseFrame = await OnRequestReceived(requestFrame, handlerCts.Token);
|
||||
|
||||
// Send response back to gateway
|
||||
if (!handlerCts.Token.IsCancellationRequested && _requestQueue is not null)
|
||||
{
|
||||
var responseMessage = new RpcRequestMessage
|
||||
{
|
||||
CorrelationId = message.CorrelationId,
|
||||
ConnectionId = _connectionId!,
|
||||
TargetService = "gateway",
|
||||
FrameType = FrameType.Response,
|
||||
PayloadBase64 = Convert.ToBase64String(responseFrame.Payload.Span),
|
||||
SenderInstanceId = _instance?.InstanceId
|
||||
};
|
||||
|
||||
await _requestQueue.EnqueueAsync(responseMessage, cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogDebug("Request {CorrelationId} was cancelled", message.CorrelationId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error handling request {CorrelationId}", message.CorrelationId);
|
||||
|
||||
// Send error response if not cancelled
|
||||
if (!handlerCts.Token.IsCancellationRequested && _requestQueue is not null)
|
||||
{
|
||||
var errorMessage = new RpcRequestMessage
|
||||
{
|
||||
CorrelationId = message.CorrelationId,
|
||||
ConnectionId = _connectionId!,
|
||||
TargetService = "gateway",
|
||||
FrameType = FrameType.Response,
|
||||
PayloadBase64 = Convert.ToBase64String(Array.Empty<byte>()),
|
||||
SenderInstanceId = _instance?.InstanceId
|
||||
};
|
||||
|
||||
await _requestQueue.EnqueueAsync(errorMessage, cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_inflightHandlers.TryRemove(message.CorrelationId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleCancelMessage(RpcResponseMessage message)
|
||||
{
|
||||
_logger.LogDebug("Received CANCEL for correlation {CorrelationId}", message.CorrelationId);
|
||||
|
||||
if (_inflightHandlers.TryGetValue(message.CorrelationId, out var handlerCts))
|
||||
{
|
||||
try
|
||||
{
|
||||
handlerCts.Cancel();
|
||||
_logger.LogInformation("Cancelled handler for request {CorrelationId}", message.CorrelationId);
|
||||
}
|
||||
catch (ObjectDisposedException) { }
|
||||
}
|
||||
|
||||
_correlationTracker.TryCancelRequest(message.CorrelationId);
|
||||
|
||||
if (OnCancelReceived is not null && Guid.TryParse(message.CorrelationId, out var correlationGuid))
|
||||
{
|
||||
_ = OnCancelReceived(correlationGuid, message.ErrorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Frame> SendRequestAsync(
|
||||
ConnectionState connection,
|
||||
Frame requestFrame,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (_requestQueue is null)
|
||||
{
|
||||
throw new InvalidOperationException("Not connected");
|
||||
}
|
||||
|
||||
var correlationId = requestFrame.CorrelationId ?? Guid.NewGuid().ToString("N");
|
||||
|
||||
// Register for response before sending
|
||||
var responseTask = _correlationTracker.RegisterRequestAsync(correlationId, timeout, cancellationToken);
|
||||
|
||||
var message = new RpcRequestMessage
|
||||
{
|
||||
CorrelationId = correlationId,
|
||||
ConnectionId = connection.ConnectionId,
|
||||
TargetService = connection.Instance.ServiceName,
|
||||
FrameType = requestFrame.Type,
|
||||
PayloadBase64 = Convert.ToBase64String(requestFrame.Payload.Span),
|
||||
Timeout = timeout,
|
||||
ReplyToQueue = _options.ResponseQueueName,
|
||||
SenderInstanceId = _instance?.InstanceId
|
||||
};
|
||||
|
||||
await _requestQueue.EnqueueAsync(message, cancellationToken: cancellationToken);
|
||||
|
||||
return await responseTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SendCancelAsync(
|
||||
ConnectionState connection,
|
||||
Guid correlationId,
|
||||
string? reason = null)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (_requestQueue is null)
|
||||
{
|
||||
throw new InvalidOperationException("Not connected");
|
||||
}
|
||||
|
||||
var message = new RpcRequestMessage
|
||||
{
|
||||
CorrelationId = correlationId.ToString("N"),
|
||||
ConnectionId = connection.ConnectionId,
|
||||
TargetService = connection.Instance.ServiceName,
|
||||
FrameType = FrameType.Cancel,
|
||||
PayloadBase64 = string.Empty,
|
||||
SenderInstanceId = _instance?.InstanceId
|
||||
};
|
||||
|
||||
await _requestQueue.EnqueueAsync(message);
|
||||
|
||||
_logger.LogDebug("Sent CANCEL for correlation {CorrelationId}", correlationId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SendStreamingAsync(
|
||||
ConnectionState connection,
|
||||
Frame requestHeader,
|
||||
Stream requestBody,
|
||||
Func<Stream, Task> readResponseBody,
|
||||
PayloadLimits limits,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (_requestQueue is null)
|
||||
{
|
||||
throw new InvalidOperationException("Not connected");
|
||||
}
|
||||
|
||||
var correlationId = requestHeader.CorrelationId ?? Guid.NewGuid().ToString("N");
|
||||
|
||||
// Send header frame
|
||||
var headerMessage = new RpcRequestMessage
|
||||
{
|
||||
CorrelationId = correlationId,
|
||||
ConnectionId = connection.ConnectionId,
|
||||
TargetService = connection.Instance.ServiceName,
|
||||
FrameType = FrameType.Request,
|
||||
PayloadBase64 = Convert.ToBase64String(requestHeader.Payload.Span),
|
||||
SenderInstanceId = _instance?.InstanceId
|
||||
};
|
||||
|
||||
await _requestQueue.EnqueueAsync(headerMessage, cancellationToken: cancellationToken);
|
||||
|
||||
// Stream request body in chunks
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(8192);
|
||||
try
|
||||
{
|
||||
long totalBytesRead = 0;
|
||||
int bytesRead;
|
||||
|
||||
while ((bytesRead = await requestBody.ReadAsync(buffer, cancellationToken)) > 0)
|
||||
{
|
||||
totalBytesRead += bytesRead;
|
||||
|
||||
if (totalBytesRead > limits.MaxRequestBytesPerCall)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Request body exceeds limit of {limits.MaxRequestBytesPerCall} bytes");
|
||||
}
|
||||
|
||||
var dataMessage = new RpcRequestMessage
|
||||
{
|
||||
CorrelationId = correlationId,
|
||||
ConnectionId = connection.ConnectionId,
|
||||
TargetService = connection.Instance.ServiceName,
|
||||
FrameType = FrameType.RequestStreamData,
|
||||
PayloadBase64 = Convert.ToBase64String(buffer, 0, bytesRead),
|
||||
SenderInstanceId = _instance?.InstanceId
|
||||
};
|
||||
|
||||
await _requestQueue.EnqueueAsync(dataMessage, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
// Signal end of stream
|
||||
var endMessage = new RpcRequestMessage
|
||||
{
|
||||
CorrelationId = correlationId,
|
||||
ConnectionId = connection.ConnectionId,
|
||||
TargetService = connection.Instance.ServiceName,
|
||||
FrameType = FrameType.RequestStreamData,
|
||||
PayloadBase64 = string.Empty,
|
||||
SenderInstanceId = _instance?.InstanceId
|
||||
};
|
||||
|
||||
await _requestQueue.EnqueueAsync(endMessage, cancellationToken: cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
|
||||
// Read streaming response
|
||||
using var responseStream = new MemoryStream();
|
||||
await readResponseBody(responseStream);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SendHeartbeatAsync(HeartbeatPayload heartbeat, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_requestQueue is null || _connectionId is null) return;
|
||||
|
||||
var message = new RpcRequestMessage
|
||||
{
|
||||
CorrelationId = Guid.NewGuid().ToString("N"),
|
||||
ConnectionId = _connectionId,
|
||||
TargetService = "gateway",
|
||||
FrameType = FrameType.Heartbeat,
|
||||
PayloadBase64 = Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(heartbeat, _jsonOptions)),
|
||||
SenderInstanceId = _instance?.InstanceId
|
||||
};
|
||||
|
||||
await _requestQueue.EnqueueAsync(message, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DisconnectAsync()
|
||||
{
|
||||
if (_connectionId is null) return;
|
||||
|
||||
// Cancel all inflight handlers
|
||||
foreach (var kvp in _inflightHandlers)
|
||||
{
|
||||
try { kvp.Value.Cancel(); }
|
||||
catch (ObjectDisposedException) { }
|
||||
}
|
||||
_inflightHandlers.Clear();
|
||||
|
||||
await _clientCts.CancelAsync();
|
||||
|
||||
if (_receiveTask is not null)
|
||||
{
|
||||
try { await _receiveTask; }
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
_connectionId = null;
|
||||
_instance = null;
|
||||
|
||||
_logger.LogInformation("Disconnected from messaging transport");
|
||||
}
|
||||
|
||||
private static Frame DecodeFrame(FrameType frameType, string? correlationId, string payloadBase64)
|
||||
{
|
||||
return new Frame
|
||||
{
|
||||
Type = frameType,
|
||||
CorrelationId = correlationId,
|
||||
Payload = string.IsNullOrEmpty(payloadBase64)
|
||||
? ReadOnlyMemory<byte>.Empty
|
||||
: Convert.FromBase64String(payloadBase64)
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
foreach (var kvp in _inflightHandlers)
|
||||
{
|
||||
try { kvp.Value.Cancel(); }
|
||||
catch (ObjectDisposedException) { }
|
||||
}
|
||||
_inflightHandlers.Clear();
|
||||
|
||||
_clientCts.Cancel();
|
||||
_clientCts.Dispose();
|
||||
_correlationTracker.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Transport.Messaging.Options;
|
||||
using StellaOps.Router.Transport.Messaging.Protocol;
|
||||
|
||||
namespace StellaOps.Router.Transport.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Transport server that receives requests from microservices via StellaOps.Messaging.
|
||||
/// Used by the Gateway to handle incoming RPC calls.
|
||||
/// </summary>
|
||||
public sealed class MessagingTransportServer : ITransportServer, IDisposable
|
||||
{
|
||||
private readonly IMessageQueueFactory _queueFactory;
|
||||
private readonly MessagingTransportOptions _options;
|
||||
private readonly ILogger<MessagingTransportServer> _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly ConcurrentDictionary<string, ConnectionState> _connections = new();
|
||||
private readonly ConcurrentDictionary<string, IMessageQueue<RpcResponseMessage>> _serviceQueues = new();
|
||||
private readonly CancellationTokenSource _serverCts = new();
|
||||
private IMessageQueue<RpcRequestMessage>? _requestQueue;
|
||||
private IMessageQueue<RpcResponseMessage>? _responseQueue;
|
||||
private Task? _requestProcessingTask;
|
||||
private Task? _responseProcessingTask;
|
||||
private bool _running;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a HELLO frame is received.
|
||||
/// </summary>
|
||||
public event Func<ConnectionState, HelloPayload, Task>? OnHelloReceived;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a HEARTBEAT frame is received.
|
||||
/// </summary>
|
||||
public event Func<ConnectionState, HeartbeatPayload, Task>? OnHeartbeatReceived;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a RESPONSE frame is received.
|
||||
/// </summary>
|
||||
public event Func<ConnectionState, Frame, Task>? OnResponseReceived;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a connection is closed.
|
||||
/// </summary>
|
||||
public event Func<string, Task>? OnConnectionClosed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MessagingTransportServer"/> class.
|
||||
/// </summary>
|
||||
public MessagingTransportServer(
|
||||
IMessageQueueFactory queueFactory,
|
||||
IOptions<MessagingTransportOptions> options,
|
||||
ILogger<MessagingTransportServer> logger)
|
||||
{
|
||||
_queueFactory = queueFactory;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (_running)
|
||||
{
|
||||
_logger.LogWarning("Messaging transport server is already running");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Create the gateway request queue (receives requests from all services)
|
||||
_requestQueue = _queueFactory.Create<RpcRequestMessage>(new MessageQueueOptions
|
||||
{
|
||||
QueueName = _options.GetRequestQueueName("gateway"),
|
||||
ConsumerName = _options.ConsumerGroup,
|
||||
DeadLetterQueue = _options.GetRequestQueueName("gateway") + _options.DeadLetterSuffix,
|
||||
DefaultLeaseDuration = _options.LeaseDuration
|
||||
});
|
||||
|
||||
// Create the gateway response queue
|
||||
_responseQueue = _queueFactory.Create<RpcResponseMessage>(new MessageQueueOptions
|
||||
{
|
||||
QueueName = _options.ResponseQueueName,
|
||||
ConsumerName = _options.ConsumerGroup,
|
||||
DefaultLeaseDuration = _options.LeaseDuration
|
||||
});
|
||||
|
||||
_running = true;
|
||||
_requestProcessingTask = Task.Run(() => ProcessRequestsAsync(_serverCts.Token), CancellationToken.None);
|
||||
_responseProcessingTask = Task.Run(() => ProcessResponsesAsync(_serverCts.Token), CancellationToken.None);
|
||||
|
||||
_logger.LogInformation("Messaging transport server started");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_running) return;
|
||||
|
||||
_logger.LogInformation("Messaging transport server stopping");
|
||||
_running = false;
|
||||
|
||||
await _serverCts.CancelAsync();
|
||||
|
||||
if (_requestProcessingTask is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _requestProcessingTask.WaitAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
if (_responseProcessingTask is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _responseProcessingTask.WaitAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
_logger.LogInformation("Messaging transport server stopped");
|
||||
}
|
||||
|
||||
private async Task ProcessRequestsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_requestQueue is null) return;
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var leases = await _requestQueue.LeaseAsync(
|
||||
new LeaseRequest { BatchSize = _options.BatchSize, LeaseDuration = _options.LeaseDuration },
|
||||
cancellationToken);
|
||||
|
||||
foreach (var lease in leases)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessRequestMessageAsync(lease, cancellationToken);
|
||||
await lease.AcknowledgeAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing request {MessageId}", lease.MessageId);
|
||||
await lease.ReleaseAsync(ReleaseDisposition.Retry, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay if no messages
|
||||
if (leases.Count == 0)
|
||||
{
|
||||
await Task.Delay(100, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in request processing loop");
|
||||
await Task.Delay(1000, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessResponsesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_responseQueue is null) return;
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var leases = await _responseQueue.LeaseAsync(
|
||||
new LeaseRequest { BatchSize = _options.BatchSize, LeaseDuration = _options.LeaseDuration },
|
||||
cancellationToken);
|
||||
|
||||
foreach (var lease in leases)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessResponseMessageAsync(lease, cancellationToken);
|
||||
await lease.AcknowledgeAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing response {MessageId}", lease.MessageId);
|
||||
await lease.ReleaseAsync(ReleaseDisposition.Retry, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
if (leases.Count == 0)
|
||||
{
|
||||
await Task.Delay(100, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in response processing loop");
|
||||
await Task.Delay(1000, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessRequestMessageAsync(IMessageLease<RpcRequestMessage> lease, CancellationToken cancellationToken)
|
||||
{
|
||||
var message = lease.Message;
|
||||
|
||||
switch (message.FrameType)
|
||||
{
|
||||
case FrameType.Hello:
|
||||
await HandleHelloMessageAsync(message, cancellationToken);
|
||||
break;
|
||||
|
||||
case FrameType.Heartbeat:
|
||||
await HandleHeartbeatMessageAsync(message, cancellationToken);
|
||||
break;
|
||||
|
||||
case FrameType.Response:
|
||||
case FrameType.ResponseStreamData:
|
||||
// Response from microservice to gateway - route to pending request
|
||||
if (_connections.TryGetValue(message.ConnectionId, out var state) && OnResponseReceived is not null)
|
||||
{
|
||||
var frame = DecodeFrame(message.FrameType, message.CorrelationId, message.PayloadBase64);
|
||||
await OnResponseReceived(state, frame);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
_logger.LogWarning("Unexpected frame type {FrameType} in request queue", message.FrameType);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessResponseMessageAsync(IMessageLease<RpcResponseMessage> lease, CancellationToken cancellationToken)
|
||||
{
|
||||
var message = lease.Message;
|
||||
|
||||
if (_connections.TryGetValue(message.ConnectionId, out var state) && OnResponseReceived is not null)
|
||||
{
|
||||
var frame = DecodeFrame(message.FrameType, message.CorrelationId, message.PayloadBase64);
|
||||
await OnResponseReceived(state, frame);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleHelloMessageAsync(RpcRequestMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
// Parse HelloPayload from the message
|
||||
var payload = JsonSerializer.Deserialize<HelloPayload>(
|
||||
Convert.FromBase64String(message.PayloadBase64), _jsonOptions);
|
||||
|
||||
if (payload is null)
|
||||
{
|
||||
_logger.LogWarning("Invalid HELLO payload from {ConnectionId}", message.ConnectionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create ConnectionState
|
||||
var state = new ConnectionState
|
||||
{
|
||||
ConnectionId = message.ConnectionId,
|
||||
Instance = payload.Instance,
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
LastHeartbeatUtc = DateTime.UtcNow,
|
||||
TransportType = TransportType.Messaging,
|
||||
Schemas = payload.Schemas,
|
||||
OpenApiInfo = payload.OpenApiInfo
|
||||
};
|
||||
|
||||
// Register endpoints
|
||||
foreach (var endpoint in payload.Endpoints)
|
||||
{
|
||||
state.Endpoints[(endpoint.Method, endpoint.Path)] = endpoint;
|
||||
}
|
||||
|
||||
_connections[message.ConnectionId] = state;
|
||||
|
||||
_logger.LogInformation(
|
||||
"HELLO received from {ServiceName}/{Version} instance {InstanceId} via messaging",
|
||||
payload.Instance.ServiceName,
|
||||
payload.Instance.Version,
|
||||
payload.Instance.InstanceId);
|
||||
|
||||
if (OnHelloReceived is not null)
|
||||
{
|
||||
await OnHelloReceived(state, payload);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleHeartbeatMessageAsync(RpcRequestMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connections.TryGetValue(message.ConnectionId, out var state))
|
||||
{
|
||||
_logger.LogWarning("Heartbeat from unknown connection {ConnectionId}", message.ConnectionId);
|
||||
return;
|
||||
}
|
||||
|
||||
state.LastHeartbeatUtc = DateTime.UtcNow;
|
||||
|
||||
var payload = JsonSerializer.Deserialize<HeartbeatPayload>(
|
||||
Convert.FromBase64String(message.PayloadBase64), _jsonOptions);
|
||||
|
||||
if (payload is not null)
|
||||
{
|
||||
state.Status = payload.Status;
|
||||
|
||||
_logger.LogDebug("Heartbeat received from {ConnectionId}", message.ConnectionId);
|
||||
|
||||
if (OnHeartbeatReceived is not null)
|
||||
{
|
||||
await OnHeartbeatReceived(state, payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a request frame to a microservice via messaging.
|
||||
/// </summary>
|
||||
public async ValueTask SendToMicroserviceAsync(
|
||||
string connectionId,
|
||||
Frame frame,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connections.TryGetValue(connectionId, out var state))
|
||||
{
|
||||
throw new InvalidOperationException($"Connection {connectionId} not found");
|
||||
}
|
||||
|
||||
var serviceName = state.Instance.ServiceName;
|
||||
|
||||
// Get or create the service-specific request queue
|
||||
var serviceQueue = _serviceQueues.GetOrAdd(serviceName, svc =>
|
||||
_queueFactory.Create<RpcResponseMessage>(new MessageQueueOptions
|
||||
{
|
||||
QueueName = _options.GetRequestQueueName(svc),
|
||||
ConsumerName = _options.ConsumerGroup
|
||||
}));
|
||||
|
||||
var message = new RpcResponseMessage
|
||||
{
|
||||
CorrelationId = frame.CorrelationId ?? Guid.NewGuid().ToString("N"),
|
||||
ConnectionId = connectionId,
|
||||
FrameType = frame.Type,
|
||||
PayloadBase64 = Convert.ToBase64String(frame.Payload.Span)
|
||||
};
|
||||
|
||||
await serviceQueue.EnqueueAsync(message, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a connection by ID.
|
||||
/// </summary>
|
||||
public ConnectionState? GetConnection(string connectionId)
|
||||
{
|
||||
_connections.TryGetValue(connectionId, out var state);
|
||||
return state;
|
||||
}
|
||||
|
||||
private static Frame DecodeFrame(FrameType frameType, string? correlationId, string payloadBase64)
|
||||
{
|
||||
return new Frame
|
||||
{
|
||||
Type = frameType,
|
||||
CorrelationId = correlationId,
|
||||
Payload = Convert.FromBase64String(payloadBase64)
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
_serverCts.Cancel();
|
||||
_serverCts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Router.Transport.Messaging.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the messaging-based router transport.
|
||||
/// </summary>
|
||||
public class MessagingTransportOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the queue name template for incoming requests.
|
||||
/// Use {service} placeholder for service-specific queues.
|
||||
/// Example: "router:requests:{service}"
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string RequestQueueTemplate { get; set; } = "router:requests:{service}";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the queue name for gateway responses.
|
||||
/// Example: "router:responses"
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string ResponseQueueName { get; set; } = "router:responses";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the consumer group name for request processing.
|
||||
/// </summary>
|
||||
public string ConsumerGroup { get; set; } = "router-gateway";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timeout for RPC requests.
|
||||
/// </summary>
|
||||
public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the lease duration for message processing.
|
||||
/// </summary>
|
||||
public TimeSpan LeaseDuration { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the batch size for leasing messages.
|
||||
/// </summary>
|
||||
public int BatchSize { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the heartbeat interval.
|
||||
/// </summary>
|
||||
public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the dead letter queue suffix.
|
||||
/// </summary>
|
||||
public string DeadLetterSuffix { get; set; } = ":dlq";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the request queue name for a specific service.
|
||||
/// </summary>
|
||||
public string GetRequestQueueName(string serviceName)
|
||||
{
|
||||
return RequestQueueTemplate.Replace("{service}", serviceName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Transport.Messaging.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks pending request/response correlations for RPC-style messaging.
|
||||
/// </summary>
|
||||
public sealed class CorrelationTracker : IDisposable
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PendingRequest> _pendingRequests = new();
|
||||
private readonly Timer _cleanupTimer;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CorrelationTracker"/> class.
|
||||
/// </summary>
|
||||
public CorrelationTracker()
|
||||
{
|
||||
// Cleanup expired requests every 30 seconds
|
||||
_cleanupTimer = new Timer(CleanupExpiredRequests, null, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a pending request and returns a task that completes when the response arrives.
|
||||
/// </summary>
|
||||
public Task<Frame> RegisterRequestAsync(
|
||||
string correlationId,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<Frame>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var pending = new PendingRequest(tcs, DateTimeOffset.UtcNow.Add(timeout), cancellationToken);
|
||||
|
||||
if (!_pendingRequests.TryAdd(correlationId, pending))
|
||||
{
|
||||
throw new InvalidOperationException($"Correlation ID {correlationId} is already in use");
|
||||
}
|
||||
|
||||
// Register cancellation callback
|
||||
cancellationToken.Register(() =>
|
||||
{
|
||||
if (_pendingRequests.TryRemove(correlationId, out var removed))
|
||||
{
|
||||
removed.TaskCompletionSource.TrySetCanceled(cancellationToken);
|
||||
}
|
||||
});
|
||||
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes a pending request with the given response.
|
||||
/// </summary>
|
||||
public bool TryCompleteRequest(string correlationId, Frame response)
|
||||
{
|
||||
if (_pendingRequests.TryRemove(correlationId, out var pending))
|
||||
{
|
||||
return pending.TaskCompletionSource.TrySetResult(response);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fails a pending request with an exception.
|
||||
/// </summary>
|
||||
public bool TryFailRequest(string correlationId, Exception exception)
|
||||
{
|
||||
if (_pendingRequests.TryRemove(correlationId, out var pending))
|
||||
{
|
||||
return pending.TaskCompletionSource.TrySetException(exception);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a pending request.
|
||||
/// </summary>
|
||||
public bool TryCancelRequest(string correlationId)
|
||||
{
|
||||
if (_pendingRequests.TryRemove(correlationId, out var pending))
|
||||
{
|
||||
return pending.TaskCompletionSource.TrySetCanceled();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of pending requests.
|
||||
/// </summary>
|
||||
public int PendingCount => _pendingRequests.Count;
|
||||
|
||||
private void CleanupExpiredRequests(object? state)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var expiredKeys = _pendingRequests
|
||||
.Where(kvp => kvp.Value.ExpiresAt < now)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in expiredKeys)
|
||||
{
|
||||
if (_pendingRequests.TryRemove(key, out var pending))
|
||||
{
|
||||
pending.TaskCompletionSource.TrySetException(
|
||||
new TimeoutException($"Request {key} timed out"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
_cleanupTimer.Dispose();
|
||||
|
||||
// Cancel all pending requests
|
||||
foreach (var kvp in _pendingRequests)
|
||||
{
|
||||
kvp.Value.TaskCompletionSource.TrySetCanceled();
|
||||
}
|
||||
_pendingRequests.Clear();
|
||||
}
|
||||
|
||||
private sealed record PendingRequest(
|
||||
TaskCompletionSource<Frame> TaskCompletionSource,
|
||||
DateTimeOffset ExpiresAt,
|
||||
CancellationToken CancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using StellaOps.Router.Common.Enums;
|
||||
|
||||
namespace StellaOps.Router.Transport.Messaging.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an RPC request message sent via the messaging transport.
|
||||
/// </summary>
|
||||
public sealed record RpcRequestMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the correlation ID for matching requests to responses.
|
||||
/// </summary>
|
||||
public required string CorrelationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the connection ID of the sender.
|
||||
/// </summary>
|
||||
public required string ConnectionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the target service name.
|
||||
/// </summary>
|
||||
public required string TargetService { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the frame type.
|
||||
/// </summary>
|
||||
public required FrameType FrameType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the frame payload as base64-encoded bytes.
|
||||
/// </summary>
|
||||
public required string PayloadBase64 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp when this request was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timeout for this request.
|
||||
/// </summary>
|
||||
public TimeSpan? Timeout { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the reply-to queue name for responses.
|
||||
/// </summary>
|
||||
public string? ReplyToQueue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the instance ID of the sender.
|
||||
/// </summary>
|
||||
public string? SenderInstanceId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using StellaOps.Router.Common.Enums;
|
||||
|
||||
namespace StellaOps.Router.Transport.Messaging.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an RPC response message sent via the messaging transport.
|
||||
/// </summary>
|
||||
public sealed record RpcResponseMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the correlation ID matching the original request.
|
||||
/// </summary>
|
||||
public required string CorrelationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the connection ID of the responder.
|
||||
/// </summary>
|
||||
public required string ConnectionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the frame type.
|
||||
/// </summary>
|
||||
public required FrameType FrameType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the frame payload as base64-encoded bytes.
|
||||
/// </summary>
|
||||
public required string PayloadBase64 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp when this response was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the request was successful.
|
||||
/// </summary>
|
||||
public bool IsSuccess { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error message if the request failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the instance ID of the responder.
|
||||
/// </summary>
|
||||
public string? ResponderInstanceId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Transport.Messaging.Options;
|
||||
using StellaOps.Router.Transport.Messaging.Protocol;
|
||||
|
||||
namespace StellaOps.Router.Transport.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering messaging transport services.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the messaging transport for both server and client.
|
||||
/// Requires StellaOps.Messaging services to be registered.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configure">Optional configuration action.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddMessagingTransport(
|
||||
this IServiceCollection services,
|
||||
Action<MessagingTransportOptions>? configure = null)
|
||||
{
|
||||
services.AddOptions<MessagingTransportOptions>();
|
||||
if (configure is not null)
|
||||
{
|
||||
services.Configure(configure);
|
||||
}
|
||||
|
||||
// Shared correlation tracker
|
||||
services.TryAddSingleton<CorrelationTracker>();
|
||||
|
||||
// Transport implementations
|
||||
services.TryAddSingleton<MessagingTransportServer>();
|
||||
services.TryAddSingleton<MessagingTransportClient>();
|
||||
|
||||
// Register interfaces
|
||||
services.TryAddSingleton<ITransportServer>(sp => sp.GetRequiredService<MessagingTransportServer>());
|
||||
services.TryAddSingleton<ITransportClient>(sp => sp.GetRequiredService<MessagingTransportClient>());
|
||||
services.TryAddSingleton<IMicroserviceTransport>(sp => sp.GetRequiredService<MessagingTransportClient>());
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the messaging transport server only (for Gateway).
|
||||
/// Requires StellaOps.Messaging services to be registered.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configure">Optional configuration action.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddMessagingTransportServer(
|
||||
this IServiceCollection services,
|
||||
Action<MessagingTransportOptions>? configure = null)
|
||||
{
|
||||
services.AddOptions<MessagingTransportOptions>();
|
||||
if (configure is not null)
|
||||
{
|
||||
services.Configure(configure);
|
||||
}
|
||||
|
||||
services.TryAddSingleton<MessagingTransportServer>();
|
||||
services.TryAddSingleton<ITransportServer>(sp => sp.GetRequiredService<MessagingTransportServer>());
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the messaging transport client only (for Microservice SDK).
|
||||
/// Requires StellaOps.Messaging services to be registered.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configure">Optional configuration action.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddMessagingTransportClient(
|
||||
this IServiceCollection services,
|
||||
Action<MessagingTransportOptions>? configure = null)
|
||||
{
|
||||
services.AddOptions<MessagingTransportOptions>();
|
||||
if (configure is not null)
|
||||
{
|
||||
services.Configure(configure);
|
||||
}
|
||||
|
||||
services.TryAddSingleton<CorrelationTracker>();
|
||||
services.TryAddSingleton<MessagingTransportClient>();
|
||||
services.TryAddSingleton<ITransportClient>(sp => sp.GetRequiredService<MessagingTransportClient>());
|
||||
services.TryAddSingleton<IMicroserviceTransport>(sp => sp.GetRequiredService<MessagingTransportClient>());
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Router.Transport.Messaging</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Messaging\StellaOps.Messaging.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -176,7 +176,7 @@ public class AuthorityPluginConfigurationLoaderTests : IDisposable
|
||||
SchemaVersion = 1
|
||||
};
|
||||
|
||||
options.Storage.ConnectionString = "mongodb://localhost:27017/authority_test";
|
||||
options.Storage.ConnectionString = "Host=localhost;Port=5432;Database=authority_test";
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/authority-test-key.pem";
|
||||
return options;
|
||||
|
||||
@@ -27,7 +27,7 @@ public class StellaOpsAuthorityOptionsTests
|
||||
Issuer = new Uri("https://authority.stella-ops.test"),
|
||||
SchemaVersion = 1
|
||||
};
|
||||
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
|
||||
options.Storage.ConnectionString = "Host=localhost;Port=5432;Database=authority";
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
|
||||
@@ -58,7 +58,7 @@ public class StellaOpsAuthorityOptionsTests
|
||||
Issuer = new Uri("https://authority.stella-ops.test"),
|
||||
SchemaVersion = 1
|
||||
};
|
||||
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
|
||||
options.Storage.ConnectionString = "Host=localhost;Port=5432;Database=authority";
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
options.AdvisoryAi.RemoteInference.Enabled = true;
|
||||
@@ -76,7 +76,7 @@ public class StellaOpsAuthorityOptionsTests
|
||||
Issuer = new Uri("https://authority.stella-ops.test"),
|
||||
SchemaVersion = 1
|
||||
};
|
||||
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
|
||||
options.Storage.ConnectionString = "Host=localhost;Port=5432;Database=authority";
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
|
||||
@@ -107,7 +107,7 @@ public class StellaOpsAuthorityOptionsTests
|
||||
Issuer = new Uri("https://authority.stella-ops.test"),
|
||||
SchemaVersion = 1
|
||||
};
|
||||
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
|
||||
options.Storage.ConnectionString = "Host=localhost;Port=5432;Database=authority";
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
options.AdvisoryAi.RemoteInference.Enabled = true;
|
||||
@@ -141,7 +141,7 @@ public class StellaOpsAuthorityOptionsTests
|
||||
Issuer = new Uri("https://authority.stella-ops.test"),
|
||||
SchemaVersion = 1
|
||||
};
|
||||
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
|
||||
options.Storage.ConnectionString = "Host=localhost;Port=5432;Database=authority";
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
options.AdvisoryAi.RemoteInference.Enabled = true;
|
||||
@@ -177,7 +177,7 @@ public class StellaOpsAuthorityOptionsTests
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
|
||||
Assert.Contains("Mongo connection string", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("connection string", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -193,7 +193,7 @@ public class StellaOpsAuthorityOptionsTests
|
||||
["Authority:Issuer"] = "https://authority.internal",
|
||||
["Authority:AccessTokenLifetime"] = "00:30:00",
|
||||
["Authority:RefreshTokenLifetime"] = "30.00:00:00",
|
||||
["Authority:Storage:ConnectionString"] = "mongodb://example/stellaops",
|
||||
["Authority:Storage:ConnectionString"] = "Host=example;Database=stellaops",
|
||||
["Authority:Storage:DatabaseName"] = "overrideDb",
|
||||
["Authority:Storage:CommandTimeout"] = "00:01:30",
|
||||
["Authority:PluginDirectories:0"] = "/var/lib/stellaops/plugins",
|
||||
@@ -219,7 +219,7 @@ public class StellaOpsAuthorityOptionsTests
|
||||
Assert.Equal(TimeSpan.FromDays(30), options.RefreshTokenLifetime);
|
||||
Assert.Equal(new[] { "/var/lib/stellaops/plugins" }, options.PluginDirectories);
|
||||
Assert.Equal(new[] { "127.0.0.1/32" }, options.BypassNetworks);
|
||||
Assert.Equal("mongodb://example/stellaops", options.Storage.ConnectionString);
|
||||
Assert.Equal("Host=example;Database=stellaops", options.Storage.ConnectionString);
|
||||
Assert.Equal("overrideDb", options.Storage.DatabaseName);
|
||||
Assert.Equal(TimeSpan.FromMinutes(1.5), options.Storage.CommandTimeout);
|
||||
Assert.Equal(25, options.Security.RateLimiting.Token.PermitLimit);
|
||||
@@ -241,7 +241,7 @@ public class StellaOpsAuthorityOptionsTests
|
||||
Issuer = new Uri("https://authority.stella-ops.test"),
|
||||
SchemaVersion = 1
|
||||
};
|
||||
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
|
||||
options.Storage.ConnectionString = "Host=localhost;Port=5432;Database=authority";
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
|
||||
@@ -272,7 +272,7 @@ public class StellaOpsAuthorityOptionsTests
|
||||
Issuer = new Uri("https://authority.stella-ops.test"),
|
||||
SchemaVersion = 1
|
||||
};
|
||||
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
|
||||
options.Storage.ConnectionString = "Host=localhost;Port=5432;Database=authority";
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
|
||||
@@ -299,7 +299,7 @@ public class StellaOpsAuthorityOptionsTests
|
||||
Issuer = new Uri("https://authority.stella-ops.test"),
|
||||
SchemaVersion = 1
|
||||
};
|
||||
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
|
||||
options.Storage.ConnectionString = "Host=localhost;Port=5432;Database=authority";
|
||||
options.Security.RateLimiting.Token.PermitLimit = 0;
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
|
||||
Reference in New Issue
Block a user