up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -1,80 +1,80 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using NATS.Client.JetStream;
namespace StellaOps.Notify.Queue.Nats;
internal sealed class NatsNotifyDeliveryLease : INotifyQueueLease<NotifyDeliveryQueueMessage>
{
private readonly NatsNotifyDeliveryQueue _queue;
private readonly NatsJSMsg<byte[]> _message;
private int _completed;
internal NatsNotifyDeliveryLease(
NatsNotifyDeliveryQueue queue,
NatsJSMsg<byte[]> message,
string messageId,
NotifyDeliveryQueueMessage payload,
int attempt,
string consumer,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt,
string idempotencyKey)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
_message = message;
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
Message = payload ?? throw new ArgumentNullException(nameof(payload));
Attempt = attempt;
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
IdempotencyKey = idempotencyKey ?? payload.IdempotencyKey;
}
public string MessageId { get; }
public int Attempt { get; internal set; }
public DateTimeOffset EnqueuedAt { get; }
public DateTimeOffset LeaseExpiresAt { get; private set; }
public string Consumer { get; }
public string Stream => Message.Stream;
public string TenantId => Message.TenantId;
public string? PartitionKey => Message.PartitionKey;
public string IdempotencyKey { get; }
public string? TraceId => Message.TraceId;
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
public NotifyDeliveryQueueMessage Message { get; }
internal NatsJSMsg<byte[]> RawMessage => _message;
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
=> _queue.AcknowledgeAsync(this, cancellationToken);
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
internal bool TryBeginCompletion()
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
internal void RefreshLease(DateTimeOffset expiresAt)
=> LeaseExpiresAt = expiresAt;
}
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using NATS.Client.JetStream;
namespace StellaOps.Notify.Queue.Nats;
internal sealed class NatsNotifyDeliveryLease : INotifyQueueLease<NotifyDeliveryQueueMessage>
{
private readonly NatsNotifyDeliveryQueue _queue;
private readonly NatsJSMsg<byte[]> _message;
private int _completed;
internal NatsNotifyDeliveryLease(
NatsNotifyDeliveryQueue queue,
NatsJSMsg<byte[]> message,
string messageId,
NotifyDeliveryQueueMessage payload,
int attempt,
string consumer,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt,
string idempotencyKey)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
_message = message;
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
Message = payload ?? throw new ArgumentNullException(nameof(payload));
Attempt = attempt;
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
IdempotencyKey = idempotencyKey ?? payload.IdempotencyKey;
}
public string MessageId { get; }
public int Attempt { get; internal set; }
public DateTimeOffset EnqueuedAt { get; }
public DateTimeOffset LeaseExpiresAt { get; private set; }
public string Consumer { get; }
public string Stream => Message.Stream;
public string TenantId => Message.TenantId;
public string? PartitionKey => Message.PartitionKey;
public string IdempotencyKey { get; }
public string? TraceId => Message.TraceId;
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
public NotifyDeliveryQueueMessage Message { get; }
internal NatsJSMsg<byte[]> RawMessage => _message;
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
=> _queue.AcknowledgeAsync(this, cancellationToken);
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
internal bool TryBeginCompletion()
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
internal void RefreshLease(DateTimeOffset expiresAt)
=> LeaseExpiresAt = expiresAt;
}

View File

@@ -1,83 +1,83 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using NATS.Client.JetStream;
namespace StellaOps.Notify.Queue.Nats;
internal sealed class NatsNotifyEventLease : INotifyQueueLease<NotifyQueueEventMessage>
{
private readonly NatsNotifyEventQueue _queue;
private readonly NatsJSMsg<byte[]> _message;
private int _completed;
internal NatsNotifyEventLease(
NatsNotifyEventQueue queue,
NatsJSMsg<byte[]> message,
string messageId,
NotifyQueueEventMessage payload,
int attempt,
string consumer,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
if (EqualityComparer<NatsJSMsg<byte[]>>.Default.Equals(message, default))
{
throw new ArgumentException("Message must be provided.", nameof(message));
}
_message = message;
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
Message = payload ?? throw new ArgumentNullException(nameof(payload));
Attempt = attempt;
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
}
public string MessageId { get; }
public int Attempt { get; internal set; }
public DateTimeOffset EnqueuedAt { get; }
public DateTimeOffset LeaseExpiresAt { get; private set; }
public string Consumer { get; }
public string Stream => Message.Stream;
public string TenantId => Message.TenantId;
public string? PartitionKey => Message.PartitionKey;
public string IdempotencyKey => Message.IdempotencyKey;
public string? TraceId => Message.TraceId;
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
public NotifyQueueEventMessage Message { get; }
internal NatsJSMsg<byte[]> RawMessage => _message;
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
=> _queue.AcknowledgeAsync(this, cancellationToken);
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
internal bool TryBeginCompletion()
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
internal void RefreshLease(DateTimeOffset expiresAt)
=> LeaseExpiresAt = expiresAt;
}
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using NATS.Client.JetStream;
namespace StellaOps.Notify.Queue.Nats;
internal sealed class NatsNotifyEventLease : INotifyQueueLease<NotifyQueueEventMessage>
{
private readonly NatsNotifyEventQueue _queue;
private readonly NatsJSMsg<byte[]> _message;
private int _completed;
internal NatsNotifyEventLease(
NatsNotifyEventQueue queue,
NatsJSMsg<byte[]> message,
string messageId,
NotifyQueueEventMessage payload,
int attempt,
string consumer,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
if (EqualityComparer<NatsJSMsg<byte[]>>.Default.Equals(message, default))
{
throw new ArgumentException("Message must be provided.", nameof(message));
}
_message = message;
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
Message = payload ?? throw new ArgumentNullException(nameof(payload));
Attempt = attempt;
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
}
public string MessageId { get; }
public int Attempt { get; internal set; }
public DateTimeOffset EnqueuedAt { get; }
public DateTimeOffset LeaseExpiresAt { get; private set; }
public string Consumer { get; }
public string Stream => Message.Stream;
public string TenantId => Message.TenantId;
public string? PartitionKey => Message.PartitionKey;
public string IdempotencyKey => Message.IdempotencyKey;
public string? TraceId => Message.TraceId;
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
public NotifyQueueEventMessage Message { get; }
internal NatsJSMsg<byte[]> RawMessage => _message;
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
=> _queue.AcknowledgeAsync(this, cancellationToken);
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
internal bool TryBeginCompletion()
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
internal void RefreshLease(DateTimeOffset expiresAt)
=> LeaseExpiresAt = expiresAt;
}

View File

