Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

183
src/Router/AGENTS.md Normal file
View File

@@ -0,0 +1,183 @@
# Router Module - Agent Guidelines
## Module Overview
The Router module provides transport-agnostic microservice communication with a unified HTTP gateway. It includes the Gateway WebService, all transport implementations, the Microservice SDK, and messaging infrastructure.
## Directory Structure
```
src/Router/
├── StellaOps.Gateway.WebService/ # HTTP ingress gateway
├── __Libraries/
│ ├── StellaOps.Router.Gateway/ # Gateway core logic
│ ├── StellaOps.Router.Common/ # Shared models, enums
│ ├── StellaOps.Router.Config/ # YAML configuration
│ ├── StellaOps.Router.AspNet/ # ASP.NET integration
│ ├── StellaOps.Microservice/ # Microservice SDK
│ ├── StellaOps.Microservice.AspNetCore/ # ASP.NET hosting
│ ├── StellaOps.Microservice.SourceGen/ # Compile-time generator
│ ├── StellaOps.Messaging/ # Queue abstractions
│ ├── StellaOps.Messaging.Transport.InMemory/
│ ├── StellaOps.Messaging.Transport.Valkey/
│ ├── StellaOps.Messaging.Transport.Postgres/
│ ├── StellaOps.Router.Transport.InMemory/
│ ├── StellaOps.Router.Transport.Tcp/
│ ├── StellaOps.Router.Transport.Tls/
│ ├── StellaOps.Router.Transport.RabbitMq/
│ ├── StellaOps.Router.Transport.Udp/
│ └── StellaOps.Router.Transport.Messaging/
├── __Tests/
│ ├── __Libraries/
│ │ └── StellaOps.Router.Testing/ # Test fixtures
│ ├── StellaOps.Gateway.WebService.Tests/
│ ├── StellaOps.Microservice.Tests/
│ ├── StellaOps.Microservice.SourceGen.Tests/
│ ├── StellaOps.Router.Common.Tests/
│ ├── StellaOps.Router.Config.Tests/
│ ├── StellaOps.Router.Integration.Tests/
│ ├── StellaOps.Router.Transport.*.Tests/
│ └── StellaOps.Messaging.Transport.*.Tests/
└── examples/
├── Examples.OrderService/
├── Examples.NotificationService/
└── Examples.MultiTransport.Gateway/
```
## Key Components
### Gateway WebService
- Entry point for all HTTP traffic
- Aggregates OpenAPI from microservices
- Handles authentication/authorization
### Microservice SDK
- `[StellaEndpoint]` attribute for routing
- `IStellaEndpoint<TRequest, TResponse>` for typed handlers
- `IRawStellaEndpoint` for streaming handlers
- Source generator creates endpoint descriptors at compile time
### Transport Layer
- All transports implement `ITransportServer`/`ITransportClient`
- Binary frame protocol for efficiency
- Pluggable - services declare transport type in config
### Messaging Layer
- Queue-based communication (Valkey, PostgreSQL)
- Message leasing for at-least-once delivery
- Consumer groups for load distribution
## Development Guidelines
### Adding a New Transport
1. Create library: `StellaOps.Router.Transport.{Name}`
2. Implement `ITransportServer` and `ITransportClient`
3. Create DI extension: `AddXxxTransport()`
4. Add tests in `__Tests/StellaOps.Router.Transport.{Name}.Tests/`
5. Update solution file
### Adding a New Endpoint Type
1. Define attribute in `StellaOps.Microservice`
2. Update source generator to handle new attribute
3. Add generator tests with expected output
4. Document in `/docs/router/`
### Common Patterns
**Registering a transport:**
```csharp
builder.Services.AddInMemoryTransport();
// or
builder.Services.AddTcpTransportServer(options => { options.Port = 5100; });
```
**Defining an endpoint:**
```csharp
[StellaEndpoint("POST", "/resource", TimeoutSeconds = 30)]
public sealed class MyEndpoint : IStellaEndpoint<Request, Response>
{
public Task<Response> HandleAsync(Request req, CancellationToken ct) => ...;
}
```
**Building RawResponse headers:**
```csharp
var headers = new HeaderCollection();
headers.Set("Content-Type", "application/json");
return new RawResponse { StatusCode = 200, Headers = headers, Body = stream };
```
## Testing
### Unit Tests
- Test individual components in isolation
- Use Moq for dependencies
- Located in `__Tests/StellaOps.*.Tests/`
### Integration Tests
- Test gateway + microservice communication
- Use `StellaOps.Router.Testing` fixtures
- Located in `__Tests/StellaOps.Router.Integration.Tests/`
### Running Tests
```bash
dotnet test src/Router/StellaOps.Router.sln
```
### Test Categories
- `Unit` - Fast, no external dependencies
- `Integration` - Requires multiple services
- `Transport` - Tests specific transport
## Build Commands
```bash
# Build entire Router module
dotnet build src/Router/StellaOps.Router.sln
# Build gateway only
dotnet build src/Router/StellaOps.Gateway.WebService/
# Run examples
dotnet run --project src/Router/examples/Examples.OrderService/
```
## Dependencies
### External
- `Microsoft.Extensions.Hosting` - Service hosting
- `Microsoft.AspNetCore.*` - HTTP/WebSocket
- `System.IO.Pipelines` - High-performance I/O
- `RabbitMQ.Client` - RabbitMQ transport
- `StackExchange.Redis` - Valkey transport
- `Npgsql` - PostgreSQL transport
### Internal
- `StellaOps.DependencyInjection` - DI abstractions
- `StellaOps.Plugin` - Plugin infrastructure
- `StellaOps.Auth.Security` - Auth integration
- `StellaOps.Configuration` - Config loading
## Common Issues
### ValueTask vs Task
- Interface methods returning `ValueTask` must return `ValueTask.CompletedTask`
- Interface methods returning `Task` must return `Task.CompletedTask`
### HeaderCollection
- `IHeaderCollection` indexer is read-only
- Create `new HeaderCollection()` and use `.Set()` method
- Pass to `RawResponse` constructor
### Transport Registration
- Use `AddXxxTransport()` without Server/Client suffix for auto-detection
- Or use `AddXxxTransportServer()` / `AddXxxTransportClient()` explicitly
## Documentation
- `/docs/router/README.md` - Product overview
- `/docs/router/ARCHITECTURE.md` - Technical architecture
- `/docs/router/GETTING_STARTED.md` - Tutorial
- `/docs/router/examples/` - Example documentation

View File

@@ -0,0 +1,99 @@
using System.Text.Json;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Gateway;
namespace StellaOps.Gateway.WebService.Authorization;
public sealed class AuthorizationMiddleware
{
private readonly RequestDelegate _next;
private readonly IEffectiveClaimsStore _claimsStore;
private readonly ILogger<AuthorizationMiddleware> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public AuthorizationMiddleware(
RequestDelegate next,
IEffectiveClaimsStore claimsStore,
ILogger<AuthorizationMiddleware> logger)
{
_next = next;
_claimsStore = claimsStore;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
if (!context.Items.TryGetValue(RouterHttpContextKeys.EndpointDescriptor, out var endpointObj) ||
endpointObj is not EndpointDescriptor endpoint)
{
await _next(context);
return;
}
var effectiveClaims = _claimsStore.GetEffectiveClaims(
endpoint.ServiceName,
endpoint.Method,
endpoint.Path);
if (effectiveClaims.Count == 0)
{
await _next(context);
return;
}
foreach (var required in effectiveClaims)
{
var userClaims = context.User.Claims;
var hasClaim = required.Value == null
? userClaims.Any(c => c.Type == required.Type)
: userClaims.Any(c => c.Type == required.Type && c.Value == required.Value);
if (!hasClaim)
{
_logger.LogWarning(
"Authorization failed for {Method} {Path}: user lacks claim {ClaimType}={ClaimValue}",
endpoint.Method,
endpoint.Path,
required.Type,
required.Value ?? "(any)");
await WriteForbiddenAsync(context, endpoint, required);
return;
}
}
await _next(context);
}
private static Task WriteForbiddenAsync(
HttpContext context,
EndpointDescriptor endpoint,
ClaimRequirement required)
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
context.Response.ContentType = "application/json; charset=utf-8";
var payload = new AuthorizationFailureResponse(
Error: "forbidden",
Message: "Authorization failed: missing required claim",
RequiredClaimType: required.Type,
RequiredClaimValue: required.Value,
Service: endpoint.ServiceName,
Version: endpoint.Version);
return JsonSerializer.SerializeAsync(context.Response.Body, payload, JsonOptions, context.RequestAborted);
}
private sealed record AuthorizationFailureResponse(
string Error,
string Message,
string RequiredClaimType,
string? RequiredClaimValue,
string Service,
string Version);
}

View File

@@ -0,0 +1,96 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Gateway.Authorization;
namespace StellaOps.Gateway.WebService.Authorization;
public sealed class EffectiveClaimsStore : IEffectiveClaimsStore
{
private readonly ConcurrentDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> _microserviceClaims = new();
private readonly ConcurrentDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> _authorityClaims = new();
private readonly ILogger<EffectiveClaimsStore> _logger;
public EffectiveClaimsStore(ILogger<EffectiveClaimsStore> logger)
{
_logger = logger;
}
public IReadOnlyList<ClaimRequirement> GetEffectiveClaims(string serviceName, string method, string path)
{
var key = EndpointKey.Create(serviceName, method, path);
if (_authorityClaims.TryGetValue(key, out var authorityClaims))
{
_logger.LogDebug(
"Using Authority claims for {Endpoint}: {ClaimCount} claims",
key,
authorityClaims.Count);
return authorityClaims;
}
if (_microserviceClaims.TryGetValue(key, out var msClaims))
{
return msClaims;
}
return [];
}
public void UpdateFromMicroservice(string serviceName, IReadOnlyList<EndpointDescriptor> endpoints)
{
foreach (var endpoint in endpoints)
{
var key = EndpointKey.Create(serviceName, endpoint.Method, endpoint.Path);
var claims = endpoint.RequiringClaims ?? [];
if (claims.Count > 0)
{
_microserviceClaims[key] = claims;
_logger.LogDebug(
"Registered {ClaimCount} claims from microservice for {Endpoint}",
claims.Count,
key);
}
else
{
_microserviceClaims.TryRemove(key, out _);
}
}
}
public void UpdateFromAuthority(IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> overrides)
{
_authorityClaims.Clear();
foreach (var (key, claims) in overrides)
{
if (claims.Count > 0)
{
_authorityClaims[key] = claims;
}
}
_logger.LogInformation(
"Updated Authority claims: {EndpointCount} endpoints with overrides",
overrides.Count);
}
public void RemoveService(string serviceName)
{
var normalizedServiceName = serviceName.ToLowerInvariant();
var keysToRemove = _microserviceClaims.Keys
.Where(k => k.ServiceName == normalizedServiceName)
.ToList();
foreach (var key in keysToRemove)
{
_microserviceClaims.TryRemove(key, out _);
}
_logger.LogDebug(
"Removed {Count} endpoint claims for service {ServiceName}",
keysToRemove.Count,
serviceName);
}
}

View File

@@ -0,0 +1,15 @@
using StellaOps.Router.Common.Models;
using StellaOps.Router.Gateway.Authorization;
namespace StellaOps.Gateway.WebService.Authorization;
public interface IEffectiveClaimsStore
{
IReadOnlyList<ClaimRequirement> GetEffectiveClaims(string serviceName, string method, string path);
void UpdateFromMicroservice(string serviceName, IReadOnlyList<EndpointDescriptor> endpoints);
void UpdateFromAuthority(IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> overrides);
void RemoveService(string serviceName);
}

View File

@@ -0,0 +1,213 @@
using System.Net;
namespace StellaOps.Gateway.WebService.Configuration;
public sealed class GatewayOptions
{
public const string SectionName = "Gateway";
public GatewayNodeOptions Node { get; set; } = new();
public GatewayTransportOptions Transports { get; set; } = new();
public GatewayRoutingOptions Routing { get; set; } = new();
public GatewayAuthOptions Auth { get; set; } = new();
public GatewayOpenApiOptions OpenApi { get; set; } = new();
public GatewayHealthOptions Health { get; set; } = new();
}
public sealed class GatewayNodeOptions
{
public string Region { get; set; } = "local";
public string NodeId { get; set; } = string.Empty;
public string Environment { get; set; } = "dev";
public List<string> NeighborRegions { get; set; } = new();
}
public sealed class GatewayTransportOptions
{
public GatewayTcpTransportOptions Tcp { get; set; } = new();
public GatewayTlsTransportOptions Tls { get; set; } = new();
public GatewayMessagingTransportOptions Messaging { get; set; } = new();
}
public sealed class GatewayMessagingTransportOptions
{
/// <summary>
/// Whether messaging (Valkey) transport is enabled.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Valkey connection string (e.g., "localhost:6379" or "valkey:6379,password=secret").
/// </summary>
public string ConnectionString { get; set; } = "localhost:6379";
/// <summary>
/// Valkey database number.
/// </summary>
public int? Database { get; set; }
/// <summary>
/// Queue name template for incoming requests. Use {service} placeholder.
/// </summary>
public string RequestQueueTemplate { get; set; } = "router:requests:{service}";
/// <summary>
/// Queue name for gateway responses.
/// </summary>
public string ResponseQueueName { get; set; } = "router:responses";
/// <summary>
/// Consumer group name for request processing.
/// </summary>
public string ConsumerGroup { get; set; } = "router-gateway";
/// <summary>
/// Timeout for RPC requests.
/// </summary>
public string RequestTimeout { get; set; } = "30s";
/// <summary>
/// Lease duration for message processing.
/// </summary>
public string LeaseDuration { get; set; } = "5m";
/// <summary>
/// Batch size for leasing messages.
/// </summary>
public int BatchSize { get; set; } = 10;
/// <summary>
/// Heartbeat interval.
/// </summary>
public string HeartbeatInterval { get; set; } = "10s";
}
public sealed class GatewayTcpTransportOptions
{
public bool Enabled { get; set; }
public string BindAddress { get; set; } = IPAddress.Any.ToString();
public int Port { get; set; } = 9100;
public int ReceiveBufferSize { get; set; } = 64 * 1024;
public int SendBufferSize { get; set; } = 64 * 1024;
public int MaxFrameSize { get; set; } = 16 * 1024 * 1024;
}
public sealed class GatewayTlsTransportOptions
{
public bool Enabled { get; set; }
public string BindAddress { get; set; } = IPAddress.Any.ToString();
public int Port { get; set; } = 9443;
public int ReceiveBufferSize { get; set; } = 64 * 1024;
public int SendBufferSize { get; set; } = 64 * 1024;
public int MaxFrameSize { get; set; } = 16 * 1024 * 1024;
public string? CertificatePath { get; set; }
public string? CertificateKeyPath { get; set; }
public string? CertificatePassword { get; set; }
public bool RequireClientCertificate { get; set; }
public bool AllowSelfSigned { get; set; }
}
public sealed class GatewayRoutingOptions
{
public string DefaultTimeout { get; set; } = "30s";
public string MaxRequestBodySize { get; set; } = "100MB";
public bool StreamingEnabled { get; set; } = true;
public bool PreferLocalRegion { get; set; } = true;
public bool AllowDegradedInstances { get; set; } = true;
public bool StrictVersionMatching { get; set; } = true;
public List<string> NeighborRegions { get; set; } = new();
}
public sealed class GatewayAuthOptions
{
public bool DpopEnabled { get; set; } = true;
public bool MtlsEnabled { get; set; }
public bool AllowAnonymous { get; set; } = true;
/// <summary>
/// Enable legacy X-Stella-* headers in addition to X-StellaOps-* headers.
/// Default: true (for migration compatibility).
/// </summary>
public bool EnableLegacyHeaders { get; set; } = true;
/// <summary>
/// Allow client-provided scope headers in offline/pre-prod mode.
/// Default: false (forbidden for security).
/// WARNING: Only enable this in explicitly isolated offline/pre-prod environments.
/// </summary>
public bool AllowScopeHeader { get; set; } = false;
public GatewayAuthorityOptions Authority { get; set; } = new();
}
public sealed class GatewayAuthorityOptions
{
public string? Issuer { get; set; }
public bool RequireHttpsMetadata { get; set; } = true;
public string? MetadataAddress { get; set; }
public List<string> Audiences { get; set; } = new();
public List<string> RequiredScopes { get; set; } = new();
}
public sealed class GatewayOpenApiOptions
{
public bool Enabled { get; set; } = true;
public int CacheTtlSeconds { get; set; } = 300;
public string Title { get; set; } = "StellaOps Gateway API";
public string Description { get; set; } = "Unified API aggregating all connected microservices.";
public string Version { get; set; } = "1.0.0";
public string ServerUrl { get; set; } = "/";
public string TokenUrl { get; set; } = "/auth/token";
}
public sealed class GatewayHealthOptions
{
public string StaleThreshold { get; set; } = "30s";
public string DegradedThreshold { get; set; } = "15s";
public string CheckInterval { get; set; } = "5s";
}

View File

@@ -0,0 +1,39 @@
namespace StellaOps.Gateway.WebService.Configuration;
public static class GatewayOptionsValidator
{
public static void Validate(GatewayOptions options)
{
ArgumentNullException.ThrowIfNull(options);
if (string.IsNullOrWhiteSpace(options.Node.Region))
{
throw new InvalidOperationException("Gateway node region is required.");
}
if (options.Transports.Tcp.Enabled && options.Transports.Tcp.Port <= 0)
{
throw new InvalidOperationException("TCP transport port must be greater than zero.");
}
if (options.Transports.Tls.Enabled)
{
if (options.Transports.Tls.Port <= 0)
{
throw new InvalidOperationException("TLS transport port must be greater than zero.");
}
if (string.IsNullOrWhiteSpace(options.Transports.Tls.CertificatePath))
{
throw new InvalidOperationException("TLS transport requires a certificate path when enabled.");
}
}
_ = GatewayValueParser.ParseDuration(options.Routing.DefaultTimeout, TimeSpan.FromSeconds(30));
_ = GatewayValueParser.ParseSizeBytes(options.Routing.MaxRequestBodySize, 0);
_ = GatewayValueParser.ParseDuration(options.Health.StaleThreshold, TimeSpan.FromSeconds(30));
_ = GatewayValueParser.ParseDuration(options.Health.DegradedThreshold, TimeSpan.FromSeconds(15));
_ = GatewayValueParser.ParseDuration(options.Health.CheckInterval, TimeSpan.FromSeconds(5));
}
}

View File

@@ -0,0 +1,82 @@
using System.Globalization;
using System.Linq;
namespace StellaOps.Gateway.WebService.Configuration;
public static class GatewayValueParser
{
public static TimeSpan ParseDuration(string? value, TimeSpan fallback)
{
if (string.IsNullOrWhiteSpace(value))
{
return fallback;
}
if (TimeSpan.TryParse(value, CultureInfo.InvariantCulture, out var parsed))
{
return parsed;
}
var trimmed = value.Trim();
var (number, unit) = SplitNumberAndUnit(trimmed, defaultUnit: "s");
if (!double.TryParse(number, NumberStyles.Float, CultureInfo.InvariantCulture, out var scalar))
{
throw new InvalidOperationException($"Invalid duration value '{value}'.");
}
return unit switch
{
"ms" => TimeSpan.FromMilliseconds(scalar),
"s" => TimeSpan.FromSeconds(scalar),
"m" => TimeSpan.FromMinutes(scalar),
"h" => TimeSpan.FromHours(scalar),
_ => throw new InvalidOperationException($"Unsupported duration unit '{unit}' in '{value}'.")
};
}
public static long ParseSizeBytes(string? value, long fallback)
{
if (string.IsNullOrWhiteSpace(value))
{
return fallback;
}
var trimmed = value.Trim();
var (number, unit) = SplitNumberAndUnit(trimmed, defaultUnit: "b");
if (!double.TryParse(number, NumberStyles.Float, CultureInfo.InvariantCulture, out var scalar))
{
throw new InvalidOperationException($"Invalid size value '{value}'.");
}
var multiplier = unit switch
{
"b" => 1L,
"kb" => 1024L,
"mb" => 1024L * 1024L,
"gb" => 1024L * 1024L * 1024L,
_ => throw new InvalidOperationException($"Unsupported size unit '{unit}' in '{value}'.")
};
return (long)(scalar * multiplier);
}
private static (string Number, string Unit) SplitNumberAndUnit(string value, string defaultUnit)
{
var trimmed = value.Trim();
var numberPart = new string(trimmed.TakeWhile(ch => char.IsDigit(ch) || ch == '.' || ch == '-' || ch == '+').ToArray());
var unitPart = trimmed[numberPart.Length..].Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(unitPart))
{
unitPart = defaultUnit;
}
if (!unitPart.EndsWith("b", StringComparison.Ordinal) &&
unitPart is not "ms" and not "s" and not "m" and not "h")
{
unitPart += "b";
}
return (numberPart, unitPart);
}
}

View File

@@ -0,0 +1,14 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview AS base
WORKDIR /app
EXPOSE 8080
EXPOSE 8443
FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS build
WORKDIR /src
COPY . .
RUN dotnet publish src/Gateway/StellaOps.Gateway.WebService/StellaOps.Gateway.WebService.csproj -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "StellaOps.Gateway.WebService.dll"]

View File

@@ -0,0 +1,88 @@
using System.Security.Claims;
using System.Text.Json;
namespace StellaOps.Gateway.WebService.Middleware;
public sealed class ClaimsPropagationMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ClaimsPropagationMiddleware> _logger;
public ClaimsPropagationMiddleware(RequestDelegate next, ILogger<ClaimsPropagationMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
if (GatewayRoutes.IsSystemPath(context.Request.Path))
{
await _next(context);
return;
}
var principal = context.User;
SetHeaderIfMissing(context, "sub", principal.FindFirstValue("sub"));
SetHeaderIfMissing(context, "tid", principal.FindFirstValue("tid"));
var scopes = principal.FindAll("scope").Select(c => c.Value).ToArray();
if (scopes.Length > 0)
{
SetHeaderIfMissing(context, "scope", string.Join(" ", scopes));
}
var cnfJson = principal.FindFirstValue("cnf");
if (!string.IsNullOrWhiteSpace(cnfJson))
{
context.Items[GatewayContextKeys.CnfJson] = cnfJson;
if (TryParseCnf(cnfJson, out var jkt))
{
context.Items[GatewayContextKeys.DpopThumbprint] = jkt;
SetHeaderIfMissing(context, "cnf.jkt", jkt);
}
}
await _next(context);
}
private void SetHeaderIfMissing(HttpContext context, string name, string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
if (!context.Request.Headers.ContainsKey(name))
{
context.Request.Headers[name] = value;
}
else
{
_logger.LogDebug("Request header {Header} already set; skipping claim propagation", name);
}
}
private static bool TryParseCnf(string json, out string? jkt)
{
jkt = null;
try
{
using var document = JsonDocument.Parse(json);
if (document.RootElement.TryGetProperty("jkt", out var jktElement) &&
jktElement.ValueKind == JsonValueKind.String)
{
jkt = jktElement.GetString();
}
return !string.IsNullOrWhiteSpace(jkt);
}
catch (JsonException)
{
return false;
}
}
}

