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,157 +1,157 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Configurations;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StackExchange.Redis;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Queue.Redis;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Configurations;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StackExchange.Redis;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Queue.Redis;
using Xunit;
namespace StellaOps.Scheduler.Queue.Tests;
public sealed class RedisSchedulerQueueTests : IAsyncLifetime
{
private readonly RedisTestcontainer _redis;
private string? _skipReason;
public RedisSchedulerQueueTests()
{
var configuration = new RedisTestcontainerConfiguration();
_redis = new TestcontainersBuilder<RedisTestcontainer>()
.WithDatabase(configuration)
.Build();
}
public async Task InitializeAsync()
{
try
{
await _redis.StartAsync();
}
catch (Exception ex) when (IsDockerUnavailable(ex))
{
_skipReason = $"Docker engine is not available for Redis-backed tests: {ex.Message}";
}
}
public async Task DisposeAsync()
{
if (_skipReason is not null)
{
return;
}
await _redis.DisposeAsync().AsTask();
}
[Fact]
public async Task PlannerQueue_EnqueueLeaseAck_RemovesMessage()
{
namespace StellaOps.Scheduler.Queue.Tests;
public sealed class RedisSchedulerQueueTests : IAsyncLifetime
{
private readonly RedisTestcontainer _redis;
private string? _skipReason;
public RedisSchedulerQueueTests()
{
var configuration = new RedisTestcontainerConfiguration();
_redis = new TestcontainersBuilder<RedisTestcontainer>()
.WithDatabase(configuration)
.Build();
}
public async Task InitializeAsync()
{
try
{
await _redis.StartAsync();
}
catch (Exception ex) when (IsDockerUnavailable(ex))
{
_skipReason = $"Docker engine is not available for Redis-backed tests: {ex.Message}";
}
}
public async Task DisposeAsync()
{
if (_skipReason is not null)
{
return;
}
await _redis.DisposeAsync().AsTask();
}
[Fact]
public async Task PlannerQueue_EnqueueLeaseAck_RemovesMessage()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = new RedisSchedulerPlannerQueue(
options,
options.Redis,
NullLogger<RedisSchedulerPlannerQueue>.Instance,
TimeProvider.System,
async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false));
var message = TestData.CreatePlannerMessage();
var enqueue = await queue.EnqueueAsync(message);
enqueue.Deduplicated.Should().BeFalse();
var leases = await queue.LeaseAsync(new SchedulerQueueLeaseRequest("planner-1", batchSize: 5, options.DefaultLeaseDuration));
leases.Should().HaveCount(1);
var lease = leases[0];
lease.Message.Run.Id.Should().Be(message.Run.Id);
lease.TenantId.Should().Be(message.TenantId);
lease.ScheduleId.Should().Be(message.ScheduleId);
await lease.AcknowledgeAsync();
var afterAck = await queue.LeaseAsync(new SchedulerQueueLeaseRequest("planner-1", 5, options.DefaultLeaseDuration));
afterAck.Should().BeEmpty();
}
[Fact]
var options = CreateOptions();
await using var queue = new RedisSchedulerPlannerQueue(
options,
options.Redis,
NullLogger<RedisSchedulerPlannerQueue>.Instance,
TimeProvider.System,
async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false));
var message = TestData.CreatePlannerMessage();
var enqueue = await queue.EnqueueAsync(message);
enqueue.Deduplicated.Should().BeFalse();
var leases = await queue.LeaseAsync(new SchedulerQueueLeaseRequest("planner-1", batchSize: 5, options.DefaultLeaseDuration));
leases.Should().HaveCount(1);
var lease = leases[0];
lease.Message.Run.Id.Should().Be(message.Run.Id);
lease.TenantId.Should().Be(message.TenantId);
lease.ScheduleId.Should().Be(message.ScheduleId);
await lease.AcknowledgeAsync();
var afterAck = await queue.LeaseAsync(new SchedulerQueueLeaseRequest("planner-1", 5, options.DefaultLeaseDuration));
afterAck.Should().BeEmpty();
}
[Fact]
public async Task RunnerQueue_Retry_IncrementsDeliveryAttempt()
{
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
options.RetryInitialBackoff = TimeSpan.Zero;
options.RetryMaxBackoff = TimeSpan.Zero;
await using var queue = new RedisSchedulerRunnerQueue(
options,
options.Redis,
NullLogger<RedisSchedulerRunnerQueue>.Instance,
TimeProvider.System,
async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false));
var message = TestData.CreateRunnerMessage();
await queue.EnqueueAsync(message);
var firstLease = await queue.LeaseAsync(new SchedulerQueueLeaseRequest("runner-1", batchSize: 1, options.DefaultLeaseDuration));
firstLease.Should().ContainSingle();
var lease = firstLease[0];
lease.Attempt.Should().Be(1);
await lease.ReleaseAsync(SchedulerQueueReleaseDisposition.Retry);
var secondLease = await queue.LeaseAsync(new SchedulerQueueLeaseRequest("runner-1", batchSize: 1, options.DefaultLeaseDuration));
secondLease.Should().ContainSingle();
secondLease[0].Attempt.Should().Be(2);
}
[Fact]
public async Task PlannerQueue_ClaimExpired_ReassignsLease()
{
var options = CreateOptions();
options.RetryInitialBackoff = TimeSpan.Zero;
options.RetryMaxBackoff = TimeSpan.Zero;
await using var queue = new RedisSchedulerRunnerQueue(
options,
options.Redis,
NullLogger<RedisSchedulerRunnerQueue>.Instance,
TimeProvider.System,
async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false));
var message = TestData.CreateRunnerMessage();
await queue.EnqueueAsync(message);
var firstLease = await queue.LeaseAsync(new SchedulerQueueLeaseRequest("runner-1", batchSize: 1, options.DefaultLeaseDuration));
firstLease.Should().ContainSingle();
var lease = firstLease[0];
lease.Attempt.Should().Be(1);
await lease.ReleaseAsync(SchedulerQueueReleaseDisposition.Retry);
var secondLease = await queue.LeaseAsync(new SchedulerQueueLeaseRequest("runner-1", batchSize: 1, options.DefaultLeaseDuration));
secondLease.Should().ContainSingle();
secondLease[0].Attempt.Should().Be(2);
}
[Fact]
public async Task PlannerQueue_ClaimExpired_ReassignsLease()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = new RedisSchedulerPlannerQueue(
options,
options.Redis,
NullLogger<RedisSchedulerPlannerQueue>.Instance,
TimeProvider.System,
async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false));
var message = TestData.CreatePlannerMessage();
await queue.EnqueueAsync(message);
var leases = await queue.LeaseAsync(new SchedulerQueueLeaseRequest("planner-a", 1, options.DefaultLeaseDuration));
leases.Should().ContainSingle();
await Task.Delay(50);
var reclaimed = await queue.ClaimExpiredAsync(new SchedulerQueueClaimOptions("planner-b", batchSize: 1, minIdleTime: TimeSpan.Zero));
reclaimed.Should().ContainSingle();
reclaimed[0].Consumer.Should().Be("planner-b");
reclaimed[0].RunId.Should().Be(message.Run.Id);
var options = CreateOptions();
await using var queue = new RedisSchedulerPlannerQueue(
options,
options.Redis,
NullLogger<RedisSchedulerPlannerQueue>.Instance,
TimeProvider.System,
async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false));
var message = TestData.CreatePlannerMessage();
await queue.EnqueueAsync(message);
var leases = await queue.LeaseAsync(new SchedulerQueueLeaseRequest("planner-a", 1, options.DefaultLeaseDuration));
leases.Should().ContainSingle();
await Task.Delay(50);
var reclaimed = await queue.ClaimExpiredAsync(new SchedulerQueueClaimOptions("planner-b", batchSize: 1, minIdleTime: TimeSpan.Zero));
reclaimed.Should().ContainSingle();
reclaimed[0].Consumer.Should().Be("planner-b");
reclaimed[0].RunId.Should().Be(message.Run.Id);
await reclaimed[0].AcknowledgeAsync();
}
@@ -220,43 +220,43 @@ public sealed class RedisSchedulerQueueTests : IAsyncLifetime
depths.TryGetValue(("redis", "runner"), out var runnerDepth).Should().BeTrue();
runnerDepth.Should().Be(0);
}
private SchedulerQueueOptions CreateOptions()
{
var unique = Guid.NewGuid().ToString("N");
return new SchedulerQueueOptions
{
Kind = SchedulerQueueTransportKind.Redis,
DefaultLeaseDuration = TimeSpan.FromSeconds(2),
MaxDeliveryAttempts = 5,
RetryInitialBackoff = TimeSpan.FromMilliseconds(10),
RetryMaxBackoff = TimeSpan.FromMilliseconds(50),
Redis = new SchedulerRedisQueueOptions
{
ConnectionString = _redis.ConnectionString,
Database = 0,
InitializationTimeout = TimeSpan.FromSeconds(10),
Planner = new RedisSchedulerStreamOptions
{
Stream = $"scheduler:test:planner:{unique}",
ConsumerGroup = $"planner-consumers-{unique}",
DeadLetterStream = $"scheduler:test:planner:{unique}:dead",
IdempotencyKeyPrefix = $"scheduler:test:planner:{unique}:idemp:",
IdempotencyWindow = TimeSpan.FromMinutes(5)
},
Runner = new RedisSchedulerStreamOptions
{
Stream = $"scheduler:test:runner:{unique}",
ConsumerGroup = $"runner-consumers-{unique}",
DeadLetterStream = $"scheduler:test:runner:{unique}:dead",
IdempotencyKeyPrefix = $"scheduler:test:runner:{unique}:idemp:",
IdempotencyWindow = TimeSpan.FromMinutes(5)
}
}
};
}
private SchedulerQueueOptions CreateOptions()
{
var unique = Guid.NewGuid().ToString("N");
return new SchedulerQueueOptions
{
Kind = SchedulerQueueTransportKind.Redis,
DefaultLeaseDuration = TimeSpan.FromSeconds(2),
MaxDeliveryAttempts = 5,
RetryInitialBackoff = TimeSpan.FromMilliseconds(10),
RetryMaxBackoff = TimeSpan.FromMilliseconds(50),
Redis = new SchedulerRedisQueueOptions
{
ConnectionString = _redis.ConnectionString,
Database = 0,
InitializationTimeout = TimeSpan.FromSeconds(10),
Planner = new RedisSchedulerStreamOptions
{
Stream = $"scheduler:test:planner:{unique}",
ConsumerGroup = $"planner-consumers-{unique}",
DeadLetterStream = $"scheduler:test:planner:{unique}:dead",
IdempotencyKeyPrefix = $"scheduler:test:planner:{unique}:idemp:",
IdempotencyWindow = TimeSpan.FromMinutes(5)
},
Runner = new RedisSchedulerStreamOptions
{
Stream = $"scheduler:test:runner:{unique}",
ConsumerGroup = $"runner-consumers-{unique}",
DeadLetterStream = $"scheduler:test:runner:{unique}:dead",
IdempotencyKeyPrefix = $"scheduler:test:runner:{unique}:idemp:",
IdempotencyWindow = TimeSpan.FromMinutes(5)
}
}
};
}
private bool SkipIfUnavailable()
{
if (_skipReason is not null)
@@ -265,82 +265,82 @@ public sealed class RedisSchedulerQueueTests : IAsyncLifetime
}
return false;
}
private static bool IsDockerUnavailable(Exception exception)
{
while (exception is AggregateException aggregate && aggregate.InnerException is not null)
{
exception = aggregate.InnerException;
}
return exception is TimeoutException
|| exception.GetType().Name.Contains("Docker", StringComparison.OrdinalIgnoreCase);
}
private static class TestData
{
public static PlannerQueueMessage CreatePlannerMessage()
{
var schedule = new Schedule(
id: "sch-test",
tenantId: "tenant-alpha",
name: "Test",
enabled: true,
cronExpression: "0 0 * * *",
timezone: "UTC",
mode: ScheduleMode.AnalysisOnly,
selection: new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha"),
onlyIf: ScheduleOnlyIf.Default,
notify: ScheduleNotify.Default,
limits: ScheduleLimits.Default,
createdAt: DateTimeOffset.UtcNow,
createdBy: "tests",
updatedAt: DateTimeOffset.UtcNow,
updatedBy: "tests");
var run = new Run(
id: "run-test",
tenantId: "tenant-alpha",
trigger: RunTrigger.Manual,
state: RunState.Planning,
stats: RunStats.Empty,
createdAt: DateTimeOffset.UtcNow,
reason: RunReason.Empty,
scheduleId: schedule.Id);
var impactSet = new ImpactSet(
selector: new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha"),
images: new[]
{
new ImpactImage(
imageDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
registry: "registry",
repository: "repo",
namespaces: new[] { "prod" },
tags: new[] { "latest" })
},
usageOnly: true,
generatedAt: DateTimeOffset.UtcNow,
total: 1);
return new PlannerQueueMessage(run, impactSet, schedule, correlationId: "corr-test");
}
public static RunnerSegmentQueueMessage CreateRunnerMessage()
{
return new RunnerSegmentQueueMessage(
segmentId: "segment-test",
runId: "run-test",
tenantId: "tenant-alpha",
imageDigests: new[]
{
"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
},
scheduleId: "sch-test",
ratePerSecond: 10,
usageOnly: true,
attributes: new Dictionary<string, string> { ["priority"] = "kev" },
correlationId: "corr-runner");
}
}
}
private static bool IsDockerUnavailable(Exception exception)
{
while (exception is AggregateException aggregate && aggregate.InnerException is not null)
{
exception = aggregate.InnerException;
}
return exception is TimeoutException
|| exception.GetType().Name.Contains("Docker", StringComparison.OrdinalIgnoreCase);
}
private static class TestData
{
public static PlannerQueueMessage CreatePlannerMessage()
{
var schedule = new Schedule(
id: "sch-test",
tenantId: "tenant-alpha",
name: "Test",
enabled: true,
cronExpression: "0 0 * * *",
timezone: "UTC",
mode: ScheduleMode.AnalysisOnly,
selection: new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha"),
onlyIf: ScheduleOnlyIf.Default,
notify: ScheduleNotify.Default,
limits: ScheduleLimits.Default,
createdAt: DateTimeOffset.UtcNow,
createdBy: "tests",
updatedAt: DateTimeOffset.UtcNow,
updatedBy: "tests");
var run = new Run(
id: "run-test",
tenantId: "tenant-alpha",
trigger: RunTrigger.Manual,
state: RunState.Planning,
stats: RunStats.Empty,
createdAt: DateTimeOffset.UtcNow,
reason: RunReason.Empty,
scheduleId: schedule.Id);
var impactSet = new ImpactSet(
selector: new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha"),
images: new[]
{
new ImpactImage(
imageDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
registry: "registry",
repository: "repo",
namespaces: new[] { "prod" },
tags: new[] { "latest" })
},
usageOnly: true,
generatedAt: DateTimeOffset.UtcNow,
total: 1);
return new PlannerQueueMessage(run, impactSet, schedule, correlationId: "corr-test");
}
public static RunnerSegmentQueueMessage CreateRunnerMessage()
{
return new RunnerSegmentQueueMessage(
segmentId: "segment-test",
runId: "run-test",
tenantId: "tenant-alpha",
imageDigests: new[]
{
"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
},
scheduleId: "sch-test",
ratePerSecond: 10,
usageOnly: true,
attributes: new Dictionary<string, string> { ["priority"] = "kev" },
correlationId: "corr-runner");
}
}
}