@@ -1,55 +1,55 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Queue.Nats;
using StellaOps.Notify.Queue.Redis;
namespace StellaOps.Notify.Queue;
public sealed class NotifyDeliveryQueueHealthCheck : IHealthCheck
{
private readonly INotifyDeliveryQueue _queue;
private readonly ILogger<NotifyDeliveryQueueHealthCheck> _logger;
public NotifyDeliveryQueueHealthCheck(
INotifyDeliveryQueue queue,
ILogger<NotifyDeliveryQueueHealthCheck> logger)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
switch (_queue)
{
case RedisNotifyDeliveryQueue redisQueue:
await redisQueue.PingAsync(cancellationToken).ConfigureAwait(false);
return HealthCheckResult.Healthy("Redis Notify delivery queue reachable.");
case NatsNotifyDeliveryQueue natsQueue:
await natsQueue.PingAsync(cancellationToken).ConfigureAwait(false);
return HealthCheckResult.Healthy("NATS Notify delivery queue reachable.");
default:
return HealthCheckResult.Healthy("Notify delivery queue transport without dedicated ping returned healthy.");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Notify delivery queue health check failed.");
return new HealthCheckResult(
context.Registration.FailureStatus,
"Notify delivery queue transport unreachable.",
ex);
}
}
}
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Queue.Nats;
using StellaOps.Notify.Queue.Redis;
namespace StellaOps.Notify.Queue;
public sealed class NotifyDeliveryQueueHealthCheck : IHealthCheck
{
private readonly INotifyDeliveryQueue _queue;
private readonly ILogger<NotifyDeliveryQueueHealthCheck> _logger;
public NotifyDeliveryQueueHealthCheck(
INotifyDeliveryQueue queue,
ILogger<NotifyDeliveryQueueHealthCheck> logger)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
switch (_queue)
{
case RedisNotifyDeliveryQueue redisQueue:
await redisQueue.PingAsync(cancellationToken).ConfigureAwait(false);
return HealthCheckResult.Healthy("Redis Notify delivery queue reachable.");
case NatsNotifyDeliveryQueue natsQueue:
await natsQueue.PingAsync(cancellationToken).ConfigureAwait(false);
return HealthCheckResult.Healthy("NATS Notify delivery queue reachable.");
default:
return HealthCheckResult.Healthy("Notify delivery queue transport without dedicated ping returned healthy.");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Notify delivery queue health check failed.");
return new HealthCheckResult(
context.Registration.FailureStatus,
"Notify delivery queue transport unreachable.",
ex);
}
}
}

View File

@@ -1,69 +1,69 @@
using System;
namespace StellaOps.Notify.Queue;
/// <summary>
/// Configuration options for the Notify delivery queue abstraction.
/// </summary>
public sealed class NotifyDeliveryQueueOptions
{
public NotifyQueueTransportKind Transport { get; set; } = NotifyQueueTransportKind.Redis;
public NotifyRedisDeliveryQueueOptions Redis { get; set; } = new();
public NotifyNatsDeliveryQueueOptions Nats { get; set; } = new();
public TimeSpan DefaultLeaseDuration { get; set; } = TimeSpan.FromMinutes(5);
public int MaxDeliveryAttempts { get; set; } = 5;
public TimeSpan RetryInitialBackoff { get; set; } = TimeSpan.FromSeconds(5);
public TimeSpan RetryMaxBackoff { get; set; } = TimeSpan.FromMinutes(2);
public TimeSpan ClaimIdleThreshold { get; set; } = TimeSpan.FromMinutes(5);
}
public sealed class NotifyRedisDeliveryQueueOptions
{
public string? ConnectionString { get; set; }
public int? Database { get; set; }
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(30);
public string StreamName { get; set; } = "notify:deliveries";
public string ConsumerGroup { get; set; } = "notify-deliveries";
public string IdempotencyKeyPrefix { get; set; } = "notify:deliveries:idemp:";
public int? ApproximateMaxLength { get; set; }
public string DeadLetterStreamName { get; set; } = "notify:deliveries:dead";
public TimeSpan DeadLetterRetention { get; set; } = TimeSpan.FromDays(7);
}
public sealed class NotifyNatsDeliveryQueueOptions
{
public string? Url { get; set; }
public string Stream { get; set; } = "NOTIFY_DELIVERIES";
public string Subject { get; set; } = "notify.deliveries";
public string DurableConsumer { get; set; } = "notify-deliveries";
public string DeadLetterStream { get; set; } = "NOTIFY_DELIVERIES_DEAD";
public string DeadLetterSubject { get; set; } = "notify.deliveries.dead";
public int MaxAckPending { get; set; } = 128;
public TimeSpan AckWait { get; set; } = TimeSpan.FromMinutes(5);
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(10);
public TimeSpan IdleHeartbeat { get; set; } = TimeSpan.FromSeconds(30);
}
using System;
namespace StellaOps.Notify.Queue;
/// <summary>
/// Configuration options for the Notify delivery queue abstraction.
/// </summary>
public sealed class NotifyDeliveryQueueOptions
{
public NotifyQueueTransportKind Transport { get; set; } = NotifyQueueTransportKind.Redis;
public NotifyRedisDeliveryQueueOptions Redis { get; set; } = new();
public NotifyNatsDeliveryQueueOptions Nats { get; set; } = new();
public TimeSpan DefaultLeaseDuration { get; set; } = TimeSpan.FromMinutes(5);
public int MaxDeliveryAttempts { get; set; } = 5;
public TimeSpan RetryInitialBackoff { get; set; } = TimeSpan.FromSeconds(5);
public TimeSpan RetryMaxBackoff { get; set; } = TimeSpan.FromMinutes(2);
public TimeSpan ClaimIdleThreshold { get; set; } = TimeSpan.FromMinutes(5);
}
public sealed class NotifyRedisDeliveryQueueOptions
{
public string? ConnectionString { get; set; }
public int? Database { get; set; }
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(30);
public string StreamName { get; set; } = "notify:deliveries";
public string ConsumerGroup { get; set; } = "notify-deliveries";
public string IdempotencyKeyPrefix { get; set; } = "notify:deliveries:idemp:";
public int? ApproximateMaxLength { get; set; }
public string DeadLetterStreamName { get; set; } = "notify:deliveries:dead";
public TimeSpan DeadLetterRetention { get; set; } = TimeSpan.FromDays(7);
}
public sealed class NotifyNatsDeliveryQueueOptions
{
public string? Url { get; set; }
public string Stream { get; set; } = "NOTIFY_DELIVERIES";
public string Subject { get; set; } = "notify.deliveries";
public string DurableConsumer { get; set; } = "notify-deliveries";
public string DeadLetterStream { get; set; } = "NOTIFY_DELIVERIES_DEAD";
public string DeadLetterSubject { get; set; } = "notify.deliveries.dead";
public int MaxAckPending { get; set; } = 128;
public TimeSpan AckWait { get; set; } = TimeSpan.FromMinutes(5);
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(10);
public TimeSpan IdleHeartbeat { get; set; } = TimeSpan.FromSeconds(30);
}

View File