View File

@@ -0,0 +1,30 @@
namespace StellaOps.Gateway.WebService.Middleware;
public sealed class CorrelationIdMiddleware
{
public const string HeaderName = "X-Correlation-Id";
private readonly RequestDelegate _next;
public CorrelationIdMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
if (context.Request.Headers.TryGetValue(HeaderName, out var headerValue) &&
!string.IsNullOrWhiteSpace(headerValue))
{
context.TraceIdentifier = headerValue.ToString();
}
else if (string.IsNullOrWhiteSpace(context.TraceIdentifier))
{
context.TraceIdentifier = Guid.NewGuid().ToString("N");
}
context.Response.Headers[HeaderName] = context.TraceIdentifier;
await _next(context);
}
}

View File

@@ -0,0 +1,13 @@
namespace StellaOps.Gateway.WebService.Middleware;
public static class GatewayContextKeys
{
public const string TenantId = "Gateway.TenantId";
public const string ProjectId = "Gateway.ProjectId";
public const string Actor = "Gateway.Actor";
public const string Scopes = "Gateway.Scopes";
public const string DpopThumbprint = "Gateway.DpopThumbprint";
public const string MtlsThumbprint = "Gateway.MtlsThumbprint";
public const string CnfJson = "Gateway.CnfJson";
public const string IsAnonymous = "Gateway.IsAnonymous";
}

View File

@@ -0,0 +1,34 @@
namespace StellaOps.Gateway.WebService.Middleware;
public static class GatewayRoutes
{
private static readonly HashSet<string> SystemPaths = new(StringComparer.OrdinalIgnoreCase)
{
"/health",
"/health/live",
"/health/ready",
"/health/startup",
"/metrics",
"/openapi.json",
"/openapi.yaml",
"/.well-known/openapi"
};
public static bool IsSystemPath(PathString path)
{
var value = path.Value ?? string.Empty;
return SystemPaths.Contains(value);
}
public static bool IsHealthPath(PathString path)
{
var value = path.Value ?? string.Empty;
return value.StartsWith("/health", StringComparison.OrdinalIgnoreCase);
}
public static bool IsMetricsPath(PathString path)
{
var value = path.Value ?? string.Empty;
return string.Equals(value, "/metrics", StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,89 @@
using System.Text;
using System.Text.Json;
using StellaOps.Gateway.WebService.Services;
namespace StellaOps.Gateway.WebService.Middleware;
public sealed class HealthCheckMiddleware
{
private readonly RequestDelegate _next;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public HealthCheckMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context, GatewayServiceStatus status, GatewayMetrics metrics)
{
if (GatewayRoutes.IsMetricsPath(context.Request.Path))
{
await WriteMetricsAsync(context, metrics);
return;
}
if (!GatewayRoutes.IsHealthPath(context.Request.Path))
{
await _next(context);
return;
}
var path = context.Request.Path.Value ?? string.Empty;
if (path.Equals("/health/live", StringComparison.OrdinalIgnoreCase))
{
await WriteHealthAsync(context, StatusCodes.Status200OK, "live", status);
return;
}
if (path.Equals("/health/ready", StringComparison.OrdinalIgnoreCase))
{
var readyStatus = status.IsReady ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable;
await WriteHealthAsync(context, readyStatus, "ready", status);
return;
}
if (path.Equals("/health/startup", StringComparison.OrdinalIgnoreCase))
{
var startupStatus = status.IsStarted ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable;
await WriteHealthAsync(context, startupStatus, "startup", status);
return;
}
await WriteHealthAsync(context, StatusCodes.Status200OK, "ok", status);
}
private static Task WriteHealthAsync(HttpContext context, int statusCode, string status, GatewayServiceStatus serviceStatus)
{
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/json; charset=utf-8";
var payload = new
{
status,
started = serviceStatus.IsStarted,
ready = serviceStatus.IsReady,
traceId = context.TraceIdentifier
};
return context.Response.WriteAsJsonAsync(payload, JsonOptions, context.RequestAborted);
}
private static Task WriteMetricsAsync(HttpContext context, GatewayMetrics metrics)
{
context.Response.StatusCode = StatusCodes.Status200OK;
context.Response.ContentType = "text/plain; version=0.0.4";
var builder = new StringBuilder();
builder.AppendLine("# TYPE gateway_active_connections gauge");
builder.Append("gateway_active_connections ").AppendLine(metrics.GetActiveConnections().ToString());
builder.AppendLine("# TYPE gateway_registered_endpoints gauge");
builder.Append("gateway_registered_endpoints ").AppendLine(metrics.GetRegisteredEndpoints().ToString());
return context.Response.WriteAsync(builder.ToString(), context.RequestAborted);
}
}

View File

@@ -0,0 +1,333 @@
using System.Security.Claims;
using System.Text.Json;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Gateway.WebService.Middleware;
/// <summary>
/// Middleware that enforces the Gateway identity header policy:
/// 1. Strips all reserved identity headers from incoming requests (prevents spoofing)
/// 2. Computes effective identity from validated principal claims
/// 3. Writes downstream identity headers for microservice consumption
/// 4. Stores normalized identity context in HttpContext.Items
/// </summary>
/// <remarks>
/// This middleware replaces the legacy ClaimsPropagationMiddleware and TenantMiddleware
/// which used "set-if-missing" semantics that allowed client header spoofing.
/// </remarks>
public sealed class IdentityHeaderPolicyMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<IdentityHeaderPolicyMiddleware> _logger;
private readonly IdentityHeaderPolicyOptions _options;
/// <summary>
/// Reserved identity headers that must never be trusted from external clients.
/// These are stripped from incoming requests and overwritten from validated claims.
/// </summary>
private static readonly string[] ReservedHeaders =
[
// StellaOps canonical headers
"X-StellaOps-Tenant",
"X-StellaOps-Project",
"X-StellaOps-Actor",
"X-StellaOps-Scopes",
"X-StellaOps-Client",
// Legacy Stella headers (compatibility)
"X-Stella-Tenant",
"X-Stella-Project",
"X-Stella-Actor",
"X-Stella-Scopes",
// Raw claim headers (internal/legacy pass-through)
"sub",
"tid",
"scope",
"scp",
"cnf",
"cnf.jkt"
];
public IdentityHeaderPolicyMiddleware(
RequestDelegate next,
ILogger<IdentityHeaderPolicyMiddleware> logger,
IdentityHeaderPolicyOptions options)
{
_next = next;
_logger = logger;
_options = options;
}
public async Task InvokeAsync(HttpContext context)
{
// Skip processing for system paths (health, metrics, openapi, etc.)
if (GatewayRoutes.IsSystemPath(context.Request.Path))
{
await _next(context);
return;
}
// Step 1: Strip all reserved identity headers from incoming request
StripReservedHeaders(context);
// Step 2: Extract identity from validated principal
var identity = ExtractIdentity(context);
// Step 3: Store normalized identity in HttpContext.Items
StoreIdentityContext(context, identity);
// Step 4: Write downstream identity headers
WriteDownstreamHeaders(context, identity);
await _next(context);
}
private void StripReservedHeaders(HttpContext context)
{
foreach (var header in ReservedHeaders)
{
if (context.Request.Headers.ContainsKey(header))
{
_logger.LogDebug(
"Stripped reserved identity header {Header} from request {TraceId}",
header,
context.TraceIdentifier);
context.Request.Headers.Remove(header);
}
}
}
private IdentityContext ExtractIdentity(HttpContext context)
{
var principal = context.User;
var isAuthenticated = principal.Identity?.IsAuthenticated == true;
if (!isAuthenticated)
{
return new IdentityContext
{
IsAnonymous = true,
Actor = "anonymous",
Scopes = _options.AnonymousScopes ?? []
};
}
// Extract subject (actor)
var actor = principal.FindFirstValue(StellaOpsClaimTypes.Subject);
// Extract tenant - try canonical claim first, then legacy 'tid'
var tenant = principal.FindFirstValue(StellaOpsClaimTypes.Tenant)
?? principal.FindFirstValue("tid");
// Extract project (optional)
var project = principal.FindFirstValue(StellaOpsClaimTypes.Project);
// Extract scopes - try 'scp' claims first (individual items), then 'scope' (space-separated)
var scopes = ExtractScopes(principal);
// Extract cnf (confirmation claim) for DPoP/sender constraint
var cnfJson = principal.FindFirstValue("cnf");
string? dpopThumbprint = null;
if (!string.IsNullOrWhiteSpace(cnfJson))
{
TryParseCnfThumbprint(cnfJson, out dpopThumbprint);
}
return new IdentityContext
{
IsAnonymous = false,
Actor = actor,
Tenant = tenant,
Project = project,
Scopes = scopes,
CnfJson = cnfJson,
DpopThumbprint = dpopThumbprint
};
}
private static HashSet<string> ExtractScopes(ClaimsPrincipal principal)
{
var scopes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// First try individual scope claims (scp)
var scpClaims = principal.FindAll(StellaOpsClaimTypes.ScopeItem);
foreach (var claim in scpClaims)
{
if (!string.IsNullOrWhiteSpace(claim.Value))
{
scopes.Add(claim.Value.Trim());
}
}
// If no scp claims, try space-separated scope claim
if (scopes.Count == 0)
{
var scopeClaims = principal.FindAll(StellaOpsClaimTypes.Scope);
foreach (var claim in scopeClaims)
{
if (!string.IsNullOrWhiteSpace(claim.Value))
{
var parts = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var part in parts)
{
scopes.Add(part);
}
}
}
}
return scopes;
}
private void StoreIdentityContext(HttpContext context, IdentityContext identity)
{
context.Items[GatewayContextKeys.IsAnonymous] = identity.IsAnonymous;
if (!string.IsNullOrEmpty(identity.Actor))
{
context.Items[GatewayContextKeys.Actor] = identity.Actor;
}
if (!string.IsNullOrEmpty(identity.Tenant))
{
context.Items[GatewayContextKeys.TenantId] = identity.Tenant;
}
if (!string.IsNullOrEmpty(identity.Project))
{
context.Items[GatewayContextKeys.ProjectId] = identity.Project;
}
if (identity.Scopes.Count > 0)
{
context.Items[GatewayContextKeys.Scopes] = identity.Scopes;
}
if (!string.IsNullOrEmpty(identity.CnfJson))
{
context.Items[GatewayContextKeys.CnfJson] = identity.CnfJson;
}
if (!string.IsNullOrEmpty(identity.DpopThumbprint))
{
context.Items[GatewayContextKeys.DpopThumbprint] = identity.DpopThumbprint;
}
}
private void WriteDownstreamHeaders(HttpContext context, IdentityContext identity)
{
var headers = context.Request.Headers;
// Actor header
if (!string.IsNullOrEmpty(identity.Actor))
{
headers["X-StellaOps-Actor"] = identity.Actor;
if (_options.EnableLegacyHeaders)
{
headers["X-Stella-Actor"] = identity.Actor;
}
}
// Tenant header
if (!string.IsNullOrEmpty(identity.Tenant))
{
headers["X-StellaOps-Tenant"] = identity.Tenant;
if (_options.EnableLegacyHeaders)
{
headers["X-Stella-Tenant"] = identity.Tenant;
}
}
// Project header (optional)
if (!string.IsNullOrEmpty(identity.Project))
{
headers["X-StellaOps-Project"] = identity.Project;
if (_options.EnableLegacyHeaders)
{
headers["X-Stella-Project"] = identity.Project;
}
}
// Scopes header (space-delimited, sorted for determinism)
if (identity.Scopes.Count > 0)
{
var sortedScopes = identity.Scopes.OrderBy(s => s, StringComparer.Ordinal);
var scopesValue = string.Join(" ", sortedScopes);
headers["X-StellaOps-Scopes"] = scopesValue;
if (_options.EnableLegacyHeaders)
{
headers["X-Stella-Scopes"] = scopesValue;
}
}
else if (identity.IsAnonymous)
{
// Explicit empty scopes for anonymous to prevent ambiguity
headers["X-StellaOps-Scopes"] = string.Empty;
if (_options.EnableLegacyHeaders)
{
headers["X-Stella-Scopes"] = string.Empty;
}
}
// DPoP thumbprint (if present)
if (!string.IsNullOrEmpty(identity.DpopThumbprint))
{
headers["cnf.jkt"] = identity.DpopThumbprint;
}
}
private static bool TryParseCnfThumbprint(string json, out string? jkt)
{
jkt = null;
try
{
using var document = JsonDocument.Parse(json);
if (document.RootElement.TryGetProperty("jkt", out var jktElement) &&
jktElement.ValueKind == JsonValueKind.String)
{
jkt = jktElement.GetString();
}
return !string.IsNullOrWhiteSpace(jkt);
}
catch (JsonException)
{
return false;
}
}
private sealed class IdentityContext
{
public bool IsAnonymous { get; init; }
public string? Actor { get; init; }
public string? Tenant { get; init; }
public string? Project { get; init; }
public HashSet<string> Scopes { get; init; } = [];
public string? CnfJson { get; init; }
public string? DpopThumbprint { get; init; }
}
}
/// <summary>
/// Configuration options for the identity header policy middleware.
/// </summary>
public sealed class IdentityHeaderPolicyOptions
{
/// <summary>
/// Enable legacy X-Stella-* headers in addition to X-StellaOps-* headers.
/// Default: true (for migration compatibility).
/// </summary>
public bool EnableLegacyHeaders { get; set; } = true;
/// <summary>
/// Scopes to assign to anonymous requests.
/// Default: empty (no scopes).
/// </summary>
public HashSet<string>? AnonymousScopes { get; set; }
/// <summary>
/// Allow client-provided scope headers in offline/pre-prod mode.
/// Default: false (forbidden for security).
/// </summary>
public bool AllowScopeHeaderOverride { get; set; } = false;
}

View File

@@ -0,0 +1,22 @@
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Gateway.Middleware;
namespace StellaOps.Gateway.WebService.Middleware;
public sealed class RequestRoutingMiddleware
{
private readonly TransportDispatchMiddleware _dispatchMiddleware;
public RequestRoutingMiddleware(
RequestDelegate next,
ILogger<RequestRoutingMiddleware> logger,
ILogger<TransportDispatchMiddleware> dispatchLogger)
{
_dispatchMiddleware = new TransportDispatchMiddleware(next, dispatchLogger);
}
public Task InvokeAsync(HttpContext context, ITransportClient transportClient, IGlobalRoutingState routingState)
{
return _dispatchMiddleware.Invoke(context, transportClient, routingState);
}
}

View File

@@ -0,0 +1,214 @@
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.IdentityModel.Tokens;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Security.Dpop;
using StellaOps.Gateway.WebService.Configuration;
namespace StellaOps.Gateway.WebService.Middleware;
public sealed class SenderConstraintMiddleware
{
private readonly RequestDelegate _next;
private readonly IOptions<GatewayOptions> _options;
private readonly IDpopProofValidator _dpopValidator;
private readonly ILogger<SenderConstraintMiddleware> _logger;
public SenderConstraintMiddleware(
RequestDelegate next,
IOptions<GatewayOptions> options,
IDpopProofValidator dpopValidator,
ILogger<SenderConstraintMiddleware> logger)
{
_next = next;
_options = options;
_dpopValidator = dpopValidator;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
if (GatewayRoutes.IsSystemPath(context.Request.Path))
{
await _next(context);
return;
}
var authOptions = _options.Value.Auth;
if (context.User.Identity?.IsAuthenticated != true)
{
if (authOptions.AllowAnonymous)
{
await _next(context);
return;
}
await WriteUnauthorizedAsync(context, "unauthenticated", "Authentication required.");
return;
}
var confirmation = ParseConfirmation(context.User.FindFirstValue("cnf"));
if (confirmation.Raw is not null)
{
context.Items[GatewayContextKeys.CnfJson] = confirmation.Raw;
}
var requireDpop = authOptions.DpopEnabled && (!authOptions.MtlsEnabled || !string.IsNullOrWhiteSpace(confirmation.Jkt));
var requireMtls = authOptions.MtlsEnabled && (!authOptions.DpopEnabled || !string.IsNullOrWhiteSpace(confirmation.X5tS256));
if (authOptions.DpopEnabled && authOptions.MtlsEnabled &&
string.IsNullOrWhiteSpace(confirmation.Jkt) && string.IsNullOrWhiteSpace(confirmation.X5tS256))
{
requireDpop = true;
requireMtls = true;
}
if (requireDpop && !await ValidateDpopAsync(context, confirmation))
{
return;
}
if (requireMtls && !await ValidateMtlsAsync(context, confirmation))
{
return;
}
await _next(context);
}
private async Task<bool> ValidateDpopAsync(HttpContext context, ConfirmationClaim confirmation)
{
if (!context.Request.Headers.TryGetValue("DPoP", out var proofHeader) ||
string.IsNullOrWhiteSpace(proofHeader))
{
_logger.LogWarning("Missing DPoP proof for request {TraceId}", context.TraceIdentifier);
await WriteUnauthorizedAsync(context, "dpop_missing", "DPoP proof is required.");
return false;
}
var proof = proofHeader.ToString();
var requestUri = new Uri(context.Request.GetDisplayUrl());
var result = await _dpopValidator.ValidateAsync(
proof,
context.Request.Method,
requestUri,
cancellationToken: context.RequestAborted);
if (!result.IsValid)
{
_logger.LogWarning("DPoP validation failed for {TraceId}: {Error}", context.TraceIdentifier, result.ErrorDescription);
await WriteUnauthorizedAsync(context, result.ErrorCode ?? "dpop_invalid", result.ErrorDescription ?? "DPoP proof invalid.");
return false;
}
if (result.PublicKey is not JsonWebKey jwk)
{
_logger.LogWarning("DPoP validation failed for {TraceId}: JWK missing", context.TraceIdentifier);
await WriteUnauthorizedAsync(context, "dpop_key_invalid", "DPoP proof must include a valid JWK.");
return false;
}
var thumbprint = ComputeJwkThumbprint(jwk);
context.Items[GatewayContextKeys.DpopThumbprint] = thumbprint;
if (!string.IsNullOrWhiteSpace(confirmation.Jkt) &&
!string.Equals(confirmation.Jkt, thumbprint, StringComparison.Ordinal))
{
_logger.LogWarning("DPoP thumbprint mismatch for {TraceId}", context.TraceIdentifier);
await WriteUnauthorizedAsync(context, "dpop_thumbprint_mismatch", "DPoP proof does not match token confirmation.");
return false;
}
return true;
}
private async Task<bool> ValidateMtlsAsync(HttpContext context, ConfirmationClaim confirmation)
{
var certificate = context.Connection.ClientCertificate;
if (certificate is null)
{
_logger.LogWarning("mTLS required but no client certificate provided for {TraceId}", context.TraceIdentifier);
await WriteUnauthorizedAsync(context, "mtls_required", "Client certificate required.");
return false;
}
var hash = certificate.GetCertHash(HashAlgorithmName.SHA256);
var thumbprint = Base64UrlEncoder.Encode(hash);
context.Items[GatewayContextKeys.MtlsThumbprint] = thumbprint;
if (!string.IsNullOrWhiteSpace(confirmation.X5tS256) &&
!string.Equals(confirmation.X5tS256, thumbprint, StringComparison.Ordinal))
{
_logger.LogWarning("mTLS thumbprint mismatch for {TraceId}", context.TraceIdentifier);
await WriteUnauthorizedAsync(context, "mtls_thumbprint_mismatch", "Client certificate does not match token confirmation.");
return false;
}
return true;
}
private static string ComputeJwkThumbprint(JsonWebKey jwk)
{
object rawThumbprint = jwk.ComputeJwkThumbprint();
return rawThumbprint switch
{
string thumbprint => thumbprint,
byte[] bytes => Base64UrlEncoder.Encode(bytes),
_ => throw new InvalidOperationException("Unable to compute JWK thumbprint.")
};
}
private static ConfirmationClaim ParseConfirmation(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return ConfirmationClaim.Empty;
}
try
{
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
root.TryGetProperty("jkt", out var jktElement);
root.TryGetProperty("x5t#S256", out var x5tElement);
return new ConfirmationClaim(
json,
jktElement.ValueKind == JsonValueKind.String ? jktElement.GetString() : null,
x5tElement.ValueKind == JsonValueKind.String ? x5tElement.GetString() : null);
}
catch (JsonException)
{
return ConfirmationClaim.Empty;
}
}
private static Task WriteUnauthorizedAsync(HttpContext context, string error, string message)
{
if (context.Response.HasStarted)
{
return Task.CompletedTask;
}
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.Response.ContentType = "application/json; charset=utf-8";
var payload = new
{
error,
message,
traceId = context.TraceIdentifier
};
return context.Response.WriteAsJsonAsync(payload, context.RequestAborted);
}
private sealed record ConfirmationClaim(string? Raw, string? Jkt, string? X5tS256)
{
public static ConfirmationClaim Empty { get; } = new(null, null, null);
}
}

View File

@@ -0,0 +1,40 @@
using System.Security.Claims;
namespace StellaOps.Gateway.WebService.Middleware;
public sealed class TenantMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<TenantMiddleware> _logger;
public TenantMiddleware(RequestDelegate next, ILogger<TenantMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
if (GatewayRoutes.IsSystemPath(context.Request.Path))
{
await _next(context);
return;
}
var tenantId = context.User.FindFirstValue("tid");
if (!string.IsNullOrWhiteSpace(tenantId))
{
context.Items[GatewayContextKeys.TenantId] = tenantId;
if (!context.Request.Headers.ContainsKey("tid"))
{
context.Request.Headers["tid"] = tenantId;
}
}
else
{
_logger.LogDebug("No tenant claim found on request {TraceId}", context.TraceIdentifier);
}
await _next(context);
}
}

View File

