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,20 +1,20 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Queue;
public interface IScanQueue
{
ValueTask<QueueEnqueueResult> EnqueueAsync(
ScanQueueMessage message,
CancellationToken cancellationToken = default);
ValueTask<IReadOnlyList<IScanQueueLease>> LeaseAsync(
QueueLeaseRequest request,
CancellationToken cancellationToken = default);
ValueTask<IReadOnlyList<IScanQueueLease>> ClaimExpiredLeasesAsync(
QueueClaimOptions options,
CancellationToken cancellationToken = default);
}
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Queue;
public interface IScanQueue
{
ValueTask<QueueEnqueueResult> EnqueueAsync(
ScanQueueMessage message,
CancellationToken cancellationToken = default);
ValueTask<IReadOnlyList<IScanQueueLease>> LeaseAsync(
QueueLeaseRequest request,
CancellationToken cancellationToken = default);
ValueTask<IReadOnlyList<IScanQueueLease>> ClaimExpiredLeasesAsync(
QueueClaimOptions options,
CancellationToken cancellationToken = default);
}

View File

@@ -1,37 +1,37 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Queue;
public interface IScanQueueLease
{
string MessageId { get; }
string JobId { get; }
ReadOnlyMemory<byte> Payload { get; }
int Attempt { get; }
DateTimeOffset EnqueuedAt { get; }
DateTimeOffset LeaseExpiresAt { get; }
string Consumer { get; }
string? IdempotencyKey { get; }
string? TraceId { get; }
IReadOnlyDictionary<string, string> Attributes { get; }
Task AcknowledgeAsync(CancellationToken cancellationToken = default);
Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default);
Task ReleaseAsync(QueueReleaseDisposition disposition, CancellationToken cancellationToken = default);
Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default);
}
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Queue;
public interface IScanQueueLease
{
string MessageId { get; }
string JobId { get; }
ReadOnlyMemory<byte> Payload { get; }
int Attempt { get; }
DateTimeOffset EnqueuedAt { get; }
DateTimeOffset LeaseExpiresAt { get; }
string Consumer { get; }
string? IdempotencyKey { get; }
string? TraceId { get; }
IReadOnlyDictionary<string, string> Attributes { get; }
Task AcknowledgeAsync(CancellationToken cancellationToken = default);
Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default);
Task ReleaseAsync(QueueReleaseDisposition disposition, CancellationToken cancellationToken = default);
Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default);
}

View File