@@ -1,177 +1,177 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Notify.Queue;
/// <summary>
/// Configuration options for the Notify event queue abstraction.
/// </summary>
public sealed class NotifyEventQueueOptions
{
/// <summary>
/// Transport backing the queue.
/// </summary>
public NotifyQueueTransportKind Transport { get; set; } = NotifyQueueTransportKind.Redis;
/// <summary>
/// Redis-specific configuration.
/// </summary>
public NotifyRedisEventQueueOptions Redis { get; set; } = new();
/// <summary>
/// NATS JetStream-specific configuration.
/// </summary>
public NotifyNatsEventQueueOptions Nats { get; set; } = new();
/// <summary>
/// Default lease duration to use when consumers do not specify one explicitly.
/// </summary>
public TimeSpan DefaultLeaseDuration { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Maximum number of deliveries before a message should be considered failed.
/// </summary>
public int MaxDeliveryAttempts { get; set; } = 5;
/// <summary>
/// Initial retry backoff applied when a message is released for retry.
/// </summary>
public TimeSpan RetryInitialBackoff { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Cap applied to exponential retry backoff.
/// </summary>
public TimeSpan RetryMaxBackoff { get; set; } = TimeSpan.FromMinutes(2);
/// <summary>
/// Minimum idle window before a pending message becomes eligible for claim.
/// </summary>
public TimeSpan ClaimIdleThreshold { get; set; } = TimeSpan.FromMinutes(5);
}
/// <summary>
/// Redis transport options for the Notify event queue.
/// </summary>
public sealed class NotifyRedisEventQueueOptions
{
private IReadOnlyList<NotifyRedisEventStreamOptions> _streams = new List<NotifyRedisEventStreamOptions>
{
NotifyRedisEventStreamOptions.ForDefaultStream()
};
/// <summary>
/// Connection string for the Redis instance.
/// </summary>
public string? ConnectionString { get; set; }
/// <summary>
/// Optional logical database to select when connecting.
/// </summary>
public int? Database { get; set; }
/// <summary>
/// Time allowed for initial connection/consumer-group creation.
/// </summary>
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// TTL applied to idempotency keys stored alongside events.
/// </summary>
public TimeSpan IdempotencyWindow { get; set; } = TimeSpan.FromHours(12);
/// <summary>
/// Streams consumed by Notify. Ordering is preserved during leasing.
/// </summary>
public IReadOnlyList<NotifyRedisEventStreamOptions> Streams
{
get => _streams;
set => _streams = value is null || value.Count == 0
? new List<NotifyRedisEventStreamOptions> { NotifyRedisEventStreamOptions.ForDefaultStream() }
: value;
}
}
/// <summary>
/// Per-Redis-stream options for the Notify event queue.
/// </summary>
public sealed class NotifyRedisEventStreamOptions
{
/// <summary>
/// Name of the Redis stream containing events.
/// </summary>
public string Stream { get; set; } = "notify:events";
/// <summary>
/// Consumer group used by Notify workers.
/// </summary>
public string ConsumerGroup { get; set; } = "notify-workers";
/// <summary>
/// Prefix used when storing idempotency keys in Redis.
/// </summary>
public string IdempotencyKeyPrefix { get; set; } = "notify:events:idemp:";
/// <summary>
/// Approximate maximum length for the stream; when set Redis will trim entries.
/// </summary>
public int? ApproximateMaxLength { get; set; }
public static NotifyRedisEventStreamOptions ForDefaultStream()
=> new();
}
/// <summary>
/// NATS JetStream options for the Notify event queue.
/// </summary>
public sealed class NotifyNatsEventQueueOptions
{
/// <summary>
/// URL for the JetStream-enabled NATS cluster.
/// </summary>
public string? Url { get; set; }
/// <summary>
/// Stream name carrying Notify events.
/// </summary>
public string Stream { get; set; } = "NOTIFY_EVENTS";
/// <summary>
/// Subject that producers publish Notify events to.
/// </summary>
public string Subject { get; set; } = "notify.events";
/// <summary>
/// Durable consumer identifier for Notify workers.
/// </summary>
public string DurableConsumer { get; set; } = "notify-workers";
/// <summary>
/// Dead-letter stream name used when deliveries exhaust retry budget.
/// </summary>
public string DeadLetterStream { get; set; } = "NOTIFY_EVENTS_DEAD";
/// <summary>
/// Subject used for dead-letter publications.
/// </summary>
public string DeadLetterSubject { get; set; } = "notify.events.dead";
/// <summary>
/// Maximum pending messages before backpressure is applied.
/// </summary>
public int MaxAckPending { get; set; } = 256;
/// <summary>
/// Visibility timeout applied to leased events.
/// </summary>
public TimeSpan AckWait { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Delay applied when releasing a message for retry.
/// </summary>
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>
/// Idle heartbeat emitted by the server to detect consumer disconnects.
/// </summary>
public TimeSpan IdleHeartbeat { get; set; } = TimeSpan.FromSeconds(30);
}
using System;
using System.Collections.Generic;
namespace StellaOps.Notify.Queue;
/// <summary>
/// Configuration options for the Notify event queue abstraction.
/// </summary>
public sealed class NotifyEventQueueOptions
{
/// <summary>
/// Transport backing the queue.
/// </summary>
public NotifyQueueTransportKind Transport { get; set; } = NotifyQueueTransportKind.Redis;
/// <summary>
/// Redis-specific configuration.
/// </summary>
public NotifyRedisEventQueueOptions Redis { get; set; } = new();
/// <summary>
/// NATS JetStream-specific configuration.
/// </summary>
public NotifyNatsEventQueueOptions Nats { get; set; } = new();
/// <summary>
/// Default lease duration to use when consumers do not specify one explicitly.
/// </summary>
public TimeSpan DefaultLeaseDuration { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Maximum number of deliveries before a message should be considered failed.
/// </summary>
public int MaxDeliveryAttempts { get; set; } = 5;
/// <summary>
/// Initial retry backoff applied when a message is released for retry.
/// </summary>
public TimeSpan RetryInitialBackoff { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Cap applied to exponential retry backoff.
/// </summary>
public TimeSpan RetryMaxBackoff { get; set; } = TimeSpan.FromMinutes(2);
/// <summary>
/// Minimum idle window before a pending message becomes eligible for claim.
/// </summary>
public TimeSpan ClaimIdleThreshold { get; set; } = TimeSpan.FromMinutes(5);
}
/// <summary>
/// Redis transport options for the Notify event queue.
/// </summary>
public sealed class NotifyRedisEventQueueOptions
{
private IReadOnlyList<NotifyRedisEventStreamOptions> _streams = new List<NotifyRedisEventStreamOptions>
{
NotifyRedisEventStreamOptions.ForDefaultStream()
};
/// <summary>
/// Connection string for the Redis instance.
/// </summary>
public string? ConnectionString { get; set; }
/// <summary>
/// Optional logical database to select when connecting.
/// </summary>
public int? Database { get; set; }
/// <summary>
/// Time allowed for initial connection/consumer-group creation.
/// </summary>
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// TTL applied to idempotency keys stored alongside events.
/// </summary>
public TimeSpan IdempotencyWindow { get; set; } = TimeSpan.FromHours(12);
/// <summary>
/// Streams consumed by Notify. Ordering is preserved during leasing.
/// </summary>
public IReadOnlyList<NotifyRedisEventStreamOptions> Streams
{
get => _streams;
set => _streams = value is null || value.Count == 0
? new List<NotifyRedisEventStreamOptions> { NotifyRedisEventStreamOptions.ForDefaultStream() }
: value;
}
}
/// <summary>
/// Per-Redis-stream options for the Notify event queue.
/// </summary>
public sealed class NotifyRedisEventStreamOptions
{
/// <summary>
/// Name of the Redis stream containing events.
/// </summary>
public string Stream { get; set; } = "notify:events";
/// <summary>
/// Consumer group used by Notify workers.
/// </summary>
public string ConsumerGroup { get; set; } = "notify-workers";
/// <summary>
/// Prefix used when storing idempotency keys in Redis.
/// </summary>
public string IdempotencyKeyPrefix { get; set; } = "notify:events:idemp:";
/// <summary>
/// Approximate maximum length for the stream; when set Redis will trim entries.
/// </summary>
public int? ApproximateMaxLength { get; set; }
public static NotifyRedisEventStreamOptions ForDefaultStream()
=> new();
}
/// <summary>
/// NATS JetStream options for the Notify event queue.
/// </summary>
public sealed class NotifyNatsEventQueueOptions
{
/// <summary>
/// URL for the JetStream-enabled NATS cluster.
/// </summary>
public string? Url { get; set; }
/// <summary>
/// Stream name carrying Notify events.
/// </summary>
public string Stream { get; set; } = "NOTIFY_EVENTS";
/// <summary>
/// Subject that producers publish Notify events to.
/// </summary>
public string Subject { get; set; } = "notify.events";
/// <summary>
/// Durable consumer identifier for Notify workers.
/// </summary>
public string DurableConsumer { get; set; } = "notify-workers";
/// <summary>
/// Dead-letter stream name used when deliveries exhaust retry budget.
/// </summary>
public string DeadLetterStream { get; set; } = "NOTIFY_EVENTS_DEAD";
/// <summary>
/// Subject used for dead-letter publications.
/// </summary>
public string DeadLetterSubject { get; set; } = "notify.events.dead";
/// <summary>
/// Maximum pending messages before backpressure is applied.
/// </summary>
public int MaxAckPending { get; set; } = 256;
/// <summary>
/// Visibility timeout applied to leased events.
/// </summary>
public TimeSpan AckWait { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Delay applied when releasing a message for retry.
/// </summary>
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>
/// Idle heartbeat emitted by the server to detect consumer disconnects.
/// </summary>
public TimeSpan IdleHeartbeat { get; set; } = TimeSpan.FromSeconds(30);
}

View File

@@ -1,231 +1,231 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Queue;
/// <summary>
/// Message queued for Notify event processing.
/// </summary>
public sealed class NotifyQueueEventMessage
{
private readonly NotifyEvent _event;
private readonly IReadOnlyDictionary<string, string> _attributes;
public NotifyQueueEventMessage(
NotifyEvent @event,
string stream,
string? idempotencyKey = null,
string? partitionKey = null,
string? traceId = null,
IReadOnlyDictionary<string, string>? attributes = null)
{
_event = @event ?? throw new ArgumentNullException(nameof(@event));
if (string.IsNullOrWhiteSpace(stream))
{
throw new ArgumentException("Stream must be provided.", nameof(stream));
}
Stream = stream;
IdempotencyKey = string.IsNullOrWhiteSpace(idempotencyKey)
? @event.EventId.ToString("N")
: idempotencyKey!;
PartitionKey = string.IsNullOrWhiteSpace(partitionKey) ? null : partitionKey.Trim();
TraceId = string.IsNullOrWhiteSpace(traceId) ? null : traceId.Trim();
_attributes = attributes is null
? EmptyReadOnlyDictionary<string, string>.Instance
: new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(attributes, StringComparer.Ordinal));
}
public NotifyEvent Event => _event;
public string Stream { get; }
public string IdempotencyKey { get; }
public string TenantId => _event.Tenant;
public string? PartitionKey { get; }
public string? TraceId { get; }
public IReadOnlyDictionary<string, string> Attributes => _attributes;
}
/// <summary>
/// Message queued for channel delivery execution.
/// </summary>
public sealed class NotifyDeliveryQueueMessage
{
public const string DefaultStream = "notify:deliveries";
private readonly IReadOnlyDictionary<string, string> _attributes;
public NotifyDeliveryQueueMessage(
NotifyDelivery delivery,
string channelId,
NotifyChannelType channelType,
string? stream = null,
string? traceId = null,
IReadOnlyDictionary<string, string>? attributes = null)
{
Delivery = delivery ?? throw new ArgumentNullException(nameof(delivery));
ChannelId = NotifyValidation.EnsureNotNullOrWhiteSpace(channelId, nameof(channelId));
ChannelType = channelType;
Stream = string.IsNullOrWhiteSpace(stream) ? DefaultStream : stream!.Trim();
TraceId = string.IsNullOrWhiteSpace(traceId) ? null : traceId.Trim();
_attributes = attributes is null
? EmptyReadOnlyDictionary<string, string>.Instance
: new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(attributes, StringComparer.Ordinal));
}
public NotifyDelivery Delivery { get; }
public string ChannelId { get; }
public NotifyChannelType ChannelType { get; }
public string Stream { get; }
public string? TraceId { get; }
public string TenantId => Delivery.TenantId;
public string IdempotencyKey => Delivery.DeliveryId;
public string PartitionKey => ChannelId;
public IReadOnlyDictionary<string, string> Attributes => _attributes;
}
public readonly record struct NotifyQueueEnqueueResult(string MessageId, bool Deduplicated);
public sealed class NotifyQueueLeaseRequest
{
public NotifyQueueLeaseRequest(string consumer, int batchSize, TimeSpan leaseDuration)
{
if (string.IsNullOrWhiteSpace(consumer))
{
throw new ArgumentException("Consumer must be provided.", nameof(consumer));
}
if (batchSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be positive.");
}
if (leaseDuration <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(leaseDuration), leaseDuration, "Lease duration must be positive.");
}
Consumer = consumer;
BatchSize = batchSize;
LeaseDuration = leaseDuration;
}
public string Consumer { get; }
public int BatchSize { get; }
public TimeSpan LeaseDuration { get; }
}
public sealed class NotifyQueueClaimOptions
{
public NotifyQueueClaimOptions(string claimantConsumer, int batchSize, TimeSpan minIdleTime)
{
if (string.IsNullOrWhiteSpace(claimantConsumer))
{
throw new ArgumentException("Consumer must be provided.", nameof(claimantConsumer));
}
if (batchSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be positive.");
}
if (minIdleTime < TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(minIdleTime), minIdleTime, "Minimum idle time cannot be negative.");
}
ClaimantConsumer = claimantConsumer;
BatchSize = batchSize;
MinIdleTime = minIdleTime;
}
public string ClaimantConsumer { get; }
public int BatchSize { get; }
public TimeSpan MinIdleTime { get; }
}
public enum NotifyQueueReleaseDisposition
{
Retry,
Abandon
}
public interface INotifyQueue<TMessage>
{
ValueTask<NotifyQueueEnqueueResult> PublishAsync(TMessage message, CancellationToken cancellationToken = default);
ValueTask<IReadOnlyList<INotifyQueueLease<TMessage>>> LeaseAsync(NotifyQueueLeaseRequest request, CancellationToken cancellationToken = default);
ValueTask<IReadOnlyList<INotifyQueueLease<TMessage>>> ClaimExpiredAsync(NotifyQueueClaimOptions options, CancellationToken cancellationToken = default);
}
public interface INotifyQueueLease<out TMessage>
{
string MessageId { get; }
int Attempt { get; }
DateTimeOffset EnqueuedAt { get; }
DateTimeOffset LeaseExpiresAt { get; }
string Consumer { get; }
string Stream { get; }
string TenantId { get; }
string? PartitionKey { get; }
string IdempotencyKey { get; }
string? TraceId { get; }
IReadOnlyDictionary<string, string> Attributes { get; }
TMessage Message { get; }
Task AcknowledgeAsync(CancellationToken cancellationToken = default);
Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default);
Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default);
Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default);
}
public interface INotifyEventQueue : INotifyQueue<NotifyQueueEventMessage>
{
}
public interface INotifyDeliveryQueue : INotifyQueue<NotifyDeliveryQueueMessage>
{
}
internal static class EmptyReadOnlyDictionary<TKey, TValue>
where TKey : notnull
{
public static readonly IReadOnlyDictionary<TKey, TValue> Instance =
new ReadOnlyDictionary<TKey, TValue>(new Dictionary<TKey, TValue>(0, EqualityComparer<TKey>.Default));
}
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Queue;
/// <summary>
/// Message queued for Notify event processing.
/// </summary>
public sealed class NotifyQueueEventMessage
{
private readonly NotifyEvent _event;
private readonly IReadOnlyDictionary<string, string> _attributes;
public NotifyQueueEventMessage(
NotifyEvent @event,
string stream,
string? idempotencyKey = null,
string? partitionKey = null,
string? traceId = null,
IReadOnlyDictionary<string, string>? attributes = null)
{
_event = @event ?? throw new ArgumentNullException(nameof(@event));
if (string.IsNullOrWhiteSpace(stream))
{
throw new ArgumentException("Stream must be provided.", nameof(stream));
}
Stream = stream;
IdempotencyKey = string.IsNullOrWhiteSpace(idempotencyKey)
? @event.EventId.ToString("N")
: idempotencyKey!;
PartitionKey = string.IsNullOrWhiteSpace(partitionKey) ? null : partitionKey.Trim();
TraceId = string.IsNullOrWhiteSpace(traceId) ? null : traceId.Trim();
_attributes = attributes is null
? EmptyReadOnlyDictionary<string, string>.Instance
: new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(attributes, StringComparer.Ordinal));
}
public NotifyEvent Event => _event;
public string Stream { get; }
public string IdempotencyKey { get; }
public string TenantId => _event.Tenant;
public string? PartitionKey { get; }
public string? TraceId { get; }
public IReadOnlyDictionary<string, string> Attributes => _attributes;
}
/// <summary>
/// Message queued for channel delivery execution.
/// </summary>
public sealed class NotifyDeliveryQueueMessage
{
public const string DefaultStream = "notify:deliveries";
private readonly IReadOnlyDictionary<string, string> _attributes;
public NotifyDeliveryQueueMessage(
NotifyDelivery delivery,
string channelId,
NotifyChannelType channelType,
string? stream = null,
string? traceId = null,
IReadOnlyDictionary<string, string>? attributes = null)
{
Delivery = delivery ?? throw new ArgumentNullException(nameof(delivery));
ChannelId = NotifyValidation.EnsureNotNullOrWhiteSpace(channelId, nameof(channelId));
ChannelType = channelType;
Stream = string.IsNullOrWhiteSpace(stream) ? DefaultStream : stream!.Trim();
TraceId = string.IsNullOrWhiteSpace(traceId) ? null : traceId.Trim();
_attributes = attributes is null
? EmptyReadOnlyDictionary<string, string>.Instance
: new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(attributes, StringComparer.Ordinal));
}
public NotifyDelivery Delivery { get; }
public string ChannelId { get; }
public NotifyChannelType ChannelType { get; }
public string Stream { get; }
public string? TraceId { get; }
public string TenantId => Delivery.TenantId;
public string IdempotencyKey => Delivery.DeliveryId;
public string PartitionKey => ChannelId;
public IReadOnlyDictionary<string, string> Attributes => _attributes;
}
public readonly record struct NotifyQueueEnqueueResult(string MessageId, bool Deduplicated);
public sealed class NotifyQueueLeaseRequest
{
public NotifyQueueLeaseRequest(string consumer, int batchSize, TimeSpan leaseDuration)
{
if (string.IsNullOrWhiteSpace(consumer))
{
throw new ArgumentException("Consumer must be provided.", nameof(consumer));
}
if (batchSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be positive.");
}
if (leaseDuration <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(leaseDuration), leaseDuration, "Lease duration must be positive.");
}
Consumer = consumer;
BatchSize = batchSize;
LeaseDuration = leaseDuration;
}
public string Consumer { get; }
public int BatchSize { get; }
public TimeSpan LeaseDuration { get; }
}
public sealed class NotifyQueueClaimOptions
{
public NotifyQueueClaimOptions(string claimantConsumer, int batchSize, TimeSpan minIdleTime)
{
if (string.IsNullOrWhiteSpace(claimantConsumer))
{
throw new ArgumentException("Consumer must be provided.", nameof(claimantConsumer));
}
if (batchSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be positive.");
}
if (minIdleTime < TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(minIdleTime), minIdleTime, "Minimum idle time cannot be negative.");
}
ClaimantConsumer = claimantConsumer;
BatchSize = batchSize;
MinIdleTime = minIdleTime;
}
public string ClaimantConsumer { get; }
public int BatchSize { get; }
public TimeSpan MinIdleTime { get; }
}
public enum NotifyQueueReleaseDisposition
{
Retry,
Abandon
}
public interface INotifyQueue<TMessage>
{
ValueTask<NotifyQueueEnqueueResult> PublishAsync(TMessage message, CancellationToken cancellationToken = default);
ValueTask<IReadOnlyList<INotifyQueueLease<TMessage>>> LeaseAsync(NotifyQueueLeaseRequest request, CancellationToken cancellationToken = default);
ValueTask<IReadOnlyList<INotifyQueueLease<TMessage>>> ClaimExpiredAsync(NotifyQueueClaimOptions options, CancellationToken cancellationToken = default);
}
public interface INotifyQueueLease<out TMessage>
{
string MessageId { get; }
int Attempt { get; }
DateTimeOffset EnqueuedAt { get; }
DateTimeOffset LeaseExpiresAt { get; }
string Consumer { get; }
string Stream { get; }
string TenantId { get; }
string? PartitionKey { get; }
string IdempotencyKey { get; }
string? TraceId { get; }
IReadOnlyDictionary<string, string> Attributes { get; }
TMessage Message { get; }
Task AcknowledgeAsync(CancellationToken cancellationToken = default);
Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default);
Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default);
Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default);
}
public interface INotifyEventQueue : INotifyQueue<NotifyQueueEventMessage>
{
}
public interface INotifyDeliveryQueue : INotifyQueue<NotifyDeliveryQueueMessage>
{
}
internal static class EmptyReadOnlyDictionary<TKey, TValue>
where TKey : notnull
{
public static readonly IReadOnlyDictionary<TKey, TValue> Instance =
new ReadOnlyDictionary<TKey, TValue>(new Dictionary<TKey, TValue>(0, EqualityComparer<TKey>.Default));
}