@@ -0,0 +1,323 @@
using System.Net;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.Security.Dpop;
using StellaOps.Configuration;
using StellaOps.Gateway.WebService.Authorization;
using StellaOps.Gateway.WebService.Configuration;
using StellaOps.Gateway.WebService.Middleware;
using StellaOps.Gateway.WebService.Security;
using StellaOps.Gateway.WebService.Services;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Gateway;
using StellaOps.Router.Gateway.Configuration;
using StellaOps.Router.Gateway.DependencyInjection;
using StellaOps.Router.Gateway.Middleware;
using StellaOps.Router.Gateway.OpenApi;
using StellaOps.Router.Gateway.RateLimit;
using StellaOps.Router.Gateway.Routing;
using StellaOps.Router.Transport.Tcp;
using StellaOps.Router.Transport.Tls;
using StellaOps.Router.Transport.Messaging;
using StellaOps.Router.Transport.Messaging.Options;
using StellaOps.Messaging.DependencyInjection;
using StellaOps.Messaging.Transport.Valkey;
using StellaOps.Router.AspNet;
using StellaOps.Router.Common.Plugins;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddStellaOpsDefaults(options =>
{
options.BasePath = builder.Environment.ContentRootPath;
options.EnvironmentPrefix = "GATEWAY_";
});
var bootstrapOptions = builder.Configuration.BindOptions<GatewayOptions>(
GatewayOptions.SectionName,
(opts, _) => GatewayOptionsValidator.Validate(opts));
builder.Services.AddOptions<GatewayOptions>()
.Bind(builder.Configuration.GetSection(GatewayOptions.SectionName))
.PostConfigure(GatewayOptionsValidator.Validate)
.ValidateOnStart();
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddRouterGatewayCore();
builder.Services.AddRouterRateLimiting(builder.Configuration);
builder.Services.AddSingleton<IEffectiveClaimsStore, EffectiveClaimsStore>();
builder.Services.AddSingleton<GatewayServiceStatus>();
builder.Services.AddSingleton<GatewayMetrics>();
// Load router transport plugins
var transportPluginLoader = new RouterTransportPluginLoader(
NullLoggerFactory.Instance.CreateLogger<RouterTransportPluginLoader>());
// Try to load from plugins directory, fallback to direct registration if not found
var pluginsPath = Path.Combine(AppContext.BaseDirectory, "plugins", "router", "transports");
if (Directory.Exists(pluginsPath))
{
transportPluginLoader.LoadFromDirectory(pluginsPath);
}
// Register TCP and TLS transports (from plugins or fallback to compile-time references)
var tcpPlugin = transportPluginLoader.GetPlugin("tcp");
var tlsPlugin = transportPluginLoader.GetPlugin("tls");
if (tcpPlugin is not null)
{
tcpPlugin.Register(new RouterTransportRegistrationContext(
builder.Services, builder.Configuration, RouterTransportMode.Server)
{
ConfigurationSection = "Gateway:Transports:Tcp"
});
}
else
{
// Fallback to compile-time registration
builder.Services.AddTcpTransportServer();
}
if (tlsPlugin is not null)
{
tlsPlugin.Register(new RouterTransportRegistrationContext(
builder.Services, builder.Configuration, RouterTransportMode.Server)
{
ConfigurationSection = "Gateway:Transports:Tls"
});
}
else
{
// Fallback to compile-time registration
builder.Services.AddTlsTransportServer();
}
// Messaging transport (Valkey)
if (bootstrapOptions.Transports.Messaging.Enabled)
{
builder.Services.AddMessagingTransport<ValkeyTransportPlugin>(builder.Configuration, "Gateway:Transports:Messaging");
builder.Services.AddMessagingTransportServer();
}
builder.Services.AddSingleton<GatewayTransportClient>();
builder.Services.AddSingleton<ITransportClient>(sp => sp.GetRequiredService<GatewayTransportClient>());
builder.Services.AddSingleton<IOpenApiDocumentGenerator, OpenApiDocumentGenerator>();
builder.Services.AddSingleton<IRouterOpenApiDocumentCache, RouterOpenApiDocumentCache>();
builder.Services.AddHostedService<GatewayHostedService>();
builder.Services.AddHostedService<GatewayHealthMonitorService>();
builder.Services.AddSingleton<IDpopReplayCache, InMemoryDpopReplayCache>();
builder.Services.AddSingleton<IDpopProofValidator, DpopProofValidator>();
// Identity header policy options
builder.Services.AddSingleton(new IdentityHeaderPolicyOptions
{
EnableLegacyHeaders = bootstrapOptions.Auth.EnableLegacyHeaders,
AllowScopeHeaderOverride = bootstrapOptions.Auth.AllowScopeHeader
});
ConfigureAuthentication(builder, bootstrapOptions);
ConfigureGatewayOptionsMapping(builder, bootstrapOptions);
// Stella Router integration
var routerOptions = builder.Configuration.GetSection("Gateway:Router").Get<StellaRouterOptionsBase>();
builder.Services.TryAddStellaRouter(
serviceName: "gateway",
version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
routerOptions: routerOptions);
var app = builder.Build();
app.UseMiddleware<CorrelationIdMiddleware>();
app.UseAuthentication();
app.UseMiddleware<SenderConstraintMiddleware>();
// IdentityHeaderPolicyMiddleware replaces TenantMiddleware and ClaimsPropagationMiddleware
// It strips reserved identity headers and overwrites them from validated claims (security fix)
app.UseMiddleware<IdentityHeaderPolicyMiddleware>();
app.UseMiddleware<HealthCheckMiddleware>();
app.TryUseStellaRouter(routerOptions);
if (bootstrapOptions.OpenApi.Enabled)
{
app.MapRouterOpenApi();
}
app.UseWhen(
context => !GatewayRoutes.IsSystemPath(context.Request.Path),
branch =>
{
branch.UseMiddleware<RequestLoggingMiddleware>();
branch.UseMiddleware<GlobalErrorHandlerMiddleware>();
branch.UseMiddleware<PayloadLimitsMiddleware>();
branch.UseMiddleware<EndpointResolutionMiddleware>();
branch.UseMiddleware<AuthorizationMiddleware>();
branch.UseRateLimiting();
branch.UseMiddleware<RoutingDecisionMiddleware>();
branch.UseMiddleware<RequestRoutingMiddleware>();
});
// Refresh Router endpoint cache
app.TryRefreshStellaRouterEndpoints(routerOptions);
await app.RunAsync();
static void ConfigureAuthentication(WebApplicationBuilder builder, GatewayOptions options)
{
var authOptions = options.Auth;
if (!string.IsNullOrWhiteSpace(authOptions.Authority.Issuer))
{
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
configurationSection: null,
configure: resourceOptions =>
{
resourceOptions.Authority = authOptions.Authority.Issuer;
resourceOptions.RequireHttpsMetadata = authOptions.Authority.RequireHttpsMetadata;
resourceOptions.MetadataAddress = authOptions.Authority.MetadataAddress;
resourceOptions.Audiences.Clear();
foreach (var audience in authOptions.Authority.Audiences)
{
resourceOptions.Audiences.Add(audience);
}
});
if (authOptions.Authority.RequiredScopes.Count > 0)
{
builder.Services.AddAuthorization(config =>
{
config.AddPolicy("gateway.default", policy =>
{
policy.RequireAuthenticatedUser();
policy.Requirements.Add(new StellaOpsScopeRequirement(authOptions.Authority.RequiredScopes));
policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme);
});
});
}
return;
}
if (authOptions.AllowAnonymous)
{
builder.Services.AddAuthentication(authConfig =>
{
authConfig.DefaultAuthenticateScheme = AllowAllAuthenticationHandler.SchemeName;
authConfig.DefaultChallengeScheme = AllowAllAuthenticationHandler.SchemeName;
}).AddScheme<AuthenticationSchemeOptions, AllowAllAuthenticationHandler>(
AllowAllAuthenticationHandler.SchemeName,
_ => { });
return;
}
throw new InvalidOperationException("Gateway authentication requires an Authority issuer or AllowAnonymous.");
}
static void ConfigureGatewayOptionsMapping(WebApplicationBuilder builder, GatewayOptions gatewayOptions)
{
builder.Services.AddOptions<RouterNodeConfig>()
.Configure<IOptions<GatewayOptions>>((options, gateway) =>
{
options.Region = gateway.Value.Node.Region;
options.NodeId = gateway.Value.Node.NodeId;
options.Environment = gateway.Value.Node.Environment;
options.NeighborRegions = gateway.Value.Node.NeighborRegions;
});
builder.Services.AddOptions<RoutingOptions>()
.Configure<IOptions<GatewayOptions>>((options, gateway) =>
{
var routing = gateway.Value.Routing;
options.RoutingTimeoutMs = (int)GatewayValueParser.ParseDuration(routing.DefaultTimeout, TimeSpan.FromSeconds(30)).TotalMilliseconds;
options.PreferLocalRegion = routing.PreferLocalRegion;
options.AllowDegradedInstances = routing.AllowDegradedInstances;
options.StrictVersionMatching = routing.StrictVersionMatching;
});
builder.Services.AddOptions<PayloadLimits>()
.Configure<IOptions<GatewayOptions>>((options, gateway) =>
{
var routing = gateway.Value.Routing;
options.MaxRequestBytesPerCall = GatewayValueParser.ParseSizeBytes(routing.MaxRequestBodySize, options.MaxRequestBytesPerCall);
});
builder.Services.AddOptions<HealthOptions>()
.Configure<IOptions<GatewayOptions>>((options, gateway) =>
{
var health = gateway.Value.Health;
options.StaleThreshold = GatewayValueParser.ParseDuration(health.StaleThreshold, options.StaleThreshold);
options.DegradedThreshold = GatewayValueParser.ParseDuration(health.DegradedThreshold, options.DegradedThreshold);
options.CheckInterval = GatewayValueParser.ParseDuration(health.CheckInterval, options.CheckInterval);
});
builder.Services.AddOptions<OpenApiAggregationOptions>()
.Configure<IOptions<GatewayOptions>>((options, gateway) =>
{
var openApi = gateway.Value.OpenApi;
options.Enabled = openApi.Enabled;
options.CacheTtlSeconds = openApi.CacheTtlSeconds;
options.Title = openApi.Title;
options.Description = openApi.Description;
options.Version = openApi.Version;
options.ServerUrl = openApi.ServerUrl;
options.TokenUrl = openApi.TokenUrl;
});
builder.Services.AddOptions<TcpTransportOptions>()
.Configure<IOptions<GatewayOptions>>((options, gateway) =>
{
var tcp = gateway.Value.Transports.Tcp;
options.Port = tcp.Port;
options.ReceiveBufferSize = tcp.ReceiveBufferSize;
options.SendBufferSize = tcp.SendBufferSize;
options.MaxFrameSize = tcp.MaxFrameSize;
options.BindAddress = IPAddress.Parse(tcp.BindAddress);
});
builder.Services.AddOptions<TlsTransportOptions>()
.Configure<IOptions<GatewayOptions>>((options, gateway) =>
{
var tls = gateway.Value.Transports.Tls;
options.Port = tls.Port;
options.ReceiveBufferSize = tls.ReceiveBufferSize;
options.SendBufferSize = tls.SendBufferSize;
options.MaxFrameSize = tls.MaxFrameSize;
options.BindAddress = IPAddress.Parse(tls.BindAddress);
options.ServerCertificatePath = tls.CertificatePath;
options.ServerCertificateKeyPath = tls.CertificateKeyPath;
options.ServerCertificatePassword = tls.CertificatePassword;
options.RequireClientCertificate = tls.RequireClientCertificate;
options.AllowSelfSigned = tls.AllowSelfSigned;
});
builder.Services.AddOptions<MessagingTransportOptions>()
.Configure<IOptions<GatewayOptions>>((options, gateway) =>
{
var messaging = gateway.Value.Transports.Messaging;
options.RequestQueueTemplate = messaging.RequestQueueTemplate;
options.ResponseQueueName = messaging.ResponseQueueName;
options.ConsumerGroup = messaging.ConsumerGroup;
options.RequestTimeout = GatewayValueParser.ParseDuration(messaging.RequestTimeout, TimeSpan.FromSeconds(30));
options.LeaseDuration = GatewayValueParser.ParseDuration(messaging.LeaseDuration, TimeSpan.FromMinutes(5));
options.BatchSize = messaging.BatchSize;
options.HeartbeatInterval = GatewayValueParser.ParseDuration(messaging.HeartbeatInterval, TimeSpan.FromSeconds(10));
});
builder.Services.AddOptions<ValkeyTransportOptions>()
.Configure<IOptions<GatewayOptions>>((options, gateway) =>
{
var messaging = gateway.Value.Transports.Messaging;
options.ConnectionString = messaging.ConnectionString;
options.Database = messaging.Database;
});
}

View File

@@ -0,0 +1,12 @@
{
"profiles": {
"StellaOps.Gateway.WebService": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:62515;http://localhost:62516"
}
}
}

View File

@@ -0,0 +1,30 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace StellaOps.Gateway.WebService.Security;
internal sealed class AllowAllAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "Gateway.AllowAll";
#pragma warning disable CS0618
public AllowAllAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock)
: base(options, logger, encoder, clock)
{
}
#pragma warning restore CS0618
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var identity = new ClaimsIdentity();
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}

View File

@@ -0,0 +1,106 @@
using Microsoft.Extensions.Options;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Gateway.Configuration;
namespace StellaOps.Gateway.WebService.Services;
public sealed class GatewayHealthMonitorService : BackgroundService
{
private readonly IGlobalRoutingState _routingState;
private readonly IOptions<HealthOptions> _options;
private readonly ILogger<GatewayHealthMonitorService> _logger;
public GatewayHealthMonitorService(
IGlobalRoutingState routingState,
IOptions<HealthOptions> options,
ILogger<GatewayHealthMonitorService> logger)
{
_routingState = routingState;
_options = options;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation(
"Health monitor started. Stale threshold: {StaleThreshold}, Check interval: {CheckInterval}",
_options.Value.StaleThreshold,
_options.Value.CheckInterval);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(_options.Value.CheckInterval, stoppingToken);
CheckStaleConnections();
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in health monitor loop");
}
}
_logger.LogInformation("Health monitor stopped");
}
private void CheckStaleConnections()
{
var staleThreshold = _options.Value.StaleThreshold;
var degradedThreshold = _options.Value.DegradedThreshold;
var now = DateTime.UtcNow;
var staleCount = 0;
var degradedCount = 0;
foreach (var connection in _routingState.GetAllConnections())
{
if (connection.Status == InstanceHealthStatus.Draining)
{
continue;
}
var age = now - connection.LastHeartbeatUtc;
if (age > staleThreshold && connection.Status != InstanceHealthStatus.Unhealthy)
{
_routingState.UpdateConnection(connection.ConnectionId, c =>
c.Status = InstanceHealthStatus.Unhealthy);
_logger.LogWarning(
"Instance {InstanceId} ({ServiceName}/{Version}) marked Unhealthy: no heartbeat for {Age:g}",
connection.Instance.InstanceId,
connection.Instance.ServiceName,
connection.Instance.Version,
age);
staleCount++;
}
else if (age > degradedThreshold && connection.Status == InstanceHealthStatus.Healthy)
{
_routingState.UpdateConnection(connection.ConnectionId, c =>
c.Status = InstanceHealthStatus.Degraded);
_logger.LogWarning(
"Instance {InstanceId} ({ServiceName}/{Version}) marked Degraded: delayed heartbeat ({Age:g})",
connection.Instance.InstanceId,
connection.Instance.ServiceName,
connection.Instance.Version,
age);
degradedCount++;
}
}
if (staleCount > 0 || degradedCount > 0)
{
_logger.LogDebug(
"Health check completed: {StaleCount} stale, {DegradedCount} degraded",
staleCount,
degradedCount);
}
}
}

View File