@@ -1,82 +1,82 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using NATS.Client.JetStream;
namespace StellaOps.Scanner.Queue.Nats;
internal sealed class NatsScanQueueLease : IScanQueueLease
{
private readonly NatsScanQueue _queue;
private readonly NatsJSMsg<byte[]> _message;
private int _completed;
internal NatsScanQueueLease(
NatsScanQueue queue,
NatsJSMsg<byte[]> message,
string messageId,
string jobId,
byte[] payload,
int attempt,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt,
string consumer,
string? idempotencyKey,
string? traceId,
IReadOnlyDictionary<string, string> attributes)
{
_queue = queue;
_message = message;
MessageId = messageId;
JobId = jobId;
Payload = payload;
Attempt = attempt;
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
Consumer = consumer;
IdempotencyKey = idempotencyKey;
TraceId = traceId;
Attributes = attributes;
}
public string MessageId { get; }
public string JobId { get; }
public ReadOnlyMemory<byte> Payload { get; }
public int Attempt { get; internal set; }
public DateTimeOffset EnqueuedAt { get; }
public DateTimeOffset LeaseExpiresAt { get; private set; }
public string Consumer { get; }
public string? IdempotencyKey { get; }
public string? TraceId { get; }
public IReadOnlyDictionary<string, string> Attributes { get; }
internal NatsJSMsg<byte[]> Message => _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(QueueReleaseDisposition 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.Scanner.Queue.Nats;
internal sealed class NatsScanQueueLease : IScanQueueLease
{
private readonly NatsScanQueue _queue;
private readonly NatsJSMsg<byte[]> _message;
private int _completed;
internal NatsScanQueueLease(
NatsScanQueue queue,
NatsJSMsg<byte[]> message,
string messageId,
string jobId,
byte[] payload,
int attempt,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt,
string consumer,
string? idempotencyKey,
string? traceId,
IReadOnlyDictionary<string, string> attributes)
{
_queue = queue;
_message = message;
MessageId = messageId;
JobId = jobId;
Payload = payload;
Attempt = attempt;
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
Consumer = consumer;
IdempotencyKey = idempotencyKey;
TraceId = traceId;
Attributes = attributes;
}
public string MessageId { get; }
public string JobId { get; }
public ReadOnlyMemory<byte> Payload { get; }
public int Attempt { get; internal set; }
public DateTimeOffset EnqueuedAt { get; }
public DateTimeOffset LeaseExpiresAt { get; private set; }
public string Consumer { get; }
public string? IdempotencyKey { get; }
public string? TraceId { get; }
public IReadOnlyDictionary<string, string> Attributes { get; }
internal NatsJSMsg<byte[]> Message => _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(QueueReleaseDisposition 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,12 +1,12 @@
namespace StellaOps.Scanner.Queue;
internal static class QueueEnvelopeFields
{
public const string Payload = "payload";
public const string JobId = "jobId";
public const string IdempotencyKey = "idempotency";
public const string Attempt = "attempt";
public const string EnqueuedAt = "enqueuedAt";
public const string TraceId = "traceId";
public const string AttributePrefix = "attr:";
}
namespace StellaOps.Scanner.Queue;
internal static class QueueEnvelopeFields
{
public const string Payload = "payload";
public const string JobId = "jobId";
public const string IdempotencyKey = "idempotency";
public const string Attempt = "attempt";
public const string EnqueuedAt = "enqueuedAt";
public const string TraceId = "traceId";
public const string AttributePrefix = "attr:";
}

View File

@@ -1,28 +1,28 @@
using System.Diagnostics.Metrics;
namespace StellaOps.Scanner.Queue;
internal static class QueueMetrics
{
private const string TransportTagName = "transport";
private static readonly Meter Meter = new("StellaOps.Scanner.Queue");
private static readonly Counter<long> EnqueuedCounter = Meter.CreateCounter<long>("scanner_queue_enqueued_total");
private static readonly Counter<long> DeduplicatedCounter = Meter.CreateCounter<long>("scanner_queue_deduplicated_total");
private static readonly Counter<long> AckCounter = Meter.CreateCounter<long>("scanner_queue_ack_total");
private static readonly Counter<long> RetryCounter = Meter.CreateCounter<long>("scanner_queue_retry_total");
private static readonly Counter<long> DeadLetterCounter = Meter.CreateCounter<long>("scanner_queue_deadletter_total");
public static void RecordEnqueued(string transport) => EnqueuedCounter.Add(1, BuildTags(transport));
public static void RecordDeduplicated(string transport) => DeduplicatedCounter.Add(1, BuildTags(transport));
public static void RecordAck(string transport) => AckCounter.Add(1, BuildTags(transport));
public static void RecordRetry(string transport) => RetryCounter.Add(1, BuildTags(transport));
public static void RecordDeadLetter(string transport) => DeadLetterCounter.Add(1, BuildTags(transport));
private static KeyValuePair<string, object?>[] BuildTags(string transport)
=> new[] { new KeyValuePair<string, object?>(TransportTagName, transport) };
}
using System.Diagnostics.Metrics;
namespace StellaOps.Scanner.Queue;
internal static class QueueMetrics
{
private const string TransportTagName = "transport";
private static readonly Meter Meter = new("StellaOps.Scanner.Queue");
private static readonly Counter<long> EnqueuedCounter = Meter.CreateCounter<long>("scanner_queue_enqueued_total");
private static readonly Counter<long> DeduplicatedCounter = Meter.CreateCounter<long>("scanner_queue_deduplicated_total");
private static readonly Counter<long> AckCounter = Meter.CreateCounter<long>("scanner_queue_ack_total");
private static readonly Counter<long> RetryCounter = Meter.CreateCounter<long>("scanner_queue_retry_total");
private static readonly Counter<long> DeadLetterCounter = Meter.CreateCounter<long>("scanner_queue_deadletter_total");
public static void RecordEnqueued(string transport) => EnqueuedCounter.Add(1, BuildTags(transport));
public static void RecordDeduplicated(string transport) => DeduplicatedCounter.Add(1, BuildTags(transport));
public static void RecordAck(string transport) => AckCounter.Add(1, BuildTags(transport));
public static void RecordRetry(string transport) => RetryCounter.Add(1, BuildTags(transport));
public static void RecordDeadLetter(string transport) => DeadLetterCounter.Add(1, BuildTags(transport));
private static KeyValuePair<string, object?>[] BuildTags(string transport)
=> new[] { new KeyValuePair<string, object?>(TransportTagName, transport) };
}

View File

@@ -1,7 +1,7 @@
namespace StellaOps.Scanner.Queue;
public enum QueueTransportKind
{
Redis,
Nats
}
namespace StellaOps.Scanner.Queue;
public enum QueueTransportKind
{
Redis,
Nats
}

View File

@@ -1,76 +1,76 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Queue.Redis;
internal sealed class RedisScanQueueLease : IScanQueueLease
{
private readonly RedisScanQueue _queue;
private int _completed;
internal RedisScanQueueLease(
RedisScanQueue queue,
string messageId,
string jobId,
byte[] payload,
int attempt,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt,
string consumer,
string? idempotencyKey,
string? traceId,
IReadOnlyDictionary<string, string> attributes)
{
_queue = queue;
MessageId = messageId;
JobId = jobId;
Payload = payload;
Attempt = attempt;
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
Consumer = consumer;
IdempotencyKey = idempotencyKey;
TraceId = traceId;
Attributes = attributes;
}
public string MessageId { get; }
public string JobId { get; }
public ReadOnlyMemory<byte> Payload { get; }
public int Attempt { get; }
public DateTimeOffset EnqueuedAt { get; }
public DateTimeOffset LeaseExpiresAt { get; private set; }
public string Consumer { get; }
public string? IdempotencyKey { get; }
public string? TraceId { get; }
public IReadOnlyDictionary<string, string> Attributes { 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(QueueReleaseDisposition 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.Scanner.Queue.Redis;
internal sealed class RedisScanQueueLease : IScanQueueLease
{
private readonly RedisScanQueue _queue;
private int _completed;
internal RedisScanQueueLease(
RedisScanQueue queue,
string messageId,
string jobId,
byte[] payload,
int attempt,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt,
string consumer,
string? idempotencyKey,
string? traceId,
IReadOnlyDictionary<string, string> attributes)
{
_queue = queue;
MessageId = messageId;
JobId = jobId;
Payload = payload;
Attempt = attempt;
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
Consumer = consumer;
IdempotencyKey = idempotencyKey;
TraceId = traceId;
Attributes = attributes;
}
public string MessageId { get; }
public string JobId { get; }
public ReadOnlyMemory<byte> Payload { get; }
public int Attempt { get; }
public DateTimeOffset EnqueuedAt { get; }
public DateTimeOffset LeaseExpiresAt { get; private set; }
public string Consumer { get; }
public string? IdempotencyKey { get; }
public string? TraceId { get; }
public IReadOnlyDictionary<string, string> Attributes { 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(QueueReleaseDisposition 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,115 +1,115 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Scanner.Queue;
public sealed class ScanQueueMessage
{
private readonly byte[] _payload;
public ScanQueueMessage(string jobId, ReadOnlyMemory<byte> payload)
{
if (string.IsNullOrWhiteSpace(jobId))
{
throw new ArgumentException("Job identifier must be provided.", nameof(jobId));
}
JobId = jobId;
_payload = CopyPayload(payload);
}
public string JobId { get; }
public string? IdempotencyKey { get; init; }
public string? TraceId { get; init; }
public IReadOnlyDictionary<string, string>? Attributes { get; init; }
public ReadOnlyMemory<byte> Payload => _payload;
private static byte[] CopyPayload(ReadOnlyMemory<byte> payload)
{
if (payload.Length == 0)
{
return Array.Empty<byte>();
}
var copy = new byte[payload.Length];
payload.Span.CopyTo(copy);
return copy;
}
}
public readonly record struct QueueEnqueueResult(string MessageId, bool Deduplicated);
public sealed class QueueLeaseRequest
{
public QueueLeaseRequest(string consumer, int batchSize, TimeSpan leaseDuration)
{
if (string.IsNullOrWhiteSpace(consumer))
{
throw new ArgumentException("Consumer name 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 QueueClaimOptions
{
public QueueClaimOptions(
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, "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 QueueReleaseDisposition
{
Retry,
Abandon
}
using System;
using System.Collections.Generic;
namespace StellaOps.Scanner.Queue;
public sealed class ScanQueueMessage
{
private readonly byte[] _payload;
public ScanQueueMessage(string jobId, ReadOnlyMemory<byte> payload)
{
if (string.IsNullOrWhiteSpace(jobId))
{
throw new ArgumentException("Job identifier must be provided.", nameof(jobId));
}
JobId = jobId;
_payload = CopyPayload(payload);
}
public string JobId { get; }
public string? IdempotencyKey { get; init; }
public string? TraceId { get; init; }
public IReadOnlyDictionary<string, string>? Attributes { get; init; }
public ReadOnlyMemory<byte> Payload => _payload;
private static byte[] CopyPayload(ReadOnlyMemory<byte> payload)
{
if (payload.Length == 0)
{
return Array.Empty<byte>();
}
var copy = new byte[payload.Length];
payload.Span.CopyTo(copy);
return copy;
}
}
public readonly record struct QueueEnqueueResult(string MessageId, bool Deduplicated);
public sealed class QueueLeaseRequest
{
public QueueLeaseRequest(string consumer, int batchSize, TimeSpan leaseDuration)
{
if (string.IsNullOrWhiteSpace(consumer))
{
throw new ArgumentException("Consumer name 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 QueueClaimOptions
{
public QueueClaimOptions(
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, "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 QueueReleaseDisposition
{
Retry,
Abandon
}

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.Scanner.Queue.Nats;
using StellaOps.Scanner.Queue.Redis;
namespace StellaOps.Scanner.Queue;
public sealed class ScannerQueueHealthCheck : IHealthCheck
{
private readonly IScanQueue _queue;
private readonly ILogger<ScannerQueueHealthCheck> _logger;
public ScannerQueueHealthCheck(
IScanQueue queue,
ILogger<ScannerQueueHealthCheck> 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 RedisScanQueue redisQueue:
await redisQueue.PingAsync(cancellationToken).ConfigureAwait(false);
return HealthCheckResult.Healthy("Redis queue reachable.");
case NatsScanQueue natsQueue:
await natsQueue.PingAsync(cancellationToken).ConfigureAwait(false);
return HealthCheckResult.Healthy("NATS queue reachable.");
default:
return HealthCheckResult.Healthy("Queue transport without dedicated ping returned healthy.");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Scanner queue health check failed.");
return new HealthCheckResult(
context.Registration.FailureStatus,
"Queue transport unreachable.",
ex);
}
}
}
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Queue.Nats;
using StellaOps.Scanner.Queue.Redis;
namespace StellaOps.Scanner.Queue;
public sealed class ScannerQueueHealthCheck : IHealthCheck
{
private readonly IScanQueue _queue;
private readonly ILogger<ScannerQueueHealthCheck> _logger;
public ScannerQueueHealthCheck(
IScanQueue queue,
ILogger<ScannerQueueHealthCheck> 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 RedisScanQueue redisQueue:
await redisQueue.PingAsync(cancellationToken).ConfigureAwait(false);
return HealthCheckResult.Healthy("Redis queue reachable.");
case NatsScanQueue natsQueue:
await natsQueue.PingAsync(cancellationToken).ConfigureAwait(false);
return HealthCheckResult.Healthy("NATS queue reachable.");
default:
return HealthCheckResult.Healthy("Queue transport without dedicated ping returned healthy.");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Scanner queue health check failed.");
return new HealthCheckResult(
context.Registration.FailureStatus,
"Queue transport unreachable.",
ex);
}
}
}

View File

@@ -1,92 +1,92 @@
using System;
namespace StellaOps.Scanner.Queue;
public sealed class ScannerQueueOptions
{
public QueueTransportKind Kind { get; set; } = QueueTransportKind.Redis;
public RedisQueueOptions Redis { get; set; } = new();
public NatsQueueOptions Nats { get; set; } = new();
/// <summary>
/// Default lease duration applied when callers do not override the visibility timeout.
/// </summary>
public TimeSpan DefaultLeaseDuration { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Maximum number of times a message may be delivered before it is shunted to the dead-letter queue.
/// </summary>
public int MaxDeliveryAttempts { get; set; } = 5;
/// <summary>
/// Options controlling retry/backoff/dead-letter handling.
/// </summary>
public DeadLetterQueueOptions DeadLetter { get; set; } = new();
/// <summary>
/// Initial backoff applied when a job is retried after failure.
/// </summary>
public TimeSpan RetryInitialBackoff { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Maximum backoff window applied for exponential retry.
/// </summary>
public TimeSpan RetryMaxBackoff { get; set; } = TimeSpan.FromMinutes(2);
}
public sealed class RedisQueueOptions
{
public string? ConnectionString { get; set; }
public int? Database { get; set; }
public string StreamName { get; set; } = "scanner:jobs";
public string ConsumerGroup { get; set; } = "scanner-workers";
public string IdempotencyKeyPrefix { get; set; } = "scanner:jobs:idemp:";
public TimeSpan IdempotencyWindow { get; set; } = TimeSpan.FromHours(12);
public int? ApproximateMaxLength { get; set; }
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(30);
public TimeSpan ClaimIdleThreshold { get; set; } = TimeSpan.FromMinutes(10);
public TimeSpan PendingScanWindow { get; set; } = TimeSpan.FromMinutes(30);
public TimeSpan RetryInitialBackoff { get; set; } = TimeSpan.FromSeconds(5);
}
public sealed class NatsQueueOptions
{
public string? Url { get; set; }
public string Stream { get; set; } = "SCANNER_JOBS";
public string Subject { get; set; } = "scanner.jobs";
public string DurableConsumer { get; set; } = "scanner-workers";
public int MaxInFlight { get; set; } = 64;
public TimeSpan AckWait { get; set; } = TimeSpan.FromMinutes(5);
public string DeadLetterStream { get; set; } = "SCANNER_JOBS_DEAD";
public string DeadLetterSubject { get; set; } = "scanner.jobs.dead";
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(10);
public TimeSpan IdleHeartbeat { get; set; } = TimeSpan.FromSeconds(30);
}
public sealed class DeadLetterQueueOptions
{
public string StreamName { get; set; } = "scanner:jobs:dead";
public TimeSpan Retention { get; set; } = TimeSpan.FromDays(7);
}
using System;
namespace StellaOps.Scanner.Queue;
public sealed class ScannerQueueOptions
{
public QueueTransportKind Kind { get; set; } = QueueTransportKind.Redis;
public RedisQueueOptions Redis { get; set; } = new();
public NatsQueueOptions Nats { get; set; } = new();
/// <summary>
/// Default lease duration applied when callers do not override the visibility timeout.
/// </summary>
public TimeSpan DefaultLeaseDuration { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Maximum number of times a message may be delivered before it is shunted to the dead-letter queue.
/// </summary>
public int MaxDeliveryAttempts { get; set; } = 5;
/// <summary>
/// Options controlling retry/backoff/dead-letter handling.
/// </summary>
public DeadLetterQueueOptions DeadLetter { get; set; } = new();
/// <summary>
/// Initial backoff applied when a job is retried after failure.
/// </summary>
public TimeSpan RetryInitialBackoff { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Maximum backoff window applied for exponential retry.
/// </summary>
public TimeSpan RetryMaxBackoff { get; set; } = TimeSpan.FromMinutes(2);
}
public sealed class RedisQueueOptions
{
public string? ConnectionString { get; set; }
public int? Database { get; set; }
public string StreamName { get; set; } = "scanner:jobs";
public string ConsumerGroup { get; set; } = "scanner-workers";
public string IdempotencyKeyPrefix { get; set; } = "scanner:jobs:idemp:";
public TimeSpan IdempotencyWindow { get; set; } = TimeSpan.FromHours(12);
public int? ApproximateMaxLength { get; set; }
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(30);
public TimeSpan ClaimIdleThreshold { get; set; } = TimeSpan.FromMinutes(10);
public TimeSpan PendingScanWindow { get; set; } = TimeSpan.FromMinutes(30);
public TimeSpan RetryInitialBackoff { get; set; } = TimeSpan.FromSeconds(5);
}
public sealed class NatsQueueOptions
{
public string? Url { get; set; }
public string Stream { get; set; } = "SCANNER_JOBS";
public string Subject { get; set; } = "scanner.jobs";
public string DurableConsumer { get; set; } = "scanner-workers";
public int MaxInFlight { get; set; } = 64;
public TimeSpan AckWait { get; set; } = TimeSpan.FromMinutes(5);
public string DeadLetterStream { get; set; } = "SCANNER_JOBS_DEAD";
public string DeadLetterSubject { get; set; } = "scanner.jobs.dead";
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(10);
public TimeSpan IdleHeartbeat { get; set; } = TimeSpan.FromSeconds(30);
}
public sealed class DeadLetterQueueOptions
{
public string StreamName { get; set; } = "scanner:jobs:dead";
public TimeSpan Retention { get; set; } = TimeSpan.FromDays(7);
}

View File

@@ -1,67 +1,67 @@
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.Scanner.Queue.Nats;
using StellaOps.Scanner.Queue.Redis;
namespace StellaOps.Scanner.Queue;
public static class ScannerQueueServiceCollectionExtensions
{
public static IServiceCollection AddScannerQueue(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = "scanner:queue")
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
var options = new ScannerQueueOptions();
configuration.GetSection(sectionName).Bind(options);
services.TryAddSingleton(TimeProvider.System);
services.AddSingleton(options);
services.AddSingleton<IScanQueue>(sp =>
{
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
return options.Kind switch
{
QueueTransportKind.Redis => new RedisScanQueue(
options,
options.Redis,
loggerFactory.CreateLogger<RedisScanQueue>(),
timeProvider),
QueueTransportKind.Nats => new NatsScanQueue(
options,
options.Nats,
loggerFactory.CreateLogger<NatsScanQueue>(),
timeProvider),
_ => throw new InvalidOperationException($"Unsupported queue transport kind '{options.Kind}'.")
};
});
services.AddSingleton<ScannerQueueHealthCheck>();
return services;
}
public static IHealthChecksBuilder AddScannerQueueHealthCheck(
this IHealthChecksBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.TryAddSingleton<ScannerQueueHealthCheck>();
builder.AddCheck<ScannerQueueHealthCheck>(
name: "scanner-queue",
failureStatus: HealthStatus.Unhealthy,
tags: new[] { "scanner", "queue" });
return builder;
}
}
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.Scanner.Queue.Nats;
using StellaOps.Scanner.Queue.Redis;
namespace StellaOps.Scanner.Queue;
public static class ScannerQueueServiceCollectionExtensions
{
public static IServiceCollection AddScannerQueue(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = "scanner:queue")
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
var options = new ScannerQueueOptions();
configuration.GetSection(sectionName).Bind(options);
services.TryAddSingleton(TimeProvider.System);
services.AddSingleton(options);
services.AddSingleton<IScanQueue>(sp =>
{
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
return options.Kind switch
{
QueueTransportKind.Redis => new RedisScanQueue(
options,
options.Redis,
loggerFactory.CreateLogger<RedisScanQueue>(),
timeProvider),
QueueTransportKind.Nats => new NatsScanQueue(
options,
options.Nats,
loggerFactory.CreateLogger<NatsScanQueue>(),
timeProvider),
_ => throw new InvalidOperationException($"Unsupported queue transport kind '{options.Kind}'.")
};
});
services.AddSingleton<ScannerQueueHealthCheck>();
return services;
}
public static IHealthChecksBuilder AddScannerQueueHealthCheck(
this IHealthChecksBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.TryAddSingleton<ScannerQueueHealthCheck>();
builder.AddCheck<ScannerQueueHealthCheck>(
name: "scanner-queue",
failureStatus: HealthStatus.Unhealthy,
tags: new[] { "scanner", "queue" });
return builder;
}
}