View File

@@ -1,115 +1,115 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Queue;
using StellaOps.Scheduler.Queue.Nats;
using Xunit;
namespace StellaOps.Scheduler.Queue.Tests;
public sealed class SchedulerQueueServiceCollectionExtensionsTests
{
[Fact]
public async Task AddSchedulerQueues_RegistersNatsTransport()
{
var services = new ServiceCollection();
services.AddSingleton<ILoggerFactory>(_ => NullLoggerFactory.Instance);
services.AddSchedulerQueues(new ConfigurationBuilder().Build());
var optionsDescriptor = services.First(descriptor => descriptor.ServiceType == typeof(SchedulerQueueOptions));
var options = (SchedulerQueueOptions)optionsDescriptor.ImplementationInstance!;
options.Kind = SchedulerQueueTransportKind.Nats;
options.Nats.Url = "nats://localhost:4222";
await using var provider = services.BuildServiceProvider();
var plannerQueue = provider.GetRequiredService<ISchedulerPlannerQueue>();
var runnerQueue = provider.GetRequiredService<ISchedulerRunnerQueue>();
plannerQueue.Should().BeOfType<NatsSchedulerPlannerQueue>();
runnerQueue.Should().BeOfType<NatsSchedulerRunnerQueue>();
}
[Fact]
public async Task SchedulerQueueHealthCheck_ReturnsHealthy_WhenTransportsReachable()
{
var healthCheck = new SchedulerQueueHealthCheck(
new FakePlannerQueue(failPing: false),
new FakeRunnerQueue(failPing: false),
NullLogger<SchedulerQueueHealthCheck>.Instance);
var context = new HealthCheckContext
{
Registration = new HealthCheckRegistration("scheduler-queue", healthCheck, HealthStatus.Unhealthy, Array.Empty<string>())
};
var result = await healthCheck.CheckHealthAsync(context);
result.Status.Should().Be(HealthStatus.Healthy);
}
[Fact]
public async Task SchedulerQueueHealthCheck_ReturnsUnhealthy_WhenRunnerPingFails()
{
var healthCheck = new SchedulerQueueHealthCheck(
new FakePlannerQueue(failPing: false),
new FakeRunnerQueue(failPing: true),
NullLogger<SchedulerQueueHealthCheck>.Instance);
var context = new HealthCheckContext
{
Registration = new HealthCheckRegistration("scheduler-queue", healthCheck, HealthStatus.Unhealthy, Array.Empty<string>())
};
var result = await healthCheck.CheckHealthAsync(context);
result.Status.Should().Be(HealthStatus.Unhealthy);
result.Description.Should().Contain("runner transport unreachable");
}
private abstract class FakeQueue<TMessage> : ISchedulerQueue<TMessage>, ISchedulerQueueTransportDiagnostics
{
private readonly bool _failPing;
protected FakeQueue(bool failPing)
{
_failPing = failPing;
}
public ValueTask<SchedulerQueueEnqueueResult> EnqueueAsync(TMessage message, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(new SchedulerQueueEnqueueResult("stub", false));
public ValueTask<IReadOnlyList<ISchedulerQueueLease<TMessage>>> LeaseAsync(SchedulerQueueLeaseRequest request, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<IReadOnlyList<ISchedulerQueueLease<TMessage>>>(Array.Empty<ISchedulerQueueLease<TMessage>>());
public ValueTask<IReadOnlyList<ISchedulerQueueLease<TMessage>>> ClaimExpiredAsync(SchedulerQueueClaimOptions options, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<IReadOnlyList<ISchedulerQueueLease<TMessage>>>(Array.Empty<ISchedulerQueueLease<TMessage>>());
public ValueTask PingAsync(CancellationToken cancellationToken)
=> _failPing
? ValueTask.FromException(new InvalidOperationException("ping failed"))
: ValueTask.CompletedTask;
}
private sealed class FakePlannerQueue : FakeQueue<PlannerQueueMessage>, ISchedulerPlannerQueue
{
public FakePlannerQueue(bool failPing) : base(failPing)
{
}
}
private sealed class FakeRunnerQueue : FakeQueue<RunnerSegmentQueueMessage>, ISchedulerRunnerQueue
{
public FakeRunnerQueue(bool failPing) : base(failPing)
{
}
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Queue;
using StellaOps.Scheduler.Queue.Nats;
using Xunit;
namespace StellaOps.Scheduler.Queue.Tests;
public sealed class SchedulerQueueServiceCollectionExtensionsTests
{
[Fact]
public async Task AddSchedulerQueues_RegistersNatsTransport()
{
var services = new ServiceCollection();
services.AddSingleton<ILoggerFactory>(_ => NullLoggerFactory.Instance);
services.AddSchedulerQueues(new ConfigurationBuilder().Build());
var optionsDescriptor = services.First(descriptor => descriptor.ServiceType == typeof(SchedulerQueueOptions));
var options = (SchedulerQueueOptions)optionsDescriptor.ImplementationInstance!;
options.Kind = SchedulerQueueTransportKind.Nats;
options.Nats.Url = "nats://localhost:4222";
await using var provider = services.BuildServiceProvider();
var plannerQueue = provider.GetRequiredService<ISchedulerPlannerQueue>();
var runnerQueue = provider.GetRequiredService<ISchedulerRunnerQueue>();
plannerQueue.Should().BeOfType<NatsSchedulerPlannerQueue>();
runnerQueue.Should().BeOfType<NatsSchedulerRunnerQueue>();
}
[Fact]
public async Task SchedulerQueueHealthCheck_ReturnsHealthy_WhenTransportsReachable()
{
var healthCheck = new SchedulerQueueHealthCheck(
new FakePlannerQueue(failPing: false),
new FakeRunnerQueue(failPing: false),
NullLogger<SchedulerQueueHealthCheck>.Instance);
var context = new HealthCheckContext
{
Registration = new HealthCheckRegistration("scheduler-queue", healthCheck, HealthStatus.Unhealthy, Array.Empty<string>())
};
var result = await healthCheck.CheckHealthAsync(context);
result.Status.Should().Be(HealthStatus.Healthy);
}
[Fact]
public async Task SchedulerQueueHealthCheck_ReturnsUnhealthy_WhenRunnerPingFails()
{
var healthCheck = new SchedulerQueueHealthCheck(
new FakePlannerQueue(failPing: false),
new FakeRunnerQueue(failPing: true),
NullLogger<SchedulerQueueHealthCheck>.Instance);
var context = new HealthCheckContext
{
Registration = new HealthCheckRegistration("scheduler-queue", healthCheck, HealthStatus.Unhealthy, Array.Empty<string>())
};
var result = await healthCheck.CheckHealthAsync(context);
result.Status.Should().Be(HealthStatus.Unhealthy);
result.Description.Should().Contain("runner transport unreachable");
}
private abstract class FakeQueue<TMessage> : ISchedulerQueue<TMessage>, ISchedulerQueueTransportDiagnostics
{
private readonly bool _failPing;
protected FakeQueue(bool failPing)
{
_failPing = failPing;
}
public ValueTask<SchedulerQueueEnqueueResult> EnqueueAsync(TMessage message, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(new SchedulerQueueEnqueueResult("stub", false));
public ValueTask<IReadOnlyList<ISchedulerQueueLease<TMessage>>> LeaseAsync(SchedulerQueueLeaseRequest request, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<IReadOnlyList<ISchedulerQueueLease<TMessage>>>(Array.Empty<ISchedulerQueueLease<TMessage>>());
public ValueTask<IReadOnlyList<ISchedulerQueueLease<TMessage>>> ClaimExpiredAsync(SchedulerQueueClaimOptions options, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<IReadOnlyList<ISchedulerQueueLease<TMessage>>>(Array.Empty<ISchedulerQueueLease<TMessage>>());
public ValueTask PingAsync(CancellationToken cancellationToken)
=> _failPing
? ValueTask.FromException(new InvalidOperationException("ping failed"))
: ValueTask.CompletedTask;
}
private sealed class FakePlannerQueue : FakeQueue<PlannerQueueMessage>, ISchedulerPlannerQueue
{
public FakePlannerQueue(bool failPing) : base(failPing)
{
}
}
private sealed class FakeRunnerQueue : FakeQueue<RunnerSegmentQueueMessage>, ISchedulerRunnerQueue
{
public FakeRunnerQueue(bool failPing) : base(failPing)
{
}
}
}