This commit is contained in:
StellaOps Bot
2025-12-13 02:22:15 +02:00
parent 564df71bfb
commit 999e26a48e
395 changed files with 25045 additions and 2224 deletions

View File

@@ -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; }
}

View File

@@ -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>

View File

@@ -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)

View File

@@ -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();
}
}

View File

@@ -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>
{
}

View File

@@ -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>
{
}

View File

@@ -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>
{
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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++;
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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; }
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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++;
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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>());
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View 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);
}

View 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
}

View File

@@ -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; }
}

View 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);
}

View File

@@ -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
};
}

View 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
};
}

View 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
}

View File

@@ -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
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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";