View File

@@ -1,18 +1,18 @@
namespace StellaOps.Notify.Queue;
internal static class NotifyQueueFields
{
public const string Payload = "payload";
public const string EventId = "eventId";
public const string DeliveryId = "deliveryId";
public const string Tenant = "tenant";
public const string Kind = "kind";
public const string Attempt = "attempt";
public const string EnqueuedAt = "enqueuedAt";
public const string TraceId = "traceId";
public const string PartitionKey = "partitionKey";
public const string ChannelId = "channelId";
public const string ChannelType = "channelType";
public const string IdempotencyKey = "idempotency";
public const string AttributePrefix = "attr:";
}
namespace StellaOps.Notify.Queue;
internal static class NotifyQueueFields
{
public const string Payload = "payload";
public const string EventId = "eventId";
public const string DeliveryId = "deliveryId";
public const string Tenant = "tenant";
public const string Kind = "kind";
public const string Attempt = "attempt";
public const string EnqueuedAt = "enqueuedAt";
public const string TraceId = "traceId";
public const string PartitionKey = "partitionKey";
public const string ChannelId = "channelId";
public const string ChannelType = "channelType";
public const string IdempotencyKey = "idempotency";
public const string AttributePrefix = "attr:";
}

View File

@@ -1,55 +1,55 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Queue.Nats;
using StellaOps.Notify.Queue.Redis;
namespace StellaOps.Notify.Queue;
public sealed class NotifyQueueHealthCheck : IHealthCheck
{
private readonly INotifyEventQueue _queue;
private readonly ILogger<NotifyQueueHealthCheck> _logger;
public NotifyQueueHealthCheck(
INotifyEventQueue queue,
ILogger<NotifyQueueHealthCheck> logger)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
switch (_queue)
{
case RedisNotifyEventQueue redisQueue:
await redisQueue.PingAsync(cancellationToken).ConfigureAwait(false);
return HealthCheckResult.Healthy("Redis Notify queue reachable.");
case NatsNotifyEventQueue natsQueue:
await natsQueue.PingAsync(cancellationToken).ConfigureAwait(false);
return HealthCheckResult.Healthy("NATS Notify queue reachable.");
default:
return HealthCheckResult.Healthy("Notify queue transport without dedicated ping returned healthy.");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Notify queue health check failed.");
return new HealthCheckResult(
context.Registration.FailureStatus,
"Notify queue transport unreachable.",
ex);
}
}
}
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Queue.Nats;
using StellaOps.Notify.Queue.Redis;
namespace StellaOps.Notify.Queue;
public sealed class NotifyQueueHealthCheck : IHealthCheck
{
private readonly INotifyEventQueue _queue;
private readonly ILogger<NotifyQueueHealthCheck> _logger;
public NotifyQueueHealthCheck(
INotifyEventQueue queue,
ILogger<NotifyQueueHealthCheck> logger)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
switch (_queue)
{
case RedisNotifyEventQueue redisQueue:
await redisQueue.PingAsync(cancellationToken).ConfigureAwait(false);
return HealthCheckResult.Healthy("Redis Notify queue reachable.");
case NatsNotifyEventQueue natsQueue:
await natsQueue.PingAsync(cancellationToken).ConfigureAwait(false);
return HealthCheckResult.Healthy("NATS Notify queue reachable.");
default:
return HealthCheckResult.Healthy("Notify queue transport without dedicated ping returned healthy.");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Notify queue health check failed.");
return new HealthCheckResult(
context.Registration.FailureStatus,
"Notify queue transport unreachable.",
ex);
}
}
}

