Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
183
src/Router/AGENTS.md
Normal file
183
src/Router/AGENTS.md
Normal 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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
14
src/Router/StellaOps.Gateway.WebService/Dockerfile
Normal file
14
src/Router/StellaOps.Gateway.WebService/Dockerfile
Normal 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"]
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
323
src/Router/StellaOps.Gateway.WebService/Program.cs
Normal file
323
src/Router/StellaOps.Gateway.WebService/Program.cs
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"StellaOps.Gateway.WebService": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:62515;http://localhost:62516"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"Gateway": {
|
||||
"Transports": {
|
||||
"Tcp": {
|
||||
"Enabled": false
|
||||
},
|
||||
"Tls": {
|
||||
"Enabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
68
src/Router/StellaOps.Gateway.WebService/appsettings.json
Normal file
68
src/Router/StellaOps.Gateway.WebService/appsettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
619
src/Router/StellaOps.Router.sln
Normal file
619
src/Router/StellaOps.Router.sln
Normal 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
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IAtomicTokenStore{TPayload}"/>.
|
||||
/// Provides atomic token issuance and consumption.
|
||||
/// </summary>
|
||||
public sealed class InMemoryAtomicTokenStore<TPayload> : IAtomicTokenStore<TPayload>
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, TokenEntry<TPayload>> _store;
|
||||
private readonly string _name;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryAtomicTokenStore(
|
||||
InMemoryQueueRegistry registry,
|
||||
string name,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_store = registry.GetOrCreateTokenStore<TPayload>(name);
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<TokenIssueResult> IssueAsync(
|
||||
string key,
|
||||
TPayload payload,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.Add(ttl);
|
||||
|
||||
// Generate secure random token
|
||||
var tokenBytes = new byte[32];
|
||||
RandomNumberGenerator.Fill(tokenBytes);
|
||||
var token = Convert.ToBase64String(tokenBytes);
|
||||
|
||||
var entry = new TokenEntry<TPayload>
|
||||
{
|
||||
Token = token,
|
||||
Payload = payload,
|
||||
IssuedAt = now,
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
|
||||
// Try to add, or update if already exists
|
||||
_store.AddOrUpdate(fullKey, entry, (_, _) => entry);
|
||||
|
||||
return ValueTask.FromResult(TokenIssueResult.Succeeded(token, expiresAt));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<TokenIssueResult> StoreAsync(
|
||||
string key,
|
||||
string token,
|
||||
TPayload payload,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(token);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.Add(ttl);
|
||||
|
||||
var entry = new TokenEntry<TPayload>
|
||||
{
|
||||
Token = token,
|
||||
Payload = payload,
|
||||
IssuedAt = now,
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
|
||||
_store.AddOrUpdate(fullKey, entry, (_, _) => entry);
|
||||
|
||||
return ValueTask.FromResult(TokenIssueResult.Succeeded(token, expiresAt));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<TokenConsumeResult<TPayload>> TryConsumeAsync(
|
||||
string key,
|
||||
string expectedToken,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(expectedToken);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Try to get and remove atomically
|
||||
if (!_store.TryGetValue(fullKey, out var entry))
|
||||
{
|
||||
return ValueTask.FromResult(TokenConsumeResult<TPayload>.NotFound());
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if (entry.ExpiresAt < now)
|
||||
{
|
||||
_store.TryRemove(fullKey, out _);
|
||||
return ValueTask.FromResult(TokenConsumeResult<TPayload>.Expired(entry.IssuedAt, entry.ExpiresAt));
|
||||
}
|
||||
|
||||
// Check token match
|
||||
if (!string.Equals(entry.Token, expectedToken, StringComparison.Ordinal))
|
||||
{
|
||||
return ValueTask.FromResult(TokenConsumeResult<TPayload>.Mismatch());
|
||||
}
|
||||
|
||||
// Atomically remove if token still matches
|
||||
if (_store.TryRemove(fullKey, out var removed) && string.Equals(removed.Token, expectedToken, StringComparison.Ordinal))
|
||||
{
|
||||
return ValueTask.FromResult(TokenConsumeResult<TPayload>.Success(
|
||||
removed.Payload,
|
||||
removed.IssuedAt,
|
||||
removed.ExpiresAt));
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(TokenConsumeResult<TPayload>.NotFound());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> ExistsAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
if (_store.TryGetValue(fullKey, out var entry))
|
||||
{
|
||||
if (entry.ExpiresAt < now)
|
||||
{
|
||||
_store.TryRemove(fullKey, out _);
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> RevokeAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
return ValueTask.FromResult(_store.TryRemove(fullKey, out _));
|
||||
}
|
||||
|
||||
private string BuildKey(string key) => $"{_name}:{key}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating in-memory atomic token store instances.
|
||||
/// </summary>
|
||||
public sealed class InMemoryAtomicTokenStoreFactory : IAtomicTokenStoreFactory
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryAtomicTokenStoreFactory(
|
||||
InMemoryQueueRegistry registry,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAtomicTokenStore<TPayload> Create<TPayload>(string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new InMemoryAtomicTokenStore<TPayload>(_registry, name, _timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating in-memory distributed cache instances.
|
||||
/// </summary>
|
||||
public sealed class InMemoryCacheFactory : IDistributedCacheFactory
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryCacheFactory(
|
||||
InMemoryQueueRegistry registry,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDistributedCache<TKey, TValue> Create<TKey, TValue>(CacheOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return new InMemoryCacheStore<TKey, TValue>(_registry, options, null, _timeProvider);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDistributedCache<TValue> Create<TValue>(CacheOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return new InMemoryCacheStore<TValue>(_registry, options, _timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IDistributedCache{TKey, TValue}"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TKey">The key type.</typeparam>
|
||||
/// <typeparam name="TValue">The value type.</typeparam>
|
||||
public sealed class InMemoryCacheStore<TKey, TValue> : IDistributedCache<TKey, TValue>
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly CacheOptions _options;
|
||||
private readonly Func<TKey, string> _keySerializer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryCacheStore(
|
||||
InMemoryQueueRegistry registry,
|
||||
CacheOptions options,
|
||||
Func<TKey, string>? keySerializer = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_keySerializer = keySerializer ?? (key => key?.ToString() ?? throw new ArgumentNullException(nameof(key)));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
private string CacheName => _options.KeyPrefix ?? "default";
|
||||
private ConcurrentDictionary<string, object> Cache => _registry.GetOrCreateCache(CacheName);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<CacheResult<TValue>> GetAsync(TKey key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = BuildKey(key);
|
||||
|
||||
if (Cache.TryGetValue(cacheKey, out var obj) && obj is CacheEntry<TValue> entry)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Check expiration
|
||||
if (entry.ExpiresAt.HasValue && entry.ExpiresAt.Value < now)
|
||||
{
|
||||
Cache.TryRemove(cacheKey, out _);
|
||||
return ValueTask.FromResult(CacheResult<TValue>.Miss());
|
||||
}
|
||||
|
||||
// Handle sliding expiration
|
||||
if (_options.SlidingExpiration && _options.DefaultTtl.HasValue)
|
||||
{
|
||||
entry.ExpiresAt = now.Add(_options.DefaultTtl.Value);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(CacheResult<TValue>.Found(entry.Value));
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(CacheResult<TValue>.Miss());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask SetAsync(
|
||||
TKey key,
|
||||
TValue value,
|
||||
CacheEntryOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
DateTimeOffset? expiresAt = null;
|
||||
|
||||
if (options?.TimeToLive.HasValue == true)
|
||||
{
|
||||
expiresAt = now.Add(options.TimeToLive.Value);
|
||||
}
|
||||
else if (options?.AbsoluteExpiration.HasValue == true)
|
||||
{
|
||||
expiresAt = options.AbsoluteExpiration.Value;
|
||||
}
|
||||
else if (_options.DefaultTtl.HasValue)
|
||||
{
|
||||
expiresAt = now.Add(_options.DefaultTtl.Value);
|
||||
}
|
||||
|
||||
var entry = new CacheEntry<TValue> { Value = value, ExpiresAt = expiresAt };
|
||||
Cache[cacheKey] = entry;
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> InvalidateAsync(TKey key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = BuildKey(key);
|
||||
return ValueTask.FromResult(Cache.TryRemove(cacheKey, out _));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<long> InvalidateByPatternAsync(string pattern, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Simple pattern matching - supports * at start or end
|
||||
var prefix = _options.KeyPrefix ?? string.Empty;
|
||||
var fullPattern = $"{prefix}{pattern}";
|
||||
|
||||
long count = 0;
|
||||
foreach (var key in Cache.Keys.ToList())
|
||||
{
|
||||
if (MatchesPattern(key, fullPattern))
|
||||
{
|
||||
if (Cache.TryRemove(key, out _))
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(count);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<TValue> GetOrSetAsync(
|
||||
TKey key,
|
||||
Func<CancellationToken, ValueTask<TValue>> factory,
|
||||
CacheEntryOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await GetAsync(key, cancellationToken).ConfigureAwait(false);
|
||||
if (result.HasValue)
|
||||
{
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
var value = await factory(cancellationToken).ConfigureAwait(false);
|
||||
await SetAsync(key, value, options, cancellationToken).ConfigureAwait(false);
|
||||
return value;
|
||||
}
|
||||
|
||||
private string BuildKey(TKey key)
|
||||
{
|
||||
var keyString = _keySerializer(key);
|
||||
return string.IsNullOrWhiteSpace(_options.KeyPrefix)
|
||||
? keyString
|
||||
: $"{_options.KeyPrefix}{keyString}";
|
||||
}
|
||||
|
||||
private static bool MatchesPattern(string input, string pattern)
|
||||
{
|
||||
if (pattern == "*")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pattern.StartsWith('*') && pattern.EndsWith('*'))
|
||||
{
|
||||
return input.Contains(pattern[1..^1], StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (pattern.StartsWith('*'))
|
||||
{
|
||||
return input.EndsWith(pattern[1..], StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (pattern.EndsWith('*'))
|
||||
{
|
||||
return input.StartsWith(pattern[..^1], StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return string.Equals(input, pattern, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private sealed class CacheEntry<T>
|
||||
{
|
||||
public required T Value { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// String-keyed in-memory cache store.
|
||||
/// </summary>
|
||||
/// <typeparam name="TValue">The value type.</typeparam>
|
||||
public sealed class InMemoryCacheStore<TValue> : IDistributedCache<TValue>
|
||||
{
|
||||
private readonly InMemoryCacheStore<string, TValue> _inner;
|
||||
|
||||
public InMemoryCacheStore(
|
||||
InMemoryQueueRegistry registry,
|
||||
CacheOptions options,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_inner = new InMemoryCacheStore<string, TValue>(registry, options, key => key, timeProvider);
|
||||
}
|
||||
|
||||
public string ProviderName => _inner.ProviderName;
|
||||
|
||||
public ValueTask<CacheResult<TValue>> GetAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> _inner.GetAsync(key, cancellationToken);
|
||||
|
||||
public ValueTask SetAsync(string key, TValue value, CacheEntryOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> _inner.SetAsync(key, value, options, cancellationToken);
|
||||
|
||||
public ValueTask<bool> InvalidateAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> _inner.InvalidateAsync(key, cancellationToken);
|
||||
|
||||
public ValueTask<long> InvalidateByPatternAsync(string pattern, CancellationToken cancellationToken = default)
|
||||
=> _inner.InvalidateByPatternAsync(pattern, cancellationToken);
|
||||
|
||||
public ValueTask<TValue> GetOrSetAsync(string key, Func<CancellationToken, ValueTask<TValue>> factory, CacheEntryOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> _inner.GetOrSetAsync(key, factory, options, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IEventStream{TEvent}"/>.
|
||||
/// Provides fire-and-forget event publishing with subscription support.
|
||||
/// </summary>
|
||||
public sealed class InMemoryEventStream<TEvent> : IEventStream<TEvent>
|
||||
where TEvent : class
|
||||
{
|
||||
private readonly EventStreamStore<TEvent> _store;
|
||||
private readonly EventStreamOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryEventStream(
|
||||
InMemoryQueueRegistry registry,
|
||||
EventStreamOptions options,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
_store = registry.GetOrCreateEventStream<TEvent>(options.StreamName);
|
||||
_options = options;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string StreamName => _options.StreamName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<EventPublishResult> PublishAsync(
|
||||
TEvent @event,
|
||||
EventPublishOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(@event);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var entryId = _store.Add(
|
||||
@event,
|
||||
options?.TenantId,
|
||||
options?.CorrelationId,
|
||||
options?.Headers,
|
||||
now);
|
||||
|
||||
// Auto-trim if configured
|
||||
if (_options.MaxLength.HasValue)
|
||||
{
|
||||
_store.Trim(_options.MaxLength.Value);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(EventPublishResult.Succeeded(entryId));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<IReadOnlyList<EventPublishResult>> PublishBatchAsync(
|
||||
IEnumerable<TEvent> events,
|
||||
EventPublishOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(events);
|
||||
|
||||
var results = new List<EventPublishResult>();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
foreach (var @event in events)
|
||||
{
|
||||
var entryId = _store.Add(
|
||||
@event,
|
||||
options?.TenantId,
|
||||
options?.CorrelationId,
|
||||
options?.Headers,
|
||||
now);
|
||||
results.Add(EventPublishResult.Succeeded(entryId));
|
||||
}
|
||||
|
||||
// Auto-trim if configured
|
||||
if (_options.MaxLength.HasValue)
|
||||
{
|
||||
_store.Trim(_options.MaxLength.Value);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<IReadOnlyList<EventPublishResult>>(results);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<StreamEvent<TEvent>> SubscribeAsync(
|
||||
StreamPosition position,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
string? lastEntryId = position.Value == "$" ? null : position.Value;
|
||||
|
||||
// First, yield existing entries after the position
|
||||
if (position.Value != "$")
|
||||
{
|
||||
var existingEntries = _store.GetEntriesAfter(lastEntryId);
|
||||
foreach (var entry in existingEntries)
|
||||
{
|
||||
yield return new StreamEvent<TEvent>(
|
||||
entry.EntryId,
|
||||
entry.Event,
|
||||
entry.Timestamp,
|
||||
entry.TenantId,
|
||||
entry.CorrelationId);
|
||||
lastEntryId = entry.EntryId;
|
||||
}
|
||||
}
|
||||
|
||||
// Then subscribe to new entries
|
||||
var reader = _store.Reader;
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// WaitToReadAsync will throw OperationCanceledException when cancelled,
|
||||
// which will naturally end the async enumeration
|
||||
if (!await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
while (reader.TryRead(out var entry))
|
||||
{
|
||||
// Skip entries we've already seen
|
||||
if (lastEntryId != null && string.CompareOrdinal(entry.EntryId, lastEntryId) <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return new StreamEvent<TEvent>(
|
||||
entry.EntryId,
|
||||
entry.Event,
|
||||
entry.Timestamp,
|
||||
entry.TenantId,
|
||||
entry.CorrelationId);
|
||||
lastEntryId = entry.EntryId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<StreamInfo> GetInfoAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (length, firstId, lastId, firstTs, lastTs) = _store.GetInfo();
|
||||
return ValueTask.FromResult(new StreamInfo(length, firstId, lastId, firstTs, lastTs));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<long> TrimAsync(
|
||||
long maxLength,
|
||||
bool approximate = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.FromResult(_store.Trim(maxLength));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating in-memory event stream instances.
|
||||
/// </summary>
|
||||
public sealed class InMemoryEventStreamFactory : IEventStreamFactory
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryEventStreamFactory(
|
||||
InMemoryQueueRegistry registry,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEventStream<TEvent> Create<TEvent>(EventStreamOptions options) where TEvent : class
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return new InMemoryEventStream<TEvent>(_registry, options, _timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IIdempotencyStore"/>.
|
||||
/// Provides idempotency key management for deduplication.
|
||||
/// </summary>
|
||||
public sealed class InMemoryIdempotencyStore : IIdempotencyStore
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly string _name;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryIdempotencyStore(
|
||||
InMemoryQueueRegistry registry,
|
||||
string name,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<IdempotencyResult> TryClaimAsync(
|
||||
string key,
|
||||
string value,
|
||||
TimeSpan window,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.Add(window);
|
||||
|
||||
// Cleanup expired keys first
|
||||
_registry.CleanupExpiredIdempotencyKeys(now);
|
||||
|
||||
if (_registry.TryClaimIdempotencyKey(fullKey, value, expiresAt, out var existingValue))
|
||||
{
|
||||
return ValueTask.FromResult(IdempotencyResult.Claimed());
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(IdempotencyResult.Duplicate(existingValue ?? string.Empty));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> ExistsAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Cleanup expired keys first
|
||||
_registry.CleanupExpiredIdempotencyKeys(now);
|
||||
|
||||
return ValueTask.FromResult(_registry.IdempotencyKeyExists(fullKey));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<string?> GetAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Cleanup expired keys first
|
||||
_registry.CleanupExpiredIdempotencyKeys(now);
|
||||
|
||||
return ValueTask.FromResult(_registry.GetIdempotencyKey(fullKey));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> ReleaseAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
return ValueTask.FromResult(_registry.ReleaseIdempotencyKey(fullKey));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> ExtendAsync(
|
||||
string key,
|
||||
TimeSpan extension,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
return ValueTask.FromResult(_registry.ExtendIdempotencyKey(fullKey, extension, _timeProvider));
|
||||
}
|
||||
|
||||
private string BuildKey(string key) => $"{_name}:{key}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating in-memory idempotency store instances.
|
||||
/// </summary>
|
||||
public sealed class InMemoryIdempotencyStoreFactory : IIdempotencyStoreFactory
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryIdempotencyStoreFactory(
|
||||
InMemoryQueueRegistry registry,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IIdempotencyStore Create(string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new InMemoryIdempotencyStore(_registry, name, _timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of a message lease.
|
||||
/// </summary>
|
||||
/// <typeparam name="TMessage">The message type.</typeparam>
|
||||
internal sealed class InMemoryMessageLease<TMessage> : IMessageLease<TMessage> where TMessage : class
|
||||
{
|
||||
private readonly InMemoryMessageQueue<TMessage> _queue;
|
||||
private readonly InMemoryQueueEntry<TMessage> _entry;
|
||||
private int _completed;
|
||||
|
||||
internal InMemoryMessageLease(
|
||||
InMemoryMessageQueue<TMessage> queue,
|
||||
InMemoryQueueEntry<TMessage> entry,
|
||||
DateTimeOffset leaseExpiresAt,
|
||||
string consumer)
|
||||
{
|
||||
_queue = queue;
|
||||
_entry = entry;
|
||||
LeaseExpiresAt = leaseExpiresAt;
|
||||
Consumer = consumer;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string MessageId => _entry.MessageId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public TMessage Message => _entry.Message;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Attempt => _entry.Attempt;
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset EnqueuedAt => _entry.EnqueuedAt;
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset LeaseExpiresAt { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Consumer { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? TenantId => _entry.TenantId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? CorrelationId => _entry.CorrelationId;
|
||||
|
||||
internal InMemoryQueueEntry<TMessage> Entry => _entry;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask AcknowledgeAsync(CancellationToken cancellationToken = default)
|
||||
=> _queue.AcknowledgeAsync(this, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask RenewAsync(TimeSpan extension, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LeaseExpiresAt = DateTimeOffset.UtcNow.Add(extension);
|
||||
_entry.LeaseExpiresAt = LeaseExpiresAt;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask ReleaseAsync(ReleaseDisposition disposition, CancellationToken cancellationToken = default)
|
||||
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
|
||||
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
internal bool TryBeginCompletion()
|
||||
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
|
||||
|
||||
internal void IncrementAttempt()
|
||||
=> _entry.Attempt++;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IMessageQueue{TMessage}"/>.
|
||||
/// Useful for testing and development scenarios.
|
||||
/// </summary>
|
||||
/// <typeparam name="TMessage">The message type.</typeparam>
|
||||
public sealed class InMemoryMessageQueue<TMessage> : IMessageQueue<TMessage>
|
||||
where TMessage : class
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly MessageQueueOptions _options;
|
||||
private readonly ILogger<InMemoryMessageQueue<TMessage>>? _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _idempotencyKeys = new(StringComparer.Ordinal);
|
||||
|
||||
private long _messageIdCounter;
|
||||
|
||||
public InMemoryMessageQueue(
|
||||
InMemoryQueueRegistry registry,
|
||||
MessageQueueOptions options,
|
||||
ILogger<InMemoryMessageQueue<TMessage>>? logger = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string QueueName => _options.QueueName;
|
||||
|
||||
private Channel<InMemoryQueueEntry<TMessage>> Queue => _registry.GetOrCreateQueue<TMessage>(_options.QueueName);
|
||||
private ConcurrentDictionary<string, InMemoryQueueEntry<TMessage>> Pending => _registry.GetOrCreatePending<TMessage>(_options.QueueName);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<EnqueueResult> EnqueueAsync(
|
||||
TMessage message,
|
||||
EnqueueOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
// Check idempotency
|
||||
if (!string.IsNullOrWhiteSpace(options?.IdempotencyKey))
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
if (_idempotencyKeys.TryGetValue(options.IdempotencyKey, out var existingTime))
|
||||
{
|
||||
if (now - existingTime < _options.IdempotencyWindow)
|
||||
{
|
||||
return EnqueueResult.Duplicate($"inmem-{options.IdempotencyKey}");
|
||||
}
|
||||
}
|
||||
_idempotencyKeys[options.IdempotencyKey] = now;
|
||||
}
|
||||
|
||||
var messageId = $"inmem-{Interlocked.Increment(ref _messageIdCounter)}";
|
||||
var entry = new InMemoryQueueEntry<TMessage>
|
||||
{
|
||||
MessageId = messageId,
|
||||
Message = message,
|
||||
Attempt = 1,
|
||||
EnqueuedAt = _timeProvider.GetUtcNow(),
|
||||
TenantId = options?.TenantId,
|
||||
CorrelationId = options?.CorrelationId,
|
||||
IdempotencyKey = options?.IdempotencyKey,
|
||||
Headers = options?.Headers
|
||||
};
|
||||
|
||||
await Queue.Writer.WriteAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
_logger?.LogDebug("Enqueued message {MessageId} to queue {Queue}", messageId, _options.QueueName);
|
||||
|
||||
return EnqueueResult.Succeeded(messageId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlyList<IMessageLease<TMessage>>> LeaseAsync(
|
||||
LeaseRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var consumer = _options.ConsumerName ?? $"{Environment.MachineName}-{Environment.ProcessId}";
|
||||
var leases = new List<IMessageLease<TMessage>>(request.BatchSize);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var leaseDuration = request.LeaseDuration ?? _options.DefaultLeaseDuration;
|
||||
|
||||
// First check pending (for redeliveries)
|
||||
if (request.PendingOnly)
|
||||
{
|
||||
foreach (var kvp in Pending)
|
||||
{
|
||||
if (leases.Count >= request.BatchSize)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var entry = kvp.Value;
|
||||
if (entry.LeaseExpiresAt.HasValue && entry.LeaseExpiresAt.Value < now)
|
||||
{
|
||||
// Expired lease - claim it
|
||||
entry.LeasedBy = consumer;
|
||||
entry.LeaseExpiresAt = now.Add(leaseDuration);
|
||||
entry.Attempt++;
|
||||
|
||||
leases.Add(new InMemoryMessageLease<TMessage>(this, entry, entry.LeaseExpiresAt.Value, consumer));
|
||||
}
|
||||
}
|
||||
return leases;
|
||||
}
|
||||
|
||||
// Try to read new messages
|
||||
for (var i = 0; i < request.BatchSize; i++)
|
||||
{
|
||||
if (Queue.Reader.TryRead(out var entry))
|
||||
{
|
||||
entry.LeasedBy = consumer;
|
||||
entry.LeaseExpiresAt = now.Add(leaseDuration);
|
||||
Pending[entry.MessageId] = entry;
|
||||
|
||||
leases.Add(new InMemoryMessageLease<TMessage>(this, entry, entry.LeaseExpiresAt.Value, consumer));
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return leases;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<IReadOnlyList<IMessageLease<TMessage>>> ClaimExpiredAsync(
|
||||
ClaimRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var consumer = _options.ConsumerName ?? $"{Environment.MachineName}-{Environment.ProcessId}";
|
||||
var leases = new List<IMessageLease<TMessage>>(request.BatchSize);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var leaseDuration = request.LeaseDuration ?? _options.DefaultLeaseDuration;
|
||||
|
||||
foreach (var kvp in Pending)
|
||||
{
|
||||
if (leases.Count >= request.BatchSize)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var entry = kvp.Value;
|
||||
if (entry.LeaseExpiresAt.HasValue &&
|
||||
now - entry.LeaseExpiresAt.Value >= request.MinIdleTime &&
|
||||
entry.Attempt >= request.MinDeliveryAttempts)
|
||||
{
|
||||
// Claim it
|
||||
entry.LeasedBy = consumer;
|
||||
entry.LeaseExpiresAt = now.Add(leaseDuration);
|
||||
entry.Attempt++;
|
||||
|
||||
leases.Add(new InMemoryMessageLease<TMessage>(this, entry, entry.LeaseExpiresAt.Value, consumer));
|
||||
}
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<IReadOnlyList<IMessageLease<TMessage>>>(leases);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<long> GetPendingCountAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.FromResult((long)Pending.Count);
|
||||
}
|
||||
|
||||
internal ValueTask AcknowledgeAsync(InMemoryMessageLease<TMessage> lease, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!lease.TryBeginCompletion())
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
Pending.TryRemove(lease.MessageId, out _);
|
||||
_logger?.LogDebug("Acknowledged message {MessageId} from queue {Queue}", lease.MessageId, _options.QueueName);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
internal async ValueTask ReleaseAsync(
|
||||
InMemoryMessageLease<TMessage> lease,
|
||||
ReleaseDisposition disposition,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (disposition == ReleaseDisposition.Retry && lease.Attempt >= _options.MaxDeliveryAttempts)
|
||||
{
|
||||
await DeadLetterAsync(lease, $"max-delivery-attempts:{lease.Attempt}", cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!lease.TryBeginCompletion())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Pending.TryRemove(lease.MessageId, out _);
|
||||
|
||||
if (disposition == ReleaseDisposition.Retry)
|
||||
{
|
||||
lease.IncrementAttempt();
|
||||
lease.Entry.LeasedBy = null;
|
||||
lease.Entry.LeaseExpiresAt = null;
|
||||
|
||||
// Re-enqueue
|
||||
await Queue.Writer.WriteAsync(lease.Entry, cancellationToken).ConfigureAwait(false);
|
||||
_logger?.LogDebug("Retrying message {MessageId}, attempt {Attempt}", lease.MessageId, lease.Attempt);
|
||||
}
|
||||
}
|
||||
|
||||
internal async ValueTask DeadLetterAsync(InMemoryMessageLease<TMessage> lease, string reason, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!lease.TryBeginCompletion())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Pending.TryRemove(lease.MessageId, out _);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.DeadLetterQueue))
|
||||
{
|
||||
var dlqChannel = _registry.GetOrCreateQueue<TMessage>(_options.DeadLetterQueue);
|
||||
lease.Entry.LeasedBy = null;
|
||||
lease.Entry.LeaseExpiresAt = null;
|
||||
await dlqChannel.Writer.WriteAsync(lease.Entry, cancellationToken).ConfigureAwait(false);
|
||||
_logger?.LogWarning("Dead-lettered message {MessageId}: {Reason}", lease.MessageId, reason);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger?.LogWarning("Dropped message {MessageId} (no DLQ configured): {Reason}", lease.MessageId, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating in-memory message queue instances.
|
||||
/// </summary>
|
||||
public sealed class InMemoryMessageQueueFactory : IMessageQueueFactory
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryMessageQueueFactory(
|
||||
InMemoryQueueRegistry registry,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_loggerFactory = loggerFactory;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IMessageQueue<TMessage> Create<TMessage>(MessageQueueOptions options)
|
||||
where TMessage : class
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
return new InMemoryMessageQueue<TMessage>(
|
||||
_registry,
|
||||
options,
|
||||
_loggerFactory?.CreateLogger<InMemoryMessageQueue<TMessage>>(),
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,741 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// Shared registry for in-memory queues. Enables message passing between
|
||||
/// producers and consumers in the same process (useful for testing).
|
||||
/// </summary>
|
||||
public sealed class InMemoryQueueRegistry
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, object> _queues = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, object> _pendingMessages = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, object>> _caches = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, RateLimitBucket> _rateLimitBuckets = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, object> _tokenStores = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, object> _sortedIndexes = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, object> _setStores = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, object> _eventStreams = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, IdempotencyEntry> _idempotencyKeys = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates a queue channel for the specified queue name.
|
||||
/// </summary>
|
||||
public Channel<InMemoryQueueEntry<TMessage>> GetOrCreateQueue<TMessage>(string queueName)
|
||||
where TMessage : class
|
||||
{
|
||||
return (Channel<InMemoryQueueEntry<TMessage>>)_queues.GetOrAdd(
|
||||
queueName,
|
||||
_ => Channel.CreateUnbounded<InMemoryQueueEntry<TMessage>>(
|
||||
new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = false,
|
||||
SingleWriter = false
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates the pending messages dictionary for a queue.
|
||||
/// </summary>
|
||||
public ConcurrentDictionary<string, InMemoryQueueEntry<TMessage>> GetOrCreatePending<TMessage>(string queueName)
|
||||
where TMessage : class
|
||||
{
|
||||
return (ConcurrentDictionary<string, InMemoryQueueEntry<TMessage>>)_pendingMessages.GetOrAdd(
|
||||
queueName,
|
||||
_ => new ConcurrentDictionary<string, InMemoryQueueEntry<TMessage>>(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates a cache dictionary for the specified cache name.
|
||||
/// </summary>
|
||||
public ConcurrentDictionary<string, object> GetOrCreateCache(string cacheName)
|
||||
{
|
||||
return _caches.GetOrAdd(cacheName, _ => new ConcurrentDictionary<string, object>(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all queues and caches (useful for test cleanup).
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_queues.Clear();
|
||||
_pendingMessages.Clear();
|
||||
_caches.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears a specific queue.
|
||||
/// </summary>
|
||||
public void ClearQueue(string queueName)
|
||||
{
|
||||
_queues.TryRemove(queueName, out _);
|
||||
_pendingMessages.TryRemove(queueName, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears a specific cache.
|
||||
/// </summary>
|
||||
public void ClearCache(string cacheName)
|
||||
{
|
||||
_caches.TryRemove(cacheName, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates a rate limit bucket for the specified key.
|
||||
/// </summary>
|
||||
public RateLimitBucket GetOrCreateRateLimitBucket(string key)
|
||||
{
|
||||
return _rateLimitBuckets.GetOrAdd(key, _ => new RateLimitBucket());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a rate limit bucket.
|
||||
/// </summary>
|
||||
public bool RemoveRateLimitBucket(string key)
|
||||
{
|
||||
return _rateLimitBuckets.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates a token store for the specified name.
|
||||
/// </summary>
|
||||
public ConcurrentDictionary<string, TokenEntry<TPayload>> GetOrCreateTokenStore<TPayload>(string name)
|
||||
{
|
||||
return (ConcurrentDictionary<string, TokenEntry<TPayload>>)_tokenStores.GetOrAdd(
|
||||
name,
|
||||
_ => new ConcurrentDictionary<string, TokenEntry<TPayload>>(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates a sorted index for the specified name.
|
||||
/// </summary>
|
||||
public SortedIndexStore<TKey, TElement> GetOrCreateSortedIndex<TKey, TElement>(string name)
|
||||
where TKey : notnull
|
||||
where TElement : notnull
|
||||
{
|
||||
return (SortedIndexStore<TKey, TElement>)_sortedIndexes.GetOrAdd(
|
||||
name,
|
||||
_ => new SortedIndexStore<TKey, TElement>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates a set store for the specified name.
|
||||
/// </summary>
|
||||
public SetStoreData<TKey, TElement> GetOrCreateSetStore<TKey, TElement>(string name)
|
||||
where TKey : notnull
|
||||
{
|
||||
return (SetStoreData<TKey, TElement>)_setStores.GetOrAdd(
|
||||
name,
|
||||
_ => new SetStoreData<TKey, TElement>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates an event stream for the specified name.
|
||||
/// </summary>
|
||||
public EventStreamStore<TEvent> GetOrCreateEventStream<TEvent>(string name)
|
||||
where TEvent : class
|
||||
{
|
||||
return (EventStreamStore<TEvent>)_eventStreams.GetOrAdd(
|
||||
name,
|
||||
_ => new EventStreamStore<TEvent>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to claim an idempotency key.
|
||||
/// </summary>
|
||||
public bool TryClaimIdempotencyKey(string key, string value, DateTimeOffset expiresAt, out string? existingValue)
|
||||
{
|
||||
var entry = new IdempotencyEntry { Value = value, ExpiresAt = expiresAt };
|
||||
|
||||
if (_idempotencyKeys.TryAdd(key, entry))
|
||||
{
|
||||
existingValue = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_idempotencyKeys.TryGetValue(key, out var existing))
|
||||
{
|
||||
existingValue = existing.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
existingValue = null;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an idempotency key exists.
|
||||
/// </summary>
|
||||
public bool IdempotencyKeyExists(string key)
|
||||
{
|
||||
return _idempotencyKeys.ContainsKey(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an idempotency key value.
|
||||
/// </summary>
|
||||
public string? GetIdempotencyKey(string key)
|
||||
{
|
||||
return _idempotencyKeys.TryGetValue(key, out var entry) ? entry.Value : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases an idempotency key.
|
||||
/// </summary>
|
||||
public bool ReleaseIdempotencyKey(string key)
|
||||
{
|
||||
return _idempotencyKeys.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extends an idempotency key's expiration.
|
||||
/// </summary>
|
||||
public bool ExtendIdempotencyKey(string key, TimeSpan extension, TimeProvider timeProvider)
|
||||
{
|
||||
if (_idempotencyKeys.TryGetValue(key, out var entry))
|
||||
{
|
||||
entry.ExpiresAt = timeProvider.GetUtcNow().Add(extension);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up expired idempotency keys.
|
||||
/// </summary>
|
||||
public void CleanupExpiredIdempotencyKeys(DateTimeOffset now)
|
||||
{
|
||||
var expiredKeys = _idempotencyKeys
|
||||
.Where(kvp => kvp.Value.ExpiresAt < now)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in expiredKeys)
|
||||
{
|
||||
_idempotencyKeys.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rate limit bucket for sliding window tracking.
|
||||
/// </summary>
|
||||
public sealed class RateLimitBucket
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly List<DateTimeOffset> _timestamps = [];
|
||||
|
||||
public int GetCount(DateTimeOffset windowStart)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
CleanupOld(windowStart);
|
||||
return _timestamps.Count;
|
||||
}
|
||||
}
|
||||
|
||||
public int Increment(DateTimeOffset now, DateTimeOffset windowStart)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
CleanupOld(windowStart);
|
||||
_timestamps.Add(now);
|
||||
return _timestamps.Count;
|
||||
}
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_timestamps.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private void CleanupOld(DateTimeOffset windowStart)
|
||||
{
|
||||
_timestamps.RemoveAll(t => t < windowStart);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Token entry for atomic token store.
|
||||
/// </summary>
|
||||
public sealed class TokenEntry<TPayload>
|
||||
{
|
||||
public required string Token { get; init; }
|
||||
public required TPayload Payload { get; init; }
|
||||
public required DateTimeOffset IssuedAt { get; init; }
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Idempotency entry.
|
||||
/// </summary>
|
||||
public sealed class IdempotencyEntry
|
||||
{
|
||||
public required string Value { get; init; }
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sorted index storage with score-based ordering.
|
||||
/// </summary>
|
||||
public sealed class SortedIndexStore<TKey, TElement> where TKey : notnull where TElement : notnull
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly Dictionary<TKey, SortedIndexData<TElement>> _indexes = [];
|
||||
|
||||
public SortedIndexData<TElement> GetOrCreateIndex(TKey key)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_indexes.TryGetValue(key, out var index))
|
||||
{
|
||||
index = new SortedIndexData<TElement>();
|
||||
_indexes[key] = index;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetIndex(TKey key, out SortedIndexData<TElement>? index)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _indexes.TryGetValue(key, out index);
|
||||
}
|
||||
}
|
||||
|
||||
public bool RemoveIndex(TKey key)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _indexes.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetExpiration(TKey key, DateTimeOffset expiresAt)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_indexes.TryGetValue(key, out var index))
|
||||
{
|
||||
index.ExpiresAt = expiresAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Data for a single sorted index.
|
||||
/// </summary>
|
||||
public sealed class SortedIndexData<TElement> where TElement : notnull
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly SortedList<double, List<TElement>> _byScore = [];
|
||||
private readonly Dictionary<TElement, double> _elementScores = [];
|
||||
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
|
||||
public bool Add(TElement element, double score)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var isNew = true;
|
||||
|
||||
// Remove existing entry if present
|
||||
if (_elementScores.TryGetValue(element, out var oldScore))
|
||||
{
|
||||
isNew = false;
|
||||
if (_byScore.TryGetValue(oldScore, out var oldList))
|
||||
{
|
||||
oldList.Remove(element);
|
||||
if (oldList.Count == 0)
|
||||
{
|
||||
_byScore.Remove(oldScore);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add new entry
|
||||
_elementScores[element] = score;
|
||||
if (!_byScore.TryGetValue(score, out var list))
|
||||
{
|
||||
list = [];
|
||||
_byScore[score] = list;
|
||||
}
|
||||
list.Add(element);
|
||||
|
||||
return isNew;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Remove(TElement element)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_elementScores.TryGetValue(element, out var score))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_elementScores.Remove(element);
|
||||
if (_byScore.TryGetValue(score, out var list))
|
||||
{
|
||||
list.Remove(element);
|
||||
if (list.Count == 0)
|
||||
{
|
||||
_byScore.Remove(score);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public long RemoveByScoreRange(double minScore, double maxScore)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var toRemove = _byScore
|
||||
.Where(kvp => kvp.Key >= minScore && kvp.Key <= maxScore)
|
||||
.SelectMany(kvp => kvp.Value)
|
||||
.ToList();
|
||||
|
||||
foreach (var element in toRemove)
|
||||
{
|
||||
Remove(element);
|
||||
}
|
||||
|
||||
return toRemove.Count;
|
||||
}
|
||||
}
|
||||
|
||||
public double? GetScore(TElement element)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _elementScores.TryGetValue(element, out var score) ? score : null;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<(TElement Element, double Score)> GetByRank(long start, long stop, bool ascending)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var all = ascending
|
||||
? _byScore.SelectMany(kvp => kvp.Value.Select(e => (Element: e, Score: kvp.Key))).ToList()
|
||||
: _byScore.Reverse().SelectMany(kvp => kvp.Value.Select(e => (Element: e, Score: kvp.Key))).ToList();
|
||||
|
||||
var count = all.Count;
|
||||
if (start < 0) start = Math.Max(0, count + start);
|
||||
if (stop < 0) stop = count + stop;
|
||||
stop = Math.Min(stop, count - 1);
|
||||
|
||||
if (start > stop || start >= count)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return all.Skip((int)start).Take((int)(stop - start + 1)).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<(TElement Element, double Score)> GetByScoreRange(double minScore, double maxScore, bool ascending, int? limit)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var filtered = _byScore
|
||||
.Where(kvp => kvp.Key >= minScore && kvp.Key <= maxScore)
|
||||
.SelectMany(kvp => kvp.Value.Select(e => (Element: e, Score: kvp.Key)));
|
||||
|
||||
if (!ascending)
|
||||
{
|
||||
filtered = filtered.Reverse();
|
||||
}
|
||||
|
||||
if (limit.HasValue)
|
||||
{
|
||||
filtered = filtered.Take(limit.Value);
|
||||
}
|
||||
|
||||
return filtered.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public long Count()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _elementScores.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set store data with multiple sets.
|
||||
/// </summary>
|
||||
public sealed class SetStoreData<TKey, TElement> where TKey : notnull
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly Dictionary<TKey, SetData<TElement>> _sets = [];
|
||||
|
||||
public SetData<TElement> GetOrCreateSet(TKey key)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_sets.TryGetValue(key, out var set))
|
||||
{
|
||||
set = new SetData<TElement>();
|
||||
_sets[key] = set;
|
||||
}
|
||||
return set;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetSet(TKey key, out SetData<TElement>? set)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _sets.TryGetValue(key, out set);
|
||||
}
|
||||
}
|
||||
|
||||
public bool RemoveSet(TKey key)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _sets.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetExpiration(TKey key, DateTimeOffset expiresAt)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_sets.TryGetValue(key, out var set))
|
||||
{
|
||||
set.ExpiresAt = expiresAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Data for a single set.
|
||||
/// </summary>
|
||||
public sealed class SetData<TElement>
|
||||
{
|
||||
private readonly HashSet<TElement> _elements = [];
|
||||
private readonly object _lock = new();
|
||||
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
|
||||
public bool Add(TElement element)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _elements.Add(element);
|
||||
}
|
||||
}
|
||||
|
||||
public long AddRange(IEnumerable<TElement> elements)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
long added = 0;
|
||||
foreach (var element in elements)
|
||||
{
|
||||
if (_elements.Add(element))
|
||||
{
|
||||
added++;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Contains(TElement element)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _elements.Contains(element);
|
||||
}
|
||||
}
|
||||
|
||||
public bool Remove(TElement element)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _elements.Remove(element);
|
||||
}
|
||||
}
|
||||
|
||||
public long RemoveRange(IEnumerable<TElement> elements)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
long removed = 0;
|
||||
foreach (var element in elements)
|
||||
{
|
||||
if (_elements.Remove(element))
|
||||
{
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlySet<TElement> GetAll()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return new HashSet<TElement>(_elements);
|
||||
}
|
||||
}
|
||||
|
||||
public long Count()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _elements.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event stream storage with ordered entries.
|
||||
/// </summary>
|
||||
public sealed class EventStreamStore<TEvent> where TEvent : class
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly List<EventStreamEntry<TEvent>> _entries = [];
|
||||
private readonly Channel<EventStreamEntry<TEvent>> _channel;
|
||||
private long _nextSequence = 1;
|
||||
|
||||
public EventStreamStore()
|
||||
{
|
||||
_channel = Channel.CreateUnbounded<EventStreamEntry<TEvent>>(
|
||||
new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = false,
|
||||
SingleWriter = false
|
||||
});
|
||||
}
|
||||
|
||||
public string Add(TEvent @event, string? tenantId, string? correlationId, IReadOnlyDictionary<string, string>? headers, DateTimeOffset timestamp)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var sequence = _nextSequence++;
|
||||
var entryId = $"{timestamp.ToUnixTimeMilliseconds()}-{sequence}";
|
||||
var entry = new EventStreamEntry<TEvent>
|
||||
{
|
||||
EntryId = entryId,
|
||||
Sequence = sequence,
|
||||
Event = @event,
|
||||
Timestamp = timestamp,
|
||||
TenantId = tenantId,
|
||||
CorrelationId = correlationId,
|
||||
Headers = headers
|
||||
};
|
||||
_entries.Add(entry);
|
||||
|
||||
// Notify subscribers
|
||||
_channel.Writer.TryWrite(entry);
|
||||
|
||||
return entryId;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<EventStreamEntry<TEvent>> GetEntriesAfter(string? afterEntryId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (string.IsNullOrEmpty(afterEntryId) || afterEntryId == "0")
|
||||
{
|
||||
return _entries.ToList();
|
||||
}
|
||||
|
||||
var startIndex = _entries.FindIndex(e => e.EntryId == afterEntryId);
|
||||
if (startIndex < 0)
|
||||
{
|
||||
return _entries.ToList();
|
||||
}
|
||||
|
||||
return _entries.Skip(startIndex + 1).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public ChannelReader<EventStreamEntry<TEvent>> Reader => _channel.Reader;
|
||||
|
||||
public (long Length, string? FirstEntryId, string? LastEntryId, DateTimeOffset? FirstTimestamp, DateTimeOffset? LastTimestamp) GetInfo()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_entries.Count == 0)
|
||||
{
|
||||
return (0, null, null, null, null);
|
||||
}
|
||||
|
||||
return (
|
||||
_entries.Count,
|
||||
_entries[0].EntryId,
|
||||
_entries[^1].EntryId,
|
||||
_entries[0].Timestamp,
|
||||
_entries[^1].Timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
public long Trim(long maxLength)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_entries.Count <= maxLength)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var toRemove = (int)(_entries.Count - maxLength);
|
||||
_entries.RemoveRange(0, toRemove);
|
||||
return toRemove;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry in an event stream.
|
||||
/// </summary>
|
||||
public sealed class EventStreamEntry<TEvent> where TEvent : class
|
||||
{
|
||||
public required string EntryId { get; init; }
|
||||
public required long Sequence { get; init; }
|
||||
public required TEvent Event { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public string? TenantId { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Headers { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry stored in an in-memory queue.
|
||||
/// </summary>
|
||||
public sealed class InMemoryQueueEntry<TMessage> where TMessage : class
|
||||
{
|
||||
public required string MessageId { get; init; }
|
||||
public required TMessage Message { get; init; }
|
||||
public required int Attempt { get; set; }
|
||||
public required DateTimeOffset EnqueuedAt { get; init; }
|
||||
public string? TenantId { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
public string? IdempotencyKey { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Headers { get; init; }
|
||||
|
||||
// Lease tracking
|
||||
public string? LeasedBy { get; set; }
|
||||
public DateTimeOffset? LeaseExpiresAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IRateLimiter"/>.
|
||||
/// Uses sliding window algorithm for rate limiting.
|
||||
/// </summary>
|
||||
public sealed class InMemoryRateLimiter : IRateLimiter
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly string _name;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryRateLimiter(
|
||||
InMemoryQueueRegistry registry,
|
||||
string name,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<RateLimitResult> TryAcquireAsync(
|
||||
string key,
|
||||
RateLimitPolicy policy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
var bucket = _registry.GetOrCreateRateLimitBucket(fullKey);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var windowStart = now - policy.Window;
|
||||
|
||||
var currentCount = bucket.GetCount(windowStart);
|
||||
|
||||
if (currentCount >= policy.MaxPermits)
|
||||
{
|
||||
// Denied - calculate retry after
|
||||
var retryAfter = policy.Window; // Simplified - actual implementation could track exact timestamps
|
||||
return ValueTask.FromResult(RateLimitResult.Denied(currentCount, retryAfter));
|
||||
}
|
||||
|
||||
// Increment and allow
|
||||
var newCount = bucket.Increment(now, windowStart);
|
||||
var remaining = Math.Max(0, policy.MaxPermits - newCount);
|
||||
return ValueTask.FromResult(RateLimitResult.Allowed(newCount, remaining));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<RateLimitStatus> GetStatusAsync(
|
||||
string key,
|
||||
RateLimitPolicy policy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
var bucket = _registry.GetOrCreateRateLimitBucket(fullKey);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var windowStart = now - policy.Window;
|
||||
|
||||
var currentCount = bucket.GetCount(windowStart);
|
||||
var remaining = Math.Max(0, policy.MaxPermits - currentCount);
|
||||
|
||||
return ValueTask.FromResult(new RateLimitStatus
|
||||
{
|
||||
CurrentCount = currentCount,
|
||||
RemainingPermits = remaining,
|
||||
WindowRemaining = policy.Window, // Simplified
|
||||
Exists = true
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> ResetAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var fullKey = BuildKey(key);
|
||||
return ValueTask.FromResult(_registry.RemoveRateLimitBucket(fullKey));
|
||||
}
|
||||
|
||||
private string BuildKey(string key) => $"{_name}:{key}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating in-memory rate limiter instances.
|
||||
/// </summary>
|
||||
public sealed class InMemoryRateLimiterFactory : IRateLimiterFactory
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryRateLimiterFactory(
|
||||
InMemoryQueueRegistry registry,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IRateLimiter Create(string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new InMemoryRateLimiter(_registry, name, _timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="ISetStore{TKey, TElement}"/>.
|
||||
/// Provides unordered set operations.
|
||||
/// </summary>
|
||||
public sealed class InMemorySetStore<TKey, TElement> : ISetStore<TKey, TElement>
|
||||
where TKey : notnull
|
||||
{
|
||||
private readonly SetStoreData<TKey, TElement> _store;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemorySetStore(
|
||||
InMemoryQueueRegistry registry,
|
||||
string name,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
|
||||
_store = registry.GetOrCreateSetStore<TKey, TElement>(name);
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> AddAsync(
|
||||
TKey setKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var set = _store.GetOrCreateSet(setKey);
|
||||
return ValueTask.FromResult(set.Add(element));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<long> AddRangeAsync(
|
||||
TKey setKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
var set = _store.GetOrCreateSet(setKey);
|
||||
return ValueTask.FromResult(set.AddRange(elements));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<IReadOnlySet<TElement>> GetMembersAsync(
|
||||
TKey setKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.TryGetSet(setKey, out var set) || set is null)
|
||||
{
|
||||
return ValueTask.FromResult<IReadOnlySet<TElement>>(new HashSet<TElement>());
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(set.GetAll());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> ContainsAsync(
|
||||
TKey setKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.TryGetSet(setKey, out var set) || set is null)
|
||||
{
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(set.Contains(element));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> RemoveAsync(
|
||||
TKey setKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.TryGetSet(setKey, out var set) || set is null)
|
||||
{
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(set.Remove(element));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<long> RemoveRangeAsync(
|
||||
TKey setKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
if (!_store.TryGetSet(setKey, out var set) || set is null)
|
||||
{
|
||||
return ValueTask.FromResult(0L);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(set.RemoveRange(elements));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> DeleteAsync(
|
||||
TKey setKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.FromResult(_store.RemoveSet(setKey));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<long> CountAsync(
|
||||
TKey setKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.TryGetSet(setKey, out var set) || set is null)
|
||||
{
|
||||
return ValueTask.FromResult(0L);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(set.Count());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask SetExpirationAsync(
|
||||
TKey setKey,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var expiresAt = _timeProvider.GetUtcNow().Add(ttl);
|
||||
_store.SetExpiration(setKey, expiresAt);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating in-memory set store instances.
|
||||
/// </summary>
|
||||
public sealed class InMemorySetStoreFactory : ISetStoreFactory
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemorySetStoreFactory(
|
||||
InMemoryQueueRegistry registry,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISetStore<TKey, TElement> Create<TKey, TElement>(string name) where TKey : notnull
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new InMemorySetStore<TKey, TElement>(_registry, name, _timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="ISortedIndex{TKey, TElement}"/>.
|
||||
/// Provides score-ordered collections with range queries.
|
||||
/// </summary>
|
||||
public sealed class InMemorySortedIndex<TKey, TElement> : ISortedIndex<TKey, TElement>
|
||||
where TKey : notnull
|
||||
where TElement : notnull
|
||||
{
|
||||
private readonly SortedIndexStore<TKey, TElement> _store;
|
||||
private readonly Func<TKey, string> _keySerializer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemorySortedIndex(
|
||||
InMemoryQueueRegistry registry,
|
||||
string name,
|
||||
Func<TKey, string>? keySerializer = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
|
||||
_store = registry.GetOrCreateSortedIndex<TKey, TElement>(name);
|
||||
_keySerializer = keySerializer ?? (key => key?.ToString() ?? throw new ArgumentNullException(nameof(key)));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> AddAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
double score,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var index = _store.GetOrCreateIndex(indexKey);
|
||||
var wasAdded = index.Add(element, score);
|
||||
return ValueTask.FromResult(wasAdded);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<long> AddRangeAsync(
|
||||
TKey indexKey,
|
||||
IEnumerable<ScoredElement<TElement>> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
var index = _store.GetOrCreateIndex(indexKey);
|
||||
long addedCount = 0;
|
||||
foreach (var item in elements)
|
||||
{
|
||||
if (index.Add(item.Element, item.Score))
|
||||
{
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
return ValueTask.FromResult(addedCount);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<IReadOnlyList<ScoredElement<TElement>>> GetByRankAsync(
|
||||
TKey indexKey,
|
||||
long start,
|
||||
long stop,
|
||||
SortOrder order = SortOrder.Ascending,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.TryGetIndex(indexKey, out var index) || index is null)
|
||||
{
|
||||
return ValueTask.FromResult<IReadOnlyList<ScoredElement<TElement>>>([]);
|
||||
}
|
||||
|
||||
var results = index.GetByRank(start, stop, order == SortOrder.Ascending);
|
||||
var mapped = results.Select(r => new ScoredElement<TElement>(r.Element, r.Score)).ToList();
|
||||
return ValueTask.FromResult<IReadOnlyList<ScoredElement<TElement>>>(mapped);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<IReadOnlyList<ScoredElement<TElement>>> GetByScoreAsync(
|
||||
TKey indexKey,
|
||||
double minScore,
|
||||
double maxScore,
|
||||
SortOrder order = SortOrder.Ascending,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.TryGetIndex(indexKey, out var index) || index is null)
|
||||
{
|
||||
return ValueTask.FromResult<IReadOnlyList<ScoredElement<TElement>>>([]);
|
||||
}
|
||||
|
||||
var results = index.GetByScoreRange(minScore, maxScore, order == SortOrder.Ascending, limit);
|
||||
var mapped = results.Select(r => new ScoredElement<TElement>(r.Element, r.Score)).ToList();
|
||||
return ValueTask.FromResult<IReadOnlyList<ScoredElement<TElement>>>(mapped);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<double?> GetScoreAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.TryGetIndex(indexKey, out var index) || index is null)
|
||||
{
|
||||
return ValueTask.FromResult<double?>(null);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(index.GetScore(element));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> RemoveAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.TryGetIndex(indexKey, out var index) || index is null)
|
||||
{
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(index.Remove(element));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<long> RemoveRangeAsync(
|
||||
TKey indexKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
if (!_store.TryGetIndex(indexKey, out var index) || index is null)
|
||||
{
|
||||
return ValueTask.FromResult(0L);
|
||||
}
|
||||
|
||||
long removed = 0;
|
||||
foreach (var element in elements)
|
||||
{
|
||||
if (index.Remove(element))
|
||||
{
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
return ValueTask.FromResult(removed);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<long> RemoveByScoreAsync(
|
||||
TKey indexKey,
|
||||
double minScore,
|
||||
double maxScore,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.TryGetIndex(indexKey, out var index) || index is null)
|
||||
{
|
||||
return ValueTask.FromResult(0L);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(index.RemoveByScoreRange(minScore, maxScore));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<long> CountAsync(
|
||||
TKey indexKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.TryGetIndex(indexKey, out var index) || index is null)
|
||||
{
|
||||
return ValueTask.FromResult(0L);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(index.Count());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> DeleteAsync(
|
||||
TKey indexKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.FromResult(_store.RemoveIndex(indexKey));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask SetExpirationAsync(
|
||||
TKey indexKey,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var expiresAt = _timeProvider.GetUtcNow().Add(ttl);
|
||||
_store.SetExpiration(indexKey, expiresAt);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating in-memory sorted index instances.
|
||||
/// </summary>
|
||||
public sealed class InMemorySortedIndexFactory : ISortedIndexFactory
|
||||
{
|
||||
private readonly InMemoryQueueRegistry _registry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemorySortedIndexFactory(
|
||||
InMemoryQueueRegistry registry,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISortedIndex<TKey, TElement> Create<TKey, TElement>(string name)
|
||||
where TKey : notnull
|
||||
where TElement : notnull
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new InMemorySortedIndex<TKey, TElement>(_registry, name, null, _timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Messaging.Plugins;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory transport plugin for StellaOps.Messaging.
|
||||
/// Useful for testing and development scenarios.
|
||||
/// </summary>
|
||||
public sealed class InMemoryTransportPlugin : IMessagingTransportPlugin
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Name => "inmemory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Register(MessagingTransportRegistrationContext context)
|
||||
{
|
||||
// Register shared registry (singleton for test state sharing)
|
||||
context.Services.AddSingleton<InMemoryQueueRegistry>();
|
||||
|
||||
// Register message queue factory
|
||||
context.Services.AddSingleton<IMessageQueueFactory, InMemoryMessageQueueFactory>();
|
||||
|
||||
// Register cache factory
|
||||
context.Services.AddSingleton<IDistributedCacheFactory, InMemoryCacheFactory>();
|
||||
|
||||
// Register rate limiter factory
|
||||
context.Services.AddSingleton<IRateLimiterFactory, InMemoryRateLimiterFactory>();
|
||||
|
||||
// Register atomic token store factory
|
||||
context.Services.AddSingleton<IAtomicTokenStoreFactory, InMemoryAtomicTokenStoreFactory>();
|
||||
|
||||
// Register sorted index factory
|
||||
context.Services.AddSingleton<ISortedIndexFactory, InMemorySortedIndexFactory>();
|
||||
|
||||
// Register set store factory
|
||||
context.Services.AddSingleton<ISetStoreFactory, InMemorySetStoreFactory>();
|
||||
|
||||
// Register event stream factory
|
||||
context.Services.AddSingleton<IEventStreamFactory, InMemoryEventStreamFactory>();
|
||||
|
||||
// Register idempotency store factory
|
||||
context.Services.AddSingleton<IIdempotencyStoreFactory, InMemoryIdempotencyStoreFactory>();
|
||||
|
||||
context.LoggerFactory?.CreateLogger<InMemoryTransportPlugin>()
|
||||
.LogDebug("Registered in-memory transport plugin");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Messaging.Transport.InMemory</RootNamespace>
|
||||
<AssemblyName>StellaOps.Messaging.Transport.InMemory</AssemblyName>
|
||||
<Description>In-memory transport plugin for StellaOps.Messaging (for testing)</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<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>
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the PostgreSQL transport.
|
||||
/// </summary>
|
||||
public class PostgresTransportOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the connection string.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string ConnectionString { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the schema name for queue tables.
|
||||
/// </summary>
|
||||
public string Schema { get; set; } = "messaging";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to auto-create tables on startup.
|
||||
/// </summary>
|
||||
public bool AutoCreateTables { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the command timeout in seconds.
|
||||
/// </summary>
|
||||
public int CommandTimeoutSeconds { get; set; } = 30;
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IAtomicTokenStore{TPayload}"/>.
|
||||
/// Uses DELETE ... RETURNING for atomic token consumption.
|
||||
/// </summary>
|
||||
public sealed class PostgresAtomicTokenStore<TPayload> : IAtomicTokenStore<TPayload>
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly string _name;
|
||||
private readonly ILogger<PostgresAtomicTokenStore<TPayload>>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private bool _tableInitialized;
|
||||
|
||||
public PostgresAtomicTokenStore(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
string name,
|
||||
ILogger<PostgresAtomicTokenStore<TPayload>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_logger = logger;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
private string TableName => $"{_connectionFactory.Schema}.atomic_token_{_name.ToLowerInvariant().Replace("-", "_")}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<TokenIssueResult> IssueAsync(
|
||||
string key,
|
||||
TPayload payload,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.Add(ttl);
|
||||
|
||||
var tokenBytes = new byte[32];
|
||||
RandomNumberGenerator.Fill(tokenBytes);
|
||||
var token = Convert.ToBase64String(tokenBytes);
|
||||
|
||||
var payloadJson = JsonSerializer.Serialize(payload, _jsonOptions);
|
||||
|
||||
var sql = $@"
|
||||
INSERT INTO {TableName} (key, token, payload, issued_at, expires_at)
|
||||
VALUES (@Key, @Token, @Payload::jsonb, @IssuedAt, @ExpiresAt)
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
token = EXCLUDED.token,
|
||||
payload = EXCLUDED.payload,
|
||||
issued_at = EXCLUDED.issued_at,
|
||||
expires_at = EXCLUDED.expires_at";
|
||||
|
||||
await conn.ExecuteAsync(new CommandDefinition(sql, new
|
||||
{
|
||||
Key = key,
|
||||
Token = token,
|
||||
Payload = payloadJson,
|
||||
IssuedAt = now.UtcDateTime,
|
||||
ExpiresAt = expiresAt.UtcDateTime
|
||||
}, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||
|
||||
return TokenIssueResult.Succeeded(token, expiresAt);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<TokenIssueResult> StoreAsync(
|
||||
string key,
|
||||
string token,
|
||||
TPayload payload,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(token);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.Add(ttl);
|
||||
|
||||
var payloadJson = JsonSerializer.Serialize(payload, _jsonOptions);
|
||||
|
||||
var sql = $@"
|
||||
INSERT INTO {TableName} (key, token, payload, issued_at, expires_at)
|
||||
VALUES (@Key, @Token, @Payload::jsonb, @IssuedAt, @ExpiresAt)
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
token = EXCLUDED.token,
|
||||
payload = EXCLUDED.payload,
|
||||
issued_at = EXCLUDED.issued_at,
|
||||
expires_at = EXCLUDED.expires_at";
|
||||
|
||||
await conn.ExecuteAsync(new CommandDefinition(sql, new
|
||||
{
|
||||
Key = key,
|
||||
Token = token,
|
||||
Payload = payloadJson,
|
||||
IssuedAt = now.UtcDateTime,
|
||||
ExpiresAt = expiresAt.UtcDateTime
|
||||
}, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||
|
||||
return TokenIssueResult.Succeeded(token, expiresAt);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<TokenConsumeResult<TPayload>> TryConsumeAsync(
|
||||
string key,
|
||||
string expectedToken,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(expectedToken);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// First, get the entry to check expiration and mismatch
|
||||
var selectSql = $@"SELECT token, payload, issued_at, expires_at FROM {TableName} WHERE key = @Key";
|
||||
var entry = await conn.QuerySingleOrDefaultAsync<TokenRow>(
|
||||
new CommandDefinition(selectSql, new { Key = key }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
return TokenConsumeResult<TPayload>.NotFound();
|
||||
}
|
||||
|
||||
var issuedAt = new DateTimeOffset(entry.IssuedAt, TimeSpan.Zero);
|
||||
var expiresAt = new DateTimeOffset(entry.ExpiresAt, TimeSpan.Zero);
|
||||
|
||||
if (expiresAt < now)
|
||||
{
|
||||
// Delete expired entry
|
||||
await conn.ExecuteAsync(new CommandDefinition(
|
||||
$"DELETE FROM {TableName} WHERE key = @Key", new { Key = key }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
return TokenConsumeResult<TPayload>.Expired(issuedAt, expiresAt);
|
||||
}
|
||||
|
||||
if (!string.Equals(entry.Token, expectedToken, StringComparison.Ordinal))
|
||||
{
|
||||
return TokenConsumeResult<TPayload>.Mismatch();
|
||||
}
|
||||
|
||||
// Atomic delete with condition
|
||||
var deleteSql = $@"
|
||||
DELETE FROM {TableName}
|
||||
WHERE key = @Key AND token = @Token
|
||||
RETURNING payload";
|
||||
|
||||
var deletedPayload = await conn.ExecuteScalarAsync<string>(
|
||||
new CommandDefinition(deleteSql, new { Key = key, Token = expectedToken }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (deletedPayload is null)
|
||||
{
|
||||
return TokenConsumeResult<TPayload>.NotFound();
|
||||
}
|
||||
|
||||
var payload = JsonSerializer.Deserialize<TPayload>(deletedPayload, _jsonOptions);
|
||||
return TokenConsumeResult<TPayload>.Success(payload!, issuedAt, expiresAt);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ExistsAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var sql = $@"SELECT EXISTS(SELECT 1 FROM {TableName} WHERE key = @Key AND expires_at > @Now)";
|
||||
|
||||
return await conn.ExecuteScalarAsync<bool>(
|
||||
new CommandDefinition(sql, new { Key = key, Now = now.UtcDateTime }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> RevokeAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $@"DELETE FROM {TableName} WHERE key = @Key";
|
||||
var deleted = await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = key }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
private async ValueTask EnsureTableExistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized) return;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $@"
|
||||
CREATE TABLE IF NOT EXISTS {TableName} (
|
||||
key TEXT PRIMARY KEY,
|
||||
token TEXT NOT NULL,
|
||||
payload JSONB,
|
||||
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_{_name}_expires ON {TableName} (expires_at);";
|
||||
|
||||
await conn.ExecuteAsync(new CommandDefinition(sql, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||
_tableInitialized = true;
|
||||
}
|
||||
|
||||
private sealed class TokenRow
|
||||
{
|
||||
public string Token { get; init; } = null!;
|
||||
public string Payload { get; init; } = null!;
|
||||
public DateTime IssuedAt { get; init; }
|
||||
public DateTime ExpiresAt { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating PostgreSQL atomic token store instances.
|
||||
/// </summary>
|
||||
public sealed class PostgresAtomicTokenStoreFactory : IAtomicTokenStoreFactory
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly JsonSerializerOptions? _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresAtomicTokenStoreFactory(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_jsonOptions = jsonOptions;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAtomicTokenStore<TPayload> Create<TPayload>(string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new PostgresAtomicTokenStore<TPayload>(
|
||||
_connectionFactory,
|
||||
name,
|
||||
_loggerFactory?.CreateLogger<PostgresAtomicTokenStore<TPayload>>(),
|
||||
_jsonOptions,
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating PostgreSQL distributed cache instances.
|
||||
/// </summary>
|
||||
public sealed class PostgresCacheFactory : IDistributedCacheFactory
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresCacheFactory(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDistributedCache<TKey, TValue> Create<TKey, TValue>(CacheOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return new PostgresCacheStore<TKey, TValue>(
|
||||
_connectionFactory,
|
||||
options,
|
||||
_loggerFactory?.CreateLogger<PostgresCacheStore<TKey, TValue>>(),
|
||||
_jsonOptions,
|
||||
null,
|
||||
_timeProvider);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDistributedCache<TValue> Create<TValue>(CacheOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return new PostgresCacheStore<TValue>(
|
||||
_connectionFactory,
|
||||
options,
|
||||
_loggerFactory?.CreateLogger<PostgresCacheStore<TValue>>(),
|
||||
_jsonOptions,
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IDistributedCache{TKey, TValue}"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TKey">The key type.</typeparam>
|
||||
/// <typeparam name="TValue">The value type.</typeparam>
|
||||
public sealed class PostgresCacheStore<TKey, TValue> : IDistributedCache<TKey, TValue>
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly CacheOptions _cacheOptions;
|
||||
private readonly ILogger<PostgresCacheStore<TKey, TValue>>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly Func<TKey, string> _keySerializer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly string _tableName;
|
||||
private readonly SemaphoreSlim _initLock = new(1, 1);
|
||||
private volatile bool _tableInitialized;
|
||||
|
||||
public PostgresCacheStore(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
CacheOptions cacheOptions,
|
||||
ILogger<PostgresCacheStore<TKey, TValue>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
Func<TKey, string>? keySerializer = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_cacheOptions = cacheOptions ?? throw new ArgumentNullException(nameof(cacheOptions));
|
||||
_logger = logger;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_keySerializer = keySerializer ?? (key => key?.ToString() ?? throw new ArgumentNullException(nameof(key)));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
var cacheName = (_cacheOptions.KeyPrefix ?? "default").Replace(":", "_").Replace("-", "_").ToLowerInvariant();
|
||||
_tableName = $"{_connectionFactory.Schema}.cache_{cacheName}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<CacheResult<TValue>> GetAsync(TKey key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var cacheKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $@"
|
||||
SELECT value, expires_at FROM {_tableName}
|
||||
WHERE key = @Key AND (expires_at IS NULL OR expires_at > @Now)
|
||||
LIMIT 1;";
|
||||
|
||||
var row = await conn.QuerySingleOrDefaultAsync(sql, new { Key = cacheKey, Now = now.UtcDateTime }).ConfigureAwait(false);
|
||||
|
||||
if (row is null)
|
||||
{
|
||||
return CacheResult<TValue>.Miss();
|
||||
}
|
||||
|
||||
// Handle sliding expiration
|
||||
if (_cacheOptions.SlidingExpiration && _cacheOptions.DefaultTtl.HasValue)
|
||||
{
|
||||
var updateSql = $"UPDATE {_tableName} SET expires_at = @ExpiresAt WHERE key = @Key;";
|
||||
await conn.ExecuteAsync(updateSql, new
|
||||
{
|
||||
Key = cacheKey,
|
||||
ExpiresAt = now.Add(_cacheOptions.DefaultTtl.Value).UtcDateTime
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var value = JsonSerializer.Deserialize<TValue>((string)row.value, _jsonOptions);
|
||||
return value is not null ? CacheResult<TValue>.Found(value) : CacheResult<TValue>.Miss();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return CacheResult<TValue>.Miss();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask SetAsync(
|
||||
TKey key,
|
||||
TValue value,
|
||||
CacheEntryOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var cacheKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var serialized = JsonSerializer.Serialize(value, _jsonOptions);
|
||||
|
||||
DateTime? expiresAt = null;
|
||||
if (options?.TimeToLive.HasValue == true)
|
||||
{
|
||||
expiresAt = now.Add(options.TimeToLive.Value).UtcDateTime;
|
||||
}
|
||||
else if (options?.AbsoluteExpiration.HasValue == true)
|
||||
{
|
||||
expiresAt = options.AbsoluteExpiration.Value.UtcDateTime;
|
||||
}
|
||||
else if (_cacheOptions.DefaultTtl.HasValue)
|
||||
{
|
||||
expiresAt = now.Add(_cacheOptions.DefaultTtl.Value).UtcDateTime;
|
||||
}
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $@"
|
||||
INSERT INTO {_tableName} (key, value, expires_at, created_at, updated_at)
|
||||
VALUES (@Key, @Value, @ExpiresAt, @Now, @Now)
|
||||
ON CONFLICT (key) DO UPDATE SET value = @Value, expires_at = @ExpiresAt, updated_at = @Now;";
|
||||
|
||||
await conn.ExecuteAsync(sql, new
|
||||
{
|
||||
Key = cacheKey,
|
||||
Value = serialized,
|
||||
ExpiresAt = expiresAt,
|
||||
Now = now.UtcDateTime
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> InvalidateAsync(TKey key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var cacheKey = BuildKey(key);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $"DELETE FROM {_tableName} WHERE key = @Key;";
|
||||
var affected = await conn.ExecuteAsync(sql, new { Key = cacheKey }).ConfigureAwait(false);
|
||||
return affected > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> InvalidateByPatternAsync(string pattern, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Convert glob pattern to SQL LIKE pattern
|
||||
var likePattern = (_cacheOptions.KeyPrefix ?? "") + pattern.Replace("*", "%");
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $"DELETE FROM {_tableName} WHERE key LIKE @Pattern;";
|
||||
return await conn.ExecuteAsync(sql, new { Pattern = likePattern }).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<TValue> GetOrSetAsync(
|
||||
TKey key,
|
||||
Func<CancellationToken, ValueTask<TValue>> factory,
|
||||
CacheEntryOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await GetAsync(key, cancellationToken).ConfigureAwait(false);
|
||||
if (result.HasValue)
|
||||
{
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
var value = await factory(cancellationToken).ConfigureAwait(false);
|
||||
await SetAsync(key, value, options, cancellationToken).ConfigureAwait(false);
|
||||
return value;
|
||||
}
|
||||
|
||||
private string BuildKey(TKey key)
|
||||
{
|
||||
var keyString = _keySerializer(key);
|
||||
return string.IsNullOrWhiteSpace(_cacheOptions.KeyPrefix)
|
||||
? keyString
|
||||
: $"{_cacheOptions.KeyPrefix}{keyString}";
|
||||
}
|
||||
|
||||
private async Task EnsureTableAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized) return;
|
||||
|
||||
await _initLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_tableInitialized) return;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await conn.ExecuteAsync($"CREATE SCHEMA IF NOT EXISTS {_connectionFactory.Schema};").ConfigureAwait(false);
|
||||
|
||||
var sql = $@"
|
||||
CREATE TABLE IF NOT EXISTS {_tableName} (
|
||||
key TEXT PRIMARY KEY,
|
||||
value JSONB NOT NULL,
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_{_tableName.Replace(".", "_")}_expires
|
||||
ON {_tableName} (expires_at)
|
||||
WHERE expires_at IS NOT NULL;
|
||||
";
|
||||
|
||||
await conn.ExecuteAsync(sql).ConfigureAwait(false);
|
||||
|
||||
_tableInitialized = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_initLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// String-keyed PostgreSQL cache store.
|
||||
/// </summary>
|
||||
public sealed class PostgresCacheStore<TValue> : IDistributedCache<TValue>
|
||||
{
|
||||
private readonly PostgresCacheStore<string, TValue> _inner;
|
||||
|
||||
public PostgresCacheStore(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
CacheOptions options,
|
||||
ILogger<PostgresCacheStore<TValue>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_inner = new PostgresCacheStore<string, TValue>(connectionFactory, options, null, jsonOptions, key => key, timeProvider);
|
||||
}
|
||||
|
||||
public string ProviderName => _inner.ProviderName;
|
||||
|
||||
public ValueTask<CacheResult<TValue>> GetAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> _inner.GetAsync(key, cancellationToken);
|
||||
|
||||
public ValueTask SetAsync(string key, TValue value, CacheEntryOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> _inner.SetAsync(key, value, options, cancellationToken);
|
||||
|
||||
public ValueTask<bool> InvalidateAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> _inner.InvalidateAsync(key, cancellationToken);
|
||||
|
||||
public ValueTask<long> InvalidateByPatternAsync(string pattern, CancellationToken cancellationToken = default)
|
||||
=> _inner.InvalidateByPatternAsync(pattern, cancellationToken);
|
||||
|
||||
public ValueTask<TValue> GetOrSetAsync(string key, Func<CancellationToken, ValueTask<TValue>> factory, CacheEntryOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> _inner.GetOrSetAsync(key, factory, options, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating PostgreSQL connections.
|
||||
/// </summary>
|
||||
public sealed class PostgresConnectionFactory : IAsyncDisposable
|
||||
{
|
||||
private readonly PostgresTransportOptions _options;
|
||||
private readonly ILogger<PostgresConnectionFactory>? _logger;
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private bool _disposed;
|
||||
|
||||
public PostgresConnectionFactory(
|
||||
IOptions<PostgresTransportOptions> options,
|
||||
ILogger<PostgresConnectionFactory>? logger = null)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
|
||||
var builder = new NpgsqlDataSourceBuilder(_options.ConnectionString);
|
||||
_dataSource = builder.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the schema name.
|
||||
/// </summary>
|
||||
public string Schema => _options.Schema;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command timeout.
|
||||
/// </summary>
|
||||
public int CommandTimeoutSeconds => _options.CommandTimeoutSeconds;
|
||||
|
||||
/// <summary>
|
||||
/// Opens a new connection.
|
||||
/// </summary>
|
||||
public async ValueTask<NpgsqlConnection> OpenConnectionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
return connection;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the connection.
|
||||
/// </summary>
|
||||
public async ValueTask PingAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var conn = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT 1";
|
||||
await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
await _dataSource.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IEventStream{TEvent}"/>.
|
||||
/// Uses polling-based subscription with optional LISTEN/NOTIFY.
|
||||
/// </summary>
|
||||
public sealed class PostgresEventStream<TEvent> : IEventStream<TEvent>
|
||||
where TEvent : class
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly EventStreamOptions _options;
|
||||
private readonly ILogger<PostgresEventStream<TEvent>>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private bool _tableInitialized;
|
||||
|
||||
public PostgresEventStream(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
EventStreamOptions options,
|
||||
ILogger<PostgresEventStream<TEvent>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string StreamName => _options.StreamName;
|
||||
|
||||
private string TableName => $"{_connectionFactory.Schema}.event_stream_{_options.StreamName.ToLowerInvariant().Replace("-", "_")}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<EventPublishResult> PublishAsync(
|
||||
TEvent @event,
|
||||
EventPublishOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(@event);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var eventJson = JsonSerializer.Serialize(@event, _jsonOptions);
|
||||
|
||||
var sql = $@"
|
||||
INSERT INTO {TableName} (data, tenant_id, correlation_id, timestamp)
|
||||
VALUES (@Data::jsonb, @TenantId, @CorrelationId, @Timestamp)
|
||||
RETURNING id";
|
||||
|
||||
var id = await conn.ExecuteScalarAsync<long>(
|
||||
new CommandDefinition(sql, new
|
||||
{
|
||||
Data = eventJson,
|
||||
TenantId = options?.TenantId,
|
||||
CorrelationId = options?.CorrelationId,
|
||||
Timestamp = now.UtcDateTime
|
||||
}, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Auto-trim if configured
|
||||
if (_options.MaxLength.HasValue)
|
||||
{
|
||||
await TrimInternalAsync(conn, _options.MaxLength.Value, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var entryId = $"{now.ToUnixTimeMilliseconds()}-{id}";
|
||||
return EventPublishResult.Succeeded(entryId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlyList<EventPublishResult>> PublishBatchAsync(
|
||||
IEnumerable<TEvent> events,
|
||||
EventPublishOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(events);
|
||||
|
||||
var results = new List<EventPublishResult>();
|
||||
foreach (var @event in events)
|
||||
{
|
||||
var result = await PublishAsync(@event, options, cancellationToken).ConfigureAwait(false);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<StreamEvent<TEvent>> SubscribeAsync(
|
||||
StreamPosition position,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
long lastId = position.Value switch
|
||||
{
|
||||
"0" => 0,
|
||||
"$" => long.MaxValue, // Will be resolved to actual max
|
||||
_ => ParseEntryId(position.Value)
|
||||
};
|
||||
|
||||
// If starting from end, get current max ID
|
||||
if (position.Value == "$")
|
||||
{
|
||||
await using var initConn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
var maxIdSql = $@"SELECT COALESCE(MAX(id), 0) FROM {TableName}";
|
||||
lastId = await initConn.ExecuteScalarAsync<long>(
|
||||
new CommandDefinition(maxIdSql, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $@"
|
||||
SELECT id, data, tenant_id, correlation_id, timestamp
|
||||
FROM {TableName}
|
||||
WHERE id > @LastId
|
||||
ORDER BY id
|
||||
LIMIT 100";
|
||||
|
||||
var entries = await conn.QueryAsync<EventRow>(
|
||||
new CommandDefinition(sql, new { LastId = lastId }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var entriesList = entries.ToList();
|
||||
|
||||
if (entriesList.Count > 0)
|
||||
{
|
||||
foreach (var entry in entriesList)
|
||||
{
|
||||
var @event = JsonSerializer.Deserialize<TEvent>(entry.Data, _jsonOptions);
|
||||
if (@event is not null)
|
||||
{
|
||||
var timestamp = new DateTimeOffset(entry.Timestamp, TimeSpan.Zero);
|
||||
var entryId = $"{timestamp.ToUnixTimeMilliseconds()}-{entry.Id}";
|
||||
|
||||
yield return new StreamEvent<TEvent>(
|
||||
entryId,
|
||||
@event,
|
||||
timestamp,
|
||||
entry.TenantId,
|
||||
entry.CorrelationId);
|
||||
}
|
||||
lastId = entry.Id;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No new entries, wait before polling again
|
||||
await Task.Delay(_options.PollInterval, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<StreamInfo> GetInfoAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $@"
|
||||
SELECT
|
||||
COUNT(*) as length,
|
||||
MIN(id) as first_id,
|
||||
MAX(id) as last_id,
|
||||
MIN(timestamp) as first_ts,
|
||||
MAX(timestamp) as last_ts
|
||||
FROM {TableName}";
|
||||
|
||||
var info = await conn.QuerySingleAsync<StreamInfoRow>(
|
||||
new CommandDefinition(sql, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
string? firstEntryId = null;
|
||||
string? lastEntryId = null;
|
||||
DateTimeOffset? firstTs = null;
|
||||
DateTimeOffset? lastTs = null;
|
||||
|
||||
if (info.FirstId.HasValue && info.FirstTs.HasValue)
|
||||
{
|
||||
firstTs = new DateTimeOffset(info.FirstTs.Value, TimeSpan.Zero);
|
||||
firstEntryId = $"{firstTs.Value.ToUnixTimeMilliseconds()}-{info.FirstId.Value}";
|
||||
}
|
||||
|
||||
if (info.LastId.HasValue && info.LastTs.HasValue)
|
||||
{
|
||||
lastTs = new DateTimeOffset(info.LastTs.Value, TimeSpan.Zero);
|
||||
lastEntryId = $"{lastTs.Value.ToUnixTimeMilliseconds()}-{info.LastId.Value}";
|
||||
}
|
||||
|
||||
return new StreamInfo(info.Length, firstEntryId, lastEntryId, firstTs, lastTs);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> TrimAsync(
|
||||
long maxLength,
|
||||
bool approximate = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await TrimInternalAsync(conn, maxLength, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask<long> TrimInternalAsync(Npgsql.NpgsqlConnection conn, long maxLength, CancellationToken cancellationToken)
|
||||
{
|
||||
var sql = $@"
|
||||
WITH to_delete AS (
|
||||
SELECT id FROM {TableName}
|
||||
ORDER BY id DESC
|
||||
OFFSET @MaxLength
|
||||
)
|
||||
DELETE FROM {TableName}
|
||||
WHERE id IN (SELECT id FROM to_delete)";
|
||||
|
||||
return await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { MaxLength = maxLength }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask EnsureTableExistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized) return;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var safeName = _options.StreamName.ToLowerInvariant().Replace("-", "_");
|
||||
var sql = $@"
|
||||
CREATE TABLE IF NOT EXISTS {TableName} (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
data JSONB NOT NULL,
|
||||
tenant_id TEXT,
|
||||
correlation_id TEXT,
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_{safeName}_timestamp ON {TableName} (timestamp);";
|
||||
|
||||
await conn.ExecuteAsync(new CommandDefinition(sql, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||
_tableInitialized = true;
|
||||
}
|
||||
|
||||
private static long ParseEntryId(string entryId)
|
||||
{
|
||||
// Format is "timestamp-id"
|
||||
var dashIndex = entryId.LastIndexOf('-');
|
||||
if (dashIndex > 0 && long.TryParse(entryId.AsSpan(dashIndex + 1), out var id))
|
||||
{
|
||||
return id;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private sealed class EventRow
|
||||
{
|
||||
public long Id { get; init; }
|
||||
public string Data { get; init; } = null!;
|
||||
public string? TenantId { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
public DateTime Timestamp { get; init; }
|
||||
}
|
||||
|
||||
private sealed class StreamInfoRow
|
||||
{
|
||||
public long Length { get; init; }
|
||||
public long? FirstId { get; init; }
|
||||
public long? LastId { get; init; }
|
||||
public DateTime? FirstTs { get; init; }
|
||||
public DateTime? LastTs { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating PostgreSQL event stream instances.
|
||||
/// </summary>
|
||||
public sealed class PostgresEventStreamFactory : IEventStreamFactory
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly JsonSerializerOptions? _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresEventStreamFactory(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_jsonOptions = jsonOptions;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEventStream<TEvent> Create<TEvent>(EventStreamOptions options) where TEvent : class
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return new PostgresEventStream<TEvent>(
|
||||
_connectionFactory,
|
||||
options,
|
||||
_loggerFactory?.CreateLogger<PostgresEventStream<TEvent>>(),
|
||||
_jsonOptions,
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IIdempotencyStore"/>.
|
||||
/// Uses INSERT ... ON CONFLICT DO NOTHING for atomic claiming.
|
||||
/// </summary>
|
||||
public sealed class PostgresIdempotencyStore : IIdempotencyStore
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly string _name;
|
||||
private readonly ILogger<PostgresIdempotencyStore>? _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private bool _tableInitialized;
|
||||
|
||||
public PostgresIdempotencyStore(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
string name,
|
||||
ILogger<PostgresIdempotencyStore>? logger = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
private string TableName => $"{_connectionFactory.Schema}.idempotency_{_name.ToLowerInvariant().Replace("-", "_")}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IdempotencyResult> TryClaimAsync(
|
||||
string key,
|
||||
string value,
|
||||
TimeSpan window,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.Add(window);
|
||||
|
||||
// Clean up expired entries first
|
||||
var cleanupSql = $@"DELETE FROM {TableName} WHERE expires_at < @Now";
|
||||
await conn.ExecuteAsync(new CommandDefinition(cleanupSql, new { Now = now.UtcDateTime }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Try to insert
|
||||
var sql = $@"
|
||||
INSERT INTO {TableName} (key, value, expires_at)
|
||||
VALUES (@Key, @Value, @ExpiresAt)
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
RETURNING TRUE";
|
||||
|
||||
var result = await conn.ExecuteScalarAsync<bool?>(
|
||||
new CommandDefinition(sql, new { Key = key, Value = value, ExpiresAt = expiresAt.UtcDateTime }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result == true)
|
||||
{
|
||||
return IdempotencyResult.Claimed();
|
||||
}
|
||||
|
||||
// Key already exists, get existing value
|
||||
var existingSql = $@"SELECT value FROM {TableName} WHERE key = @Key";
|
||||
var existingValue = await conn.ExecuteScalarAsync<string?>(
|
||||
new CommandDefinition(existingSql, new { Key = key }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return IdempotencyResult.Duplicate(existingValue ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ExistsAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var sql = $@"SELECT EXISTS(SELECT 1 FROM {TableName} WHERE key = @Key AND expires_at > @Now)";
|
||||
|
||||
return await conn.ExecuteScalarAsync<bool>(
|
||||
new CommandDefinition(sql, new { Key = key, Now = now.UtcDateTime }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<string?> GetAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var sql = $@"SELECT value FROM {TableName} WHERE key = @Key AND expires_at > @Now";
|
||||
|
||||
return await conn.ExecuteScalarAsync<string?>(
|
||||
new CommandDefinition(sql, new { Key = key, Now = now.UtcDateTime }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ReleaseAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $@"DELETE FROM {TableName} WHERE key = @Key";
|
||||
var deleted = await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = key }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ExtendAsync(
|
||||
string key,
|
||||
TimeSpan extension,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $@"
|
||||
UPDATE {TableName}
|
||||
SET expires_at = expires_at + @Extension
|
||||
WHERE key = @Key";
|
||||
|
||||
var updated = await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = key, Extension = extension }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return updated > 0;
|
||||
}
|
||||
|
||||
private async ValueTask EnsureTableExistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized) return;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var safeName = _name.ToLowerInvariant().Replace("-", "_");
|
||||
var sql = $@"
|
||||
CREATE TABLE IF NOT EXISTS {TableName} (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_{safeName}_expires ON {TableName} (expires_at);";
|
||||
|
||||
await conn.ExecuteAsync(new CommandDefinition(sql, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||
_tableInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating PostgreSQL idempotency store instances.
|
||||
/// </summary>
|
||||
public sealed class PostgresIdempotencyStoreFactory : IIdempotencyStoreFactory
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresIdempotencyStoreFactory(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IIdempotencyStore Create(string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new PostgresIdempotencyStore(
|
||||
_connectionFactory,
|
||||
name,
|
||||
_loggerFactory?.CreateLogger<PostgresIdempotencyStore>(),
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of a message lease.
|
||||
/// </summary>
|
||||
/// <typeparam name="TMessage">The message type.</typeparam>
|
||||
internal sealed class PostgresMessageLease<TMessage> : IMessageLease<TMessage> where TMessage : class
|
||||
{
|
||||
private readonly PostgresMessageQueue<TMessage> _queue;
|
||||
private int _completed;
|
||||
|
||||
internal PostgresMessageLease(
|
||||
PostgresMessageQueue<TMessage> queue,
|
||||
string messageId,
|
||||
TMessage message,
|
||||
int attempt,
|
||||
DateTimeOffset enqueuedAt,
|
||||
DateTimeOffset leaseExpiresAt,
|
||||
string consumer,
|
||||
string? tenantId,
|
||||
string? correlationId)
|
||||
{
|
||||
_queue = queue;
|
||||
MessageId = messageId;
|
||||
Message = message;
|
||||
Attempt = attempt;
|
||||
EnqueuedAt = enqueuedAt;
|
||||
LeaseExpiresAt = leaseExpiresAt;
|
||||
Consumer = consumer;
|
||||
TenantId = tenantId;
|
||||
CorrelationId = correlationId;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string MessageId { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public TMessage Message { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Attempt { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset EnqueuedAt { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset LeaseExpiresAt { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Consumer { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? TenantId { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? CorrelationId { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask AcknowledgeAsync(CancellationToken cancellationToken = default)
|
||||
=> _queue.AcknowledgeAsync(this, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask RenewAsync(TimeSpan extension, CancellationToken cancellationToken = default)
|
||||
=> _queue.RenewLeaseAsync(this, extension, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask ReleaseAsync(ReleaseDisposition disposition, CancellationToken cancellationToken = default)
|
||||
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
|
||||
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
internal bool TryBeginCompletion()
|
||||
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
|
||||
|
||||
internal void RefreshLease(DateTimeOffset expiresAt)
|
||||
=> LeaseExpiresAt = expiresAt;
|
||||
|
||||
internal void IncrementAttempt()
|
||||
=> Attempt++;
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IMessageQueue{TMessage}"/> using FOR UPDATE SKIP LOCKED.
|
||||
/// </summary>
|
||||
/// <typeparam name="TMessage">The message type.</typeparam>
|
||||
public sealed class PostgresMessageQueue<TMessage> : IMessageQueue<TMessage>
|
||||
where TMessage : class
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly MessageQueueOptions _queueOptions;
|
||||
private readonly ILogger<PostgresMessageQueue<TMessage>>? _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly string _tableName;
|
||||
private readonly SemaphoreSlim _initLock = new(1, 1);
|
||||
private volatile bool _tableInitialized;
|
||||
|
||||
public PostgresMessageQueue(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
MessageQueueOptions queueOptions,
|
||||
ILogger<PostgresMessageQueue<TMessage>>? logger = null,
|
||||
TimeProvider? timeProvider = null,
|
||||
JsonSerializerOptions? jsonOptions = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_queueOptions = queueOptions ?? throw new ArgumentNullException(nameof(queueOptions));
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
// Sanitize queue name for table name
|
||||
var sanitizedName = queueOptions.QueueName.Replace(":", "_").Replace("-", "_").ToLowerInvariant();
|
||||
_tableName = $"{_connectionFactory.Schema}.queue_{sanitizedName}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string QueueName => _queueOptions.QueueName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<EnqueueResult> EnqueueAsync(
|
||||
TMessage message,
|
||||
EnqueueOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var messageId = Guid.NewGuid().ToString("N");
|
||||
var payload = JsonSerializer.Serialize(message, _jsonOptions);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Check idempotency if key provided
|
||||
if (!string.IsNullOrWhiteSpace(options?.IdempotencyKey))
|
||||
{
|
||||
var existingSql = $@"
|
||||
SELECT id FROM {_tableName}
|
||||
WHERE idempotency_key = @IdempotencyKey
|
||||
AND enqueued_at > @WindowStart
|
||||
LIMIT 1;";
|
||||
|
||||
var existing = await conn.QuerySingleOrDefaultAsync<string>(existingSql, new
|
||||
{
|
||||
IdempotencyKey = options.IdempotencyKey,
|
||||
WindowStart = now.Subtract(_queueOptions.IdempotencyWindow)
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
_logger?.LogDebug("Duplicate enqueue detected for queue {Queue} with key {Key}", _queueOptions.QueueName, options.IdempotencyKey);
|
||||
return EnqueueResult.Duplicate(existing);
|
||||
}
|
||||
}
|
||||
|
||||
var visibleAt = options?.VisibleAt ?? now;
|
||||
|
||||
var sql = $@"
|
||||
INSERT INTO {_tableName} (
|
||||
id, payload, status, attempt_count, enqueued_at, available_at,
|
||||
tenant_id, correlation_id, idempotency_key, priority
|
||||
) VALUES (
|
||||
@Id, @Payload, 'pending', 1, @EnqueuedAt, @AvailableAt,
|
||||
@TenantId, @CorrelationId, @IdempotencyKey, @Priority
|
||||
);";
|
||||
|
||||
await conn.ExecuteAsync(sql, new
|
||||
{
|
||||
Id = messageId,
|
||||
Payload = payload,
|
||||
EnqueuedAt = now.UtcDateTime,
|
||||
AvailableAt = visibleAt.UtcDateTime,
|
||||
TenantId = options?.TenantId,
|
||||
CorrelationId = options?.CorrelationId,
|
||||
IdempotencyKey = options?.IdempotencyKey,
|
||||
Priority = options?.Priority ?? 0
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
_logger?.LogDebug("Enqueued message {MessageId} to queue {Queue}", messageId, _queueOptions.QueueName);
|
||||
return EnqueueResult.Succeeded(messageId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlyList<IMessageLease<TMessage>>> LeaseAsync(
|
||||
LeaseRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var consumer = _queueOptions.ConsumerName ?? $"{Environment.MachineName}-{Environment.ProcessId}";
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var leaseDuration = request.LeaseDuration ?? _queueOptions.DefaultLeaseDuration;
|
||||
var leaseExpires = now.Add(leaseDuration);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Use FOR UPDATE SKIP LOCKED to atomically claim messages
|
||||
var sql = $@"
|
||||
WITH candidates AS (
|
||||
SELECT id
|
||||
FROM {_tableName}
|
||||
WHERE status IN ('pending', 'retrying')
|
||||
AND available_at <= @Now
|
||||
ORDER BY priority DESC, available_at ASC, enqueued_at ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT @BatchSize
|
||||
)
|
||||
UPDATE {_tableName} q
|
||||
SET status = 'processing',
|
||||
lease_owner = @Consumer,
|
||||
lease_expires_at = @LeaseExpires,
|
||||
attempt_count = attempt_count + 1,
|
||||
last_attempt_at = @Now,
|
||||
updated_at = @Now
|
||||
FROM candidates c
|
||||
WHERE q.id = c.id
|
||||
RETURNING q.*;";
|
||||
|
||||
var rows = await conn.QueryAsync(sql, new
|
||||
{
|
||||
Now = now.UtcDateTime,
|
||||
BatchSize = request.BatchSize,
|
||||
Consumer = consumer,
|
||||
LeaseExpires = leaseExpires.UtcDateTime
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
var leases = new List<IMessageLease<TMessage>>();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var lease = TryMapLease(row, consumer, leaseExpires);
|
||||
if (lease is not null)
|
||||
{
|
||||
leases.Add(lease);
|
||||
}
|
||||
}
|
||||
|
||||
return leases;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlyList<IMessageLease<TMessage>>> ClaimExpiredAsync(
|
||||
ClaimRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var consumer = _queueOptions.ConsumerName ?? $"{Environment.MachineName}-{Environment.ProcessId}";
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var leaseDuration = request.LeaseDuration ?? _queueOptions.DefaultLeaseDuration;
|
||||
var leaseExpires = now.Add(leaseDuration);
|
||||
var minIdleThreshold = now.Subtract(request.MinIdleTime);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Claim messages with expired leases
|
||||
var sql = $@"
|
||||
WITH candidates AS (
|
||||
SELECT id
|
||||
FROM {_tableName}
|
||||
WHERE status = 'processing'
|
||||
AND lease_expires_at < @MinIdleThreshold
|
||||
AND attempt_count >= @MinAttempts
|
||||
ORDER BY lease_expires_at ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT @BatchSize
|
||||
)
|
||||
UPDATE {_tableName} q
|
||||
SET lease_owner = @Consumer,
|
||||
lease_expires_at = @LeaseExpires,
|
||||
attempt_count = attempt_count + 1,
|
||||
last_attempt_at = @Now,
|
||||
updated_at = @Now
|
||||
FROM candidates c
|
||||
WHERE q.id = c.id
|
||||
RETURNING q.*;";
|
||||
|
||||
var rows = await conn.QueryAsync(sql, new
|
||||
{
|
||||
MinIdleThreshold = minIdleThreshold.UtcDateTime,
|
||||
MinAttempts = request.MinDeliveryAttempts,
|
||||
BatchSize = request.BatchSize,
|
||||
Consumer = consumer,
|
||||
LeaseExpires = leaseExpires.UtcDateTime,
|
||||
Now = now.UtcDateTime
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
var leases = new List<IMessageLease<TMessage>>();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var lease = TryMapLease(row, consumer, leaseExpires);
|
||||
if (lease is not null)
|
||||
{
|
||||
leases.Add(lease);
|
||||
}
|
||||
}
|
||||
|
||||
return leases;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> GetPendingCountAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $"SELECT COUNT(*) FROM {_tableName} WHERE status IN ('pending', 'retrying', 'processing');";
|
||||
return await conn.ExecuteScalarAsync<long>(sql).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
internal async ValueTask AcknowledgeAsync(PostgresMessageLease<TMessage> lease, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!lease.TryBeginCompletion()) return;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $"DELETE FROM {_tableName} WHERE id = @Id AND lease_owner = @Consumer;";
|
||||
await conn.ExecuteAsync(sql, new { Id = lease.MessageId, Consumer = lease.Consumer }).ConfigureAwait(false);
|
||||
|
||||
_logger?.LogDebug("Acknowledged message {MessageId} from queue {Queue}", lease.MessageId, _queueOptions.QueueName);
|
||||
}
|
||||
|
||||
internal async ValueTask RenewLeaseAsync(PostgresMessageLease<TMessage> lease, TimeSpan extension, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var newExpiry = now.Add(extension);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $@"
|
||||
UPDATE {_tableName}
|
||||
SET lease_expires_at = @LeaseExpires, updated_at = @Now
|
||||
WHERE id = @Id AND lease_owner = @Consumer;";
|
||||
|
||||
await conn.ExecuteAsync(sql, new
|
||||
{
|
||||
Id = lease.MessageId,
|
||||
Consumer = lease.Consumer,
|
||||
LeaseExpires = newExpiry.UtcDateTime,
|
||||
Now = now.UtcDateTime
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
lease.RefreshLease(newExpiry);
|
||||
}
|
||||
|
||||
internal async ValueTask ReleaseAsync(
|
||||
PostgresMessageLease<TMessage> lease,
|
||||
ReleaseDisposition disposition,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (disposition == ReleaseDisposition.Retry && lease.Attempt >= _queueOptions.MaxDeliveryAttempts)
|
||||
{
|
||||
await DeadLetterAsync(lease, $"max-delivery-attempts:{lease.Attempt}", cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!lease.TryBeginCompletion()) return;
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (disposition == ReleaseDisposition.Retry)
|
||||
{
|
||||
var backoff = CalculateBackoff(lease.Attempt + 1);
|
||||
var availableAt = now.Add(backoff);
|
||||
|
||||
var sql = $@"
|
||||
UPDATE {_tableName}
|
||||
SET status = 'retrying',
|
||||
lease_owner = NULL,
|
||||
lease_expires_at = NULL,
|
||||
available_at = @AvailableAt,
|
||||
updated_at = @Now
|
||||
WHERE id = @Id;";
|
||||
|
||||
await conn.ExecuteAsync(sql, new
|
||||
{
|
||||
Id = lease.MessageId,
|
||||
AvailableAt = availableAt.UtcDateTime,
|
||||
Now = now.UtcDateTime
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
_logger?.LogDebug("Released message {MessageId} for retry, attempt {Attempt}", lease.MessageId, lease.Attempt + 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Abandon - just delete
|
||||
var sql = $"DELETE FROM {_tableName} WHERE id = @Id;";
|
||||
await conn.ExecuteAsync(sql, new { Id = lease.MessageId }).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
internal async ValueTask DeadLetterAsync(PostgresMessageLease<TMessage> lease, string reason, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!lease.TryBeginCompletion()) return;
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_queueOptions.DeadLetterQueue))
|
||||
{
|
||||
// Move to dead-letter status (or separate table)
|
||||
var sql = $@"
|
||||
UPDATE {_tableName}
|
||||
SET status = 'dead_letter',
|
||||
lease_owner = NULL,
|
||||
lease_expires_at = NULL,
|
||||
last_error = @Reason,
|
||||
updated_at = @Now
|
||||
WHERE id = @Id;";
|
||||
|
||||
await conn.ExecuteAsync(sql, new
|
||||
{
|
||||
Id = lease.MessageId,
|
||||
Reason = reason,
|
||||
Now = now.UtcDateTime
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
_logger?.LogWarning("Dead-lettered message {MessageId}: {Reason}", lease.MessageId, reason);
|
||||
}
|
||||
else
|
||||
{
|
||||
var sql = $"DELETE FROM {_tableName} WHERE id = @Id;";
|
||||
await conn.ExecuteAsync(sql, new { Id = lease.MessageId }).ConfigureAwait(false);
|
||||
_logger?.LogWarning("Dropped message {MessageId} (no DLQ): {Reason}", lease.MessageId, reason);
|
||||
}
|
||||
}
|
||||
|
||||
private TimeSpan CalculateBackoff(int attempt)
|
||||
{
|
||||
if (attempt <= 1) return _queueOptions.RetryInitialBackoff;
|
||||
|
||||
var initial = _queueOptions.RetryInitialBackoff;
|
||||
var max = _queueOptions.RetryMaxBackoff;
|
||||
var multiplier = _queueOptions.RetryBackoffMultiplier;
|
||||
|
||||
var scaledTicks = initial.Ticks * Math.Pow(multiplier, attempt - 1);
|
||||
var cappedTicks = Math.Min(max.Ticks, scaledTicks);
|
||||
|
||||
return TimeSpan.FromTicks((long)Math.Max(initial.Ticks, cappedTicks));
|
||||
}
|
||||
|
||||
private PostgresMessageLease<TMessage>? TryMapLease(dynamic row, string consumer, DateTimeOffset leaseExpires)
|
||||
{
|
||||
try
|
||||
{
|
||||
var payload = (string)row.payload;
|
||||
var message = JsonSerializer.Deserialize<TMessage>(payload, _jsonOptions);
|
||||
if (message is null) return null;
|
||||
|
||||
return new PostgresMessageLease<TMessage>(
|
||||
this,
|
||||
(string)row.id,
|
||||
message,
|
||||
(int)row.attempt_count,
|
||||
new DateTimeOffset(DateTime.SpecifyKind((DateTime)row.enqueued_at, DateTimeKind.Utc)),
|
||||
leaseExpires,
|
||||
consumer,
|
||||
(string?)row.tenant_id,
|
||||
(string?)row.correlation_id);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureTableAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized) return;
|
||||
|
||||
await _initLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_tableInitialized) return;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Create schema if not exists
|
||||
await conn.ExecuteAsync($"CREATE SCHEMA IF NOT EXISTS {_connectionFactory.Schema};").ConfigureAwait(false);
|
||||
|
||||
// Create table
|
||||
var createTableSql = $@"
|
||||
CREATE TABLE IF NOT EXISTS {_tableName} (
|
||||
id TEXT PRIMARY KEY,
|
||||
payload JSONB NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
attempt_count INTEGER NOT NULL DEFAULT 1,
|
||||
enqueued_at TIMESTAMPTZ NOT NULL,
|
||||
available_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ,
|
||||
last_attempt_at TIMESTAMPTZ,
|
||||
last_error TEXT,
|
||||
lease_owner TEXT,
|
||||
lease_expires_at TIMESTAMPTZ,
|
||||
tenant_id TEXT,
|
||||
correlation_id TEXT,
|
||||
idempotency_key TEXT,
|
||||
priority INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_{_queueOptions.QueueName.Replace(":", "_").Replace("-", "_")}_status_available
|
||||
ON {_tableName} (status, available_at, priority DESC)
|
||||
WHERE status IN ('pending', 'retrying');
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_{_queueOptions.QueueName.Replace(":", "_").Replace("-", "_")}_lease
|
||||
ON {_tableName} (lease_expires_at)
|
||||
WHERE status = 'processing';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_{_queueOptions.QueueName.Replace(":", "_").Replace("-", "_")}_idempotency
|
||||
ON {_tableName} (idempotency_key, enqueued_at)
|
||||
WHERE idempotency_key IS NOT NULL;
|
||||
";
|
||||
|
||||
await conn.ExecuteAsync(createTableSql).ConfigureAwait(false);
|
||||
|
||||
_tableInitialized = true;
|
||||
_logger?.LogDebug("Initialized queue table {Table}", _tableName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_initLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating PostgreSQL message queue instances.
|
||||
/// </summary>
|
||||
public sealed class PostgresMessageQueueFactory : IMessageQueueFactory
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public PostgresMessageQueueFactory(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
TimeProvider? timeProvider = null,
|
||||
JsonSerializerOptions? jsonOptions = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IMessageQueue<TMessage> Create<TMessage>(MessageQueueOptions options)
|
||||
where TMessage : class
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
return new PostgresMessageQueue<TMessage>(
|
||||
_connectionFactory,
|
||||
options,
|
||||
_loggerFactory?.CreateLogger<PostgresMessageQueue<TMessage>>(),
|
||||
_timeProvider,
|
||||
_jsonOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IRateLimiter"/>.
|
||||
/// Uses sliding window algorithm with INSERT ON CONFLICT UPDATE.
|
||||
/// </summary>
|
||||
public sealed class PostgresRateLimiter : IRateLimiter
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly string _name;
|
||||
private readonly ILogger<PostgresRateLimiter>? _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private bool _tableInitialized;
|
||||
|
||||
public PostgresRateLimiter(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
string name,
|
||||
ILogger<PostgresRateLimiter>? logger = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
private string TableName => $"{_connectionFactory.Schema}.rate_limit_{_name.ToLowerInvariant().Replace("-", "_")}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<RateLimitResult> TryAcquireAsync(
|
||||
string key,
|
||||
RateLimitPolicy policy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var windowStart = now - policy.Window;
|
||||
|
||||
// Clean up old entries and count + increment
|
||||
var sql = $@"
|
||||
WITH cleaned AS (
|
||||
DELETE FROM {TableName}
|
||||
WHERE key = @Key AND timestamp < @WindowStart
|
||||
),
|
||||
existing AS (
|
||||
SELECT COUNT(*) as cnt FROM {TableName} WHERE key = @Key
|
||||
),
|
||||
inserted AS (
|
||||
INSERT INTO {TableName} (key, timestamp)
|
||||
VALUES (@Key, @Now)
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT (SELECT cnt FROM existing) + 1 as count";
|
||||
|
||||
var currentCount = await conn.ExecuteScalarAsync<int>(
|
||||
new CommandDefinition(sql, new { Key = key, WindowStart = windowStart.UtcDateTime, Now = now.UtcDateTime }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (currentCount > policy.MaxPermits)
|
||||
{
|
||||
var retryAfter = policy.Window;
|
||||
return RateLimitResult.Denied(currentCount, retryAfter);
|
||||
}
|
||||
|
||||
var remaining = Math.Max(0, policy.MaxPermits - currentCount);
|
||||
return RateLimitResult.Allowed(currentCount, remaining);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<RateLimitStatus> GetStatusAsync(
|
||||
string key,
|
||||
RateLimitPolicy policy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var windowStart = now - policy.Window;
|
||||
|
||||
var sql = $@"SELECT COUNT(*) FROM {TableName} WHERE key = @Key AND timestamp >= @WindowStart";
|
||||
var currentCount = await conn.ExecuteScalarAsync<int>(
|
||||
new CommandDefinition(sql, new { Key = key, WindowStart = windowStart.UtcDateTime }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var remaining = Math.Max(0, policy.MaxPermits - currentCount);
|
||||
|
||||
return new RateLimitStatus
|
||||
{
|
||||
CurrentCount = currentCount,
|
||||
RemainingPermits = remaining,
|
||||
WindowRemaining = policy.Window,
|
||||
Exists = currentCount > 0
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ResetAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $@"DELETE FROM {TableName} WHERE key = @Key";
|
||||
var deleted = await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = key }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
private async ValueTask EnsureTableExistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized) return;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = $@"
|
||||
CREATE TABLE IF NOT EXISTS {TableName} (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
key TEXT NOT NULL,
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_{_name}_key_ts ON {TableName} (key, timestamp);";
|
||||
|
||||
await conn.ExecuteAsync(new CommandDefinition(sql, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||
_tableInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating PostgreSQL rate limiter instances.
|
||||
/// </summary>
|
||||
public sealed class PostgresRateLimiterFactory : IRateLimiterFactory
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresRateLimiterFactory(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IRateLimiter Create(string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new PostgresRateLimiter(
|
||||
_connectionFactory,
|
||||
name,
|
||||
_loggerFactory?.CreateLogger<PostgresRateLimiter>(),
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="ISetStore{TKey, TElement}"/>.
|
||||
/// Uses unique constraint for set semantics.
|
||||
/// </summary>
|
||||
public sealed class PostgresSetStore<TKey, TElement> : ISetStore<TKey, TElement>
|
||||
where TKey : notnull
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly string _name;
|
||||
private readonly ILogger<PostgresSetStore<TKey, TElement>>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly Func<TKey, string> _keySerializer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private bool _tableInitialized;
|
||||
|
||||
public PostgresSetStore(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
string name,
|
||||
ILogger<PostgresSetStore<TKey, TElement>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
Func<TKey, string>? keySerializer = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_logger = logger;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_keySerializer = keySerializer ?? (key => key?.ToString() ?? throw new ArgumentNullException(nameof(key)));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
private string TableName => $"{_connectionFactory.Schema}.set_store_{_name.ToLowerInvariant().Replace("-", "_")}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> AddAsync(
|
||||
TKey setKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(setKey);
|
||||
var elementJson = Serialize(element);
|
||||
|
||||
var sql = $@"
|
||||
INSERT INTO {TableName} (set_key, element, element_hash)
|
||||
VALUES (@Key, @Element::jsonb, md5(@Element))
|
||||
ON CONFLICT (set_key, element_hash) DO NOTHING
|
||||
RETURNING TRUE";
|
||||
|
||||
var result = await conn.ExecuteScalarAsync<bool?>(
|
||||
new CommandDefinition(sql, new { Key = keyString, Element = elementJson }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result == true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> AddRangeAsync(
|
||||
TKey setKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var elementsList = elements.ToList();
|
||||
if (elementsList.Count == 0) return 0;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(setKey);
|
||||
long addedCount = 0;
|
||||
|
||||
foreach (var element in elementsList)
|
||||
{
|
||||
var elementJson = Serialize(element);
|
||||
var sql = $@"
|
||||
INSERT INTO {TableName} (set_key, element, element_hash)
|
||||
VALUES (@Key, @Element::jsonb, md5(@Element))
|
||||
ON CONFLICT (set_key, element_hash) DO NOTHING
|
||||
RETURNING TRUE";
|
||||
|
||||
var result = await conn.ExecuteScalarAsync<bool?>(
|
||||
new CommandDefinition(sql, new { Key = keyString, Element = elementJson }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result == true) addedCount++;
|
||||
}
|
||||
|
||||
return addedCount;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlySet<TElement>> GetMembersAsync(
|
||||
TKey setKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(setKey);
|
||||
|
||||
var sql = $@"SELECT element FROM {TableName} WHERE set_key = @Key";
|
||||
var results = await conn.QueryAsync<string>(
|
||||
new CommandDefinition(sql, new { Key = keyString }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var set = new HashSet<TElement>();
|
||||
foreach (var json in results)
|
||||
{
|
||||
var element = Deserialize(json);
|
||||
if (element is not null)
|
||||
{
|
||||
set.Add(element);
|
||||
}
|
||||
}
|
||||
|
||||
return set;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ContainsAsync(
|
||||
TKey setKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(setKey);
|
||||
var elementJson = Serialize(element);
|
||||
|
||||
var sql = $@"SELECT EXISTS(SELECT 1 FROM {TableName} WHERE set_key = @Key AND element_hash = md5(@Element))";
|
||||
return await conn.ExecuteScalarAsync<bool>(
|
||||
new CommandDefinition(sql, new { Key = keyString, Element = elementJson }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> RemoveAsync(
|
||||
TKey setKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(setKey);
|
||||
var elementJson = Serialize(element);
|
||||
|
||||
var sql = $@"DELETE FROM {TableName} WHERE set_key = @Key AND element_hash = md5(@Element)";
|
||||
var deleted = await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = keyString, Element = elementJson }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> RemoveRangeAsync(
|
||||
TKey setKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var elementsList = elements.ToList();
|
||||
if (elementsList.Count == 0) return 0;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(setKey);
|
||||
long removedCount = 0;
|
||||
|
||||
foreach (var element in elementsList)
|
||||
{
|
||||
var elementJson = Serialize(element);
|
||||
var sql = $@"DELETE FROM {TableName} WHERE set_key = @Key AND element_hash = md5(@Element)";
|
||||
var deleted = await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = keyString, Element = elementJson }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
removedCount += deleted;
|
||||
}
|
||||
|
||||
return removedCount;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> DeleteAsync(
|
||||
TKey setKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(setKey);
|
||||
|
||||
var sql = $@"DELETE FROM {TableName} WHERE set_key = @Key";
|
||||
var deleted = await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = keyString }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> CountAsync(
|
||||
TKey setKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(setKey);
|
||||
|
||||
var sql = $@"SELECT COUNT(*) FROM {TableName} WHERE set_key = @Key";
|
||||
return await conn.ExecuteScalarAsync<long>(
|
||||
new CommandDefinition(sql, new { Key = keyString }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask SetExpirationAsync(
|
||||
TKey setKey,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(setKey);
|
||||
var expiresAt = _timeProvider.GetUtcNow().Add(ttl);
|
||||
|
||||
var sql = $@"UPDATE {TableName} SET expires_at = @ExpiresAt WHERE set_key = @Key";
|
||||
await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = keyString, ExpiresAt = expiresAt.UtcDateTime }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask EnsureTableExistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized) return;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var safeName = _name.ToLowerInvariant().Replace("-", "_");
|
||||
var sql = $@"
|
||||
CREATE TABLE IF NOT EXISTS {TableName} (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
set_key TEXT NOT NULL,
|
||||
element JSONB NOT NULL,
|
||||
element_hash TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ,
|
||||
UNIQUE (set_key, element_hash)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_{safeName}_set_key ON {TableName} (set_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_{safeName}_expires ON {TableName} (expires_at) WHERE expires_at IS NOT NULL;";
|
||||
|
||||
await conn.ExecuteAsync(new CommandDefinition(sql, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||
_tableInitialized = true;
|
||||
}
|
||||
|
||||
private string Serialize(TElement element)
|
||||
{
|
||||
if (element is string s) return JsonSerializer.Serialize(s, _jsonOptions);
|
||||
return JsonSerializer.Serialize(element, _jsonOptions);
|
||||
}
|
||||
|
||||
private TElement? Deserialize(string value)
|
||||
{
|
||||
if (typeof(TElement) == typeof(string))
|
||||
{
|
||||
return JsonSerializer.Deserialize<TElement>(value, _jsonOptions);
|
||||
}
|
||||
return JsonSerializer.Deserialize<TElement>(value, _jsonOptions);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating PostgreSQL set store instances.
|
||||
/// </summary>
|
||||
public sealed class PostgresSetStoreFactory : ISetStoreFactory
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly JsonSerializerOptions? _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresSetStoreFactory(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_jsonOptions = jsonOptions;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISetStore<TKey, TElement> Create<TKey, TElement>(string name)
|
||||
where TKey : notnull
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new PostgresSetStore<TKey, TElement>(
|
||||
_connectionFactory,
|
||||
name,
|
||||
_loggerFactory?.CreateLogger<PostgresSetStore<TKey, TElement>>(),
|
||||
_jsonOptions,
|
||||
null,
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="ISortedIndex{TKey, TElement}"/>.
|
||||
/// Uses B-tree indexes for efficient score-based queries.
|
||||
/// </summary>
|
||||
public sealed class PostgresSortedIndex<TKey, TElement> : ISortedIndex<TKey, TElement>
|
||||
where TKey : notnull
|
||||
where TElement : notnull
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly string _name;
|
||||
private readonly ILogger<PostgresSortedIndex<TKey, TElement>>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly Func<TKey, string> _keySerializer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private bool _tableInitialized;
|
||||
|
||||
public PostgresSortedIndex(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
string name,
|
||||
ILogger<PostgresSortedIndex<TKey, TElement>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
Func<TKey, string>? keySerializer = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_logger = logger;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_keySerializer = keySerializer ?? (key => key?.ToString() ?? throw new ArgumentNullException(nameof(key)));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
private string TableName => $"{_connectionFactory.Schema}.sorted_index_{_name.ToLowerInvariant().Replace("-", "_")}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> AddAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
double score,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
var elementJson = JsonSerializer.Serialize(element, _jsonOptions);
|
||||
|
||||
var sql = $@"
|
||||
INSERT INTO {TableName} (index_key, element, element_hash, score)
|
||||
VALUES (@Key, @Element::jsonb, md5(@Element), @Score)
|
||||
ON CONFLICT (index_key, element_hash) DO UPDATE SET score = EXCLUDED.score
|
||||
RETURNING (xmax = 0)";
|
||||
|
||||
var wasInserted = await conn.ExecuteScalarAsync<bool>(
|
||||
new CommandDefinition(sql, new { Key = keyString, Element = elementJson, Score = score }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return wasInserted;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> AddRangeAsync(
|
||||
TKey indexKey,
|
||||
IEnumerable<ScoredElement<TElement>> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var elementsList = elements.ToList();
|
||||
if (elementsList.Count == 0) return 0;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
long addedCount = 0;
|
||||
|
||||
foreach (var item in elementsList)
|
||||
{
|
||||
var elementJson = JsonSerializer.Serialize(item.Element, _jsonOptions);
|
||||
var sql = $@"
|
||||
INSERT INTO {TableName} (index_key, element, element_hash, score)
|
||||
VALUES (@Key, @Element::jsonb, md5(@Element), @Score)
|
||||
ON CONFLICT (index_key, element_hash) DO UPDATE SET score = EXCLUDED.score
|
||||
RETURNING (xmax = 0)";
|
||||
|
||||
var wasInserted = await conn.ExecuteScalarAsync<bool>(
|
||||
new CommandDefinition(sql, new { Key = keyString, Element = elementJson, Score = item.Score }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (wasInserted) addedCount++;
|
||||
}
|
||||
|
||||
return addedCount;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlyList<ScoredElement<TElement>>> GetByRankAsync(
|
||||
TKey indexKey,
|
||||
long start,
|
||||
long stop,
|
||||
SortOrder order = SortOrder.Ascending,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
var orderSql = order == SortOrder.Ascending ? "ASC" : "DESC";
|
||||
|
||||
// Handle negative indices like Redis
|
||||
var sql = $@"
|
||||
WITH ranked AS (
|
||||
SELECT element, score, ROW_NUMBER() OVER (ORDER BY score {orderSql}) - 1 AS rank
|
||||
FROM {TableName}
|
||||
WHERE index_key = @Key
|
||||
)
|
||||
SELECT element, score FROM ranked
|
||||
WHERE rank >= @Start AND rank <= @Stop";
|
||||
|
||||
var results = await conn.QueryAsync<ElementScoreRow>(
|
||||
new CommandDefinition(sql, new { Key = keyString, Start = start, Stop = stop }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return results
|
||||
.Select(r => new ScoredElement<TElement>(
|
||||
JsonSerializer.Deserialize<TElement>(r.Element, _jsonOptions)!,
|
||||
r.Score))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlyList<ScoredElement<TElement>>> GetByScoreAsync(
|
||||
TKey indexKey,
|
||||
double minScore,
|
||||
double maxScore,
|
||||
SortOrder order = SortOrder.Ascending,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
var orderSql = order == SortOrder.Ascending ? "ASC" : "DESC";
|
||||
var limitSql = limit.HasValue ? $"LIMIT {limit.Value}" : "";
|
||||
|
||||
var sql = $@"
|
||||
SELECT element, score FROM {TableName}
|
||||
WHERE index_key = @Key AND score >= @MinScore AND score <= @MaxScore
|
||||
ORDER BY score {orderSql}
|
||||
{limitSql}";
|
||||
|
||||
var results = await conn.QueryAsync<ElementScoreRow>(
|
||||
new CommandDefinition(sql, new { Key = keyString, MinScore = minScore, MaxScore = maxScore }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return results
|
||||
.Select(r => new ScoredElement<TElement>(
|
||||
JsonSerializer.Deserialize<TElement>(r.Element, _jsonOptions)!,
|
||||
r.Score))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<double?> GetScoreAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
var elementJson = JsonSerializer.Serialize(element, _jsonOptions);
|
||||
|
||||
var sql = $@"SELECT score FROM {TableName} WHERE index_key = @Key AND element_hash = md5(@Element)";
|
||||
return await conn.ExecuteScalarAsync<double?>(
|
||||
new CommandDefinition(sql, new { Key = keyString, Element = elementJson }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> RemoveAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
var elementJson = JsonSerializer.Serialize(element, _jsonOptions);
|
||||
|
||||
var sql = $@"DELETE FROM {TableName} WHERE index_key = @Key AND element_hash = md5(@Element)";
|
||||
var deleted = await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = keyString, Element = elementJson }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> RemoveRangeAsync(
|
||||
TKey indexKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var elementsList = elements.ToList();
|
||||
if (elementsList.Count == 0) return 0;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
long removedCount = 0;
|
||||
|
||||
foreach (var element in elementsList)
|
||||
{
|
||||
var elementJson = JsonSerializer.Serialize(element, _jsonOptions);
|
||||
var sql = $@"DELETE FROM {TableName} WHERE index_key = @Key AND element_hash = md5(@Element)";
|
||||
var deleted = await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = keyString, Element = elementJson }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
removedCount += deleted;
|
||||
}
|
||||
|
||||
return removedCount;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> RemoveByScoreAsync(
|
||||
TKey indexKey,
|
||||
double minScore,
|
||||
double maxScore,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
|
||||
var sql = $@"DELETE FROM {TableName} WHERE index_key = @Key AND score >= @MinScore AND score <= @MaxScore";
|
||||
return await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = keyString, MinScore = minScore, MaxScore = maxScore }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> CountAsync(
|
||||
TKey indexKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
|
||||
var sql = $@"SELECT COUNT(*) FROM {TableName} WHERE index_key = @Key";
|
||||
return await conn.ExecuteScalarAsync<long>(
|
||||
new CommandDefinition(sql, new { Key = keyString }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> DeleteAsync(
|
||||
TKey indexKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
|
||||
var sql = $@"DELETE FROM {TableName} WHERE index_key = @Key";
|
||||
var deleted = await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = keyString }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask SetExpirationAsync(
|
||||
TKey indexKey,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureTableExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var keyString = _keySerializer(indexKey);
|
||||
var expiresAt = _timeProvider.GetUtcNow().Add(ttl);
|
||||
|
||||
var sql = $@"UPDATE {TableName} SET expires_at = @ExpiresAt WHERE index_key = @Key";
|
||||
await conn.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Key = keyString, ExpiresAt = expiresAt.UtcDateTime }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask EnsureTableExistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized) return;
|
||||
|
||||
await using var conn = await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var safeName = _name.ToLowerInvariant().Replace("-", "_");
|
||||
var sql = $@"
|
||||
CREATE TABLE IF NOT EXISTS {TableName} (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
index_key TEXT NOT NULL,
|
||||
element JSONB NOT NULL,
|
||||
element_hash TEXT NOT NULL,
|
||||
score DOUBLE PRECISION NOT NULL,
|
||||
expires_at TIMESTAMPTZ,
|
||||
UNIQUE (index_key, element_hash)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_{safeName}_score ON {TableName} (index_key, score);
|
||||
CREATE INDEX IF NOT EXISTS idx_{safeName}_expires ON {TableName} (expires_at) WHERE expires_at IS NOT NULL;";
|
||||
|
||||
await conn.ExecuteAsync(new CommandDefinition(sql, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||
_tableInitialized = true;
|
||||
}
|
||||
|
||||
private sealed class ElementScoreRow
|
||||
{
|
||||
public string Element { get; init; } = null!;
|
||||
public double Score { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating PostgreSQL sorted index instances.
|
||||
/// </summary>
|
||||
public sealed class PostgresSortedIndexFactory : ISortedIndexFactory
|
||||
{
|
||||
private readonly PostgresConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly JsonSerializerOptions? _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresSortedIndexFactory(
|
||||
PostgresConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_jsonOptions = jsonOptions;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISortedIndex<TKey, TElement> Create<TKey, TElement>(string name)
|
||||
where TKey : notnull
|
||||
where TElement : notnull
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new PostgresSortedIndex<TKey, TElement>(
|
||||
_connectionFactory,
|
||||
name,
|
||||
_loggerFactory?.CreateLogger<PostgresSortedIndex<TKey, TElement>>(),
|
||||
_jsonOptions,
|
||||
null,
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Messaging.Plugins;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL transport plugin for StellaOps.Messaging.
|
||||
/// Uses FOR UPDATE SKIP LOCKED for reliable queue semantics.
|
||||
/// Ideal for air-gap deployments with PostgreSQL-only infrastructure.
|
||||
/// </summary>
|
||||
public sealed class PostgresTransportPlugin : IMessagingTransportPlugin
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Name => "postgres";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Register(MessagingTransportRegistrationContext context)
|
||||
{
|
||||
// Register options
|
||||
context.Services.AddOptions<PostgresTransportOptions>()
|
||||
.Bind(context.GetTransportConfiguration())
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
// Register connection factory
|
||||
context.Services.AddSingleton<PostgresConnectionFactory>();
|
||||
|
||||
// Register message queue factory
|
||||
context.Services.AddSingleton<IMessageQueueFactory, PostgresMessageQueueFactory>();
|
||||
|
||||
// Register cache factory
|
||||
context.Services.AddSingleton<IDistributedCacheFactory, PostgresCacheFactory>();
|
||||
|
||||
// Register rate limiter factory
|
||||
context.Services.AddSingleton<IRateLimiterFactory, PostgresRateLimiterFactory>();
|
||||
|
||||
// Register atomic token store factory
|
||||
context.Services.AddSingleton<IAtomicTokenStoreFactory, PostgresAtomicTokenStoreFactory>();
|
||||
|
||||
// Register sorted index factory
|
||||
context.Services.AddSingleton<ISortedIndexFactory, PostgresSortedIndexFactory>();
|
||||
|
||||
// Register set store factory
|
||||
context.Services.AddSingleton<ISetStoreFactory, PostgresSetStoreFactory>();
|
||||
|
||||
// Register event stream factory
|
||||
context.Services.AddSingleton<IEventStreamFactory, PostgresEventStreamFactory>();
|
||||
|
||||
// Register idempotency store factory
|
||||
context.Services.AddSingleton<IIdempotencyStoreFactory, PostgresIdempotencyStoreFactory>();
|
||||
|
||||
context.LoggerFactory?.CreateLogger<PostgresTransportPlugin>()
|
||||
.LogDebug("Registered PostgreSQL transport plugin");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Messaging.Transport.Postgres</RootNamespace>
|
||||
<AssemblyName>StellaOps.Messaging.Transport.Postgres</AssemblyName>
|
||||
<Description>PostgreSQL transport plugin for StellaOps.Messaging (FOR UPDATE SKIP LOCKED pattern)</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapper" />
|
||||
<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>
|
||||
@@ -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:";
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,282 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Valkey/Redis implementation of <see cref="IAtomicTokenStore{TPayload}"/>.
|
||||
/// Uses Lua scripts for atomic compare-and-delete operations.
|
||||
/// </summary>
|
||||
public sealed class ValkeyAtomicTokenStore<TPayload> : IAtomicTokenStore<TPayload>
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly string _name;
|
||||
private readonly ILogger<ValkeyAtomicTokenStore<TPayload>>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
// Lua script for atomic consume: GET, compare, DELETE if matches
|
||||
private const string ConsumeScript = @"
|
||||
local value = redis.call('GET', KEYS[1])
|
||||
if not value then
|
||||
return {0, nil}
|
||||
end
|
||||
local data = cjson.decode(value)
|
||||
if data.token ~= ARGV[1] then
|
||||
return {2, value}
|
||||
end
|
||||
redis.call('DEL', KEYS[1])
|
||||
return {1, value}
|
||||
";
|
||||
|
||||
public ValkeyAtomicTokenStore(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
string name,
|
||||
ILogger<ValkeyAtomicTokenStore<TPayload>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_logger = logger;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<TokenIssueResult> IssueAsync(
|
||||
string key,
|
||||
TPayload payload,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.Add(ttl);
|
||||
|
||||
// Generate secure random token
|
||||
var tokenBytes = new byte[32];
|
||||
RandomNumberGenerator.Fill(tokenBytes);
|
||||
var token = Convert.ToBase64String(tokenBytes);
|
||||
|
||||
var entry = new TokenData<TPayload>
|
||||
{
|
||||
Token = token,
|
||||
Payload = payload,
|
||||
IssuedAt = now,
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
|
||||
var serialized = JsonSerializer.Serialize(entry, _jsonOptions);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await db.StringSetAsync(redisKey, serialized, ttl).ConfigureAwait(false);
|
||||
|
||||
return TokenIssueResult.Succeeded(token, expiresAt);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<TokenIssueResult> StoreAsync(
|
||||
string key,
|
||||
string token,
|
||||
TPayload payload,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(token);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.Add(ttl);
|
||||
|
||||
var entry = new TokenData<TPayload>
|
||||
{
|
||||
Token = token,
|
||||
Payload = payload,
|
||||
IssuedAt = now,
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
|
||||
var serialized = JsonSerializer.Serialize(entry, _jsonOptions);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await db.StringSetAsync(redisKey, serialized, ttl).ConfigureAwait(false);
|
||||
|
||||
return TokenIssueResult.Succeeded(token, expiresAt);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<TokenConsumeResult<TPayload>> TryConsumeAsync(
|
||||
string key,
|
||||
string expectedToken,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(expectedToken);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
var result = await db.ScriptEvaluateAsync(
|
||||
ConsumeScript,
|
||||
new RedisKey[] { redisKey },
|
||||
new RedisValue[] { expectedToken }).ConfigureAwait(false);
|
||||
|
||||
var results = (RedisResult[])result!;
|
||||
var status = (int)results[0];
|
||||
|
||||
switch (status)
|
||||
{
|
||||
case 0: // Not found
|
||||
return TokenConsumeResult<TPayload>.NotFound();
|
||||
|
||||
case 1: // Success
|
||||
var data = JsonSerializer.Deserialize<TokenData<TPayload>>((string)results[1]!, _jsonOptions);
|
||||
if (data is null)
|
||||
{
|
||||
return TokenConsumeResult<TPayload>.NotFound();
|
||||
}
|
||||
|
||||
if (data.ExpiresAt < now)
|
||||
{
|
||||
return TokenConsumeResult<TPayload>.Expired(data.IssuedAt, data.ExpiresAt);
|
||||
}
|
||||
|
||||
return TokenConsumeResult<TPayload>.Success(data.Payload!, data.IssuedAt, data.ExpiresAt);
|
||||
|
||||
case 2: // Mismatch
|
||||
return TokenConsumeResult<TPayload>.Mismatch();
|
||||
|
||||
default:
|
||||
return TokenConsumeResult<TPayload>.NotFound();
|
||||
}
|
||||
}
|
||||
catch (RedisServerException ex) when (ex.Message.Contains("NOSCRIPT"))
|
||||
{
|
||||
// Fallback: non-atomic approach (less safe but works without Lua)
|
||||
return await TryConsumeNonAtomicAsync(db, redisKey, expectedToken, now).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask<TokenConsumeResult<TPayload>> TryConsumeNonAtomicAsync(
|
||||
IDatabase db,
|
||||
string redisKey,
|
||||
string expectedToken,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
var value = await db.StringGetAsync(redisKey).ConfigureAwait(false);
|
||||
if (value.IsNullOrEmpty)
|
||||
{
|
||||
return TokenConsumeResult<TPayload>.NotFound();
|
||||
}
|
||||
|
||||
var data = JsonSerializer.Deserialize<TokenData<TPayload>>((string)value!, _jsonOptions);
|
||||
if (data is null)
|
||||
{
|
||||
return TokenConsumeResult<TPayload>.NotFound();
|
||||
}
|
||||
|
||||
if (data.ExpiresAt < now)
|
||||
{
|
||||
await db.KeyDeleteAsync(redisKey).ConfigureAwait(false);
|
||||
return TokenConsumeResult<TPayload>.Expired(data.IssuedAt, data.ExpiresAt);
|
||||
}
|
||||
|
||||
if (!string.Equals(data.Token, expectedToken, StringComparison.Ordinal))
|
||||
{
|
||||
return TokenConsumeResult<TPayload>.Mismatch();
|
||||
}
|
||||
|
||||
// Try to delete - if someone else deleted it first, we lost the race
|
||||
if (await db.KeyDeleteAsync(redisKey).ConfigureAwait(false))
|
||||
{
|
||||
return TokenConsumeResult<TPayload>.Success(data.Payload!, data.IssuedAt, data.ExpiresAt);
|
||||
}
|
||||
|
||||
return TokenConsumeResult<TPayload>.NotFound();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ExistsAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await db.KeyExistsAsync(redisKey).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> RevokeAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await db.KeyDeleteAsync(redisKey).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private string BuildKey(string key) => $"token:{_name}:{key}";
|
||||
|
||||
private sealed class TokenData<T>
|
||||
{
|
||||
public required string Token { get; init; }
|
||||
public T? Payload { get; init; }
|
||||
public DateTimeOffset IssuedAt { get; init; }
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating Valkey atomic token store instances.
|
||||
/// </summary>
|
||||
public sealed class ValkeyAtomicTokenStoreFactory : IAtomicTokenStoreFactory
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly JsonSerializerOptions? _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ValkeyAtomicTokenStoreFactory(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_jsonOptions = jsonOptions;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAtomicTokenStore<TPayload> Create<TPayload>(string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new ValkeyAtomicTokenStore<TPayload>(
|
||||
_connectionFactory,
|
||||
name,
|
||||
_loggerFactory?.CreateLogger<ValkeyAtomicTokenStore<TPayload>>(),
|
||||
_jsonOptions,
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating Valkey distributed cache instances.
|
||||
/// </summary>
|
||||
public sealed class ValkeyCacheFactory : IDistributedCacheFactory
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public ValkeyCacheFactory(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
JsonSerializerOptions? jsonOptions = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDistributedCache<TKey, TValue> Create<TKey, TValue>(CacheOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
return new ValkeyCacheStore<TKey, TValue>(
|
||||
_connectionFactory,
|
||||
options,
|
||||
_loggerFactory?.CreateLogger<ValkeyCacheStore<TKey, TValue>>(),
|
||||
_jsonOptions);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDistributedCache<TValue> Create<TValue>(CacheOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
return new ValkeyCacheStore<TValue>(
|
||||
_connectionFactory,
|
||||
options,
|
||||
_loggerFactory?.CreateLogger<ValkeyCacheStore<TValue>>(),
|
||||
_jsonOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Valkey/Redis implementation of <see cref="IDistributedCache{TKey, TValue}"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TKey">The key type.</typeparam>
|
||||
/// <typeparam name="TValue">The value type.</typeparam>
|
||||
public sealed class ValkeyCacheStore<TKey, TValue> : IDistributedCache<TKey, TValue>
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly CacheOptions _cacheOptions;
|
||||
private readonly ILogger<ValkeyCacheStore<TKey, TValue>>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly Func<TKey, string> _keySerializer;
|
||||
|
||||
public ValkeyCacheStore(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
CacheOptions cacheOptions,
|
||||
ILogger<ValkeyCacheStore<TKey, TValue>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
Func<TKey, string>? keySerializer = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_cacheOptions = cacheOptions ?? throw new ArgumentNullException(nameof(cacheOptions));
|
||||
_logger = logger;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_keySerializer = keySerializer ?? (key => key?.ToString() ?? throw new ArgumentNullException(nameof(key)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<CacheResult<TValue>> GetAsync(TKey key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var value = await db.StringGetAsync(redisKey).ConfigureAwait(false);
|
||||
|
||||
if (value.IsNullOrEmpty)
|
||||
{
|
||||
return CacheResult<TValue>.Miss();
|
||||
}
|
||||
|
||||
// Handle sliding expiration by refreshing TTL
|
||||
if (_cacheOptions.SlidingExpiration && _cacheOptions.DefaultTtl.HasValue)
|
||||
{
|
||||
await db.KeyExpireAsync(redisKey, _cacheOptions.DefaultTtl.Value).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = JsonSerializer.Deserialize<TValue>((string)value!, _jsonOptions);
|
||||
return result is not null ? CacheResult<TValue>.Found(result) : CacheResult<TValue>.Miss();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Failed to deserialize cached value for key {Key}", redisKey);
|
||||
return CacheResult<TValue>.Miss();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask SetAsync(
|
||||
TKey key,
|
||||
TValue value,
|
||||
CacheEntryOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(key);
|
||||
var serialized = JsonSerializer.Serialize(value, _jsonOptions);
|
||||
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
TimeSpan? expiry = null;
|
||||
|
||||
if (options?.TimeToLive.HasValue == true)
|
||||
{
|
||||
expiry = options.TimeToLive.Value;
|
||||
}
|
||||
else if (options?.AbsoluteExpiration.HasValue == true)
|
||||
{
|
||||
expiry = options.AbsoluteExpiration.Value - DateTimeOffset.UtcNow;
|
||||
if (expiry.Value < TimeSpan.Zero)
|
||||
{
|
||||
expiry = TimeSpan.Zero;
|
||||
}
|
||||
}
|
||||
else if (_cacheOptions.DefaultTtl.HasValue)
|
||||
{
|
||||
expiry = _cacheOptions.DefaultTtl.Value;
|
||||
}
|
||||
|
||||
await db.StringSetAsync(redisKey, serialized, expiry ?? 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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Valkey/Redis implementation of <see cref="IEventStream{TEvent}"/>.
|
||||
/// Uses stream commands (XADD, XREAD, XINFO, XTRIM) without consumer groups.
|
||||
/// </summary>
|
||||
public sealed class ValkeyEventStream<TEvent> : IEventStream<TEvent>
|
||||
where TEvent : class
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly EventStreamOptions _options;
|
||||
private readonly ILogger<ValkeyEventStream<TEvent>>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private const string DataField = "data";
|
||||
private const string TenantIdField = "tenantId";
|
||||
private const string CorrelationIdField = "correlationId";
|
||||
|
||||
public ValkeyEventStream(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
EventStreamOptions options,
|
||||
ILogger<ValkeyEventStream<TEvent>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string StreamName => _options.StreamName;
|
||||
|
||||
private string RedisKey => $"stream:{_options.StreamName}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<EventPublishResult> PublishAsync(
|
||||
TEvent @event,
|
||||
EventPublishOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(@event);
|
||||
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var entries = new List<NameValueEntry>
|
||||
{
|
||||
new(DataField, JsonSerializer.Serialize(@event, _jsonOptions))
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(options?.TenantId))
|
||||
{
|
||||
entries.Add(new(TenantIdField, options.TenantId));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(options?.CorrelationId))
|
||||
{
|
||||
entries.Add(new(CorrelationIdField, options.CorrelationId));
|
||||
}
|
||||
|
||||
// Add custom headers
|
||||
if (options?.Headers is not null)
|
||||
{
|
||||
foreach (var header in options.Headers)
|
||||
{
|
||||
entries.Add(new($"h:{header.Key}", header.Value));
|
||||
}
|
||||
}
|
||||
|
||||
var entryId = await db.StreamAddAsync(
|
||||
RedisKey,
|
||||
entries.ToArray(),
|
||||
maxLength: _options.MaxLength.HasValue ? (int)_options.MaxLength.Value : null,
|
||||
useApproximateMaxLength: _options.ApproximateTrimming).ConfigureAwait(false);
|
||||
|
||||
return EventPublishResult.Succeeded(entryId!);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlyList<EventPublishResult>> PublishBatchAsync(
|
||||
IEnumerable<TEvent> events,
|
||||
EventPublishOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(events);
|
||||
|
||||
var results = new List<EventPublishResult>();
|
||||
|
||||
foreach (var @event in events)
|
||||
{
|
||||
var result = await PublishAsync(@event, options, cancellationToken).ConfigureAwait(false);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<StreamEvent<TEvent>> SubscribeAsync(
|
||||
StreamPosition position,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Convert position to Redis format
|
||||
var lastId = position.Value == "0" ? "0-0" :
|
||||
position.Value == "$" ? "$" :
|
||||
position.Value;
|
||||
|
||||
// If starting from end, get current last entry ID
|
||||
if (lastId == "$")
|
||||
{
|
||||
var info = await GetInfoAsync(cancellationToken).ConfigureAwait(false);
|
||||
lastId = info.LastEntryId ?? "0-0";
|
||||
}
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var entries = await db.StreamReadAsync(
|
||||
RedisKey,
|
||||
lastId,
|
||||
count: 100).ConfigureAwait(false);
|
||||
|
||||
if (entries.Length > 0)
|
||||
{
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var streamEvent = ParseEntry(entry);
|
||||
if (streamEvent is not null)
|
||||
{
|
||||
yield return streamEvent;
|
||||
}
|
||||
lastId = entry.Id!;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No new entries, wait before polling again
|
||||
await Task.Delay(_options.PollInterval, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<StreamInfo> GetInfoAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var info = await db.StreamInfoAsync(RedisKey).ConfigureAwait(false);
|
||||
|
||||
return new StreamInfo(
|
||||
info.Length,
|
||||
info.FirstEntry.Id,
|
||||
info.LastEntry.Id,
|
||||
ParseTimestamp(info.FirstEntry.Id),
|
||||
ParseTimestamp(info.LastEntry.Id));
|
||||
}
|
||||
catch (RedisServerException ex) when (ex.Message.Contains("no such key"))
|
||||
{
|
||||
return new StreamInfo(0, null, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> TrimAsync(
|
||||
long maxLength,
|
||||
bool approximate = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await db.StreamTrimAsync(RedisKey, (int)maxLength, approximate).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private StreamEvent<TEvent>? ParseEntry(StreamEntry entry)
|
||||
{
|
||||
var data = entry[DataField];
|
||||
if (data.IsNullOrEmpty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var @event = JsonSerializer.Deserialize<TEvent>((string)data!, _jsonOptions);
|
||||
if (@event is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var tenantId = entry[TenantIdField];
|
||||
var correlationId = entry[CorrelationIdField];
|
||||
|
||||
return new StreamEvent<TEvent>(
|
||||
entry.Id!,
|
||||
@event,
|
||||
ParseTimestamp(entry.Id) ?? _timeProvider.GetUtcNow(),
|
||||
tenantId.IsNullOrEmpty ? null : (string)tenantId!,
|
||||
correlationId.IsNullOrEmpty ? null : (string)correlationId!);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
_logger?.LogWarning("Failed to deserialize stream event {EntryId}", entry.Id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ParseTimestamp(string? entryId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(entryId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Redis stream IDs are formatted as "timestamp-sequence"
|
||||
var dashIndex = entryId.IndexOf('-');
|
||||
if (dashIndex <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (long.TryParse(entryId.AsSpan(0, dashIndex), out var timestamp))
|
||||
{
|
||||
return DateTimeOffset.FromUnixTimeMilliseconds(timestamp);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating Valkey event stream instances.
|
||||
/// </summary>
|
||||
public sealed class ValkeyEventStreamFactory : IEventStreamFactory
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly JsonSerializerOptions? _jsonOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ValkeyEventStreamFactory(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_jsonOptions = jsonOptions;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEventStream<TEvent> Create<TEvent>(EventStreamOptions options) where TEvent : class
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return new ValkeyEventStream<TEvent>(
|
||||
_connectionFactory,
|
||||
options,
|
||||
_loggerFactory?.CreateLogger<ValkeyEventStream<TEvent>>(),
|
||||
_jsonOptions,
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Valkey/Redis implementation of <see cref="IIdempotencyStore"/>.
|
||||
/// Uses SET NX EX for atomic key claiming.
|
||||
/// </summary>
|
||||
public sealed class ValkeyIdempotencyStore : IIdempotencyStore
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly string _name;
|
||||
private readonly ILogger<ValkeyIdempotencyStore>? _logger;
|
||||
|
||||
public ValkeyIdempotencyStore(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
string name,
|
||||
ILogger<ValkeyIdempotencyStore>? logger = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IdempotencyResult> TryClaimAsync(
|
||||
string key,
|
||||
string value,
|
||||
TimeSpan window,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// SET key value NX EX ttl - only sets if key doesn't exist
|
||||
var wasSet = await db.StringSetAsync(redisKey, value, window, When.NotExists).ConfigureAwait(false);
|
||||
|
||||
if (wasSet)
|
||||
{
|
||||
return IdempotencyResult.Claimed();
|
||||
}
|
||||
|
||||
// Key already exists, get the existing value
|
||||
var existing = await db.StringGetAsync(redisKey).ConfigureAwait(false);
|
||||
return IdempotencyResult.Duplicate(existing.HasValue ? (string)existing! : string.Empty);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ExistsAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await db.KeyExistsAsync(redisKey).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<string?> GetAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var value = await db.StringGetAsync(redisKey).ConfigureAwait(false);
|
||||
return value.HasValue ? (string)value! : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ReleaseAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await db.KeyDeleteAsync(redisKey).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ExtendAsync(
|
||||
string key,
|
||||
TimeSpan extension,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Get current TTL and extend it
|
||||
var currentTtl = await db.KeyTimeToLiveAsync(redisKey).ConfigureAwait(false);
|
||||
if (!currentTtl.HasValue)
|
||||
{
|
||||
return false; // Key doesn't exist or has no TTL
|
||||
}
|
||||
|
||||
var newTtl = currentTtl.Value + extension;
|
||||
return await db.KeyExpireAsync(redisKey, newTtl).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private string BuildKey(string key) => $"idempotency:{_name}:{key}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating Valkey idempotency store instances.
|
||||
/// </summary>
|
||||
public sealed class ValkeyIdempotencyStoreFactory : IIdempotencyStoreFactory
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
|
||||
public ValkeyIdempotencyStoreFactory(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IIdempotencyStore Create(string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new ValkeyIdempotencyStore(
|
||||
_connectionFactory,
|
||||
name,
|
||||
_loggerFactory?.CreateLogger<ValkeyIdempotencyStore>());
|
||||
}
|
||||
}
|
||||
@@ -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++;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating Valkey message queue instances.
|
||||
/// </summary>
|
||||
public sealed class ValkeyMessageQueueFactory : IMessageQueueFactory
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly ValkeyTransportOptions _transportOptions;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public ValkeyMessageQueueFactory(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
IOptions<ValkeyTransportOptions> transportOptions,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
TimeProvider? timeProvider = null,
|
||||
JsonSerializerOptions? jsonOptions = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_transportOptions = transportOptions?.Value ?? throw new ArgumentNullException(nameof(transportOptions));
|
||||
_loggerFactory = loggerFactory;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IMessageQueue<TMessage> Create<TMessage>(MessageQueueOptions options)
|
||||
where TMessage : class
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
return new ValkeyMessageQueue<TMessage>(
|
||||
_connectionFactory,
|
||||
options,
|
||||
_transportOptions,
|
||||
_loggerFactory?.CreateLogger<ValkeyMessageQueue<TMessage>>(),
|
||||
_timeProvider,
|
||||
_jsonOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Valkey/Redis implementation of <see cref="IRateLimiter"/>.
|
||||
/// Uses sliding window algorithm with INCR and EXPIRE commands.
|
||||
/// </summary>
|
||||
public sealed class ValkeyRateLimiter : IRateLimiter
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly string _name;
|
||||
private readonly ILogger<ValkeyRateLimiter>? _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ValkeyRateLimiter(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
string name,
|
||||
ILogger<ValkeyRateLimiter>? logger = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<RateLimitResult> TryAcquireAsync(
|
||||
string key,
|
||||
RateLimitPolicy policy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Use sliding window with timestamp-based keys
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var windowKey = $"{redisKey}:{now.ToUnixTimeSeconds() / (long)policy.Window.TotalSeconds}";
|
||||
|
||||
var transaction = db.CreateTransaction();
|
||||
var incrTask = transaction.StringIncrementAsync(windowKey);
|
||||
var expireTask = transaction.KeyExpireAsync(windowKey, policy.Window + TimeSpan.FromSeconds(1));
|
||||
|
||||
await transaction.ExecuteAsync().ConfigureAwait(false);
|
||||
|
||||
var currentCount = (int)await incrTask.ConfigureAwait(false);
|
||||
|
||||
if (currentCount > policy.MaxPermits)
|
||||
{
|
||||
// We incremented but exceeded, calculate retry after
|
||||
var retryAfter = policy.Window;
|
||||
return RateLimitResult.Denied(currentCount, retryAfter);
|
||||
}
|
||||
|
||||
var remaining = Math.Max(0, policy.MaxPermits - currentCount);
|
||||
return RateLimitResult.Allowed(currentCount, remaining);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<RateLimitStatus> GetStatusAsync(
|
||||
string key,
|
||||
RateLimitPolicy policy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var windowKey = $"{redisKey}:{now.ToUnixTimeSeconds() / (long)policy.Window.TotalSeconds}";
|
||||
|
||||
var value = await db.StringGetAsync(windowKey).ConfigureAwait(false);
|
||||
var currentCount = value.HasValue ? (int)(long)value : 0;
|
||||
var remaining = Math.Max(0, policy.MaxPermits - currentCount);
|
||||
|
||||
return new RateLimitStatus
|
||||
{
|
||||
CurrentCount = currentCount,
|
||||
RemainingPermits = remaining,
|
||||
WindowRemaining = policy.Window,
|
||||
Exists = value.HasValue
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ResetAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var redisKey = BuildKey(key);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Delete all keys matching the pattern
|
||||
var connection = await _connectionFactory.GetConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
var server = connection.GetServers().First();
|
||||
|
||||
var deleted = false;
|
||||
await foreach (var matchingKey in server.KeysAsync(pattern: $"{redisKey}:*"))
|
||||
{
|
||||
if (await db.KeyDeleteAsync(matchingKey).ConfigureAwait(false))
|
||||
{
|
||||
deleted = true;
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
private string BuildKey(string key) => $"ratelimit:{_name}:{key}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating Valkey rate limiter instances.
|
||||
/// </summary>
|
||||
public sealed class ValkeyRateLimiterFactory : IRateLimiterFactory
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ValkeyRateLimiterFactory(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IRateLimiter Create(string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new ValkeyRateLimiter(
|
||||
_connectionFactory,
|
||||
name,
|
||||
_loggerFactory?.CreateLogger<ValkeyRateLimiter>(),
|
||||
_timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Valkey/Redis implementation of <see cref="ISetStore{TKey, TElement}"/>.
|
||||
/// Uses set commands (SADD, SMEMBERS, SISMEMBER, SREM, etc.).
|
||||
/// </summary>
|
||||
public sealed class ValkeySetStore<TKey, TElement> : ISetStore<TKey, TElement>
|
||||
where TKey : notnull
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly string _name;
|
||||
private readonly ILogger<ValkeySetStore<TKey, TElement>>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly Func<TKey, string> _keySerializer;
|
||||
|
||||
public ValkeySetStore(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
string name,
|
||||
ILogger<ValkeySetStore<TKey, TElement>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
Func<TKey, string>? keySerializer = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_logger = logger;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_keySerializer = keySerializer ?? (key => key?.ToString() ?? throw new ArgumentNullException(nameof(key)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> AddAsync(
|
||||
TKey setKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(setKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var serialized = Serialize(element);
|
||||
return await db.SetAddAsync(redisKey, serialized).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> AddRangeAsync(
|
||||
TKey setKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
var redisKey = BuildKey(setKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var values = elements.Select(e => (RedisValue)Serialize(e)).ToArray();
|
||||
if (values.Length == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return await db.SetAddAsync(redisKey, values).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlySet<TElement>> GetMembersAsync(
|
||||
TKey setKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(setKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var members = await db.SetMembersAsync(redisKey).ConfigureAwait(false);
|
||||
var result = new HashSet<TElement>();
|
||||
|
||||
foreach (var member in members)
|
||||
{
|
||||
if (!member.IsNullOrEmpty)
|
||||
{
|
||||
var element = Deserialize((string)member!);
|
||||
if (element is not null)
|
||||
{
|
||||
result.Add(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> ContainsAsync(
|
||||
TKey setKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(setKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var serialized = Serialize(element);
|
||||
return await db.SetContainsAsync(redisKey, serialized).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> RemoveAsync(
|
||||
TKey setKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(setKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var serialized = Serialize(element);
|
||||
return await db.SetRemoveAsync(redisKey, serialized).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> RemoveRangeAsync(
|
||||
TKey setKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
var redisKey = BuildKey(setKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var values = elements.Select(e => (RedisValue)Serialize(e)).ToArray();
|
||||
if (values.Length == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return await db.SetRemoveAsync(redisKey, values).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> DeleteAsync(
|
||||
TKey setKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(setKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await db.KeyDeleteAsync(redisKey).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> CountAsync(
|
||||
TKey setKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(setKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await db.SetLengthAsync(redisKey).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask SetExpirationAsync(
|
||||
TKey setKey,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(setKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await db.KeyExpireAsync(redisKey, ttl).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private string BuildKey(TKey setKey)
|
||||
{
|
||||
var keyString = _keySerializer(setKey);
|
||||
return $"set:{_name}:{keyString}";
|
||||
}
|
||||
|
||||
private string Serialize(TElement element)
|
||||
{
|
||||
// For primitive types, use ToString directly
|
||||
if (element is string s)
|
||||
{
|
||||
return s;
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(element, _jsonOptions);
|
||||
}
|
||||
|
||||
private TElement? Deserialize(string value)
|
||||
{
|
||||
// For string types, return directly
|
||||
if (typeof(TElement) == typeof(string))
|
||||
{
|
||||
return (TElement)(object)value;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<TElement>(value, _jsonOptions);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating Valkey set store instances.
|
||||
/// </summary>
|
||||
public sealed class ValkeySetStoreFactory : ISetStoreFactory
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly JsonSerializerOptions? _jsonOptions;
|
||||
|
||||
public ValkeySetStoreFactory(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
JsonSerializerOptions? jsonOptions = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_jsonOptions = jsonOptions;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISetStore<TKey, TElement> Create<TKey, TElement>(string name)
|
||||
where TKey : notnull
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new ValkeySetStore<TKey, TElement>(
|
||||
_connectionFactory,
|
||||
name,
|
||||
_loggerFactory?.CreateLogger<ValkeySetStore<TKey, TElement>>(),
|
||||
_jsonOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Valkey/Redis implementation of <see cref="ISortedIndex{TKey, TElement}"/>.
|
||||
/// Uses sorted set commands (ZADD, ZRANGE, ZRANGEBYSCORE, etc.).
|
||||
/// </summary>
|
||||
public sealed class ValkeySortedIndex<TKey, TElement> : ISortedIndex<TKey, TElement>
|
||||
where TKey : notnull
|
||||
where TElement : notnull
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly string _name;
|
||||
private readonly ILogger<ValkeySortedIndex<TKey, TElement>>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly Func<TKey, string> _keySerializer;
|
||||
|
||||
public ValkeySortedIndex(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
string name,
|
||||
ILogger<ValkeySortedIndex<TKey, TElement>>? logger = null,
|
||||
JsonSerializerOptions? jsonOptions = null,
|
||||
Func<TKey, string>? keySerializer = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_logger = logger;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
_keySerializer = keySerializer ?? (key => key?.ToString() ?? throw new ArgumentNullException(nameof(key)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> AddAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
double score,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var serialized = JsonSerializer.Serialize(element, _jsonOptions);
|
||||
return await db.SortedSetAddAsync(redisKey, serialized, score).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> AddRangeAsync(
|
||||
TKey indexKey,
|
||||
IEnumerable<ScoredElement<TElement>> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var entries = elements
|
||||
.Select(e => new SortedSetEntry(JsonSerializer.Serialize(e.Element, _jsonOptions), e.Score))
|
||||
.ToArray();
|
||||
|
||||
if (entries.Length == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return await db.SortedSetAddAsync(redisKey, entries).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlyList<ScoredElement<TElement>>> GetByRankAsync(
|
||||
TKey indexKey,
|
||||
long start,
|
||||
long stop,
|
||||
SortOrder order = SortOrder.Ascending,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var redisOrder = order == SortOrder.Ascending ? Order.Ascending : Order.Descending;
|
||||
var entries = await db.SortedSetRangeByRankWithScoresAsync(redisKey, start, stop, redisOrder).ConfigureAwait(false);
|
||||
|
||||
return entries
|
||||
.Select(e => new ScoredElement<TElement>(
|
||||
JsonSerializer.Deserialize<TElement>((string)e.Element!, _jsonOptions)!,
|
||||
e.Score))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlyList<ScoredElement<TElement>>> GetByScoreAsync(
|
||||
TKey indexKey,
|
||||
double minScore,
|
||||
double maxScore,
|
||||
SortOrder order = SortOrder.Ascending,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var redisOrder = order == SortOrder.Ascending ? Order.Ascending : Order.Descending;
|
||||
var take = limit ?? -1;
|
||||
|
||||
var entries = await db.SortedSetRangeByScoreWithScoresAsync(
|
||||
redisKey,
|
||||
minScore,
|
||||
maxScore,
|
||||
order: redisOrder,
|
||||
take: take).ConfigureAwait(false);
|
||||
|
||||
return entries
|
||||
.Select(e => new ScoredElement<TElement>(
|
||||
JsonSerializer.Deserialize<TElement>((string)e.Element!, _jsonOptions)!,
|
||||
e.Score))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<double?> GetScoreAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var serialized = JsonSerializer.Serialize(element, _jsonOptions);
|
||||
return await db.SortedSetScoreAsync(redisKey, serialized).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> RemoveAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var serialized = JsonSerializer.Serialize(element, _jsonOptions);
|
||||
return await db.SortedSetRemoveAsync(redisKey, serialized).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> RemoveRangeAsync(
|
||||
TKey indexKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(elements);
|
||||
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var values = elements
|
||||
.Select(e => (RedisValue)JsonSerializer.Serialize(e, _jsonOptions))
|
||||
.ToArray();
|
||||
|
||||
if (values.Length == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return await db.SortedSetRemoveAsync(redisKey, values).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> RemoveByScoreAsync(
|
||||
TKey indexKey,
|
||||
double minScore,
|
||||
double maxScore,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await db.SortedSetRemoveRangeByScoreAsync(redisKey, minScore, maxScore).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> CountAsync(
|
||||
TKey indexKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await db.SortedSetLengthAsync(redisKey).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> DeleteAsync(
|
||||
TKey indexKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await db.KeyDeleteAsync(redisKey).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask SetExpirationAsync(
|
||||
TKey indexKey,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var redisKey = BuildKey(indexKey);
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await db.KeyExpireAsync(redisKey, ttl).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private string BuildKey(TKey indexKey)
|
||||
{
|
||||
var keyString = _keySerializer(indexKey);
|
||||
return $"sortedindex:{_name}:{keyString}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating Valkey sorted index instances.
|
||||
/// </summary>
|
||||
public sealed class ValkeySortedIndexFactory : ISortedIndexFactory
|
||||
{
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly JsonSerializerOptions? _jsonOptions;
|
||||
|
||||
public ValkeySortedIndexFactory(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
JsonSerializerOptions? jsonOptions = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_loggerFactory = loggerFactory;
|
||||
_jsonOptions = jsonOptions;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISortedIndex<TKey, TElement> Create<TKey, TElement>(string name)
|
||||
where TKey : notnull
|
||||
where TElement : notnull
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return new ValkeySortedIndex<TKey, TElement>(
|
||||
_connectionFactory,
|
||||
name,
|
||||
_loggerFactory?.CreateLogger<ValkeySortedIndex<TKey, TElement>>(),
|
||||
_jsonOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Messaging.Plugins;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Valkey/Redis transport plugin for StellaOps.Messaging.
|
||||
/// </summary>
|
||||
public sealed class ValkeyTransportPlugin : IMessagingTransportPlugin
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Name => "valkey";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Register(MessagingTransportRegistrationContext context)
|
||||
{
|
||||
// Register options
|
||||
context.Services.AddOptions<ValkeyTransportOptions>()
|
||||
.Bind(context.GetTransportConfiguration())
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
// Register connection factory
|
||||
context.Services.AddSingleton<ValkeyConnectionFactory>();
|
||||
|
||||
// Register message queue factory
|
||||
context.Services.AddSingleton<IMessageQueueFactory, ValkeyMessageQueueFactory>();
|
||||
|
||||
// Register cache factory
|
||||
context.Services.AddSingleton<IDistributedCacheFactory, ValkeyCacheFactory>();
|
||||
|
||||
// Register rate limiter factory
|
||||
context.Services.AddSingleton<IRateLimiterFactory, ValkeyRateLimiterFactory>();
|
||||
|
||||
// Register atomic token store factory
|
||||
context.Services.AddSingleton<IAtomicTokenStoreFactory, ValkeyAtomicTokenStoreFactory>();
|
||||
|
||||
// Register sorted index factory
|
||||
context.Services.AddSingleton<ISortedIndexFactory, ValkeySortedIndexFactory>();
|
||||
|
||||
// Register set store factory
|
||||
context.Services.AddSingleton<ISetStoreFactory, ValkeySetStoreFactory>();
|
||||
|
||||
// Register event stream factory
|
||||
context.Services.AddSingleton<IEventStreamFactory, ValkeyEventStreamFactory>();
|
||||
|
||||
// Register idempotency store factory
|
||||
context.Services.AddSingleton<IIdempotencyStoreFactory, ValkeyIdempotencyStoreFactory>();
|
||||
|
||||
context.LoggerFactory?.CreateLogger<ValkeyTransportPlugin>()
|
||||
.LogDebug("Registered Valkey transport plugin");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
namespace StellaOps.Messaging.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Transport-agnostic atomic token store for one-time consumable tokens.
|
||||
/// Supports issuing tokens with TTL and atomic consumption (single use).
|
||||
/// </summary>
|
||||
/// <typeparam name="TPayload">The type of metadata payload stored with the token.</typeparam>
|
||||
public interface IAtomicTokenStore<TPayload>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the provider name for diagnostics (e.g., "valkey", "postgres", "inmemory").
|
||||
/// </summary>
|
||||
string ProviderName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Issues a token with the given payload and TTL.
|
||||
/// </summary>
|
||||
/// <param name="key">The storage key for the token.</param>
|
||||
/// <param name="payload">The metadata payload to store with the token.</param>
|
||||
/// <param name="ttl">The time-to-live for the token.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The result containing the generated token.</returns>
|
||||
ValueTask<TokenIssueResult> IssueAsync(
|
||||
string key,
|
||||
TPayload payload,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Stores a caller-provided token with payload and TTL.
|
||||
/// Use when the token must be generated externally (e.g., cryptographic nonces).
|
||||
/// </summary>
|
||||
/// <param name="key">The storage key for the token.</param>
|
||||
/// <param name="token">The caller-provided token value.</param>
|
||||
/// <param name="payload">The metadata payload to store with the token.</param>
|
||||
/// <param name="ttl">The time-to-live for the token.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The result containing the stored token information.</returns>
|
||||
ValueTask<TokenIssueResult> StoreAsync(
|
||||
string key,
|
||||
string token,
|
||||
TPayload payload,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Atomically consumes a token if it exists and matches.
|
||||
/// The token is deleted after successful consumption (single use).
|
||||
/// </summary>
|
||||
/// <param name="key">The storage key for the token.</param>
|
||||
/// <param name="expectedToken">The token value to match.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The result of the consumption attempt.</returns>
|
||||
ValueTask<TokenConsumeResult<TPayload>> TryConsumeAsync(
|
||||
string key,
|
||||
string expectedToken,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a token exists without consuming it.
|
||||
/// </summary>
|
||||
/// <param name="key">The storage key for the token.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the token exists.</returns>
|
||||
ValueTask<bool> ExistsAsync(
|
||||
string key,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Revokes a token before it expires.
|
||||
/// </summary>
|
||||
/// <param name="key">The storage key for the token.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the token existed and was revoked.</returns>
|
||||
ValueTask<bool> RevokeAsync(
|
||||
string key,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
namespace StellaOps.Messaging.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Transport-agnostic event stream interface.
|
||||
/// Provides fire-and-forget event publishing without consumer group semantics.
|
||||
/// Unlike <see cref="IMessageQueue{TMessage}"/>, events are not acknowledged and may be consumed by multiple subscribers.
|
||||
/// </summary>
|
||||
/// <typeparam name="TEvent">The event type.</typeparam>
|
||||
public interface IEventStream<TEvent> where TEvent : class
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the provider name for diagnostics (e.g., "valkey", "postgres", "inmemory").
|
||||
/// </summary>
|
||||
string ProviderName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the stream name.
|
||||
/// </summary>
|
||||
string StreamName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Publishes an event to the stream.
|
||||
/// </summary>
|
||||
/// <param name="event">The event to publish.</param>
|
||||
/// <param name="options">Optional publish options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The result of the publish operation.</returns>
|
||||
ValueTask<EventPublishResult> PublishAsync(
|
||||
TEvent @event,
|
||||
EventPublishOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Publishes multiple events to the stream.
|
||||
/// </summary>
|
||||
/// <param name="events">The events to publish.</param>
|
||||
/// <param name="options">Optional publish options (applied to all events).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The results of the publish operations.</returns>
|
||||
ValueTask<IReadOnlyList<EventPublishResult>> PublishBatchAsync(
|
||||
IEnumerable<TEvent> events,
|
||||
EventPublishOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to events from a position.
|
||||
/// Events are delivered as they become available.
|
||||
/// </summary>
|
||||
/// <param name="position">The stream position to start from.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>An async enumerable of events.</returns>
|
||||
IAsyncEnumerable<StreamEvent<TEvent>> SubscribeAsync(
|
||||
StreamPosition position,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets stream metadata.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Information about the stream.</returns>
|
||||
ValueTask<StreamInfo> GetInfoAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Trims stream to approximate max length.
|
||||
/// </summary>
|
||||
/// <param name="maxLength">The maximum length to retain.</param>
|
||||
/// <param name="approximate">Whether to use approximate trimming (more efficient).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The number of entries removed.</returns>
|
||||
ValueTask<long> TrimAsync(
|
||||
long maxLength,
|
||||
bool approximate = true,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace StellaOps.Messaging.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Transport-agnostic idempotency store interface.
|
||||
/// Provides deduplication keys with configurable time windows.
|
||||
/// </summary>
|
||||
public interface IIdempotencyStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the provider name for diagnostics (e.g., "valkey", "postgres", "inmemory").
|
||||
/// </summary>
|
||||
string ProviderName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to claim an idempotency key.
|
||||
/// If the key doesn't exist, it's claimed for the duration of the window.
|
||||
/// </summary>
|
||||
/// <param name="key">The idempotency key.</param>
|
||||
/// <param name="value">The value to store (e.g., message ID, operation ID).</param>
|
||||
/// <param name="window">The idempotency window duration.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The result indicating whether this was the first claim.</returns>
|
||||
ValueTask<IdempotencyResult> TryClaimAsync(
|
||||
string key,
|
||||
string value,
|
||||
TimeSpan window,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a key was already claimed.
|
||||
/// </summary>
|
||||
/// <param name="key">The idempotency key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the key exists (was previously claimed).</returns>
|
||||
ValueTask<bool> ExistsAsync(
|
||||
string key,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the value for a claimed key.
|
||||
/// </summary>
|
||||
/// <param name="key">The idempotency key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The stored value, or null if the key doesn't exist.</returns>
|
||||
ValueTask<string?> GetAsync(
|
||||
string key,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Releases a claimed key before the window expires.
|
||||
/// </summary>
|
||||
/// <param name="key">The idempotency key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the key existed and was released.</returns>
|
||||
ValueTask<bool> ReleaseAsync(
|
||||
string key,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Extends the window for a claimed key.
|
||||
/// </summary>
|
||||
/// <param name="key">The idempotency key.</param>
|
||||
/// <param name="extension">The time to extend by.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the key existed and was extended.</returns>
|
||||
ValueTask<bool> ExtendAsync(
|
||||
string key,
|
||||
TimeSpan extension,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
namespace StellaOps.Messaging.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Transport-agnostic sorted index interface.
|
||||
/// Provides score-ordered collections with range queries.
|
||||
/// </summary>
|
||||
/// <typeparam name="TKey">The index key type.</typeparam>
|
||||
/// <typeparam name="TElement">The element type stored in the index.</typeparam>
|
||||
public interface ISortedIndex<TKey, TElement>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the provider name for diagnostics (e.g., "valkey", "postgres", "inmemory").
|
||||
/// </summary>
|
||||
string ProviderName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Adds an element with a score.
|
||||
/// </summary>
|
||||
/// <param name="indexKey">The index key.</param>
|
||||
/// <param name="element">The element to add.</param>
|
||||
/// <param name="score">The score for ordering.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the element was added (false if updated).</returns>
|
||||
ValueTask<bool> AddAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
double score,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds multiple elements with scores atomically.
|
||||
/// </summary>
|
||||
/// <param name="indexKey">The index key.</param>
|
||||
/// <param name="elements">The elements with scores to add.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The number of elements added (not updated).</returns>
|
||||
ValueTask<long> AddRangeAsync(
|
||||
TKey indexKey,
|
||||
IEnumerable<ScoredElement<TElement>> elements,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets elements by rank range (0-based, inclusive).
|
||||
/// </summary>
|
||||
/// <param name="indexKey">The index key.</param>
|
||||
/// <param name="start">The start rank (0-based).</param>
|
||||
/// <param name="stop">The stop rank (inclusive, use -1 for last).</param>
|
||||
/// <param name="order">The sort order.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Elements within the rank range.</returns>
|
||||
ValueTask<IReadOnlyList<ScoredElement<TElement>>> GetByRankAsync(
|
||||
TKey indexKey,
|
||||
long start,
|
||||
long stop,
|
||||
SortOrder order = SortOrder.Ascending,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets elements by score range.
|
||||
/// </summary>
|
||||
/// <param name="indexKey">The index key.</param>
|
||||
/// <param name="minScore">The minimum score (inclusive).</param>
|
||||
/// <param name="maxScore">The maximum score (inclusive).</param>
|
||||
/// <param name="order">The sort order.</param>
|
||||
/// <param name="limit">Optional limit on returned elements.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Elements within the score range.</returns>
|
||||
ValueTask<IReadOnlyList<ScoredElement<TElement>>> GetByScoreAsync(
|
||||
TKey indexKey,
|
||||
double minScore,
|
||||
double maxScore,
|
||||
SortOrder order = SortOrder.Ascending,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the score of an element.
|
||||
/// </summary>
|
||||
/// <param name="indexKey">The index key.</param>
|
||||
/// <param name="element">The element to look up.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The score, or null if the element doesn't exist.</returns>
|
||||
ValueTask<double?> GetScoreAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes an element from the index.
|
||||
/// </summary>
|
||||
/// <param name="indexKey">The index key.</param>
|
||||
/// <param name="element">The element to remove.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the element was removed.</returns>
|
||||
ValueTask<bool> RemoveAsync(
|
||||
TKey indexKey,
|
||||
TElement element,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes multiple elements from the index.
|
||||
/// </summary>
|
||||
/// <param name="indexKey">The index key.</param>
|
||||
/// <param name="elements">The elements to remove.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The number of elements removed.</returns>
|
||||
ValueTask<long> RemoveRangeAsync(
|
||||
TKey indexKey,
|
||||
IEnumerable<TElement> elements,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes elements by score range.
|
||||
/// </summary>
|
||||
/// <param name="indexKey">The index key.</param>
|
||||
/// <param name="minScore">The minimum score (inclusive).</param>
|
||||
/// <param name="maxScore">The maximum score (inclusive).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The number of elements removed.</returns>
|
||||
ValueTask<long> RemoveByScoreAsync(
|
||||
TKey indexKey,
|
||||
double minScore,
|
||||
double maxScore,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total count of elements in the index.
|
||||
/// </summary>
|
||||
/// <param name="indexKey">The index key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The element count.</returns>
|
||||
ValueTask<long> CountAsync(
|
||||
TKey indexKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the entire index.
|
||||
/// </summary>
|
||||
/// <param name="indexKey">The index key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the index existed and was deleted.</returns>
|
||||
ValueTask<bool> DeleteAsync(
|
||||
TKey indexKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sets TTL on the index key.
|
||||
/// </summary>
|
||||
/// <param name="indexKey">The index key.</param>
|
||||
/// <param name="ttl">The time-to-live.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
ValueTask SetExpirationAsync(
|
||||
TKey indexKey,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An element with an associated score.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The element type.</typeparam>
|
||||
/// <param name="Element">The element value.</param>
|
||||
/// <param name="Score">The score for ordering.</param>
|
||||
public readonly record struct ScoredElement<T>(T Element, double Score);
|
||||
|
||||
/// <summary>
|
||||
/// Sort order for index queries.
|
||||
/// </summary>
|
||||
public enum SortOrder
|
||||
{
|
||||
/// <summary>
|
||||
/// Sort by ascending score (lowest first).
|
||||
/// </summary>
|
||||
Ascending,
|
||||
|
||||
/// <summary>
|
||||
/// Sort by descending score (highest first).
|
||||
/// </summary>
|
||||
Descending
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
namespace StellaOps.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Options for publishing events to a stream.
|
||||
/// </summary>
|
||||
public sealed record EventPublishOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the idempotency key for deduplication.
|
||||
/// </summary>
|
||||
public string? IdempotencyKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the tenant identifier.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the correlation identifier for tracing.
|
||||
/// </summary>
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets additional headers.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Headers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum stream length (triggers trimming).
|
||||
/// </summary>
|
||||
public long? MaxStreamLength { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an event publish operation.
|
||||
/// </summary>
|
||||
public readonly struct EventPublishResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether the publish was successful.
|
||||
/// </summary>
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the entry ID assigned by the stream.
|
||||
/// </summary>
|
||||
public string? EntryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this was a duplicate (based on idempotency key).
|
||||
/// </summary>
|
||||
public bool WasDeduplicated { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error message if the operation failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful publish result.
|
||||
/// </summary>
|
||||
public static EventPublishResult Succeeded(string entryId, bool wasDeduplicated = false) =>
|
||||
new()
|
||||
{
|
||||
Success = true,
|
||||
EntryId = entryId,
|
||||
WasDeduplicated = wasDeduplicated
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed publish result.
|
||||
/// </summary>
|
||||
public static EventPublishResult Failed(string error) =>
|
||||
new()
|
||||
{
|
||||
Success = false,
|
||||
Error = error
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a deduplicated result.
|
||||
/// </summary>
|
||||
public static EventPublishResult Deduplicated(string existingEntryId) =>
|
||||
new()
|
||||
{
|
||||
Success = true,
|
||||
EntryId = existingEntryId,
|
||||
WasDeduplicated = true
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An event from the stream with metadata.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The event type.</typeparam>
|
||||
/// <param name="EntryId">The stream entry identifier.</param>
|
||||
/// <param name="Event">The event payload.</param>
|
||||
/// <param name="Timestamp">When the event was published.</param>
|
||||
/// <param name="TenantId">The tenant identifier, if present.</param>
|
||||
/// <param name="CorrelationId">The correlation identifier, if present.</param>
|
||||
public sealed record StreamEvent<T>(
|
||||
string EntryId,
|
||||
T Event,
|
||||
DateTimeOffset Timestamp,
|
||||
string? TenantId,
|
||||
string? CorrelationId);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a position in the stream.
|
||||
/// </summary>
|
||||
public readonly struct StreamPosition : IEquatable<StreamPosition>
|
||||
{
|
||||
/// <summary>
|
||||
/// Position at the beginning of the stream (read all).
|
||||
/// </summary>
|
||||
public static StreamPosition Beginning => new("0");
|
||||
|
||||
/// <summary>
|
||||
/// Position at the end of the stream (only new entries).
|
||||
/// </summary>
|
||||
public static StreamPosition End => new("$");
|
||||
|
||||
/// <summary>
|
||||
/// Creates a position after a specific entry ID.
|
||||
/// </summary>
|
||||
public static StreamPosition After(string entryId) => new(entryId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the position value.
|
||||
/// </summary>
|
||||
public string Value { get; }
|
||||
|
||||
private StreamPosition(string value) => Value = value;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Equals(StreamPosition other) => Value == other.Value;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Equals(object? obj) => obj is StreamPosition other && Equals(other);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode() => Value?.GetHashCode() ?? 0;
|
||||
|
||||
/// <summary>
|
||||
/// Equality operator.
|
||||
/// </summary>
|
||||
public static bool operator ==(StreamPosition left, StreamPosition right) => left.Equals(right);
|
||||
|
||||
/// <summary>
|
||||
/// Inequality operator.
|
||||
/// </summary>
|
||||
public static bool operator !=(StreamPosition left, StreamPosition right) => !left.Equals(right);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a stream.
|
||||
/// </summary>
|
||||
/// <param name="Length">The number of entries in the stream.</param>
|
||||
/// <param name="FirstEntryId">The ID of the first entry, if any.</param>
|
||||
/// <param name="LastEntryId">The ID of the last entry, if any.</param>
|
||||
/// <param name="FirstEntryTimestamp">The timestamp of the first entry, if available.</param>
|
||||
/// <param name="LastEntryTimestamp">The timestamp of the last entry, if available.</param>
|
||||
public sealed record StreamInfo(
|
||||
long Length,
|
||||
string? FirstEntryId,
|
||||
string? LastEntryId,
|
||||
DateTimeOffset? FirstEntryTimestamp,
|
||||
DateTimeOffset? LastEntryTimestamp)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an empty stream info.
|
||||
/// </summary>
|
||||
public static StreamInfo Empty => new(0, null, null, null, null);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace StellaOps.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Result of an idempotency claim attempt.
|
||||
/// </summary>
|
||||
public readonly struct IdempotencyResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether this was the first claim (not a duplicate).
|
||||
/// </summary>
|
||||
public bool IsFirstClaim { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the existing value if this was a duplicate.
|
||||
/// </summary>
|
||||
public string? ExistingValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this was a duplicate (key already claimed).
|
||||
/// </summary>
|
||||
public bool IsDuplicate => !IsFirstClaim;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a result indicating the key was successfully claimed.
|
||||
/// </summary>
|
||||
public static IdempotencyResult Claimed() =>
|
||||
new()
|
||||
{
|
||||
IsFirstClaim = true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a result indicating the key was already claimed (duplicate).
|
||||
/// </summary>
|
||||
public static IdempotencyResult Duplicate(string existingValue) =>
|
||||
new()
|
||||
{
|
||||
IsFirstClaim = false,
|
||||
ExistingValue = existingValue
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user