@@ -0,0 +1,531 @@
using System.Linq;
using System.Text.Json;
using Microsoft.Extensions.Options;
using StellaOps.Gateway.WebService.Authorization;
using StellaOps.Gateway.WebService.Configuration;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Gateway.OpenApi;
using StellaOps.Router.Transport.Tcp;
using StellaOps.Router.Transport.Tls;
using StellaOps.Router.Transport.Messaging;
namespace StellaOps.Gateway.WebService.Services;
public sealed class GatewayHostedService : IHostedService
{
private readonly TcpTransportServer _tcpServer;
private readonly TlsTransportServer _tlsServer;
private readonly MessagingTransportServer? _messagingServer;
private readonly IGlobalRoutingState _routingState;
private readonly GatewayTransportClient _transportClient;
private readonly IEffectiveClaimsStore _claimsStore;
private readonly IRouterOpenApiDocumentCache? _openApiCache;
private readonly IOptions<GatewayOptions> _options;
private readonly GatewayServiceStatus _status;
private readonly ILogger<GatewayHostedService> _logger;
private readonly JsonSerializerOptions _jsonOptions;
private bool _tcpEnabled;
private bool _tlsEnabled;
private bool _messagingEnabled;
public GatewayHostedService(
TcpTransportServer tcpServer,
TlsTransportServer tlsServer,
IGlobalRoutingState routingState,
GatewayTransportClient transportClient,
IEffectiveClaimsStore claimsStore,
IOptions<GatewayOptions> options,
GatewayServiceStatus status,
ILogger<GatewayHostedService> logger,
IRouterOpenApiDocumentCache? openApiCache = null,
MessagingTransportServer? messagingServer = null)
{
_tcpServer = tcpServer;
_tlsServer = tlsServer;
_messagingServer = messagingServer;
_routingState = routingState;
_transportClient = transportClient;
_claimsStore = claimsStore;
_options = options;
_status = status;
_logger = logger;
_openApiCache = openApiCache;
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
}
public async Task StartAsync(CancellationToken cancellationToken)
{
var options = _options.Value;
_tcpEnabled = options.Transports.Tcp.Enabled;
_tlsEnabled = options.Transports.Tls.Enabled;
_messagingEnabled = options.Transports.Messaging.Enabled && _messagingServer is not null;
if (!_tcpEnabled && !_tlsEnabled && !_messagingEnabled)
{
_logger.LogWarning("No transports enabled; gateway will not accept microservice connections.");
_status.MarkStarted();
_status.MarkReady();
return;
}
if (_tcpEnabled)
{
_tcpServer.OnFrame += HandleTcpFrame;
_tcpServer.OnDisconnection += HandleTcpDisconnection;
await _tcpServer.StartAsync(cancellationToken);
_logger.LogInformation("TCP transport started on port {Port}", options.Transports.Tcp.Port);
}
if (_tlsEnabled)
{
_tlsServer.OnFrame += HandleTlsFrame;
_tlsServer.OnDisconnection += HandleTlsDisconnection;
await _tlsServer.StartAsync(cancellationToken);
_logger.LogInformation("TLS transport started on port {Port}", options.Transports.Tls.Port);
}
if (_messagingEnabled && _messagingServer is not null)
{
_messagingServer.OnHelloReceived += HandleMessagingHello;
_messagingServer.OnHeartbeatReceived += HandleMessagingHeartbeat;
_messagingServer.OnResponseReceived += HandleMessagingResponse;
_messagingServer.OnConnectionClosed += HandleMessagingDisconnection;
await _messagingServer.StartAsync(cancellationToken);
_logger.LogInformation("Messaging transport started (Valkey connection: {Connection})",
options.Transports.Messaging.ConnectionString);
}
_status.MarkStarted();
_status.MarkReady();
}
public async Task StopAsync(CancellationToken cancellationToken)
{
_status.MarkNotReady();
foreach (var connection in _routingState.GetAllConnections())
{
_routingState.UpdateConnection(connection.ConnectionId, c => c.Status = InstanceHealthStatus.Draining);
}
if (_tcpEnabled)
{
await _tcpServer.StopAsync(cancellationToken);
_tcpServer.OnFrame -= HandleTcpFrame;
_tcpServer.OnDisconnection -= HandleTcpDisconnection;
}
if (_tlsEnabled)
{
await _tlsServer.StopAsync(cancellationToken);
_tlsServer.OnFrame -= HandleTlsFrame;
_tlsServer.OnDisconnection -= HandleTlsDisconnection;
}
if (_messagingEnabled && _messagingServer is not null)
{
await _messagingServer.StopAsync(cancellationToken);
_messagingServer.OnHelloReceived -= HandleMessagingHello;
_messagingServer.OnHeartbeatReceived -= HandleMessagingHeartbeat;
_messagingServer.OnResponseReceived -= HandleMessagingResponse;
_messagingServer.OnConnectionClosed -= HandleMessagingDisconnection;
}
}
private void HandleTcpFrame(string connectionId, Frame frame)
{
_ = HandleFrameAsync(TransportType.Tcp, connectionId, frame);
}
private void HandleTlsFrame(string connectionId, Frame frame)
{
_ = HandleFrameAsync(TransportType.Certificate, connectionId, frame);
}
private void HandleTcpDisconnection(string connectionId)
{
HandleDisconnect(connectionId);
}
private void HandleTlsDisconnection(string connectionId)
{
HandleDisconnect(connectionId);
}
private async Task HandleFrameAsync(TransportType transportType, string connectionId, Frame frame)
{
try
{
switch (frame.Type)
{
case FrameType.Hello:
await HandleHelloAsync(transportType, connectionId, frame);
break;
case FrameType.Heartbeat:
await HandleHeartbeatAsync(connectionId, frame);
break;
case FrameType.Response:
case FrameType.ResponseStreamData:
_transportClient.HandleResponseFrame(frame);
break;
case FrameType.Cancel:
_logger.LogDebug("Received CANCEL for {ConnectionId} correlation {CorrelationId}", connectionId, frame.CorrelationId);
break;
default:
_logger.LogDebug("Ignoring frame type {FrameType} from {ConnectionId}", frame.Type, connectionId);
break;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling frame {FrameType} from {ConnectionId}", frame.Type, connectionId);
}
}
private async Task HandleHelloAsync(TransportType transportType, string connectionId, Frame frame)
{
if (!TryParseHelloPayload(frame, out var payload, out var parseError))
{
_logger.LogWarning("Invalid HELLO payload for {ConnectionId}: {Error}", connectionId, parseError);
CloseConnection(transportType, connectionId);
return;
}
if (payload is not null && !TryValidateHelloPayload(payload, out var validationError))
{
_logger.LogWarning("HELLO validation failed for {ConnectionId}: {Error}", connectionId, validationError);
CloseConnection(transportType, connectionId);
return;
}
var state = payload is null
? BuildFallbackState(transportType, connectionId)
: BuildConnectionState(transportType, connectionId, payload);
_routingState.AddConnection(state);
if (payload is not null)
{
_claimsStore.UpdateFromMicroservice(payload.Instance.ServiceName, payload.Endpoints);
}
_openApiCache?.Invalidate();
_logger.LogInformation(
"Connection registered: {ConnectionId} service={ServiceName} version={Version}",
connectionId,
state.Instance.ServiceName,
state.Instance.Version);
await Task.CompletedTask;
}
private async Task HandleHeartbeatAsync(string connectionId, Frame frame)
{
if (!_routingState.GetAllConnections().Any(c => c.ConnectionId == connectionId))
{
_logger.LogDebug("Heartbeat received for unknown connection {ConnectionId}", connectionId);
return;
}
if (TryParseHeartbeatPayload(frame, out var payload))
{
_routingState.UpdateConnection(connectionId, conn =>
{
conn.LastHeartbeatUtc = DateTime.UtcNow;
conn.Status = payload.Status;
});
}
else
{
_routingState.UpdateConnection(connectionId, conn =>
{
conn.LastHeartbeatUtc = DateTime.UtcNow;
});
}
await Task.CompletedTask;
}
private void HandleDisconnect(string connectionId)
{
var connection = _routingState.GetConnection(connectionId);
if (connection is null)
{
return;
}
_routingState.RemoveConnection(connectionId);
_openApiCache?.Invalidate();
var serviceName = connection.Instance.ServiceName;
if (!string.IsNullOrWhiteSpace(serviceName))
{
var remaining = _routingState.GetAllConnections()
.Any(c => string.Equals(c.Instance.ServiceName, serviceName, StringComparison.OrdinalIgnoreCase));
if (!remaining)
{
_claimsStore.RemoveService(serviceName);
}
}
}
private bool TryParseHelloPayload(Frame frame, out HelloPayload? payload, out string? error)
{
payload = null;
error = null;
if (frame.Payload.IsEmpty)
{
return true;
}
try
{
payload = JsonSerializer.Deserialize<HelloPayload>(frame.Payload.Span, _jsonOptions);
if (payload is null)
{
error = "HELLO payload missing";
return false;
}
return true;
}
catch (JsonException ex)
{
error = ex.Message;
return false;
}
}
private bool TryParseHeartbeatPayload(Frame frame, out HeartbeatPayload payload)
{
payload = new HeartbeatPayload
{
InstanceId = string.Empty,
Status = InstanceHealthStatus.Healthy,
TimestampUtc = DateTime.UtcNow
};
if (frame.Payload.IsEmpty)
{
return false;
}
try
{
var parsed = JsonSerializer.Deserialize<HeartbeatPayload>(frame.Payload.Span, _jsonOptions);
if (parsed is null)
{
return false;
}
payload = parsed;
return true;
}
catch (JsonException)
{
return false;
}
}
private static bool TryValidateHelloPayload(HelloPayload payload, out string error)
{
if (string.IsNullOrWhiteSpace(payload.Instance.ServiceName))
{
error = "Instance.ServiceName is required";
return false;
}
if (string.IsNullOrWhiteSpace(payload.Instance.Version))
{
error = "Instance.Version is required";
return false;
}
if (string.IsNullOrWhiteSpace(payload.Instance.Region))
{
error = "Instance.Region is required";
return false;
}
if (string.IsNullOrWhiteSpace(payload.Instance.InstanceId))
{
error = "Instance.InstanceId is required";
return false;
}
var seen = new HashSet<(string Method, string Path)>(new EndpointKeyComparer());
foreach (var endpoint in payload.Endpoints)
{
if (string.IsNullOrWhiteSpace(endpoint.Method))
{
error = "Endpoint.Method is required";
return false;
}
if (string.IsNullOrWhiteSpace(endpoint.Path) || !endpoint.Path.StartsWith('/'))
{
error = "Endpoint.Path must start with '/'";
return false;
}
if (!string.Equals(endpoint.ServiceName, payload.Instance.ServiceName, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(endpoint.Version, payload.Instance.Version, StringComparison.Ordinal))
{
error = "Endpoint.ServiceName/Version must match HelloPayload.Instance";
return false;
}
if (!seen.Add((endpoint.Method, endpoint.Path)))
{
error = $"Duplicate endpoint registration for {endpoint.Method} {endpoint.Path}";
return false;
}
if (endpoint.SchemaInfo is not null)
{
if (endpoint.SchemaInfo.RequestSchemaId is not null &&
!payload.Schemas.ContainsKey(endpoint.SchemaInfo.RequestSchemaId))
{
error = $"Endpoint schema reference missing: requestSchemaId='{endpoint.SchemaInfo.RequestSchemaId}'";
return false;
}
if (endpoint.SchemaInfo.ResponseSchemaId is not null &&
!payload.Schemas.ContainsKey(endpoint.SchemaInfo.ResponseSchemaId))
{
error = $"Endpoint schema reference missing: responseSchemaId='{endpoint.SchemaInfo.ResponseSchemaId}'";
return false;
}
}
}
error = string.Empty;
return true;
}
private static ConnectionState BuildFallbackState(TransportType transportType, string connectionId)
{
return new ConnectionState
{
ConnectionId = connectionId,
Instance = new InstanceDescriptor
{
InstanceId = connectionId,
ServiceName = "unknown",
Version = "unknown",
Region = "unknown"
},
Status = InstanceHealthStatus.Healthy,
LastHeartbeatUtc = DateTime.UtcNow,
TransportType = transportType
};
}
private static ConnectionState BuildConnectionState(TransportType transportType, string connectionId, HelloPayload payload)
{
var state = new ConnectionState
{
ConnectionId = connectionId,
Instance = payload.Instance,
Status = InstanceHealthStatus.Healthy,
LastHeartbeatUtc = DateTime.UtcNow,
TransportType = transportType,
Schemas = payload.Schemas,
OpenApiInfo = payload.OpenApiInfo
};
foreach (var endpoint in payload.Endpoints)
{
state.Endpoints[(endpoint.Method, endpoint.Path)] = endpoint;
}
return state;
}
private void CloseConnection(TransportType transportType, string connectionId)
{
if (transportType == TransportType.Tcp)
{
_tcpServer.GetConnection(connectionId)?.Close();
return;
}
if (transportType == TransportType.Certificate)
{
_tlsServer.GetConnection(connectionId)?.Close();
}
// Messaging transport connections are managed by the queue system
// and do not support explicit close operations
}
#region Messaging Transport Event Handlers
private Task HandleMessagingHello(ConnectionState state, HelloPayload payload)
{
// The MessagingTransportServer already built the ConnectionState with TransportType.Messaging
// We need to add it to the routing state and update the claims store
_routingState.AddConnection(state);
_claimsStore.UpdateFromMicroservice(payload.Instance.ServiceName, payload.Endpoints);
_openApiCache?.Invalidate();
_logger.LogInformation(
"Messaging connection registered: {ConnectionId} service={ServiceName} version={Version}",
state.ConnectionId,
state.Instance.ServiceName,
state.Instance.Version);
return Task.CompletedTask;
}
private Task HandleMessagingHeartbeat(ConnectionState state, HeartbeatPayload payload)
{
_routingState.UpdateConnection(state.ConnectionId, conn =>
{
conn.LastHeartbeatUtc = DateTime.UtcNow;
conn.Status = payload.Status;
});
return Task.CompletedTask;
}
private Task HandleMessagingResponse(ConnectionState state, Frame frame)
{
_transportClient.HandleResponseFrame(frame);
return Task.CompletedTask;
}
private Task HandleMessagingDisconnection(string connectionId)
{
HandleDisconnect(connectionId);
return Task.CompletedTask;
}
#endregion
private sealed class EndpointKeyComparer : IEqualityComparer<(string Method, string Path)>
{
public bool Equals((string Method, string Path) x, (string Method, string Path) y)
{
return string.Equals(x.Method, y.Method, StringComparison.OrdinalIgnoreCase) &&
string.Equals(x.Path, y.Path, StringComparison.OrdinalIgnoreCase);
}
public int GetHashCode((string Method, string Path) obj)
{
return HashCode.Combine(
StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Method),
StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Path));
}
}
}

View File

@@ -0,0 +1,38 @@
using System.Diagnostics.Metrics;
using System.Linq;
using StellaOps.Router.Common.Abstractions;
namespace StellaOps.Gateway.WebService.Services;
public sealed class GatewayMetrics
{
public const string MeterName = "StellaOps.Gateway.WebService";
private static readonly Meter Meter = new(MeterName, "1.0.0");
private readonly IGlobalRoutingState _routingState;
public GatewayMetrics(IGlobalRoutingState routingState)
{
_routingState = routingState;
Meter.CreateObservableGauge(
"gateway_active_connections",
() => GetActiveConnections(),
description: "Number of active microservice connections.");
Meter.CreateObservableGauge(
"gateway_registered_endpoints",
() => GetRegisteredEndpoints(),
description: "Number of registered endpoints across all connections.");
}
public long GetActiveConnections()
{
return _routingState.GetAllConnections().Count;
}
public long GetRegisteredEndpoints()
{
return _routingState.GetAllConnections().Sum(c => c.Endpoints.Count);
}
}

View File

@@ -0,0 +1,28 @@
using System.Threading;
namespace StellaOps.Gateway.WebService.Services;
public sealed class GatewayServiceStatus
{
private int _started;
private int _ready;
public bool IsStarted => Volatile.Read(ref _started) == 1;
public bool IsReady => Volatile.Read(ref _ready) == 1;
public void MarkStarted()
{
Volatile.Write(ref _started, 1);
}
public void MarkReady()
{
Volatile.Write(ref _ready, 1);
}
public void MarkNotReady()
{
Volatile.Write(ref _ready, 0);
}
}

View File

@@ -0,0 +1,253 @@
using System.Buffers;
using System.Collections.Concurrent;
using System.Threading.Channels;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Transport.Tcp;
using StellaOps.Router.Transport.Tls;
using StellaOps.Router.Transport.Messaging;
namespace StellaOps.Gateway.WebService.Services;
public sealed class GatewayTransportClient : ITransportClient
{
private readonly TcpTransportServer _tcpServer;
private readonly TlsTransportServer _tlsServer;
private readonly MessagingTransportServer? _messagingServer;
private readonly ILogger<GatewayTransportClient> _logger;
private readonly ConcurrentDictionary<string, TaskCompletionSource<Frame>> _pendingRequests = new();
private readonly ConcurrentDictionary<string, Channel<Frame>> _streamingResponses = new();
public GatewayTransportClient(
TcpTransportServer tcpServer,
TlsTransportServer tlsServer,
ILogger<GatewayTransportClient> logger,
MessagingTransportServer? messagingServer = null)
{
_tcpServer = tcpServer;
_tlsServer = tlsServer;
_messagingServer = messagingServer;
_logger = logger;
}
public async Task<Frame> SendRequestAsync(
ConnectionState connection,
Frame requestFrame,
TimeSpan timeout,
CancellationToken cancellationToken)
{
var correlationId = EnsureCorrelationId(requestFrame);
var frame = requestFrame with { CorrelationId = correlationId };
var tcs = new TaskCompletionSource<Frame>(TaskCreationOptions.RunContinuationsAsynchronously);
if (!_pendingRequests.TryAdd(correlationId, tcs))
{
throw new InvalidOperationException($"Duplicate correlation ID {correlationId}");
}
try
{
await SendFrameAsync(connection, frame, cancellationToken);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(timeout);
return await tcs.Task.WaitAsync(timeoutCts.Token);
}
finally
{
_pendingRequests.TryRemove(correlationId, out _);
}
}
public async Task SendCancelAsync(ConnectionState connection, Guid correlationId, string? reason = null)
{
var frame = new Frame
{
Type = FrameType.Cancel,
CorrelationId = correlationId.ToString("N"),
Payload = ReadOnlyMemory<byte>.Empty
};
await SendFrameAsync(connection, frame, CancellationToken.None);
}
public async Task SendStreamingAsync(
ConnectionState connection,
Frame requestHeader,
Stream requestBody,
Func<Stream, Task> readResponseBody,
PayloadLimits limits,
CancellationToken cancellationToken)
{
var correlationId = EnsureCorrelationId(requestHeader);
var headerFrame = requestHeader with
{
Type = FrameType.Request,
CorrelationId = correlationId
};
var channel = Channel.CreateUnbounded<Frame>(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false
});
if (!_streamingResponses.TryAdd(correlationId, channel))
{
throw new InvalidOperationException($"Duplicate correlation ID {correlationId}");
}
try
{
await SendFrameAsync(connection, headerFrame, cancellationToken);
await StreamRequestBodyAsync(connection, correlationId, requestBody, limits, cancellationToken);
using var responseStream = new MemoryStream();
await ReadStreamingResponseAsync(channel.Reader, responseStream, cancellationToken);
responseStream.Position = 0;
await readResponseBody(responseStream);
}
finally
{
if (_streamingResponses.TryRemove(correlationId, out var removed))
{
removed.Writer.TryComplete();
}
}
}
public void HandleResponseFrame(Frame frame)
{
if (string.IsNullOrWhiteSpace(frame.CorrelationId))
{
_logger.LogDebug("Ignoring response frame without correlation ID");
return;
}
if (_pendingRequests.TryGetValue(frame.CorrelationId, out var pending))
{
pending.TrySetResult(frame);
return;
}
if (_streamingResponses.TryGetValue(frame.CorrelationId, out var channel))
{
channel.Writer.TryWrite(frame);
return;
}
_logger.LogDebug("No pending request for correlation ID {CorrelationId}", frame.CorrelationId);
}
private async Task SendFrameAsync(ConnectionState connection, Frame frame, CancellationToken cancellationToken)
{
switch (connection.TransportType)
{
case TransportType.Tcp:
await _tcpServer.SendFrameAsync(connection.ConnectionId, frame, cancellationToken);
break;
case TransportType.Certificate:
await _tlsServer.SendFrameAsync(connection.ConnectionId, frame, cancellationToken);
break;
case TransportType.Messaging:
if (_messagingServer is null)
{
throw new InvalidOperationException("Messaging transport is not enabled");
}
await _messagingServer.SendToMicroserviceAsync(connection.ConnectionId, frame, cancellationToken);
break;
default:
throw new NotSupportedException($"Transport type {connection.TransportType} is not supported by the gateway.");
}
}
private static string EnsureCorrelationId(Frame frame)
{
if (!string.IsNullOrWhiteSpace(frame.CorrelationId))
{
return frame.CorrelationId;
}
return Guid.NewGuid().ToString("N");
}
private async Task StreamRequestBodyAsync(
ConnectionState connection,
string correlationId,
Stream requestBody,
PayloadLimits limits,
CancellationToken cancellationToken)
{
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 dataFrame = new Frame
{
Type = FrameType.RequestStreamData,
CorrelationId = correlationId,
Payload = new ReadOnlyMemory<byte>(buffer, 0, bytesRead)
};
await SendFrameAsync(connection, dataFrame, cancellationToken);
}
var endFrame = new Frame
{
Type = FrameType.RequestStreamData,
CorrelationId = correlationId,
Payload = ReadOnlyMemory<byte>.Empty
};
await SendFrameAsync(connection, endFrame, cancellationToken);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
private static async Task ReadStreamingResponseAsync(
ChannelReader<Frame> reader,
Stream responseStream,
CancellationToken cancellationToken)
{
while (await reader.WaitToReadAsync(cancellationToken))
{
while (reader.TryRead(out var frame))
{
if (frame.Type == FrameType.ResponseStreamData)
{
if (frame.Payload.Length == 0)
{
return;
}
await responseStream.WriteAsync(frame.Payload, cancellationToken);
continue;
}
if (frame.Type == FrameType.Response)
{
if (frame.Payload.Length > 0)
{
await responseStream.WriteAsync(frame.Payload, cancellationToken);
}
return;
}
}
}
}
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<!-- Router Libraries (now in same module) -->
<ProjectReference Include="..\__Libraries\StellaOps.Router.Gateway\StellaOps.Router.Gateway.csproj" />
<ProjectReference Include="..\__Libraries\StellaOps.Router.Transport.Tcp\StellaOps.Router.Transport.Tcp.csproj" />
<ProjectReference Include="..\__Libraries\StellaOps.Router.Transport.Tls\StellaOps.Router.Transport.Tls.csproj" />
<ProjectReference Include="..\__Libraries\StellaOps.Router.Transport.Messaging\StellaOps.Router.Transport.Messaging.csproj" />
<ProjectReference Include="..\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj" />
<ProjectReference Include="..\__Libraries\StellaOps.Messaging.Transport.Valkey\StellaOps.Messaging.Transport.Valkey.csproj" />
<ProjectReference Include="..\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
<!-- External Dependencies -->
<ProjectReference Include="..\..\__Libraries\StellaOps.Auth.Security\StellaOps.Auth.Security.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj" />
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,12 @@
{
"Gateway": {
"Transports": {
"Tcp": {
"Enabled": false
},
"Tls": {
"Enabled": false
}
}
}
}

View File

@@ -0,0 +1,68 @@
{
"Gateway": {
"Node": {
"Region": "local",
"NodeId": "gw-local-01",
"Environment": "dev",
"NeighborRegions": []
},
"Transports": {
"Tcp": {
"Enabled": false,
"BindAddress": "0.0.0.0",
"Port": 9100,
"ReceiveBufferSize": 65536,
"SendBufferSize": 65536,
"MaxFrameSize": 16777216
},
"Tls": {
"Enabled": false,
"BindAddress": "0.0.0.0",
"Port": 9443,
"ReceiveBufferSize": 65536,
"SendBufferSize": 65536,
"MaxFrameSize": 16777216,
"CertificatePath": "",
"CertificateKeyPath": "",
"CertificatePassword": "",
"RequireClientCertificate": false,
"AllowSelfSigned": false
}
},
"Routing": {
"DefaultTimeout": "30s",
"MaxRequestBodySize": "100MB",
"StreamingEnabled": true,
"PreferLocalRegion": true,
"AllowDegradedInstances": true,
"StrictVersionMatching": true,
"NeighborRegions": []
},
"Auth": {
"DpopEnabled": true,
"MtlsEnabled": false,
"AllowAnonymous": true,
"Authority": {
"Issuer": "",
"RequireHttpsMetadata": true,
"MetadataAddress": "",
"Audiences": [],
"RequiredScopes": []
}
},
"OpenApi": {
"Enabled": true,
"CacheTtlSeconds": 300,
"Title": "StellaOps Gateway API",
"Description": "Unified API aggregating all connected microservices.",
"Version": "1.0.0",
"ServerUrl": "/",
"TokenUrl": "/auth/token"
},
"Health": {
"StaleThreshold": "30s",
"DegradedThreshold": "15s",
"CheckInterval": "5s"
}
}
}

View File