View File

@@ -1,39 +1,39 @@
using System.Collections.Generic;
using System.Diagnostics.Metrics;
namespace StellaOps.Notify.Queue;
internal static class NotifyQueueMetrics
{
private const string TransportTag = "transport";
private const string StreamTag = "stream";
private static readonly Meter Meter = new("StellaOps.Notify.Queue");
private static readonly Counter<long> EnqueuedCounter = Meter.CreateCounter<long>("notify_queue_enqueued_total");
private static readonly Counter<long> DeduplicatedCounter = Meter.CreateCounter<long>("notify_queue_deduplicated_total");
private static readonly Counter<long> AckCounter = Meter.CreateCounter<long>("notify_queue_ack_total");
private static readonly Counter<long> RetryCounter = Meter.CreateCounter<long>("notify_queue_retry_total");
private static readonly Counter<long> DeadLetterCounter = Meter.CreateCounter<long>("notify_queue_deadletter_total");
public static void RecordEnqueued(string transport, string stream)
=> EnqueuedCounter.Add(1, BuildTags(transport, stream));
public static void RecordDeduplicated(string transport, string stream)
=> DeduplicatedCounter.Add(1, BuildTags(transport, stream));
public static void RecordAck(string transport, string stream)
=> AckCounter.Add(1, BuildTags(transport, stream));
public static void RecordRetry(string transport, string stream)
=> RetryCounter.Add(1, BuildTags(transport, stream));
public static void RecordDeadLetter(string transport, string stream)
=> DeadLetterCounter.Add(1, BuildTags(transport, stream));
private static KeyValuePair<string, object?>[] BuildTags(string transport, string stream)
=> new[]
{
new KeyValuePair<string, object?>(TransportTag, transport),
new KeyValuePair<string, object?>(StreamTag, stream)
};
}
using System.Collections.Generic;
using System.Diagnostics.Metrics;
namespace StellaOps.Notify.Queue;
internal static class NotifyQueueMetrics
{
private const string TransportTag = "transport";
private const string StreamTag = "stream";
private static readonly Meter Meter = new("StellaOps.Notify.Queue");
private static readonly Counter<long> EnqueuedCounter = Meter.CreateCounter<long>("notify_queue_enqueued_total");
private static readonly Counter<long> DeduplicatedCounter = Meter.CreateCounter<long>("notify_queue_deduplicated_total");
private static readonly Counter<long> AckCounter = Meter.CreateCounter<long>("notify_queue_ack_total");
private static readonly Counter<long> RetryCounter = Meter.CreateCounter<long>("notify_queue_retry_total");
private static readonly Counter<long> DeadLetterCounter = Meter.CreateCounter<long>("notify_queue_deadletter_total");
public static void RecordEnqueued(string transport, string stream)
=> EnqueuedCounter.Add(1, BuildTags(transport, stream));
public static void RecordDeduplicated(string transport, string stream)
=> DeduplicatedCounter.Add(1, BuildTags(transport, stream));
public static void RecordAck(string transport, string stream)
=> AckCounter.Add(1, BuildTags(transport, stream));
public static void RecordRetry(string transport, string stream)
=> RetryCounter.Add(1, BuildTags(transport, stream));
public static void RecordDeadLetter(string transport, string stream)
=> DeadLetterCounter.Add(1, BuildTags(transport, stream));
private static KeyValuePair<string, object?>[] BuildTags(string transport, string stream)
=> new[]
{
new KeyValuePair<string, object?>(TransportTag, transport),
new KeyValuePair<string, object?>(StreamTag, stream)
};
}