@@ -0,0 +1,619 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Gateway.WebService", "StellaOps.Gateway.WebService", "{2CE01F07-BA6C-6110-4E93-D8C4EFF50DF1}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__External", "__External", "{5B52EF8A-3661-DCFF-797D-BC4D6AC60BDA}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Authority", "Authority", "{C1DCEFBD-12A5-EAAE-632E-8EEB9BE491B6}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Authority", "StellaOps.Authority", "{A6928CBA-4D4D-AB2B-CA19-FEE6E73ECE70}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Auth.Abstractions", "StellaOps.Auth.Abstractions", "{F2E6CB0E-DF77-1FAA-582B-62B040DF3848}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Auth.ServerIntegration", "StellaOps.Auth.ServerIntegration", "{7E890DF9-B715-B6DF-2498-FD74DDA87D71}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Authority.Plugins.Abstractions", "StellaOps.Authority.Plugins.Abstractions", "{64689413-46D7-8499-68A6-B6367ACBC597}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{1345DD29-BB3A-FB5F-4B3D-E29F6045A27A}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Auth.Security", "StellaOps.Auth.Security", "{9C2DD234-FA33-FDB6-86F0-EF9B75A13450}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Canonical.Json", "StellaOps.Canonical.Json", "{79E122F4-2325-3E92-438E-5825A307B594}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Configuration", "StellaOps.Configuration", "{538E2D98-5325-3F54-BE74-EFE5FC1ECBD8}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography", "StellaOps.Cryptography", "{66557252-B5C4-664B-D807-07018C627474}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.DependencyInjection", "StellaOps.Cryptography.DependencyInjection", "{7203223D-FF02-7BEB-2798-D1639ACC01C4}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.CryptoPro", "StellaOps.Cryptography.Plugin.CryptoPro", "{3C69853C-90E3-D889-1960-3B9229882590}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.OpenSslGost", "StellaOps.Cryptography.Plugin.OpenSslGost", "{643E4D4C-BC96-A37F-E0EC-488127F0B127}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.Pkcs11Gost", "StellaOps.Cryptography.Plugin.Pkcs11Gost", "{6F2CA7F5-3E7C-C61B-94E6-E7DD1227B5B1}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.PqSoft", "StellaOps.Cryptography.Plugin.PqSoft", "{F04B7DBB-77A5-C978-B2DE-8C189A32AA72}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.SimRemote", "StellaOps.Cryptography.Plugin.SimRemote", "{7C72F22A-20FF-DF5B-9191-6DFD0D497DB2}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.SmRemote", "StellaOps.Cryptography.Plugin.SmRemote", "{C896CC0A-F5E6-9AA4-C582-E691441F8D32}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.SmSoft", "StellaOps.Cryptography.Plugin.SmSoft", "{0AA3A418-AB45-CCA4-46D4-EEBFE011FECA}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.WineCsp", "StellaOps.Cryptography.Plugin.WineCsp", "{225D9926-4AE8-E539-70AD-8698E688F271}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.PluginLoader", "StellaOps.Cryptography.PluginLoader", "{D6E8E69C-F721-BBCB-8C39-9716D53D72AD}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.DependencyInjection", "StellaOps.DependencyInjection", "{589A43FD-8213-E9E3-6CFF-9CBA72D53E98}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Plugin", "StellaOps.Plugin", "{772B02B5-6280-E1D4-3E2E-248D0455C2FB}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.TestKit", "StellaOps.TestKit", "{8380A20C-A5B8-EE91-1A58-270323688CB9}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{A5C98087-E847-D2C4-2143-20869479839D}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Messaging", "StellaOps.Messaging", "{E003AEDB-E60E-6F40-42D6-AD25E156E256}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Messaging.Transport.InMemory", "StellaOps.Messaging.Transport.InMemory", "{82B15D42-0C1D-2778-BB40-0BBA0A83B5EA}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Messaging.Transport.Postgres", "StellaOps.Messaging.Transport.Postgres", "{DE864E29-EEF1-2604-B72B-A93B57754300}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Messaging.Transport.Valkey", "StellaOps.Messaging.Transport.Valkey", "{B80ED591-E89B-0446-7AC6-61C05F7C6B9B}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Microservice", "StellaOps.Microservice", "{5C5EBF62-8EC2-36E2-30CE-C24701ACAC0B}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Microservice.AspNetCore", "StellaOps.Microservice.AspNetCore", "{C64CDF02-E924-454D-380A-F11488DA0598}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Microservice.SourceGen", "StellaOps.Microservice.SourceGen", "{0043332D-E482-D20E-8F29-953D9314DD79}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Router.AspNet", "StellaOps.Router.AspNet", "{DA724E1B-DD23-C513-0154-9A3CBB538719}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Router.Common", "StellaOps.Router.Common", "{5EE9C735-B1F6-4C6E-DFCD-2F6F50766434}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Router.Config", "StellaOps.Router.Config", "{2A27D701-F1D8-DD6E-1BCD-CA03E58910A9}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Router.Gateway", "StellaOps.Router.Gateway", "{F3FAB933-1551-A47E-10B0-55EB4193E9D1}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Router.Transport.InMemory", "StellaOps.Router.Transport.InMemory", "{DFB3B8D5-FF8C-74A6-470F-2C1D3767F9A1}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Router.Transport.Messaging", "StellaOps.Router.Transport.Messaging", "{D96D664C-6A3A-6316-5771-75573BD3A872}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Router.Transport.RabbitMq", "StellaOps.Router.Transport.RabbitMq", "{0113BE47-7BA5-06F9-4087-A26197E55F90}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Router.Transport.Tcp", "StellaOps.Router.Transport.Tcp", "{48EEC582-E0B3-5991-9BBF-DA7401A7F0B6}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Router.Transport.Tls", "StellaOps.Router.Transport.Tls", "{941EA46D-41FE-1294-A093-97AD874639E0}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Router.Transport.Udp", "StellaOps.Router.Transport.Udp", "{51B7000E-E747-9FB1-C51F-30374E080821}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{BB76B5A5-14BA-E317-828D-110B711D71F5}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Gateway.WebService.Tests", "StellaOps.Gateway.WebService.Tests", "{0503F42D-32CF-F14C-4FE2-A3ABD7740D75}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Messaging.Transport.Valkey.Tests", "StellaOps.Messaging.Transport.Valkey.Tests", "{80161048-A3D5-07D0-63CD-CF593A0D8EE8}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Microservice.SourceGen.Tests", "StellaOps.Microservice.SourceGen.Tests", "{C737011A-3537-7EF1-229F-9A852B4F2686}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Microservice.Tests", "StellaOps.Microservice.Tests", "{75EFB51E-01C1-F4DB-A303-9DACF318E268}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Router.Common.Tests", "StellaOps.Router.Common.Tests", "{E2AA9353-31D5-CCE3-1AFC-8C2B3DA104D0}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Router.Config.Tests", "StellaOps.Router.Config.Tests", "{EAC38265-1FAB-5CA9-FC41-16CA357BF49C}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Router.Integration.Tests", "StellaOps.Router.Integration.Tests", "{BA4B3BB5-BE95-CCDE-BB9E-E1451C38177E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Router.Transport.InMemory.Tests", "StellaOps.Router.Transport.InMemory.Tests", "{23E7F733-C096-1C25-5A69-04FE4C3766CE}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Router.Transport.RabbitMq.Tests", "StellaOps.Router.Transport.RabbitMq.Tests", "{8ED55097-AAB3-0531-341F-5C0DCEC02CFD}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Router.Transport.Tcp.Tests", "StellaOps.Router.Transport.Tcp.Tests", "{E23010E5-9CF9-7E81-A310-B6AF3B1A3998}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Router.Transport.Tls.Tests", "StellaOps.Router.Transport.Tls.Tests", "{96B73D53-13A8-1575-2AAA-0F413DAA433A}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Router.Transport.Udp.Tests", "StellaOps.Router.Transport.Udp.Tests", "{49CEBE57-416C-3393-2C28-60E1CE998553}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{BDF2DFB4-824A-F7D1-11E9-069CD3CDF987}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Messaging.Testing", "StellaOps.Messaging.Testing", "{C84B96EA-DD98-F736-2F65-3F5928C9D5A2}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Router.Testing", "StellaOps.Router.Testing", "{7BC1DC47-A7A3-4AE8-D930-8FF784396F4B}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{C590B3C9-24C3-5C2F-2417-46522284E470}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Examples.Billing.Microservice", "Examples.Billing.Microservice", "{6EF9E075-3A04-6B47-B511-9D23FFC7E00C}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Examples.Gateway", "Examples.Gateway", "{0D5F641B-311C-3DE5-3630-DD3869C577BA}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Examples.Inventory.Microservice", "Examples.Inventory.Microservice", "{E6AEE6FA-37E8-2FD9-148F-59D808E23F5F}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Examples.MultiTransport.Gateway", "Examples.MultiTransport.Gateway", "{023BFBC2-2029-A9BF-17E3-619EA101E281}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Examples.NotificationService", "Examples.NotificationService", "{514A48EE-496D-B3A9-1BE7-0B94886414EA}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Examples.OrderService", "Examples.OrderService", "{2F37662D-21F9-C26F-FB88-D44D379D7FD3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Examples.Billing.Microservice", "examples\Examples.Billing.Microservice\Examples.Billing.Microservice.csproj", "{695980BF-FD88-D785-1A49-FCE0F485B250}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Examples.Gateway", "examples\Examples.Gateway\Examples.Gateway.csproj", "{21E23AE9-96BF-B9B2-6F4E-09B120C322C9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Examples.Inventory.Microservice", "examples\Examples.Inventory.Microservice\Examples.Inventory.Microservice.csproj", "{66B2A1FF-F571-AA62-7464-99401CE74278}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Examples.MultiTransport.Gateway", "examples\Examples.MultiTransport.Gateway\Examples.MultiTransport.Gateway.csproj", "{E8778A66-25B7-C810-E26E-11C359F41CA4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Examples.NotificationService", "examples\Examples.NotificationService\Examples.NotificationService.csproj", "{44B62CBC-D65B-5E2B-29DF-1769EC17EE24}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Examples.OrderService", "examples\Examples.OrderService\Examples.OrderService.csproj", "{94ADB66D-5E85-1495-8726-119908AAED3E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions", "E:\dev\git.stella-ops.org\src\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj", "{55D9B653-FB76-FCE8-1A3C-67B1BEDEC214}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Security", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Auth.Security\StellaOps.Auth.Security.csproj", "{335E62C0-9E69-A952-680B-753B1B17C6D0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegration", "E:\dev\git.stella-ops.org\src\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj", "{ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugins.Abstractions", "E:\dev\git.stella-ops.org\src\Authority\StellaOps.Authority\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj", "{97F94029-5419-6187-5A63-5C8FD9232FAE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Canonical.Json", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj", "{AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj", "{92C62F7B-8028-6EE1-B71B-F45F459B8E97}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{F664A948-E352-5808-E780-77A03F19E93E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.DependencyInjection", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj", "{FA83F778-5252-0B80-5555-E69F790322EA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.CryptoPro", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.CryptoPro\StellaOps.Cryptography.Plugin.CryptoPro.csproj", "{C53E0895-879A-D9E6-0A43-24AD17A2F270}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.OpenSslGost", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.OpenSslGost\StellaOps.Cryptography.Plugin.OpenSslGost.csproj", "{0AED303F-69E6-238F-EF80-81985080EDB7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.Pkcs11Gost", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.Pkcs11Gost\StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj", "{2904D288-CE64-A565-2C46-C2E85A96A1EE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.PqSoft", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.PqSoft\StellaOps.Cryptography.Plugin.PqSoft.csproj", "{A6667CC3-B77F-023E-3A67-05F99E9FF46A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.SimRemote", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.SimRemote\StellaOps.Cryptography.Plugin.SimRemote.csproj", "{A26E2816-F787-F76B-1D6C-E086DD3E19CE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.SmRemote", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.SmRemote\StellaOps.Cryptography.Plugin.SmRemote.csproj", "{B3DEC619-67AC-1B5A-4F3E-A1F24C3F6877}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.SmSoft", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.SmSoft\StellaOps.Cryptography.Plugin.SmSoft.csproj", "{90DB65B4-8F6E-FB8E-0281-505AD8BC6BA6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.WineCsp", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.WineCsp\StellaOps.Cryptography.Plugin.WineCsp.csproj", "{059FBB86-DEE6-8207-3F23-2A1A3EC00DEA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.PluginLoader", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.PluginLoader\StellaOps.Cryptography.PluginLoader.csproj", "{8BBA3159-C4CC-F685-A28C-7FE6CBD3D2A1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{632A1F0D-1BA5-C84B-B716-2BE638A92780}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Gateway.WebService", "StellaOps.Gateway.WebService\StellaOps.Gateway.WebService.csproj", "{9739E2B2-147A-FD51-BCBB-E5AFDAA74B80}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Gateway.WebService.Tests", "__Tests\StellaOps.Gateway.WebService.Tests\StellaOps.Gateway.WebService.Tests.csproj", "{025AF085-94B1-AAA6-980C-B9B4FD7BCE45}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Messaging", "__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj", "{97998C88-E6E1-D5E2-B632-537B58E00CBF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Messaging.Testing", "__Tests\__Libraries\StellaOps.Messaging.Testing\StellaOps.Messaging.Testing.csproj", "{884EE414-0CFE-B9D3-48EB-9E3BD06FE04E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Messaging.Transport.InMemory", "__Libraries\StellaOps.Messaging.Transport.InMemory\StellaOps.Messaging.Transport.InMemory.csproj", "{96279C16-30E6-95B0-7759-EBF32CCAB6F8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Messaging.Transport.Postgres", "__Libraries\StellaOps.Messaging.Transport.Postgres\StellaOps.Messaging.Transport.Postgres.csproj", "{4CDE8730-52CD-45E3-44B8-5ED84B62AD5B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Messaging.Transport.Valkey", "__Libraries\StellaOps.Messaging.Transport.Valkey\StellaOps.Messaging.Transport.Valkey.csproj", "{CB0EA9C0-9989-0BE2-EA0B-AF2D6803C1AB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Messaging.Transport.Valkey.Tests", "__Tests\StellaOps.Messaging.Transport.Valkey.Tests\StellaOps.Messaging.Transport.Valkey.Tests.csproj", "{E360C487-10D2-7477-2A0C-6F50005523C7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Microservice", "__Libraries\StellaOps.Microservice\StellaOps.Microservice.csproj", "{BAD08D96-A80A-D27F-5D9C-656AEEB3D568}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Microservice.AspNetCore", "__Libraries\StellaOps.Microservice.AspNetCore\StellaOps.Microservice.AspNetCore.csproj", "{F63694F1-B56D-6E72-3F5D-5D38B1541F0F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Microservice.SourceGen", "__Libraries\StellaOps.Microservice.SourceGen\StellaOps.Microservice.SourceGen.csproj", "{1C76B5CA-47B5-312F-3F44-735B781FDEEC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Microservice.SourceGen.Tests", "__Tests\StellaOps.Microservice.SourceGen.Tests\StellaOps.Microservice.SourceGen.Tests.csproj", "{06329124-E6D4-DDA5-C48D-77473CE0238B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Microservice.Tests", "__Tests\StellaOps.Microservice.Tests\StellaOps.Microservice.Tests.csproj", "{7E82B1EB-96B1-8FA7-9A34-5BB140089662}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj", "{38A9EE9B-6FC8-93BC-0D43-2A906E678D66}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.AspNet", "__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj", "{79104479-B087-E5D0-5523-F1803282A246}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Common", "__Libraries\StellaOps.Router.Common\StellaOps.Router.Common.csproj", "{F17A6F0B-3120-2BA9-84D8-5F8BA0B9705D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Common.Tests", "__Tests\StellaOps.Router.Common.Tests\StellaOps.Router.Common.Tests.csproj", "{A310C0C2-14A9-C9A4-A3B6-631789DAC761}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Config", "__Libraries\StellaOps.Router.Config\StellaOps.Router.Config.csproj", "{27087363-C210-36D6-3F5C-58857E3AF322}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Config.Tests", "__Tests\StellaOps.Router.Config.Tests\StellaOps.Router.Config.Tests.csproj", "{408FC2DA-E539-6C45-52C2-1DAD262F675C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Gateway", "__Libraries\StellaOps.Router.Gateway\StellaOps.Router.Gateway.csproj", "{976908CC-C4F7-A951-B49E-675666679CD4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Integration.Tests", "__Tests\StellaOps.Router.Integration.Tests\StellaOps.Router.Integration.Tests.csproj", "{A16512D3-E871-196B-604D-C66F003F0DA1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Testing", "__Tests\__Libraries\StellaOps.Router.Testing\StellaOps.Router.Testing.csproj", "{8C5A1EE6-8568-A575-609D-7CBC1F822AF3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Transport.InMemory", "__Libraries\StellaOps.Router.Transport.InMemory\StellaOps.Router.Transport.InMemory.csproj", "{DE17074A-ADF0-DDC8-DD63-E62A23B68514}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Transport.InMemory.Tests", "__Tests\StellaOps.Router.Transport.InMemory.Tests\StellaOps.Router.Transport.InMemory.Tests.csproj", "{0C765620-10CD-FACB-49FF-C3F3CF190425}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Transport.Messaging", "__Libraries\StellaOps.Router.Transport.Messaging\StellaOps.Router.Transport.Messaging.csproj", "{80399908-C7BC-1D3D-4381-91B0A41C1B27}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Transport.RabbitMq", "__Libraries\StellaOps.Router.Transport.RabbitMq\StellaOps.Router.Transport.RabbitMq.csproj", "{16CC361C-37F6-1957-60B4-8D6A858FF3B6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Transport.RabbitMq.Tests", "__Tests\StellaOps.Router.Transport.RabbitMq.Tests\StellaOps.Router.Transport.RabbitMq.Tests.csproj", "{AF6AC965-0BC6-097D-2EF3-A8EA41FF9952}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Transport.Tcp", "__Libraries\StellaOps.Router.Transport.Tcp\StellaOps.Router.Transport.Tcp.csproj", "{EB8B8909-813F-394E-6EA0-9436E1835010}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Transport.Tcp.Tests", "__Tests\StellaOps.Router.Transport.Tcp.Tests\StellaOps.Router.Transport.Tcp.Tests.csproj", "{EEDD8FFB-C6B5-3593-251C-F83CF75FB042}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Transport.Tls", "__Libraries\StellaOps.Router.Transport.Tls\StellaOps.Router.Transport.Tls.csproj", "{D743B669-7CCD-92F5-15BC-A1761CB51940}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Transport.Tls.Tests", "__Tests\StellaOps.Router.Transport.Tls.Tests\StellaOps.Router.Transport.Tls.Tests.csproj", "{B418AD25-EAC7-5B6F-7B6E-065F2598BFB0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Transport.Udp", "__Libraries\StellaOps.Router.Transport.Udp\StellaOps.Router.Transport.Udp.csproj", "{008FB2AD-5BC8-F358-528F-C17B66792F39}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Transport.Udp.Tests", "__Tests\StellaOps.Router.Transport.Udp.Tests\StellaOps.Router.Transport.Udp.Tests.csproj", "{CA96DA95-C840-97D6-6D33-34332EAE5B98}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TestKit", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj", "{AF043113-CCE3-59C1-DF71-9804155F26A8}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{695980BF-FD88-D785-1A49-FCE0F485B250}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{695980BF-FD88-D785-1A49-FCE0F485B250}.Debug|Any CPU.Build.0 = Debug|Any CPU
{695980BF-FD88-D785-1A49-FCE0F485B250}.Release|Any CPU.ActiveCfg = Release|Any CPU
{695980BF-FD88-D785-1A49-FCE0F485B250}.Release|Any CPU.Build.0 = Release|Any CPU
{21E23AE9-96BF-B9B2-6F4E-09B120C322C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{21E23AE9-96BF-B9B2-6F4E-09B120C322C9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{21E23AE9-96BF-B9B2-6F4E-09B120C322C9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{21E23AE9-96BF-B9B2-6F4E-09B120C322C9}.Release|Any CPU.Build.0 = Release|Any CPU
{66B2A1FF-F571-AA62-7464-99401CE74278}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{66B2A1FF-F571-AA62-7464-99401CE74278}.Debug|Any CPU.Build.0 = Debug|Any CPU
{66B2A1FF-F571-AA62-7464-99401CE74278}.Release|Any CPU.ActiveCfg = Release|Any CPU
{66B2A1FF-F571-AA62-7464-99401CE74278}.Release|Any CPU.Build.0 = Release|Any CPU
{E8778A66-25B7-C810-E26E-11C359F41CA4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E8778A66-25B7-C810-E26E-11C359F41CA4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E8778A66-25B7-C810-E26E-11C359F41CA4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E8778A66-25B7-C810-E26E-11C359F41CA4}.Release|Any CPU.Build.0 = Release|Any CPU
{44B62CBC-D65B-5E2B-29DF-1769EC17EE24}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{44B62CBC-D65B-5E2B-29DF-1769EC17EE24}.Debug|Any CPU.Build.0 = Debug|Any CPU
{44B62CBC-D65B-5E2B-29DF-1769EC17EE24}.Release|Any CPU.ActiveCfg = Release|Any CPU
{44B62CBC-D65B-5E2B-29DF-1769EC17EE24}.Release|Any CPU.Build.0 = Release|Any CPU
{94ADB66D-5E85-1495-8726-119908AAED3E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{94ADB66D-5E85-1495-8726-119908AAED3E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{94ADB66D-5E85-1495-8726-119908AAED3E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{94ADB66D-5E85-1495-8726-119908AAED3E}.Release|Any CPU.Build.0 = Release|Any CPU
{55D9B653-FB76-FCE8-1A3C-67B1BEDEC214}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{55D9B653-FB76-FCE8-1A3C-67B1BEDEC214}.Debug|Any CPU.Build.0 = Debug|Any CPU
{55D9B653-FB76-FCE8-1A3C-67B1BEDEC214}.Release|Any CPU.ActiveCfg = Release|Any CPU
{55D9B653-FB76-FCE8-1A3C-67B1BEDEC214}.Release|Any CPU.Build.0 = Release|Any CPU
{335E62C0-9E69-A952-680B-753B1B17C6D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{335E62C0-9E69-A952-680B-753B1B17C6D0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{335E62C0-9E69-A952-680B-753B1B17C6D0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{335E62C0-9E69-A952-680B-753B1B17C6D0}.Release|Any CPU.Build.0 = Release|Any CPU
{ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA}.Release|Any CPU.Build.0 = Release|Any CPU
{97F94029-5419-6187-5A63-5C8FD9232FAE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{97F94029-5419-6187-5A63-5C8FD9232FAE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{97F94029-5419-6187-5A63-5C8FD9232FAE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{97F94029-5419-6187-5A63-5C8FD9232FAE}.Release|Any CPU.Build.0 = Release|Any CPU
{AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}.Release|Any CPU.Build.0 = Release|Any CPU
{92C62F7B-8028-6EE1-B71B-F45F459B8E97}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{92C62F7B-8028-6EE1-B71B-F45F459B8E97}.Debug|Any CPU.Build.0 = Debug|Any CPU
{92C62F7B-8028-6EE1-B71B-F45F459B8E97}.Release|Any CPU.ActiveCfg = Release|Any CPU
{92C62F7B-8028-6EE1-B71B-F45F459B8E97}.Release|Any CPU.Build.0 = Release|Any CPU
{F664A948-E352-5808-E780-77A03F19E93E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F664A948-E352-5808-E780-77A03F19E93E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F664A948-E352-5808-E780-77A03F19E93E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F664A948-E352-5808-E780-77A03F19E93E}.Release|Any CPU.Build.0 = Release|Any CPU
{FA83F778-5252-0B80-5555-E69F790322EA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU

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" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
</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" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
<PackageReference Include="Npgsql" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Messaging/StellaOps.Messaging.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,40 @@
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Messaging.Transport.Valkey;
/// <summary>
/// Configuration options for the Valkey/Redis transport.
/// </summary>
public class ValkeyTransportOptions
{
/// <summary>
/// Gets or sets the connection string (e.g., "localhost:6379" or "valkey:6379,password=secret").
/// </summary>
[Required]
public string ConnectionString { get; set; } = "localhost:6379";
/// <summary>
/// Gets or sets the default database number.
/// </summary>
public int? Database { get; set; }
/// <summary>
/// Gets or sets the connection initialization timeout.
/// </summary>
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Gets or sets the number of connection retries.
/// </summary>
public int ConnectRetry { get; set; } = 3;
/// <summary>
/// Gets or sets whether to abort on connect fail.
/// </summary>
public bool AbortOnConnectFail { get; set; } = false;
/// <summary>
/// Gets or sets the prefix for idempotency keys.
/// </summary>
public string IdempotencyKeyPrefix { get; set; } = "msgq:idem:";
}

View File

@@ -0,0 +1,30 @@
<?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.Valkey</RootNamespace>
<AssemblyName>StellaOps.Messaging.Transport.Valkey</AssemblyName>
<Description>Valkey/Redis transport plugin for StellaOps.Messaging</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
<PackageReference Include="StackExchange.Redis" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Messaging/StellaOps.Messaging.csproj" />
</ItemGroup>
</Project>

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 ?? TimeSpan.MaxValue).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,110 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
namespace StellaOps.Messaging.Transport.Valkey;
/// <summary>
/// Factory for creating and managing Valkey/Redis connections.
/// </summary>
public sealed class ValkeyConnectionFactory : IAsyncDisposable
{
private readonly ValkeyTransportOptions _options;
private readonly ILogger<ValkeyConnectionFactory>? _logger;
private readonly SemaphoreSlim _connectionLock = new(1, 1);
private readonly Func<ConfigurationOptions, Task<IConnectionMultiplexer>> _connectionFactory;
private IConnectionMultiplexer? _connection;
private bool _disposed;
public ValkeyConnectionFactory(
IOptions<ValkeyTransportOptions> options,
ILogger<ValkeyConnectionFactory>? logger = null,
Func<ConfigurationOptions, Task<IConnectionMultiplexer>>? connectionFactory = null)
{
_options = options.Value;
_logger = logger;
_connectionFactory = connectionFactory ??
(config => Task.FromResult<IConnectionMultiplexer>(ConnectionMultiplexer.Connect(config)));
}
/// <summary>
/// Gets a database connection.
/// </summary>
public async ValueTask<IDatabase> GetDatabaseAsync(CancellationToken cancellationToken = default)
{
var connection = await GetConnectionAsync(cancellationToken).ConfigureAwait(false);
return connection.GetDatabase(_options.Database ?? -1);
}
/// <summary>
/// Gets the underlying connection multiplexer.
/// </summary>
public async ValueTask<IConnectionMultiplexer> GetConnectionAsync(CancellationToken cancellationToken = default)
{
if (_connection is not null && _connection.IsConnected)
{
return _connection;
}
await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_connection is null || !_connection.IsConnected)
{
if (_connection is not null)
{
await _connection.CloseAsync().ConfigureAwait(false);
_connection.Dispose();
}
var config = ConfigurationOptions.Parse(_options.ConnectionString);
config.AbortOnConnectFail = _options.AbortOnConnectFail;
config.ConnectTimeout = (int)_options.InitializationTimeout.TotalMilliseconds;
config.ConnectRetry = _options.ConnectRetry;
if (_options.Database.HasValue)
{
config.DefaultDatabase = _options.Database.Value;
}
_logger?.LogDebug("Connecting to Valkey at {Endpoint}", _options.ConnectionString);
_connection = await _connectionFactory(config).ConfigureAwait(false);
_logger?.LogInformation("Connected to Valkey");
}
}
finally
{
_connectionLock.Release();
}
return _connection;
}
/// <summary>
/// Tests the connection by sending a PING command.
/// </summary>
public async ValueTask PingAsync(CancellationToken cancellationToken = default)
{
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await db.ExecuteAsync("PING").ConfigureAwait(false);
}
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
if (_connection is not null)
{
await _connection.CloseAsync().ConfigureAwait(false);
_connection.Dispose();
}
_connectionLock.Dispose();
}
}

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

@@ -0,0 +1,98 @@
using StellaOps.Messaging.Abstractions;
namespace StellaOps.Messaging.Transport.Valkey;
/// <summary>
/// Valkey/Redis implementation of a message lease.
/// </summary>
/// <typeparam name="TMessage">The message type.</typeparam>
internal sealed class ValkeyMessageLease<TMessage> : IMessageLease<TMessage> where TMessage : class
{
private readonly ValkeyMessageQueue<TMessage> _queue;
private int _completed;
internal ValkeyMessageLease(
ValkeyMessageQueue<TMessage> queue,
string messageId,
TMessage message,
int attempt,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt,
string consumer,
string? tenantId,
string? correlationId,
IReadOnlyDictionary<string, string>? headers)
{
_queue = queue;
MessageId = messageId;
Message = message;
Attempt = attempt;
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
Consumer = consumer;
TenantId = tenantId;
CorrelationId = correlationId;
Headers = headers;
}
/// <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; }
/// <summary>
/// Gets the message headers.
/// </summary>
public IReadOnlyDictionary<string, string>? Headers { 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()
{
// No resources to dispose - lease state is managed by the queue
return 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,641 @@
using System.Buffers;
using System.Collections.ObjectModel;
using System.Text.Json;
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;
/// <summary>
/// Valkey/Redis Streams implementation of <see cref="IMessageQueue{TMessage}"/>.
/// </summary>
/// <typeparam name="TMessage">The message type.</typeparam>
public sealed class ValkeyMessageQueue<TMessage> : IMessageQueue<TMessage>, IAsyncDisposable
where TMessage : class
{
private const string ProviderNameValue = "valkey";
private static class Fields
{
public const string Payload = "payload";
public const string TenantId = "tenant";
public const string CorrelationId = "correlation";
public const string IdempotencyKey = "idem";
public const string Attempt = "attempt";
public const string EnqueuedAt = "enq_at";
public const string HeaderPrefix = "h:";
}
private readonly ValkeyConnectionFactory _connectionFactory;
private readonly MessageQueueOptions _queueOptions;
private readonly ValkeyTransportOptions _transportOptions;
private readonly ILogger<ValkeyMessageQueue<TMessage>>? _logger;
private readonly TimeProvider _timeProvider;
private readonly SemaphoreSlim _groupInitLock = new(1, 1);
private readonly JsonSerializerOptions _jsonOptions;
private volatile bool _groupInitialized;
private bool _disposed;
public ValkeyMessageQueue(
ValkeyConnectionFactory connectionFactory,
MessageQueueOptions queueOptions,
ValkeyTransportOptions transportOptions,
ILogger<ValkeyMessageQueue<TMessage>>? logger = null,
TimeProvider? timeProvider = null,
JsonSerializerOptions? jsonOptions = null)
{
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
_queueOptions = queueOptions ?? throw new ArgumentNullException(nameof(queueOptions));
_transportOptions = transportOptions ?? throw new ArgumentNullException(nameof(transportOptions));
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
}
/// <inheritdoc />
public string ProviderName => ProviderNameValue;
/// <inheritdoc />
public string QueueName => _queueOptions.QueueName;
/// <inheritdoc />
public async ValueTask<EnqueueResult> EnqueueAsync(
TMessage message,
EnqueueOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(message);
cancellationToken.ThrowIfCancellationRequested();
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await EnsureConsumerGroupAsync(db, cancellationToken).ConfigureAwait(false);
var now = _timeProvider.GetUtcNow();
var entries = BuildEntries(message, now, 1, options);
var messageId = await AddToStreamAsync(
db,
_queueOptions.QueueName,
entries,
_queueOptions.ApproximateMaxLength)
.ConfigureAwait(false);
// Handle idempotency if key provided
if (!string.IsNullOrWhiteSpace(options?.IdempotencyKey))
{
var idempotencyKey = BuildIdempotencyKey(options.IdempotencyKey);
var stored = await db.StringSetAsync(
idempotencyKey,
messageId,
when: When.NotExists,
expiry: _queueOptions.IdempotencyWindow)
.ConfigureAwait(false);
if (!stored)
{
// Duplicate detected - delete the message we just added and return existing
await db.StreamDeleteAsync(_queueOptions.QueueName, [(RedisValue)messageId]).ConfigureAwait(false);
var existing = await db.StringGetAsync(idempotencyKey).ConfigureAwait(false);
var existingId = existing.IsNullOrEmpty ? messageId : existing.ToString();
_logger?.LogDebug(
"Duplicate enqueue detected for queue {Queue} with key {Key}; returning existing id {MessageId}",
_queueOptions.QueueName, idempotencyKey, existingId);
return EnqueueResult.Duplicate(existingId);
}
}
_logger?.LogDebug("Enqueued message to {Queue} with id {MessageId}", _queueOptions.QueueName, messageId);
return EnqueueResult.Succeeded(messageId);
}
/// <inheritdoc />
public async ValueTask<IReadOnlyList<IMessageLease<TMessage>>> LeaseAsync(
LeaseRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
cancellationToken.ThrowIfCancellationRequested();
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await EnsureConsumerGroupAsync(db, cancellationToken).ConfigureAwait(false);
var consumer = _queueOptions.ConsumerName ?? $"{Environment.MachineName}-{Environment.ProcessId}";
StreamEntry[] entries;
if (request.PendingOnly)
{
// Read from pending only (redeliveries)
entries = await db.StreamReadGroupAsync(
_queueOptions.QueueName,
_queueOptions.ConsumerGroup,
consumer,
position: "0",
count: request.BatchSize)
.ConfigureAwait(false);
}
else
{
// Read new messages
entries = await db.StreamReadGroupAsync(
_queueOptions.QueueName,
_queueOptions.ConsumerGroup,
consumer,
position: ">",
count: request.BatchSize)
.ConfigureAwait(false);
}
if (entries is null || entries.Length == 0)
{
return [];
}
var now = _timeProvider.GetUtcNow();
var leaseDuration = request.LeaseDuration ?? _queueOptions.DefaultLeaseDuration;
var leases = new List<IMessageLease<TMessage>>(entries.Length);
foreach (var entry in entries)
{
var lease = TryMapLease(entry, consumer, now, leaseDuration, attemptOverride: null);
if (lease is null)
{
await HandlePoisonEntryAsync(db, entry.Id).ConfigureAwait(false);
continue;
}
leases.Add(lease);
}
return leases;
}
/// <inheritdoc />
public async ValueTask<IReadOnlyList<IMessageLease<TMessage>>> ClaimExpiredAsync(
ClaimRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
cancellationToken.ThrowIfCancellationRequested();
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await EnsureConsumerGroupAsync(db, cancellationToken).ConfigureAwait(false);
var consumer = _queueOptions.ConsumerName ?? $"{Environment.MachineName}-{Environment.ProcessId}";
var pending = await db.StreamPendingMessagesAsync(
_queueOptions.QueueName,
_queueOptions.ConsumerGroup,
request.BatchSize,
RedisValue.Null,
(long)request.MinIdleTime.TotalMilliseconds)
.ConfigureAwait(false);
if (pending is null || pending.Length == 0)
{
return [];
}
var eligible = pending
.Where(info => info.IdleTimeInMilliseconds >= request.MinIdleTime.TotalMilliseconds
&& info.DeliveryCount >= request.MinDeliveryAttempts)
.ToArray();
if (eligible.Length == 0)
{
return [];
}
var messageIds = eligible.Select(info => (RedisValue)info.MessageId).ToArray();
var claimed = await db.StreamClaimAsync(
_queueOptions.QueueName,
_queueOptions.ConsumerGroup,
consumer,
0,
messageIds)
.ConfigureAwait(false);
if (claimed is null || claimed.Length == 0)
{
return [];
}
var now = _timeProvider.GetUtcNow();
var leaseDuration = request.LeaseDuration ?? _queueOptions.DefaultLeaseDuration;
var attemptLookup = eligible.ToDictionary(
info => info.MessageId.IsNullOrEmpty ? string.Empty : info.MessageId.ToString(),
info => (int)Math.Max(1, info.DeliveryCount),
StringComparer.Ordinal);
var leases = new List<IMessageLease<TMessage>>(claimed.Length);
foreach (var entry in claimed)
{
var entryId = entry.Id.ToString();
attemptLookup.TryGetValue(entryId, out var attempt);
var lease = TryMapLease(entry, consumer, now, leaseDuration, attemptOverride: attempt);
if (lease is null)
{
await HandlePoisonEntryAsync(db, entry.Id).ConfigureAwait(false);
continue;
}
leases.Add(lease);
}
return leases;
}
/// <inheritdoc />
public async ValueTask<long> GetPendingCountAsync(CancellationToken cancellationToken = default)
{
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
var info = await db.StreamPendingAsync(_queueOptions.QueueName, _queueOptions.ConsumerGroup).ConfigureAwait(false);
return info.PendingMessageCount;
}
internal async ValueTask AcknowledgeAsync(ValkeyMessageLease<TMessage> lease, CancellationToken cancellationToken)
{
if (!lease.TryBeginCompletion())
{
return;
}
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await db.StreamAcknowledgeAsync(
_queueOptions.QueueName,
_queueOptions.ConsumerGroup,
[(RedisValue)lease.MessageId])
.ConfigureAwait(false);
await db.StreamDeleteAsync(_queueOptions.QueueName, [(RedisValue)lease.MessageId]).ConfigureAwait(false);
_logger?.LogDebug("Acknowledged message {MessageId} from queue {Queue}", lease.MessageId, _queueOptions.QueueName);
}
internal async ValueTask RenewLeaseAsync(ValkeyMessageLease<TMessage> lease, TimeSpan extension, CancellationToken cancellationToken)
{
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await db.StreamClaimAsync(
_queueOptions.QueueName,
_queueOptions.ConsumerGroup,
lease.Consumer,
0,
[(RedisValue)lease.MessageId])
.ConfigureAwait(false);
var expires = _timeProvider.GetUtcNow().Add(extension);
lease.RefreshLease(expires);
}
internal async ValueTask ReleaseAsync(
ValkeyMessageLease<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 db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
// Acknowledge and delete the current entry
await db.StreamAcknowledgeAsync(
_queueOptions.QueueName,
_queueOptions.ConsumerGroup,
[(RedisValue)lease.MessageId])
.ConfigureAwait(false);
await db.StreamDeleteAsync(_queueOptions.QueueName, [(RedisValue)lease.MessageId]).ConfigureAwait(false);
if (disposition == ReleaseDisposition.Retry)
{
lease.IncrementAttempt();
// Calculate backoff delay
var backoff = CalculateBackoff(lease.Attempt);
if (backoff > TimeSpan.Zero)
{
try
{
await Task.Delay(backoff, cancellationToken).ConfigureAwait(false);
}
catch (TaskCanceledException)
{
return;
}
}
// Re-enqueue with incremented attempt
var now = _timeProvider.GetUtcNow();
var entries = BuildEntries(lease.Message, now, lease.Attempt, null);
await AddToStreamAsync(db, _queueOptions.QueueName, entries, _queueOptions.ApproximateMaxLength)
.ConfigureAwait(false);
_logger?.LogDebug("Retrying message {MessageId}, attempt {Attempt}", lease.MessageId, lease.Attempt);
}
}
internal async ValueTask DeadLetterAsync(ValkeyMessageLease<TMessage> lease, string reason, CancellationToken cancellationToken)
{
if (!lease.TryBeginCompletion())
{
return;
}
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
// Acknowledge and delete from main queue
await db.StreamAcknowledgeAsync(
_queueOptions.QueueName,
_queueOptions.ConsumerGroup,
[(RedisValue)lease.MessageId])
.ConfigureAwait(false);
await db.StreamDeleteAsync(_queueOptions.QueueName, [(RedisValue)lease.MessageId]).ConfigureAwait(false);
// Move to dead-letter queue if configured
if (!string.IsNullOrWhiteSpace(_queueOptions.DeadLetterQueue))
{
var now = _timeProvider.GetUtcNow();
var entries = BuildEntries(lease.Message, now, lease.Attempt, null);
await AddToStreamAsync(db, _queueOptions.DeadLetterQueue, entries, null).ConfigureAwait(false);
_logger?.LogWarning(
"Dead-lettered message {MessageId} after {Attempt} attempt(s): {Reason}",
lease.MessageId, lease.Attempt, reason);
}
else
{
_logger?.LogWarning(
"Dropped message {MessageId} after {Attempt} attempt(s); dead-letter queue not configured. Reason: {Reason}",
lease.MessageId, lease.Attempt, reason);
}
}
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
_groupInitLock.Dispose();
}
private string BuildIdempotencyKey(string key) => $"{_transportOptions.IdempotencyKeyPrefix}{key}";
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 async Task EnsureConsumerGroupAsync(IDatabase database, CancellationToken cancellationToken)
{
if (_groupInitialized)
{
return;
}
await _groupInitLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_groupInitialized)
{
return;
}
try
{
await database.StreamCreateConsumerGroupAsync(
_queueOptions.QueueName,
_queueOptions.ConsumerGroup,
RedisStreamPosition.Beginning,
createStream: true)
.ConfigureAwait(false);
}
catch (RedisServerException ex) when (ex.Message.Contains("BUSYGROUP", StringComparison.OrdinalIgnoreCase))
{
// Group already exists
}
_groupInitialized = true;
}
finally
{
_groupInitLock.Release();
}
}
private NameValueEntry[] BuildEntries(TMessage message, DateTimeOffset enqueuedAt, int attempt, EnqueueOptions? options)
{
var headerCount = options?.Headers?.Count ?? 0;
var entries = ArrayPool<NameValueEntry>.Shared.Rent(6 + headerCount);
var index = 0;
entries[index++] = new NameValueEntry(Fields.Payload, JsonSerializer.Serialize(message, _jsonOptions));
entries[index++] = new NameValueEntry(Fields.Attempt, attempt);
entries[index++] = new NameValueEntry(Fields.EnqueuedAt, enqueuedAt.ToUnixTimeMilliseconds());
if (!string.IsNullOrWhiteSpace(options?.TenantId))
{
entries[index++] = new NameValueEntry(Fields.TenantId, options.TenantId);
}
if (!string.IsNullOrWhiteSpace(options?.CorrelationId))
{
entries[index++] = new NameValueEntry(Fields.CorrelationId, options.CorrelationId);
}
if (!string.IsNullOrWhiteSpace(options?.IdempotencyKey))
{
entries[index++] = new NameValueEntry(Fields.IdempotencyKey, options.IdempotencyKey);
}
if (options?.Headers is not null)
{
foreach (var kvp in options.Headers)
{
entries[index++] = new NameValueEntry(Fields.HeaderPrefix + kvp.Key, kvp.Value);
}
}
var result = entries.AsSpan(0, index).ToArray();
ArrayPool<NameValueEntry>.Shared.Return(entries, clearArray: true);
return result;
}
private ValkeyMessageLease<TMessage>? TryMapLease(
StreamEntry entry,
string consumer,
DateTimeOffset now,
TimeSpan leaseDuration,
int? attemptOverride)
{
if (entry.Values is null || entry.Values.Length == 0)
{
return null;
}
string? payload = null;
string? tenantId = null;
string? correlationId = null;
long? enqueuedAtUnix = null;
var attempt = attemptOverride ?? 1;
Dictionary<string, string>? headers = null;
foreach (var field in entry.Values)
{
var name = field.Name.ToString();
var value = field.Value;
if (name.Equals(Fields.Payload, StringComparison.Ordinal))
{
payload = value.ToString();
}
else if (name.Equals(Fields.TenantId, StringComparison.Ordinal))
{
tenantId = NormalizeOptional(value.ToString());
}
else if (name.Equals(Fields.CorrelationId, StringComparison.Ordinal))
{
correlationId = NormalizeOptional(value.ToString());
}
else if (name.Equals(Fields.EnqueuedAt, StringComparison.Ordinal))
{
if (long.TryParse(value.ToString(), out var unixMs))
{
enqueuedAtUnix = unixMs;
}
}
else if (name.Equals(Fields.Attempt, StringComparison.Ordinal))
{
if (int.TryParse(value.ToString(), out var parsedAttempt))
{
attempt = attemptOverride.HasValue
? Math.Max(attemptOverride.Value, parsedAttempt)
: Math.Max(1, parsedAttempt);
}
}
else if (name.StartsWith(Fields.HeaderPrefix, StringComparison.Ordinal))
{
headers ??= new Dictionary<string, string>(StringComparer.Ordinal);
var key = name[Fields.HeaderPrefix.Length..];
headers[key] = value.ToString();
}
}
if (payload is null || enqueuedAtUnix is null)
{
return null;
}
TMessage message;
try
{
message = JsonSerializer.Deserialize<TMessage>(payload, _jsonOptions)!;
}
catch
{
return null;
}
var enqueuedAt = DateTimeOffset.FromUnixTimeMilliseconds(enqueuedAtUnix.Value);
var leaseExpires = now.Add(leaseDuration);
IReadOnlyDictionary<string, string>? headersView = headers is null || headers.Count == 0
? null
: new ReadOnlyDictionary<string, string>(headers);
return new ValkeyMessageLease<TMessage>(
this,
entry.Id.ToString(),
message,
attempt,
enqueuedAt,
leaseExpires,
consumer,
tenantId,
correlationId,
headersView);
}
private async Task HandlePoisonEntryAsync(IDatabase database, RedisValue entryId)
{
await database.StreamAcknowledgeAsync(
_queueOptions.QueueName,
_queueOptions.ConsumerGroup,
[entryId])
.ConfigureAwait(false);
await database.StreamDeleteAsync(_queueOptions.QueueName, [entryId]).ConfigureAwait(false);
_logger?.LogWarning("Removed poison entry {EntryId} from queue {Queue}", entryId, _queueOptions.QueueName);
}
private async Task<string> AddToStreamAsync(
IDatabase database,
string stream,
NameValueEntry[] entries,
int? maxLength)
{
var capacity = 4 + (entries.Length * 2);
var args = new List<object>(capacity) { (RedisKey)stream };
if (maxLength.HasValue)
{
args.Add("MAXLEN");
args.Add("~");
args.Add(maxLength.Value);
}
args.Add("*");
foreach (var entry in entries)
{
args.Add((RedisValue)entry.Name);
args.Add(entry.Value);
}
var result = await database.ExecuteAsync("XADD", [.. args]).ConfigureAwait(false);
return result!.ToString()!;
}
private static string? NormalizeOptional(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value;
}

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,69 @@
namespace StellaOps.Messaging.Abstractions;
/// <summary>
/// Transport-agnostic distributed cache interface.
/// </summary>
/// <typeparam name="TKey">The key type.</typeparam>
/// <typeparam name="TValue">The value type.</typeparam>
public interface IDistributedCache<TKey, TValue>
{
/// <summary>
/// Gets the provider name for diagnostics (e.g., "valkey", "postgres").
/// </summary>
string ProviderName { get; }
/// <summary>
/// Gets a value from the cache.
/// </summary>
/// <param name="key">The cache key.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The cache result.</returns>
ValueTask<CacheResult<TValue>> GetAsync(TKey key, CancellationToken cancellationToken = default);
/// <summary>
/// Sets a value in the cache.
/// </summary>
/// <param name="key">The cache key.</param>
/// <param name="value">The value to cache.</param>
/// <param name="options">Optional cache entry options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask SetAsync(TKey key, TValue value, CacheEntryOptions? options = null, CancellationToken cancellationToken = default);
/// <summary>
/// Removes a value from the cache.
/// </summary>
/// <param name="key">The cache key.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if the key existed and was removed.</returns>
ValueTask<bool> InvalidateAsync(TKey key, CancellationToken cancellationToken = default);
/// <summary>
/// Removes values matching a pattern from the cache.
/// </summary>
/// <param name="pattern">The key pattern (supports wildcards).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The number of keys invalidated.</returns>
ValueTask<long> InvalidateByPatternAsync(string pattern, CancellationToken cancellationToken = default);
/// <summary>
/// Gets or sets a value in the cache, using a factory function if the value is not present.
/// </summary>
/// <param name="key">The cache key.</param>
/// <param name="factory">Factory function to create the value if not cached.</param>
/// <param name="options">Optional cache entry options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The cached or newly created value.</returns>
ValueTask<TValue> GetOrSetAsync(
TKey key,
Func<CancellationToken, ValueTask<TValue>> factory,
CacheEntryOptions? options = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Simple string-keyed distributed cache interface.
/// </summary>
/// <typeparam name="TValue">The value type.</typeparam>
public interface IDistributedCache<TValue> : IDistributedCache<string, TValue>
{
}

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

@@ -0,0 +1,99 @@
namespace StellaOps.Messaging.Abstractions;
/// <summary>
/// Represents a leased message from a queue.
/// The lease provides exclusive access to process the message.
/// </summary>
/// <typeparam name="TMessage">The message type.</typeparam>
public interface IMessageLease<out TMessage> : IAsyncDisposable where TMessage : class
{
/// <summary>
/// Gets the unique message identifier.
/// </summary>
string MessageId { get; }
/// <summary>
/// Gets the message payload.
/// </summary>
TMessage Message { get; }
/// <summary>
/// Gets the delivery attempt number (1-based).
/// </summary>
int Attempt { get; }
/// <summary>
/// Gets the timestamp when the message was enqueued.
/// </summary>
DateTimeOffset EnqueuedAt { get; }
/// <summary>
/// Gets the timestamp when the lease expires.
/// </summary>
DateTimeOffset LeaseExpiresAt { get; }
/// <summary>
/// Gets the consumer name that owns this lease.
/// </summary>
string Consumer { get; }
/// <summary>
/// Gets the tenant identifier, if present.
/// </summary>
string? TenantId { get; }
/// <summary>
/// Gets the correlation identifier for tracing, if present.
/// </summary>
string? CorrelationId { get; }
/// <summary>
/// Acknowledges successful processing of the message.
/// The message is removed from the queue.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask AcknowledgeAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Extends the lease duration.
/// </summary>
/// <param name="extension">The time to extend the lease by.</param>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask RenewAsync(TimeSpan extension, CancellationToken cancellationToken = default);
/// <summary>
/// Releases the lease with the specified disposition.
/// </summary>
/// <param name="disposition">How to handle the message after release.</param>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask ReleaseAsync(ReleaseDisposition disposition, CancellationToken cancellationToken = default);
/// <summary>
/// Moves the message to the dead-letter queue.
/// </summary>
/// <param name="reason">The reason for dead-lettering.</param>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask DeadLetterAsync(string reason, CancellationToken cancellationToken = default);
}
/// <summary>
/// Specifies how to handle a message when releasing a lease.
/// </summary>
public enum ReleaseDisposition
{
/// <summary>
/// Retry the message (make it available for redelivery).
/// </summary>
Retry,
/// <summary>
/// Delay the message before making it available again.
/// </summary>
Delay,
/// <summary>
/// Abandon the message (do not retry, but don't dead-letter either).
/// Implementation may vary by transport.
/// </summary>
Abandon
}

View File

@@ -0,0 +1,59 @@
namespace StellaOps.Messaging.Abstractions;
/// <summary>
/// Transport-agnostic message queue interface.
/// Consumers depend only on this abstraction without knowing which transport is used.
/// </summary>
/// <typeparam name="TMessage">The message type.</typeparam>
public interface IMessageQueue<TMessage> where TMessage : class
{
/// <summary>
/// Gets the provider name for diagnostics (e.g., "valkey", "nats", "postgres").
/// </summary>
string ProviderName { get; }
/// <summary>
/// Gets the queue/stream name.
/// </summary>
string QueueName { get; }
/// <summary>
/// Enqueues a message to the queue.
/// </summary>
/// <param name="message">The message to enqueue.</param>
/// <param name="options">Optional enqueue options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The result of the enqueue operation.</returns>
ValueTask<EnqueueResult> EnqueueAsync(
TMessage message,
EnqueueOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Leases messages from the queue for processing.
/// Messages remain invisible to other consumers until acknowledged or lease expires.
/// </summary>
/// <param name="request">The lease request parameters.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A list of message leases.</returns>
ValueTask<IReadOnlyList<IMessageLease<TMessage>>> LeaseAsync(
LeaseRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Claims expired leases from other consumers (pending entry list recovery).
/// </summary>
/// <param name="request">The claim request parameters.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A list of claimed message leases.</returns>
ValueTask<IReadOnlyList<IMessageLease<TMessage>>> ClaimExpiredAsync(
ClaimRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the approximate number of pending messages in the queue.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The approximate pending message count.</returns>
ValueTask<long> GetPendingCountAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,165 @@
namespace StellaOps.Messaging.Abstractions;
/// <summary>
/// Factory for creating message queue instances.
/// </summary>
public interface IMessageQueueFactory
{
/// <summary>
/// Gets the provider name for this factory.
/// </summary>
string ProviderName { get; }
/// <summary>
/// Creates a message queue for the specified message type and options.
/// </summary>
/// <typeparam name="TMessage">The message type.</typeparam>
/// <param name="options">The queue options.</param>
/// <returns>A configured message queue instance.</returns>
IMessageQueue<TMessage> Create<TMessage>(MessageQueueOptions options) where TMessage : class;
}
/// <summary>
/// Factory for creating distributed cache instances.
/// </summary>
public interface IDistributedCacheFactory
{
/// <summary>
/// Gets the provider name for this factory.
/// </summary>
string ProviderName { get; }
/// <summary>
/// Creates a distributed cache for the specified key and value types.
/// </summary>
/// <typeparam name="TKey">The key type.</typeparam>
/// <typeparam name="TValue">The value type.</typeparam>
/// <param name="options">The cache options.</param>
/// <returns>A configured distributed cache instance.</returns>
IDistributedCache<TKey, TValue> Create<TKey, TValue>(CacheOptions options);
/// <summary>
/// Creates a string-keyed distributed cache.
/// </summary>
/// <typeparam name="TValue">The value type.</typeparam>
/// <param name="options">The cache options.</param>
/// <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,125 @@
using System.Reflection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Messaging.Abstractions;
using StellaOps.Messaging.Plugins;
namespace StellaOps.Messaging.DependencyInjection;
/// <summary>
/// Extension methods for registering messaging services.
/// </summary>
public static class MessagingServiceCollectionExtensions
{
/// <summary>
/// Adds messaging services with plugin-based transport discovery.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">The configuration.</param>
/// <param name="configure">Optional configuration callback.</param>
/// <returns>The service collection.</returns>
public static IServiceCollection AddMessagingPlugins(
this IServiceCollection services,
IConfiguration configuration,
Action<MessagingPluginOptions>? configure = null)
{
var options = new MessagingPluginOptions();
configure?.Invoke(options);
services.AddSingleton<MessagingPluginLoader>();
var loader = new MessagingPluginLoader();
var plugins = loader.LoadFromDirectory(options.PluginDirectory, options.SearchPattern);
// Also load from assemblies in the current domain that might contain plugins
var domainAssemblies = AppDomain.CurrentDomain.GetAssemblies()
.Where(a => a.GetName().Name?.StartsWith("StellaOps.Messaging.Transport.") == true);
var domainPlugins = loader.LoadFromAssemblies(domainAssemblies);
var allPlugins = plugins.Concat(domainPlugins)
.GroupBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.ToList();
var registered = loader.RegisterConfiguredTransport(
allPlugins,
services,
configuration,
options.ConfigurationSection);
if (!registered && options.RequireTransport)
{
throw new InvalidOperationException(
$"No messaging transport configured. Set '{options.ConfigurationSection}:transport' to one of: {string.Join(", ", allPlugins.Select(p => p.Name))}");
}
return services;
}
/// <summary>
/// Adds messaging services with a specific transport plugin.
/// </summary>
/// <typeparam name="TPlugin">The transport plugin type.</typeparam>
/// <param name="services">The service collection.</param>
/// <param name="configuration">The configuration.</param>
/// <param name="configSection">The configuration section for the transport.</param>
/// <returns>The service collection.</returns>
public static IServiceCollection AddMessagingTransport<TPlugin>(
this IServiceCollection services,
IConfiguration configuration,
string configSection = "messaging")
where TPlugin : IMessagingTransportPlugin, new()
{
var plugin = new TPlugin();
var context = new MessagingTransportRegistrationContext(
services,
configuration,
$"{configSection}:{plugin.Name}");
plugin.Register(context);
return services;
}
/// <summary>
/// Adds a message queue for a specific message type.
/// </summary>
/// <typeparam name="TMessage">The message type.</typeparam>
/// <param name="services">The service collection.</param>
/// <param name="options">The queue options.</param>
/// <returns>The service collection.</returns>
public static IServiceCollection AddMessageQueue<TMessage>(
this IServiceCollection services,
MessageQueueOptions options)
where TMessage : class
{
services.AddSingleton(sp =>
{
var factory = sp.GetRequiredService<IMessageQueueFactory>();
return factory.Create<TMessage>(options);
});
return services;
}
/// <summary>
/// Adds a distributed cache for a specific value type.
/// </summary>
/// <typeparam name="TValue">The value type.</typeparam>
/// <param name="services">The service collection.</param>
/// <param name="options">The cache options.</param>
/// <returns>The service collection.</returns>
public static IServiceCollection AddDistributedCache<TValue>(
this IServiceCollection services,
CacheOptions options)
{
services.AddSingleton(sp =>
{
var factory = sp.GetRequiredService<IDistributedCacheFactory>();
return factory.Create<TValue>(options);
});
return services;
}
}

View File

@@ -0,0 +1,59 @@
namespace StellaOps.Messaging;
/// <summary>
/// Configuration options for a distributed cache.
/// </summary>
public class CacheOptions
{
/// <summary>
/// Gets or sets the key prefix for all cache entries.
/// </summary>
public string? KeyPrefix { get; set; }
/// <summary>
/// Gets or sets the default time-to-live for cache entries.
/// </summary>
public TimeSpan? DefaultTtl { get; set; }
/// <summary>
/// Gets or sets whether to use sliding expiration.
/// If true, TTL is reset on each access.
/// </summary>
public bool SlidingExpiration { get; set; }
}
/// <summary>
/// Options for individual cache entries.
/// </summary>
public class CacheEntryOptions
{
/// <summary>
/// Gets or sets the absolute expiration time.
/// </summary>
public DateTimeOffset? AbsoluteExpiration { get; set; }
/// <summary>
/// Gets or sets the time-to-live relative to now.
/// </summary>
public TimeSpan? TimeToLive { get; set; }
/// <summary>
/// Gets or sets whether to use sliding expiration for this entry.
/// </summary>
public bool? SlidingExpiration { get; set; }
/// <summary>
/// Creates options with a specific TTL.
/// </summary>
public static CacheEntryOptions WithTtl(TimeSpan ttl) => new() { TimeToLive = ttl };
/// <summary>
/// Creates options with absolute expiration.
/// </summary>
public static CacheEntryOptions ExpiresAt(DateTimeOffset expiration) => new() { AbsoluteExpiration = expiration };
/// <summary>
/// Creates options with sliding expiration.
/// </summary>
public static CacheEntryOptions Sliding(TimeSpan slidingWindow) => new() { TimeToLive = slidingWindow, SlidingExpiration = true };
}

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,69 @@
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Messaging;
/// <summary>
/// Configuration options for a message queue.
/// </summary>
public class MessageQueueOptions
{
/// <summary>
/// Gets or sets the queue/stream name.
/// </summary>
[Required]
public string QueueName { get; set; } = null!;
/// <summary>
/// Gets or sets the consumer group name.
/// </summary>
[Required]
public string ConsumerGroup { get; set; } = null!;
/// <summary>
/// Gets or sets the consumer name within the group.
/// Defaults to machine name + process ID.
/// </summary>
public string? ConsumerName { get; set; }
/// <summary>
/// Gets or sets the dead-letter queue name.
/// If null, dead-lettering may not be supported.
/// </summary>
public string? DeadLetterQueue { get; set; }
/// <summary>
/// Gets or sets the default lease duration for messages.
/// </summary>
public TimeSpan DefaultLeaseDuration { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Gets or sets the maximum number of delivery attempts before dead-lettering.
/// </summary>
public int MaxDeliveryAttempts { get; set; } = 5;
/// <summary>
/// Gets or sets the idempotency window for duplicate detection.
/// </summary>
public TimeSpan IdempotencyWindow { get; set; } = TimeSpan.FromHours(24);
/// <summary>
/// Gets or sets the approximate maximum queue length (stream trimming).
/// Null means no limit.
/// </summary>
public int? ApproximateMaxLength { get; set; }
/// <summary>
/// Gets or sets the initial backoff for retry delays.
/// </summary>
public TimeSpan RetryInitialBackoff { get; set; } = TimeSpan.FromSeconds(1);
/// <summary>
/// Gets or sets the maximum backoff for retry delays.
/// </summary>
public TimeSpan RetryMaxBackoff { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Gets or sets the backoff multiplier for exponential backoff.
/// </summary>
public double RetryBackoffMultiplier { get; set; } = 2.0;
}

View File

@@ -0,0 +1,27 @@
namespace StellaOps.Messaging;
/// <summary>
/// Options for configuring messaging plugin discovery and loading.
/// </summary>
public class MessagingPluginOptions
{
/// <summary>
/// Gets or sets the directory to search for transport plugins.
/// </summary>
public string PluginDirectory { get; set; } = "plugins/messaging";
/// <summary>
/// Gets or sets the search pattern for plugin assemblies.
/// </summary>
public string SearchPattern { get; set; } = "StellaOps.Messaging.Transport.*.dll";
/// <summary>
/// Gets or sets the configuration section path for messaging options.
/// </summary>
public string ConfigurationSection { get; set; } = "messaging";
/// <summary>
/// Gets or sets whether to throw if no transport is configured.
/// </summary>
public bool RequireTransport { get; set; } = true;
}

View File

@@ -0,0 +1,63 @@
namespace StellaOps.Messaging;
/// <summary>
/// Result of a cache get operation.
/// </summary>
/// <typeparam name="TValue">The value type.</typeparam>
public readonly struct CacheResult<TValue>
{
private readonly TValue? _value;
private CacheResult(TValue? value, bool hasValue)
{
_value = value;
HasValue = hasValue;
}
/// <summary>
/// Gets whether a value was found in the cache.
/// </summary>
public bool HasValue { get; }
/// <summary>
/// Gets the cached value.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown when no value is present.</exception>
public TValue Value => HasValue
? _value!
: throw new InvalidOperationException("No value present in cache result.");
/// <summary>
/// Gets the value or a default.
/// </summary>
/// <param name="defaultValue">The default value to return if not cached.</param>
/// <returns>The cached value or the default.</returns>
public TValue GetValueOrDefault(TValue defaultValue = default!) =>
HasValue ? _value! : defaultValue;
/// <summary>
/// Attempts to get the value.
/// </summary>
/// <param name="value">The cached value, if present.</param>
/// <returns>True if a value was present.</returns>
public bool TryGetValue(out TValue? value)
{
value = _value;
return HasValue;
}
/// <summary>
/// Creates a result with a value.
/// </summary>
public static CacheResult<TValue> Found(TValue value) => new(value, true);
/// <summary>
/// Creates a result indicating cache miss.
/// </summary>
public static CacheResult<TValue> Miss() => new(default, false);
/// <summary>
/// Implicitly converts a value to a found result.
/// </summary>
public static implicit operator CacheResult<TValue>(TValue value) => Found(value);
}

View File

@@ -0,0 +1,54 @@
namespace StellaOps.Messaging;
/// <summary>
/// Options for enqueue operations.
/// </summary>
public class EnqueueOptions
{
/// <summary>
/// Gets or sets the idempotency key for duplicate detection.
/// If null, no duplicate detection is performed.
/// </summary>
public string? IdempotencyKey { get; set; }
/// <summary>
/// Gets or sets the correlation ID for tracing.
/// </summary>
public string? CorrelationId { get; set; }
/// <summary>
/// Gets or sets the tenant ID for multi-tenant scenarios.
/// </summary>
public string? TenantId { get; set; }
/// <summary>
/// Gets or sets the message priority (if supported by transport).
/// Higher values indicate higher priority.
/// </summary>
public int Priority { get; set; }
/// <summary>
/// Gets or sets when the message should become visible (delayed delivery).
/// </summary>
public DateTimeOffset? VisibleAt { get; set; }
/// <summary>
/// Gets or sets custom headers/metadata for the message.
/// </summary>
public IReadOnlyDictionary<string, string>? Headers { get; set; }
/// <summary>
/// Creates options with an idempotency key.
/// </summary>
public static EnqueueOptions WithIdempotencyKey(string key) => new() { IdempotencyKey = key };
/// <summary>
/// Creates options for delayed delivery.
/// </summary>
public static EnqueueOptions DelayedUntil(DateTimeOffset visibleAt) => new() { VisibleAt = visibleAt };
/// <summary>
/// Creates options with correlation ID.
/// </summary>
public static EnqueueOptions WithCorrelation(string correlationId) => new() { CorrelationId = correlationId };
}

View File

@@ -0,0 +1,45 @@
namespace StellaOps.Messaging;
/// <summary>
/// Result of an enqueue operation.
/// </summary>
public readonly struct EnqueueResult
{
/// <summary>
/// Gets the message ID assigned by the queue.
/// </summary>
public string MessageId { get; init; }
/// <summary>
/// Gets whether the message was enqueued successfully.
/// </summary>
public bool Success { get; init; }
/// <summary>
/// Gets whether this was a duplicate message (idempotency).
/// </summary>
public bool WasDuplicate { get; init; }
/// <summary>
/// Gets the error message if the operation failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Creates a successful result.
/// </summary>
public static EnqueueResult Succeeded(string messageId, bool wasDuplicate = false) =>
new() { MessageId = messageId, Success = true, WasDuplicate = wasDuplicate };
/// <summary>
/// Creates a failed result.
/// </summary>
public static EnqueueResult Failed(string error) =>
new() { Success = false, Error = error, MessageId = string.Empty };
/// <summary>
/// Creates a duplicate result.
/// </summary>
public static EnqueueResult Duplicate(string messageId) =>
new() { MessageId = messageId, Success = true, WasDuplicate = true };
}

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,58 @@
namespace StellaOps.Messaging;
/// <summary>
/// Request parameters for leasing messages.
/// </summary>
public class LeaseRequest
{
/// <summary>
/// Gets or sets the maximum number of messages to lease.
/// </summary>
public int BatchSize { get; set; } = 1;
/// <summary>
/// Gets or sets the lease duration for the messages.
/// If null, uses the queue's default lease duration.
/// </summary>
public TimeSpan? LeaseDuration { get; set; }
/// <summary>
/// Gets or sets the maximum time to wait for messages if none are available.
/// Zero means don't wait (poll). Null means use transport default.
/// </summary>
public TimeSpan? WaitTimeout { get; set; }
/// <summary>
/// Gets or sets whether to only return messages from the pending entry list (redeliveries).
/// </summary>
public bool PendingOnly { get; set; }
}
/// <summary>
/// Request parameters for claiming expired leases.
/// </summary>
public class ClaimRequest
{
/// <summary>
/// Gets or sets the maximum number of messages to claim.
/// </summary>
public int BatchSize { get; set; } = 10;
/// <summary>
/// Gets or sets the minimum idle time for a message to be claimed.
/// Messages must have been idle (not processed) for at least this duration.
/// </summary>
public TimeSpan MinIdleTime { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Gets or sets the new lease duration for claimed messages.
/// If null, uses the queue's default lease duration.
/// </summary>
public TimeSpan? LeaseDuration { get; set; }
/// <summary>
/// Gets or sets the minimum number of delivery attempts for messages to claim.
/// This helps avoid claiming messages that are still being processed for the first time.
/// </summary>
public int MinDeliveryAttempts { get; set; } = 1;
}

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

@@ -0,0 +1,29 @@
<?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</RootNamespace>
<AssemblyName>StellaOps.Messaging</AssemblyName>
<Description>Transport-agnostic messaging abstractions for StellaOps (queues, caching, pub/sub)</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,425 @@
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Patterns;
using Microsoft.Extensions.Logging;
using StellaOps.Router.Common.Models;
namespace StellaOps.Microservice.AspNetCore;
/// <summary>
/// Discovers ASP.NET Core endpoints and converts them to Router endpoint descriptors.
/// </summary>
public sealed partial class AspNetCoreEndpointDiscoveryProvider : IAspNetEndpointDiscoveryProvider
{
private static readonly string[] MethodOrder =
["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"];
private readonly EndpointDataSource _endpointDataSource;
private readonly StellaRouterBridgeOptions _options;
private readonly IAuthorizationClaimMapper _authMapper;
private readonly ILogger<AspNetCoreEndpointDiscoveryProvider> _logger;
private IReadOnlyList<AspNetEndpointDescriptor>? _cachedEndpoints;
private readonly object _cacheLock = new();
public AspNetCoreEndpointDiscoveryProvider(
EndpointDataSource endpointDataSource,
StellaRouterBridgeOptions options,
IAuthorizationClaimMapper authMapper,
ILogger<AspNetCoreEndpointDiscoveryProvider> logger)
{
_endpointDataSource = endpointDataSource ?? throw new ArgumentNullException(nameof(endpointDataSource));
_options = options ?? throw new ArgumentNullException(nameof(options));
_authMapper = authMapper ?? throw new ArgumentNullException(nameof(authMapper));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public IReadOnlyList<EndpointDescriptor> DiscoverEndpoints()
{
return DiscoverAspNetEndpoints()
.Select(e => e.ToEndpointDescriptor())
.ToList();
}
/// <inheritdoc />
public IReadOnlyList<AspNetEndpointDescriptor> DiscoverAspNetEndpoints()
{
lock (_cacheLock)
{
if (_cachedEndpoints is not null)
{
return _cachedEndpoints;
}
_cachedEndpoints = DiscoverEndpointsCore();
return _cachedEndpoints;
}
}
/// <inheritdoc />
public void RefreshEndpoints()
{
lock (_cacheLock)
{
_cachedEndpoints = null;
}
}
private IReadOnlyList<AspNetEndpointDescriptor> DiscoverEndpointsCore()
{
var descriptors = new List<AspNetEndpointDescriptor>();
var seenEndpoints = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var endpoint in _endpointDataSource.Endpoints.OfType<RouteEndpoint>())
{
// Skip endpoints without HTTP method metadata
var httpMethodMetadata = endpoint.Metadata.GetMetadata<HttpMethodMetadata>();
if (httpMethodMetadata?.HttpMethods is not { Count: > 0 })
{
continue;
}
// Apply custom filter if configured
if (_options.EndpointFilter is { } filter && !filter(endpoint))
{
_logger.LogDebug(
"Endpoint {DisplayName} excluded by custom filter",
endpoint.DisplayName);
continue;
}
// Normalize the route pattern
var normalizedPath = NormalizeRoutePattern(endpoint.RoutePattern);
if (normalizedPath is null)
{
_logger.LogWarning(
"Could not normalize route pattern for endpoint {DisplayName}",
endpoint.DisplayName);
continue;
}
// Check excluded path prefixes
if (IsExcludedPath(normalizedPath))
{
_logger.LogDebug(
"Endpoint {Path} excluded by path prefix filter",
normalizedPath);
continue;
}
// Process each HTTP method
foreach (var method in httpMethodMetadata.HttpMethods)
{
var key = $"{method.ToUpperInvariant()}:{normalizedPath}";
if (!seenEndpoints.Add(key))
{
_logger.LogDebug(
"Duplicate endpoint {Method} {Path} skipped",
method, normalizedPath);
continue;
}
var descriptor = BuildDescriptor(endpoint, method, normalizedPath);
if (descriptor is not null)
{
descriptors.Add(descriptor);
}
}
}
// Sort for deterministic ordering
return descriptors
.OrderBy(e => e.Path, StringComparer.OrdinalIgnoreCase)
.ThenBy(e => GetMethodOrder(e.Method))
.ThenBy(e => e.OperationId ?? "")
.ToList();
}
private AspNetEndpointDescriptor? BuildDescriptor(
RouteEndpoint endpoint,
string method,
string normalizedPath)
{
// Map authorization
var authResult = _authMapper.Map(endpoint);
// Check authorization requirements based on configuration
if (!authResult.HasAuthorization && !authResult.AllowAnonymous)
{
switch (_options.OnMissingAuthorization)
{
case MissingAuthorizationBehavior.RequireExplicit:
_logger.LogError(
"Endpoint {Method} {Path} has no authorization metadata. " +
"Add [Authorize] or [AllowAnonymous], or configure YAML override.",
method, normalizedPath);
throw new InvalidOperationException(
$"Endpoint {method} {normalizedPath} has no authorization metadata. " +
"Configure OnMissingAuthorization to allow or add authorization.");
case MissingAuthorizationBehavior.WarnAndAllow:
_logger.LogWarning(
"Endpoint {Method} {Path} has no authorization metadata. " +
"It will require authentication but no specific claims.",
method, normalizedPath);
break;
case MissingAuthorizationBehavior.AllowAuthenticated:
// Silent - this is expected behavior
break;
}
}
// Extract parameters
var parameters = ExtractParameters(endpoint);
// Extract responses
var responses = ExtractResponses(endpoint);
// Extract OpenAPI metadata
var (operationId, summary, description, tags) = ExtractOpenApiMetadata(endpoint);
return new AspNetEndpointDescriptor
{
ServiceName = _options.ServiceName,
Version = _options.Version,
Method = method.ToUpperInvariant(),
Path = normalizedPath,
DefaultTimeout = _options.DefaultTimeout,
SupportsStreaming = _options.EnableStreaming && HasStreamingResponse(endpoint),
RequiringClaims = authResult.Claims,
AuthorizationPolicies = authResult.Policies,
Roles = authResult.Roles,
AllowAnonymous = authResult.AllowAnonymous,
AuthorizationSource = authResult.Source,
Parameters = parameters,
Responses = responses,
OperationId = operationId,
Summary = summary,
Description = description,
Tags = tags,
OriginalEndpoint = endpoint,
OriginalRoutePattern = endpoint.RoutePattern.RawText
};
}
private string? NormalizeRoutePattern(RoutePattern pattern)
{
string raw;
if (!string.IsNullOrWhiteSpace(pattern.RawText))
{
raw = pattern.RawText;
}
else
{
// Build from segments
var segments = new List<string>();
foreach (var segment in pattern.PathSegments)
{
var parts = new List<string>();
foreach (var part in segment.Parts)
{
switch (part)
{
case RoutePatternLiteralPart literal:
parts.Add(literal.Content);
break;
case RoutePatternParameterPart param:
var prefix = param.ParameterKind == RoutePatternParameterKind.CatchAll ? "*" : "";
parts.Add($"{{{prefix}{param.Name}}}");
break;
}
}
segments.Add(string.Concat(parts));
}
raw = "/" + string.Join('/', segments);
}
// 1. Ensure leading slash
if (!raw.StartsWith('/'))
{
raw = "/" + raw;
}
// 2. Strip constraints: {id:int} → {id}, {**path:regex} → {path}
raw = ConstraintPattern().Replace(raw, "{$2}");
// 3. Normalize catch-all: {**path} → {path}
raw = raw.Replace("**", "", StringComparison.Ordinal);
raw = raw.Replace("{*", "{", StringComparison.Ordinal);
// 4. Remove trailing slash
raw = raw.TrimEnd('/');
// 5. Empty path becomes "/"
return string.IsNullOrEmpty(raw) ? "/" : raw;
}
[GeneratedRegex(@"\{(\*{0,2})([A-Za-z0-9_]+)(:[^}]+)?\}", RegexOptions.Compiled)]
private static partial Regex ConstraintPattern();
private bool IsExcludedPath(string path)
{
if (_options.IncludeExcludedPathsInRouter)
{
return false;
}
foreach (var prefix in _options.ExcludedPathPrefixes)
{
if (path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static int GetMethodOrder(string method)
{
var index = Array.IndexOf(MethodOrder, method.ToUpperInvariant());
return index >= 0 ? index : MethodOrder.Length;
}
private IReadOnlyList<ParameterDescriptor> ExtractParameters(RouteEndpoint endpoint)
{
var parameters = new List<ParameterDescriptor>();
// Extract route parameters from pattern
foreach (var param in endpoint.RoutePattern.Parameters)
{
parameters.Add(new ParameterDescriptor
{
Name = param.Name,
Source = ParameterSource.Route,
Type = typeof(string), // Route params are strings by default
IsRequired = !param.IsOptional && param.Default is null,
DefaultValue = param.Default,
JsonSchemaType = "string"
});
}
// Try to extract from endpoint metadata (IAcceptsMetadata for body)
var acceptsMetadata = endpoint.Metadata.GetMetadata<IAcceptsMetadata>();
if (acceptsMetadata?.RequestType is { } bodyType)
{
parameters.Add(new ParameterDescriptor
{
Name = "body",
Source = ParameterSource.Body,
Type = bodyType,
IsRequired = !acceptsMetadata.IsOptional,
JsonSchemaType = GetJsonSchemaType(bodyType)
});
}
return parameters;
}
private IReadOnlyList<ResponseDescriptor> ExtractResponses(RouteEndpoint endpoint)
{
var responses = new List<ResponseDescriptor>();
// Extract from IProducesResponseTypeMetadata
var producesMetadata = endpoint.Metadata.GetOrderedMetadata<IProducesResponseTypeMetadata>();
foreach (var produces in producesMetadata)
{
responses.Add(new ResponseDescriptor
{
StatusCode = produces.StatusCode,
ResponseType = produces.Type,
ContentType = produces.ContentTypes.FirstOrDefault() ?? "application/json",
SchemaRef = produces.Type?.FullName
});
}
// If no explicit responses, add default 200 OK
if (responses.Count == 0)
{
responses.Add(new ResponseDescriptor
{
StatusCode = StatusCodes.Status200OK,
Description = "Success"
});
}
return responses
.OrderBy(r => r.StatusCode)
.ToList();
}
private (string? OperationId, string? Summary, string? Description, IReadOnlyList<string> Tags)
ExtractOpenApiMetadata(RouteEndpoint endpoint)
{
if (!_options.ExtractOpenApiMetadata)
{
return (null, null, null, []);
}
// Operation ID from IEndpointNameMetadata or display name
var nameMetadata = endpoint.Metadata.GetMetadata<IEndpointNameMetadata>();
var operationId = nameMetadata?.EndpointName
?? endpoint.Metadata.GetMetadata<RouteNameMetadata>()?.RouteName;
// Summary
var summaryMetadata = endpoint.Metadata.GetMetadata<IEndpointSummaryMetadata>();
var summary = summaryMetadata?.Summary ?? endpoint.DisplayName;
// Description
var descriptionMetadata = endpoint.Metadata.GetMetadata<IEndpointDescriptionMetadata>();
var description = descriptionMetadata?.Description;
// Tags
var tagsMetadata = endpoint.Metadata.GetMetadata<ITagsMetadata>();
var tags = tagsMetadata?.Tags.ToList() ?? new List<string>();
return (operationId, summary, description, tags);
}
private static bool HasStreamingResponse(RouteEndpoint endpoint)
{
// Check for streaming indicators in response metadata
var produces = endpoint.Metadata.GetOrderedMetadata<IProducesResponseTypeMetadata>();
foreach (var p in produces)
{
if (p.ContentTypes.Any(ct =>
ct.Contains("stream", StringComparison.OrdinalIgnoreCase) ||
ct.Contains("octet", StringComparison.OrdinalIgnoreCase)))
{
return true;
}
}
return false;
}
private static string GetJsonSchemaType(Type type)
{
var underlying = Nullable.GetUnderlyingType(type) ?? type;
return underlying switch
{
_ when underlying == typeof(string) => "string",
_ when underlying == typeof(int) => "integer",
_ when underlying == typeof(long) => "integer",
_ when underlying == typeof(short) => "integer",
_ when underlying == typeof(byte) => "integer",
_ when underlying == typeof(float) => "number",
_ when underlying == typeof(double) => "number",
_ when underlying == typeof(decimal) => "number",
_ when underlying == typeof(bool) => "boolean",
_ when underlying == typeof(DateTime) => "string",
_ when underlying == typeof(DateTimeOffset) => "string",
_ when underlying == typeof(Guid) => "string",
_ when underlying.IsArray => "array",
_ when underlying.IsGenericType &&
underlying.GetGenericTypeDefinition() == typeof(IEnumerable<>) => "array",
_ => "object"
};
}
}

View File

@@ -0,0 +1,281 @@
using Microsoft.AspNetCore.Routing;
using StellaOps.Router.Common.Models;
namespace StellaOps.Microservice.AspNetCore;
/// <summary>
/// Extended endpoint descriptor with full ASP.NET metadata.
/// Captures all discoverable information from ASP.NET endpoints for Router registration.
/// </summary>
public sealed record AspNetEndpointDescriptor
{
// === Core Identity (compatible with EndpointDescriptor) ===
/// <summary>
/// Name of the service that owns this endpoint.
/// </summary>
public required string ServiceName { get; init; }
/// <summary>
/// Semantic version of the service.
/// </summary>
public required string Version { get; init; }
/// <summary>
/// HTTP method (GET, POST, PUT, PATCH, DELETE, etc.).
/// </summary>
public required string Method { get; init; }
/// <summary>
/// Normalized path template (e.g., "/api/scans/{id}").
/// Constraints are stripped; catch-all markers are normalized.
/// </summary>
public required string Path { get; init; }
/// <summary>
/// Default timeout for this endpoint.
/// </summary>
public TimeSpan DefaultTimeout { get; init; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Whether this endpoint supports streaming responses.
/// </summary>
public bool SupportsStreaming { get; init; }
// === Authorization ===
/// <summary>
/// Claim requirements for authorization, derived from ASP.NET metadata and/or YAML.
/// </summary>
public IReadOnlyList<ClaimRequirement> RequiringClaims { get; init; } = [];
/// <summary>
/// Named authorization policies applied to this endpoint.
/// </summary>
public IReadOnlyList<string> AuthorizationPolicies { get; init; } = [];
/// <summary>
/// Role names required for this endpoint (from [Authorize(Roles = "...")]).
/// </summary>
public IReadOnlyList<string> Roles { get; init; } = [];
/// <summary>
/// Whether anonymous access is explicitly allowed ([AllowAnonymous]).
/// </summary>
public bool AllowAnonymous { get; init; }
/// <summary>
/// Source of the authorization metadata.
/// </summary>
public AuthorizationSource AuthorizationSource { get; init; }
// === Parameters ===
/// <summary>
/// Parameter metadata for this endpoint (route, query, header, body).
/// </summary>
public IReadOnlyList<ParameterDescriptor> Parameters { get; init; } = [];
// === Responses ===
/// <summary>
/// Response metadata for this endpoint (status codes, types).
/// </summary>
public IReadOnlyList<ResponseDescriptor> Responses { get; init; } = [];
// === OpenAPI Metadata ===
/// <summary>
/// Operation ID for OpenAPI (from .WithName() or endpoint name).
/// </summary>
public string? OperationId { get; init; }
/// <summary>
/// Summary for OpenAPI (from .WithSummary() or display name).
/// </summary>
public string? Summary { get; init; }
/// <summary>
/// Description for OpenAPI (from .WithDescription()).
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Tags for OpenAPI grouping (from .WithTags()).
/// </summary>
public IReadOnlyList<string> Tags { get; init; } = [];
// === Schema ===
/// <summary>
/// Schema information for request/response validation.
/// </summary>
public EndpointSchemaInfo? SchemaInfo { get; init; }
// === Internal (not serialized to HELLO) ===
/// <summary>
/// Reference to the original ASP.NET RouteEndpoint for dispatch.
/// </summary>
internal RouteEndpoint? OriginalEndpoint { get; init; }
/// <summary>
/// Original route pattern before normalization (for debugging).
/// </summary>
internal string? OriginalRoutePattern { get; init; }
/// <summary>
/// Convert to standard Router EndpointDescriptor for HELLO payload.
/// </summary>
public EndpointDescriptor ToEndpointDescriptor() => new()
{
ServiceName = ServiceName,
Version = Version,
Method = Method,
Path = Path,
DefaultTimeout = DefaultTimeout,
SupportsStreaming = SupportsStreaming,
RequiringClaims = RequiringClaims,
SchemaInfo = SchemaInfo
};
}
/// <summary>
/// Describes a parameter for an endpoint.
/// </summary>
public sealed record ParameterDescriptor
{
/// <summary>
/// Parameter name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Source of the parameter value (route, query, header, body, services).
/// </summary>
public required ParameterSource Source { get; init; }
/// <summary>
/// CLR type of the parameter.
/// </summary>
public required Type Type { get; init; }
/// <summary>
/// Whether the parameter is required.
/// </summary>
public bool IsRequired { get; init; } = true;
/// <summary>
/// Default value if the parameter is optional.
/// </summary>
public object? DefaultValue { get; init; }
/// <summary>
/// Description for documentation.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// JSON Schema type name (for OpenAPI).
/// </summary>
public string? JsonSchemaType { get; init; }
}
/// <summary>
/// Source of a parameter value.
/// </summary>
public enum ParameterSource
{
/// <summary>
/// From route template (e.g., {id} in /api/items/{id}).
/// </summary>
Route,
/// <summary>
/// From query string (e.g., ?page=1).
/// </summary>
Query,
/// <summary>
/// From request header.
/// </summary>
Header,
/// <summary>
/// From request body (JSON deserialization).
/// </summary>
Body,
/// <summary>
/// From dependency injection container.
/// </summary>
Services
}
/// <summary>
/// Describes a response for an endpoint.
/// </summary>
public sealed record ResponseDescriptor
{
/// <summary>
/// HTTP status code.
/// </summary>
public required int StatusCode { get; init; }
/// <summary>
/// CLR type of the response body (null for no content).
/// </summary>
public Type? ResponseType { get; init; }
/// <summary>
/// Description for documentation.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Content type (default: application/json).
/// </summary>
public string ContentType { get; init; } = "application/json";
/// <summary>
/// JSON Schema reference for the response type.
/// </summary>
public string? SchemaRef { get; init; }
}
/// <summary>
/// Result of authorization mapping for an endpoint.
/// </summary>
public sealed record AuthorizationMappingResult
{
/// <summary>
/// Claim requirements for Router authorization.
/// </summary>
public IReadOnlyList<ClaimRequirement> Claims { get; init; } = [];
/// <summary>
/// Named policies found on the endpoint.
/// </summary>
public IReadOnlyList<string> Policies { get; init; } = [];
/// <summary>
/// Roles found on the endpoint.
/// </summary>
public IReadOnlyList<string> Roles { get; init; } = [];
/// <summary>
/// Whether [AllowAnonymous] was found.
/// </summary>
public bool AllowAnonymous { get; init; }
/// <summary>
/// Source of the authorization metadata.
/// </summary>
public AuthorizationSource Source { get; init; }
/// <summary>
/// Whether any authorization metadata was found.
/// </summary>
public bool HasAuthorization =>
AllowAnonymous || Policies.Count > 0 || Roles.Count > 0 || Claims.Count > 0;
}

View File

@@ -0,0 +1,212 @@
using Microsoft.Extensions.Logging;
using StellaOps.Router.Common.Models;
namespace StellaOps.Microservice.AspNetCore;
/// <summary>
/// Interface for merging endpoint overrides with ASP.NET-specific authorization mapping strategy support.
/// Extends the base <see cref="IEndpointOverrideMerger"/> to support strategy-aware claim merging.
/// </summary>
public interface IAspNetEndpointOverrideMerger : IEndpointOverrideMerger
{
}
/// <summary>
/// Merges endpoint overrides from YAML configuration with ASP.NET-discovered endpoints,
/// supporting different authorization mapping strategies.
/// </summary>
public sealed class AspNetEndpointOverrideMerger : IAspNetEndpointOverrideMerger
{
private readonly StellaRouterBridgeOptions _bridgeOptions;
private readonly ILogger<AspNetEndpointOverrideMerger> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="AspNetEndpointOverrideMerger"/> class.
/// </summary>
public AspNetEndpointOverrideMerger(
StellaRouterBridgeOptions bridgeOptions,
ILogger<AspNetEndpointOverrideMerger> logger)
{
_bridgeOptions = bridgeOptions ?? throw new ArgumentNullException(nameof(bridgeOptions));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public IReadOnlyList<EndpointDescriptor> Merge(
IReadOnlyList<EndpointDescriptor> codeEndpoints,
MicroserviceYamlConfig? yamlConfig)
{
if (yamlConfig == null || yamlConfig.Endpoints.Count == 0)
{
// No YAML config - use code endpoints as-is
return ApplyStrategyForCodeOnly(codeEndpoints);
}
WarnUnmatchedOverrides(codeEndpoints, yamlConfig);
return codeEndpoints.Select(ep =>
{
var yamlOverride = FindMatchingOverride(ep, yamlConfig);
return yamlOverride == null
? ApplyStrategyForCodeOnly(ep)
: MergeEndpoint(ep, yamlOverride);
}).ToList();
}
private IReadOnlyList<EndpointDescriptor> ApplyStrategyForCodeOnly(
IReadOnlyList<EndpointDescriptor> endpoints)
{
return _bridgeOptions.AuthorizationMapping switch
{
AuthorizationMappingStrategy.YamlOnly =>
// Clear code claims when YamlOnly is configured
endpoints.Select(e => e with { RequiringClaims = [] }).ToList(),
_ => endpoints // AspNetMetadataOnly or Hybrid - keep code claims
};
}
private EndpointDescriptor ApplyStrategyForCodeOnly(EndpointDescriptor endpoint)
{
return _bridgeOptions.AuthorizationMapping switch
{
AuthorizationMappingStrategy.YamlOnly =>
// Clear code claims when YamlOnly is configured
endpoint with { RequiringClaims = [] },
_ => endpoint // AspNetMetadataOnly or Hybrid - keep code claims
};
}
private static EndpointOverrideConfig? FindMatchingOverride(
EndpointDescriptor endpoint,
MicroserviceYamlConfig yamlConfig)
{
return yamlConfig.Endpoints.FirstOrDefault(y =>
string.Equals(y.Method, endpoint.Method, StringComparison.OrdinalIgnoreCase) &&
string.Equals(y.Path, endpoint.Path, StringComparison.OrdinalIgnoreCase));
}
private EndpointDescriptor MergeEndpoint(
EndpointDescriptor codeDefault,
EndpointOverrideConfig yamlOverride)
{
// Determine claims based on strategy
var mergedClaims = MergeClaimsBasedOnStrategy(codeDefault.RequiringClaims, yamlOverride);
var merged = codeDefault with
{
DefaultTimeout = yamlOverride.GetDefaultTimeoutAsTimeSpan() ?? codeDefault.DefaultTimeout,
SupportsStreaming = yamlOverride.SupportsStreaming ?? codeDefault.SupportsStreaming,
RequiringClaims = mergedClaims
};
LogMergeDetails(merged, yamlOverride, mergedClaims.Count);
return merged;
}
private IReadOnlyList<ClaimRequirement> MergeClaimsBasedOnStrategy(
IReadOnlyList<ClaimRequirement> codeClaims,
EndpointOverrideConfig yamlOverride)
{
var yamlClaims = yamlOverride.RequiringClaims?
.Select(c => c.ToClaimRequirement())
.ToList() ?? [];
return _bridgeOptions.AuthorizationMapping switch
{
AuthorizationMappingStrategy.YamlOnly =>
// Use only YAML claims (code claims are ignored)
yamlClaims,
AuthorizationMappingStrategy.AspNetMetadataOnly =>
// Use only code claims (YAML claims are ignored)
codeClaims.ToList(),
AuthorizationMappingStrategy.Hybrid =>
// Hybrid: YAML claims supplement code claims
// If YAML specifies any claims, they replace code claims for that endpoint
// This allows YAML to either add to or override code claims
yamlClaims.Count > 0
? MergeClaimsHybrid(codeClaims, yamlClaims)
: codeClaims.ToList(),
_ => codeClaims.ToList()
};
}
/// <summary>
/// Merges code and YAML claims in Hybrid mode.
/// YAML claims take precedence for the same claim type/value, but code claims are retained
/// for types not specified in YAML.
/// </summary>
private static List<ClaimRequirement> MergeClaimsHybrid(
IReadOnlyList<ClaimRequirement> codeClaims,
List<ClaimRequirement> yamlClaims)
{
// Start with YAML claims (they take precedence)
var merged = new List<ClaimRequirement>(yamlClaims);
// Get claim types already specified in YAML
var yamlClaimTypes = yamlClaims
.Select(c => c.Type)
.Where(t => !string.IsNullOrEmpty(t))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
// Add code claims for types NOT already in YAML
foreach (var codeClaim in codeClaims)
{
if (!string.IsNullOrEmpty(codeClaim.Type) &&
!yamlClaimTypes.Contains(codeClaim.Type))
{
merged.Add(codeClaim);
}
}
return merged;
}
private void LogMergeDetails(
EndpointDescriptor merged,
EndpointOverrideConfig yamlOverride,
int claimCount)
{
if (yamlOverride.GetDefaultTimeoutAsTimeSpan().HasValue ||
yamlOverride.SupportsStreaming.HasValue ||
yamlOverride.RequiringClaims?.Count > 0)
{
_logger.LogDebug(
"Applied YAML overrides to endpoint {Method} {Path}: " +
"Timeout={Timeout}, Streaming={Streaming}, Claims={Claims} (Strategy={Strategy})",
merged.Method,
merged.Path,
merged.DefaultTimeout,
merged.SupportsStreaming,
claimCount,
_bridgeOptions.AuthorizationMapping);
}
}
private void WarnUnmatchedOverrides(
IReadOnlyList<EndpointDescriptor> codeEndpoints,
MicroserviceYamlConfig yamlConfig)
{
var codeKeys = codeEndpoints
.Select(e => (Method: e.Method.ToUpperInvariant(), Path: e.Path.ToLowerInvariant()))
.ToHashSet();
foreach (var yamlEntry in yamlConfig.Endpoints)
{
var key = (Method: yamlEntry.Method.ToUpperInvariant(), Path: yamlEntry.Path.ToLowerInvariant());
if (!codeKeys.Contains(key))
{
_logger.LogWarning(
"YAML override for {Method} {Path} does not match any discovered endpoint. " +
"YAML cannot create endpoints, only modify existing ones.",
yamlEntry.Method,
yamlEntry.Path);
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More