View File

@@ -1,146 +1,146 @@
using System;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Queue.Nats;
using StellaOps.Notify.Queue.Redis;
namespace StellaOps.Notify.Queue;
public static class NotifyQueueServiceCollectionExtensions
{
public static IServiceCollection AddNotifyEventQueue(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = "notify:queue")
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
var eventOptions = new NotifyEventQueueOptions();
configuration.GetSection(sectionName).Bind(eventOptions);
services.TryAddSingleton(TimeProvider.System);
services.AddSingleton(eventOptions);
services.AddSingleton<INotifyEventQueue>(sp =>
{
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
var opts = sp.GetRequiredService<NotifyEventQueueOptions>();
return opts.Transport switch
{
NotifyQueueTransportKind.Redis => new RedisNotifyEventQueue(
opts,
opts.Redis,
loggerFactory.CreateLogger<RedisNotifyEventQueue>(),
timeProvider),
NotifyQueueTransportKind.Nats => new NatsNotifyEventQueue(
opts,
opts.Nats,
loggerFactory.CreateLogger<NatsNotifyEventQueue>(),
timeProvider),
_ => throw new InvalidOperationException($"Unsupported Notify queue transport kind '{opts.Transport}'.")
};
});
services.AddSingleton<NotifyQueueHealthCheck>();
return services;
}
public static IServiceCollection AddNotifyDeliveryQueue(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = "notify:deliveryQueue")
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
var deliveryOptions = new NotifyDeliveryQueueOptions();
configuration.GetSection(sectionName).Bind(deliveryOptions);
services.AddSingleton(deliveryOptions);
services.AddSingleton<INotifyDeliveryQueue>(sp =>
{
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
var opts = sp.GetRequiredService<NotifyDeliveryQueueOptions>();
var eventOpts = sp.GetService<NotifyEventQueueOptions>();
ApplyDeliveryFallbacks(opts, eventOpts);
return opts.Transport switch
{
NotifyQueueTransportKind.Redis => new RedisNotifyDeliveryQueue(
opts,
opts.Redis,
loggerFactory.CreateLogger<RedisNotifyDeliveryQueue>(),
timeProvider),
NotifyQueueTransportKind.Nats => new NatsNotifyDeliveryQueue(
opts,
opts.Nats,
loggerFactory.CreateLogger<NatsNotifyDeliveryQueue>(),
timeProvider),
_ => throw new InvalidOperationException($"Unsupported Notify delivery queue transport kind '{opts.Transport}'.")
};
});
services.AddSingleton<NotifyDeliveryQueueHealthCheck>();
return services;
}
public static IHealthChecksBuilder AddNotifyQueueHealthCheck(
this IHealthChecksBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.TryAddSingleton<NotifyQueueHealthCheck>();
builder.AddCheck<NotifyQueueHealthCheck>(
name: "notify-queue",
failureStatus: HealthStatus.Unhealthy,
tags: new[] { "notify", "queue" });
return builder;
}
public static IHealthChecksBuilder AddNotifyDeliveryQueueHealthCheck(
this IHealthChecksBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.TryAddSingleton<NotifyDeliveryQueueHealthCheck>();
builder.AddCheck<NotifyDeliveryQueueHealthCheck>(
name: "notify-delivery-queue",
failureStatus: HealthStatus.Unhealthy,
tags: new[] { "notify", "queue", "delivery" });
return builder;
}
private static void ApplyDeliveryFallbacks(
NotifyDeliveryQueueOptions deliveryOptions,
NotifyEventQueueOptions? eventOptions)
{
if (eventOptions is null)
{
return;
}
if (string.IsNullOrWhiteSpace(deliveryOptions.Redis.ConnectionString))
{
deliveryOptions.Redis.ConnectionString = eventOptions.Redis.ConnectionString;
deliveryOptions.Redis.Database ??= eventOptions.Redis.Database;
}
if (string.IsNullOrWhiteSpace(deliveryOptions.Nats.Url))
{
deliveryOptions.Nats.Url = eventOptions.Nats.Url;
}
}
}
using System;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Queue.Nats;
using StellaOps.Notify.Queue.Redis;
namespace StellaOps.Notify.Queue;
public static class NotifyQueueServiceCollectionExtensions
{
public static IServiceCollection AddNotifyEventQueue(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = "notify:queue")
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
var eventOptions = new NotifyEventQueueOptions();
configuration.GetSection(sectionName).Bind(eventOptions);
services.TryAddSingleton(TimeProvider.System);
services.AddSingleton(eventOptions);
services.AddSingleton<INotifyEventQueue>(sp =>
{
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
var opts = sp.GetRequiredService<NotifyEventQueueOptions>();
return opts.Transport switch
{
NotifyQueueTransportKind.Redis => new RedisNotifyEventQueue(
opts,
opts.Redis,
loggerFactory.CreateLogger<RedisNotifyEventQueue>(),
timeProvider),
NotifyQueueTransportKind.Nats => new NatsNotifyEventQueue(
opts,
opts.Nats,
loggerFactory.CreateLogger<NatsNotifyEventQueue>(),
timeProvider),
_ => throw new InvalidOperationException($"Unsupported Notify queue transport kind '{opts.Transport}'.")
};
});
services.AddSingleton<NotifyQueueHealthCheck>();
return services;
}
public static IServiceCollection AddNotifyDeliveryQueue(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = "notify:deliveryQueue")
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
var deliveryOptions = new NotifyDeliveryQueueOptions();
configuration.GetSection(sectionName).Bind(deliveryOptions);
services.AddSingleton(deliveryOptions);
services.AddSingleton<INotifyDeliveryQueue>(sp =>
{
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
var opts = sp.GetRequiredService<NotifyDeliveryQueueOptions>();
var eventOpts = sp.GetService<NotifyEventQueueOptions>();
ApplyDeliveryFallbacks(opts, eventOpts);
return opts.Transport switch
{
NotifyQueueTransportKind.Redis => new RedisNotifyDeliveryQueue(
opts,
opts.Redis,
loggerFactory.CreateLogger<RedisNotifyDeliveryQueue>(),
timeProvider),
NotifyQueueTransportKind.Nats => new NatsNotifyDeliveryQueue(
opts,
opts.Nats,
loggerFactory.CreateLogger<NatsNotifyDeliveryQueue>(),
timeProvider),
_ => throw new InvalidOperationException($"Unsupported Notify delivery queue transport kind '{opts.Transport}'.")
};
});
services.AddSingleton<NotifyDeliveryQueueHealthCheck>();
return services;
}
public static IHealthChecksBuilder AddNotifyQueueHealthCheck(
this IHealthChecksBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.TryAddSingleton<NotifyQueueHealthCheck>();
builder.AddCheck<NotifyQueueHealthCheck>(
name: "notify-queue",
failureStatus: HealthStatus.Unhealthy,
tags: new[] { "notify", "queue" });
return builder;
}
public static IHealthChecksBuilder AddNotifyDeliveryQueueHealthCheck(
this IHealthChecksBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.TryAddSingleton<NotifyDeliveryQueueHealthCheck>();
builder.AddCheck<NotifyDeliveryQueueHealthCheck>(
name: "notify-delivery-queue",
failureStatus: HealthStatus.Unhealthy,
tags: new[] { "notify", "queue", "delivery" });
return builder;
}
private static void ApplyDeliveryFallbacks(
NotifyDeliveryQueueOptions deliveryOptions,
NotifyEventQueueOptions? eventOptions)
{
if (eventOptions is null)
{
return;
}
if (string.IsNullOrWhiteSpace(deliveryOptions.Redis.ConnectionString))
{
deliveryOptions.Redis.ConnectionString = eventOptions.Redis.ConnectionString;
deliveryOptions.Redis.Database ??= eventOptions.Redis.Database;
}
if (string.IsNullOrWhiteSpace(deliveryOptions.Nats.Url))
{
deliveryOptions.Nats.Url = eventOptions.Nats.Url;
}
}
}

View File

@@ -1,10 +1,10 @@
namespace StellaOps.Notify.Queue;
/// <summary>
/// Supported transports for the Notify event queue.
/// </summary>
public enum NotifyQueueTransportKind
{
Redis,
Nats
}
namespace StellaOps.Notify.Queue;
/// <summary>
/// Supported transports for the Notify event queue.
/// </summary>
public enum NotifyQueueTransportKind
{
Redis,
Nats
}

View File

@@ -1,3 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Notify.Queue.Tests")]
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Notify.Queue.Tests")]

View File

@@ -1,76 +1,76 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Notify.Queue.Redis;
internal sealed class RedisNotifyDeliveryLease : INotifyQueueLease<NotifyDeliveryQueueMessage>
{
private readonly RedisNotifyDeliveryQueue _queue;
private int _completed;
internal RedisNotifyDeliveryLease(
RedisNotifyDeliveryQueue queue,
string messageId,
NotifyDeliveryQueueMessage message,
int attempt,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt,
string consumer,
string? idempotencyKey,
string partitionKey)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
Message = message ?? throw new ArgumentNullException(nameof(message));
Attempt = attempt;
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
IdempotencyKey = idempotencyKey ?? message.IdempotencyKey;
PartitionKey = partitionKey ?? message.ChannelId;
}
public string MessageId { get; }
public int Attempt { get; internal set; }
public DateTimeOffset EnqueuedAt { get; }
public DateTimeOffset LeaseExpiresAt { get; private set; }
public string Consumer { get; }
public string Stream => Message.Stream;
public string TenantId => Message.TenantId;
public string PartitionKey { get; }
public string IdempotencyKey { get; }
public string? TraceId => Message.TraceId;
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
public NotifyDeliveryQueueMessage Message { get; }
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
=> _queue.AcknowledgeAsync(this, cancellationToken);
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
internal bool TryBeginCompletion()
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
internal void RefreshLease(DateTimeOffset expiresAt)
=> LeaseExpiresAt = expiresAt;
}
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Notify.Queue.Redis;
internal sealed class RedisNotifyDeliveryLease : INotifyQueueLease<NotifyDeliveryQueueMessage>
{
private readonly RedisNotifyDeliveryQueue _queue;
private int _completed;
internal RedisNotifyDeliveryLease(
RedisNotifyDeliveryQueue queue,
string messageId,
NotifyDeliveryQueueMessage message,
int attempt,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt,
string consumer,
string? idempotencyKey,
string partitionKey)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
Message = message ?? throw new ArgumentNullException(nameof(message));
Attempt = attempt;
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
IdempotencyKey = idempotencyKey ?? message.IdempotencyKey;
PartitionKey = partitionKey ?? message.ChannelId;
}
public string MessageId { get; }
public int Attempt { get; internal set; }
public DateTimeOffset EnqueuedAt { get; }
public DateTimeOffset LeaseExpiresAt { get; private set; }
public string Consumer { get; }
public string Stream => Message.Stream;
public string TenantId => Message.TenantId;
public string PartitionKey { get; }
public string IdempotencyKey { get; }
public string? TraceId => Message.TraceId;
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
public NotifyDeliveryQueueMessage Message { get; }
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
=> _queue.AcknowledgeAsync(this, cancellationToken);
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
internal bool TryBeginCompletion()
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
internal void RefreshLease(DateTimeOffset expiresAt)
=> LeaseExpiresAt = expiresAt;
}

View File

@@ -1,76 +1,76 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Notify.Queue.Redis;
internal sealed class RedisNotifyEventLease : INotifyQueueLease<NotifyQueueEventMessage>
{
private readonly RedisNotifyEventQueue _queue;
private int _completed;
internal RedisNotifyEventLease(
RedisNotifyEventQueue queue,
NotifyRedisEventStreamOptions streamOptions,
string messageId,
NotifyQueueEventMessage message,
int attempt,
string consumer,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
StreamOptions = streamOptions ?? throw new ArgumentNullException(nameof(streamOptions));
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
Message = message ?? throw new ArgumentNullException(nameof(message));
Attempt = attempt;
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
}
internal NotifyRedisEventStreamOptions StreamOptions { get; }
public string MessageId { get; }
public int Attempt { get; }
public DateTimeOffset EnqueuedAt { get; }
public DateTimeOffset LeaseExpiresAt { get; private set; }
public string Consumer { get; }
public string Stream => StreamOptions.Stream;
public string TenantId => Message.TenantId;
public string? PartitionKey => Message.PartitionKey;
public string IdempotencyKey => Message.IdempotencyKey;
public string? TraceId => Message.TraceId;
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
public NotifyQueueEventMessage Message { get; }
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
=> _queue.AcknowledgeAsync(this, cancellationToken);
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
internal bool TryBeginCompletion()
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
internal void RefreshLease(DateTimeOffset expiresAt)
=> LeaseExpiresAt = expiresAt;
}
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Notify.Queue.Redis;
internal sealed class RedisNotifyEventLease : INotifyQueueLease<NotifyQueueEventMessage>
{
private readonly RedisNotifyEventQueue _queue;
private int _completed;
internal RedisNotifyEventLease(
RedisNotifyEventQueue queue,
NotifyRedisEventStreamOptions streamOptions,
string messageId,
NotifyQueueEventMessage message,
int attempt,
string consumer,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
StreamOptions = streamOptions ?? throw new ArgumentNullException(nameof(streamOptions));
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
Message = message ?? throw new ArgumentNullException(nameof(message));
Attempt = attempt;
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
}
internal NotifyRedisEventStreamOptions StreamOptions { get; }
public string MessageId { get; }
public int Attempt { get; }
public DateTimeOffset EnqueuedAt { get; }
public DateTimeOffset LeaseExpiresAt { get; private set; }
public string Consumer { get; }
public string Stream => StreamOptions.Stream;
public string TenantId => Message.TenantId;
public string? PartitionKey => Message.PartitionKey;
public string IdempotencyKey => Message.IdempotencyKey;
public string? TraceId => Message.TraceId;
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
public NotifyQueueEventMessage Message { get; }
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
=> _queue.AcknowledgeAsync(this, cancellationToken);
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
internal bool TryBeginCompletion()
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
internal void RefreshLease(DateTimeOffset expiresAt)
=> LeaseExpiresAt = expiresAt;
}