Restructure solution layout by module
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Analyzers.Lang;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Plugin;
|
||||
using StellaOps.Scanner.Analyzers.OS.Abstractions;
|
||||
using StellaOps.Scanner.Analyzers.OS.Plugin;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.Worker.Processing;
|
||||
using Xunit;
|
||||
using WorkerOptions = StellaOps.Scanner.Worker.Options.ScannerWorkerOptions;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Tests;
|
||||
|
||||
public sealed class CompositeScanAnalyzerDispatcherTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_RunsLanguageAnalyzers_StoresResults()
|
||||
{
|
||||
using var workspace = new TempDirectory();
|
||||
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
{ ScanMetadataKeys.RootFilesystemPath, workspace.Path },
|
||||
{ ScanMetadataKeys.WorkspacePath, workspace.Path },
|
||||
};
|
||||
|
||||
var osCatalog = new FakeOsCatalog();
|
||||
var languageCatalog = new FakeLanguageCatalog(new FakeLanguageAnalyzer());
|
||||
|
||||
var services = new ServiceCollection()
|
||||
.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug))
|
||||
.BuildServiceProvider();
|
||||
|
||||
var scopeFactory = services.GetRequiredService<IServiceScopeFactory>();
|
||||
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new WorkerOptions());
|
||||
var dispatcher = new CompositeScanAnalyzerDispatcher(
|
||||
scopeFactory,
|
||||
osCatalog,
|
||||
languageCatalog,
|
||||
options,
|
||||
loggerFactory.CreateLogger<CompositeScanAnalyzerDispatcher>());
|
||||
|
||||
var lease = new TestJobLease(metadata);
|
||||
var context = new ScanJobContext(lease, TimeProvider.System, TimeProvider.System.GetUtcNow(), CancellationToken.None);
|
||||
|
||||
await dispatcher.ExecuteAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.True(context.Analysis.TryGet<ReadOnlyDictionary<string, LanguageAnalyzerResult>>(ScanAnalysisKeys.LanguageAnalyzerResults, out var results));
|
||||
Assert.Single(results);
|
||||
Assert.True(context.Analysis.TryGet<ImmutableArray<LayerComponentFragment>>(ScanAnalysisKeys.LanguageComponentFragments, out var fragments));
|
||||
Assert.False(fragments.IsDefaultOrEmpty);
|
||||
Assert.True(context.Analysis.GetLayerFragments().Any(fragment => fragment.Components.Any(component => component.Identity.Name == "demo-package")));
|
||||
}
|
||||
|
||||
private sealed class FakeOsCatalog : IOSAnalyzerPluginCatalog
|
||||
{
|
||||
public IReadOnlyCollection<IOSAnalyzerPlugin> Plugins => Array.Empty<IOSAnalyzerPlugin>();
|
||||
|
||||
public void LoadFromDirectory(string directory, bool seal = true)
|
||||
{
|
||||
}
|
||||
|
||||
public IReadOnlyList<IOSPackageAnalyzer> CreateAnalyzers(IServiceProvider services) => Array.Empty<IOSPackageAnalyzer>();
|
||||
}
|
||||
|
||||
private sealed class FakeLanguageCatalog : ILanguageAnalyzerPluginCatalog
|
||||
{
|
||||
private readonly IReadOnlyList<ILanguageAnalyzer> _analyzers;
|
||||
|
||||
public FakeLanguageCatalog(params ILanguageAnalyzer[] analyzers)
|
||||
{
|
||||
_analyzers = analyzers ?? Array.Empty<ILanguageAnalyzer>();
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<ILanguageAnalyzerPlugin> Plugins => Array.Empty<ILanguageAnalyzerPlugin>();
|
||||
|
||||
public void LoadFromDirectory(string directory, bool seal = true)
|
||||
{
|
||||
}
|
||||
|
||||
public IReadOnlyList<ILanguageAnalyzer> CreateAnalyzers(IServiceProvider services) => _analyzers;
|
||||
}
|
||||
|
||||
private sealed class FakeLanguageAnalyzer : ILanguageAnalyzer
|
||||
{
|
||||
public string Id => "lang.fake";
|
||||
|
||||
public string DisplayName => "Fake Language Analyzer";
|
||||
|
||||
public ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken)
|
||||
{
|
||||
writer.AddFromPurl(
|
||||
analyzerId: Id,
|
||||
purl: "pkg:npm/demo-package@1.0.0",
|
||||
name: "demo-package",
|
||||
version: "1.0.0",
|
||||
type: "npm");
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestJobLease : IScanJobLease
|
||||
{
|
||||
private readonly Dictionary<string, string> _metadata;
|
||||
|
||||
public TestJobLease(Dictionary<string, string> metadata)
|
||||
{
|
||||
_metadata = metadata;
|
||||
JobId = Guid.NewGuid().ToString("n");
|
||||
ScanId = $"scan-{Guid.NewGuid():n}";
|
||||
Attempt = 1;
|
||||
EnqueuedAtUtc = DateTimeOffset.UtcNow.AddSeconds(-1);
|
||||
LeasedAtUtc = DateTimeOffset.UtcNow;
|
||||
LeaseDuration = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
||||
public string JobId { get; }
|
||||
|
||||
public string ScanId { get; }
|
||||
|
||||
public int Attempt { get; }
|
||||
|
||||
public DateTimeOffset EnqueuedAtUtc { get; }
|
||||
|
||||
public DateTimeOffset LeasedAtUtc { get; }
|
||||
|
||||
public TimeSpan LeaseDuration { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, string> Metadata => _metadata;
|
||||
|
||||
public ValueTask RenewAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask CompleteAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask AbandonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask PoisonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class TempDirectory : IDisposable
|
||||
{
|
||||
public TempDirectory()
|
||||
{
|
||||
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"stellaops-test-{Guid.NewGuid():n}");
|
||||
Directory.CreateDirectory(Path);
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(Path))
|
||||
{
|
||||
Directory.Delete(Path, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.EntryTrace;
|
||||
using StellaOps.Scanner.Worker.Options;
|
||||
using StellaOps.Scanner.Worker.Processing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Tests;
|
||||
|
||||
public sealed class EntryTraceExecutionServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _tempRoot;
|
||||
|
||||
public EntryTraceExecutionServiceTests()
|
||||
{
|
||||
_tempRoot = Path.Combine(Path.GetTempPath(), $"entrytrace-service-{Guid.NewGuid():n}");
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Skips_When_ConfigMetadataMissing()
|
||||
{
|
||||
var analyzer = new CapturingEntryTraceAnalyzer();
|
||||
var service = CreateService(analyzer);
|
||||
|
||||
var context = CreateContext(new Dictionary<string, string>());
|
||||
|
||||
await service.ExecuteAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.False(analyzer.Invoked);
|
||||
Assert.False(context.Analysis.TryGet<EntryTraceGraph>(ScanAnalysisKeys.EntryTraceGraph, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_BuildsContext_AndStoresGraph()
|
||||
{
|
||||
var configPath = Path.Combine(_tempRoot, "config.json");
|
||||
File.WriteAllText(configPath, """
|
||||
{
|
||||
"config": {
|
||||
"Env": ["PATH=/bin:/usr/bin"],
|
||||
"Entrypoint": ["/entrypoint.sh"],
|
||||
"WorkingDir": "/workspace",
|
||||
"User": "scanner"
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var layerDirectory = Path.Combine(_tempRoot, "layer-1");
|
||||
Directory.CreateDirectory(layerDirectory);
|
||||
File.WriteAllText(Path.Combine(layerDirectory, "entrypoint.sh"), "#!/bin/sh\necho hello\n");
|
||||
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
[ScanMetadataKeys.ImageConfigPath] = configPath,
|
||||
[ScanMetadataKeys.LayerDirectories] = layerDirectory,
|
||||
["image.digest"] = "sha256:test-digest"
|
||||
};
|
||||
|
||||
var analyzer = new CapturingEntryTraceAnalyzer();
|
||||
var service = CreateService(analyzer);
|
||||
|
||||
var context = CreateContext(metadata);
|
||||
|
||||
await service.ExecuteAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.True(analyzer.Invoked);
|
||||
Assert.NotNull(analyzer.LastEntrypoint);
|
||||
Assert.Equal("/entrypoint.sh", analyzer.LastEntrypoint!.Entrypoint[0]);
|
||||
Assert.NotNull(analyzer.LastContext);
|
||||
Assert.Equal("scanner", analyzer.LastContext!.User);
|
||||
Assert.Equal("/workspace", analyzer.LastContext.WorkingDirectory);
|
||||
Assert.Contains("/bin", analyzer.LastContext.Path);
|
||||
|
||||
Assert.True(context.Analysis.TryGet(ScanAnalysisKeys.EntryTraceGraph, out EntryTraceGraph stored));
|
||||
Assert.Same(analyzer.Graph, stored);
|
||||
}
|
||||
|
||||
private EntryTraceExecutionService CreateService(IEntryTraceAnalyzer analyzer)
|
||||
{
|
||||
var workerOptions = new ScannerWorkerOptions();
|
||||
var entryTraceOptions = new EntryTraceAnalyzerOptions();
|
||||
|
||||
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Trace));
|
||||
return new EntryTraceExecutionService(
|
||||
analyzer,
|
||||
Options.Create(entryTraceOptions),
|
||||
Options.Create(workerOptions),
|
||||
loggerFactory.CreateLogger<EntryTraceExecutionService>(),
|
||||
loggerFactory);
|
||||
}
|
||||
|
||||
private static ScanJobContext CreateContext(IReadOnlyDictionary<string, string> metadata)
|
||||
{
|
||||
var lease = new TestLease(metadata);
|
||||
return new ScanJobContext(lease, TimeProvider.System, DateTimeOffset.UtcNow, CancellationToken.None);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_tempRoot))
|
||||
{
|
||||
Directory.Delete(_tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore cleanup failures
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingEntryTraceAnalyzer : IEntryTraceAnalyzer
|
||||
{
|
||||
public bool Invoked { get; private set; }
|
||||
|
||||
public EntrypointSpecification? LastEntrypoint { get; private set; }
|
||||
|
||||
public EntryTraceContext? LastContext { get; private set; }
|
||||
|
||||
public EntryTraceGraph Graph { get; } = new(
|
||||
EntryTraceOutcome.Resolved,
|
||||
ImmutableArray<EntryTraceNode>.Empty,
|
||||
ImmutableArray<EntryTraceEdge>.Empty,
|
||||
ImmutableArray<EntryTraceDiagnostic>.Empty);
|
||||
|
||||
public ValueTask<EntryTraceGraph> ResolveAsync(EntrypointSpecification entrypoint, EntryTraceContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Invoked = true;
|
||||
LastEntrypoint = entrypoint;
|
||||
LastContext = context;
|
||||
return ValueTask.FromResult(Graph);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestLease : IScanJobLease
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, string> _metadata;
|
||||
|
||||
public TestLease(IReadOnlyDictionary<string, string> metadata)
|
||||
{
|
||||
_metadata = metadata;
|
||||
EnqueuedAtUtc = DateTimeOffset.UtcNow;
|
||||
LeasedAtUtc = EnqueuedAtUtc;
|
||||
}
|
||||
|
||||
public string JobId { get; } = $"job-{Guid.NewGuid():n}";
|
||||
|
||||
public string ScanId { get; } = $"scan-{Guid.NewGuid():n}";
|
||||
|
||||
public int Attempt => 1;
|
||||
|
||||
public DateTimeOffset EnqueuedAtUtc { get; }
|
||||
|
||||
public DateTimeOffset LeasedAtUtc { get; }
|
||||
|
||||
public TimeSpan LeaseDuration => TimeSpan.FromMinutes(5);
|
||||
|
||||
public IReadOnlyDictionary<string, string> Metadata => _metadata;
|
||||
|
||||
public ValueTask RenewAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask CompleteAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask AbandonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask PoisonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Worker.Options;
|
||||
using StellaOps.Scanner.Worker.Processing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Tests;
|
||||
|
||||
public sealed class LeaseHeartbeatServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RunAsync_RespectsSafetyFactorBudget()
|
||||
{
|
||||
var options = new ScannerWorkerOptions
|
||||
{
|
||||
MaxConcurrentJobs = 1,
|
||||
};
|
||||
options.Queue.HeartbeatSafetyFactor = 3.0;
|
||||
options.Queue.MinHeartbeatInterval = TimeSpan.FromSeconds(5);
|
||||
options.Queue.MaxHeartbeatInterval = TimeSpan.FromSeconds(60);
|
||||
options.Queue.SetHeartbeatRetryDelays(Array.Empty<TimeSpan>());
|
||||
options.Queue.MaxHeartbeatJitterMilliseconds = 750;
|
||||
|
||||
var optionsMonitor = new StaticOptionsMonitor<ScannerWorkerOptions>(options);
|
||||
using var cts = new CancellationTokenSource();
|
||||
var scheduler = new RecordingDelayScheduler(cts);
|
||||
var lease = new TestJobLease(TimeSpan.FromSeconds(90));
|
||||
|
||||
var service = new LeaseHeartbeatService(TimeProvider.System, scheduler, optionsMonitor, NullLogger<LeaseHeartbeatService>.Instance);
|
||||
|
||||
await service.RunAsync(lease, cts.Token);
|
||||
|
||||
var delay = Assert.Single(scheduler.Delays);
|
||||
var expectedMax = TimeSpan.FromTicks((long)(lease.LeaseDuration.Ticks / Math.Max(3.0, options.Queue.HeartbeatSafetyFactor)));
|
||||
Assert.True(delay <= expectedMax, $"Heartbeat delay {delay} should stay within safety factor budget {expectedMax}.");
|
||||
Assert.True(delay >= options.Queue.MinHeartbeatInterval, $"Heartbeat delay {delay} should respect minimum interval {options.Queue.MinHeartbeatInterval}.");
|
||||
}
|
||||
|
||||
private sealed class RecordingDelayScheduler : IDelayScheduler
|
||||
{
|
||||
private readonly CancellationTokenSource _cts;
|
||||
|
||||
public RecordingDelayScheduler(CancellationTokenSource cts)
|
||||
{
|
||||
_cts = cts ?? throw new ArgumentNullException(nameof(cts));
|
||||
}
|
||||
|
||||
public List<TimeSpan> Delays { get; } = new();
|
||||
|
||||
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
|
||||
{
|
||||
Delays.Add(delay);
|
||||
_cts.Cancel();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestJobLease : IScanJobLease
|
||||
{
|
||||
public TestJobLease(TimeSpan leaseDuration)
|
||||
{
|
||||
LeaseDuration = leaseDuration;
|
||||
EnqueuedAtUtc = DateTimeOffset.UtcNow - leaseDuration;
|
||||
LeasedAtUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public string JobId { get; } = Guid.NewGuid().ToString("n");
|
||||
|
||||
public string ScanId { get; } = $"scan-{Guid.NewGuid():n}";
|
||||
|
||||
public int Attempt { get; } = 1;
|
||||
|
||||
public DateTimeOffset EnqueuedAtUtc { get; }
|
||||
|
||||
public DateTimeOffset LeasedAtUtc { get; }
|
||||
|
||||
public TimeSpan LeaseDuration { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; } = new Dictionary<string, string>();
|
||||
|
||||
public ValueTask RenewAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask CompleteAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask AbandonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask PoisonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class StaticOptionsMonitor<TOptions> : IOptionsMonitor<TOptions>
|
||||
where TOptions : class
|
||||
{
|
||||
private readonly TOptions _value;
|
||||
|
||||
public StaticOptionsMonitor(TOptions value)
|
||||
{
|
||||
_value = value ?? throw new ArgumentNullException(nameof(value));
|
||||
}
|
||||
|
||||
public TOptions CurrentValue => _value;
|
||||
|
||||
public TOptions Get(string? name) => _value;
|
||||
|
||||
public IDisposable OnChange(Action<TOptions, string?> listener) => NullDisposable.Instance;
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
{
|
||||
public static readonly NullDisposable Instance = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Queue;
|
||||
using StellaOps.Scanner.Worker.Diagnostics;
|
||||
using StellaOps.Scanner.Worker.Hosting;
|
||||
using StellaOps.Scanner.Worker.Options;
|
||||
using StellaOps.Scanner.Worker.Processing;
|
||||
using StellaOps.Scanner.Worker.Tests.TestInfrastructure;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Tests;
|
||||
|
||||
public sealed class RedisWorkerSmokeTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Worker_CompletesJob_ViaRedisQueue()
|
||||
{
|
||||
var flag = Environment.GetEnvironmentVariable("STELLAOPS_REDIS_SMOKE");
|
||||
if (string.IsNullOrWhiteSpace(flag))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var redisConnection = Environment.GetEnvironmentVariable("STELLAOPS_REDIS_CONNECTION") ?? "localhost:6379";
|
||||
var streamName = $"scanner:jobs:{Guid.NewGuid():n}";
|
||||
var consumerGroup = $"worker-smoke-{Guid.NewGuid():n}";
|
||||
var configuration = BuildQueueConfiguration(redisConnection, streamName, consumerGroup);
|
||||
|
||||
var queueOptions = new ScannerQueueOptions();
|
||||
configuration.GetSection("scanner:queue").Bind(queueOptions);
|
||||
|
||||
var workerOptions = new ScannerWorkerOptions
|
||||
{
|
||||
MaxConcurrentJobs = 1,
|
||||
};
|
||||
workerOptions.Queue.HeartbeatSafetyFactor = 3.0;
|
||||
workerOptions.Queue.MinHeartbeatInterval = TimeSpan.FromSeconds(2);
|
||||
workerOptions.Queue.MaxHeartbeatInterval = TimeSpan.FromSeconds(8);
|
||||
workerOptions.Queue.SetHeartbeatRetryDelays(new[]
|
||||
{
|
||||
TimeSpan.FromMilliseconds(200),
|
||||
TimeSpan.FromMilliseconds(500),
|
||||
TimeSpan.FromSeconds(1),
|
||||
});
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(builder =>
|
||||
{
|
||||
builder.SetMinimumLevel(LogLevel.Debug);
|
||||
builder.AddConsole();
|
||||
});
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddScannerQueue(configuration, "scanner:queue");
|
||||
services.AddSingleton<IScanJobSource, QueueBackedScanJobSource>();
|
||||
services.AddSingleton<QueueBackedScanJobSourceDependencies>();
|
||||
services.AddSingleton(queueOptions);
|
||||
services.AddSingleton<IScanAnalyzerDispatcher, SmokeAnalyzerDispatcher>();
|
||||
services.AddSingleton<IScanStageExecutor, AnalyzerStageExecutor>();
|
||||
services.AddSingleton<ScannerWorkerMetrics>();
|
||||
services.AddSingleton<ScanProgressReporter>();
|
||||
services.AddSingleton<ScanJobProcessor>();
|
||||
services.AddSingleton<LeaseHeartbeatService>();
|
||||
services.AddSingleton<IDelayScheduler, SystemDelayScheduler>();
|
||||
services.AddSingleton<IOptionsMonitor<ScannerWorkerOptions>>(new StaticOptionsMonitor<ScannerWorkerOptions>(workerOptions));
|
||||
services.AddSingleton<ScannerWorkerHostedService>();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var queue = provider.GetRequiredService<IScanQueue>();
|
||||
|
||||
var jobId = $"job-{Guid.NewGuid():n}";
|
||||
var scanId = $"scan-{Guid.NewGuid():n}";
|
||||
await queue.EnqueueAsync(new ScanQueueMessage(jobId, Encoding.UTF8.GetBytes("smoke"))
|
||||
{
|
||||
Attributes = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["scanId"] = scanId,
|
||||
["queue"] = "redis",
|
||||
}
|
||||
});
|
||||
|
||||
var hostedService = provider.GetRequiredService<ScannerWorkerHostedService>();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
|
||||
await hostedService.StartAsync(cts.Token);
|
||||
|
||||
var smokeObserver = provider.GetRequiredService<QueueBackedScanJobSourceDependencies>();
|
||||
await smokeObserver.JobCompleted.Task.WaitAsync(TimeSpan.FromSeconds(20));
|
||||
|
||||
await hostedService.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private static IConfiguration BuildQueueConfiguration(string connection, string stream, string consumerGroup)
|
||||
{
|
||||
return new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["scanner:queue:kind"] = "redis",
|
||||
["scanner:queue:defaultLeaseDuration"] = "00:00:30",
|
||||
["scanner:queue:redis:connectionString"] = connection,
|
||||
["scanner:queue:redis:streamName"] = stream,
|
||||
["scanner:queue:redis:consumerGroup"] = consumerGroup,
|
||||
["scanner:queue:redis:idempotencyKeyPrefix"] = $"{stream}:idemp:",
|
||||
["scanner:queue:redis:initializationTimeout"] = "00:00:10",
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
private sealed class SmokeAnalyzerDispatcher : IScanAnalyzerDispatcher
|
||||
{
|
||||
public ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class QueueBackedScanJobSourceDependencies
|
||||
{
|
||||
public QueueBackedScanJobSourceDependencies()
|
||||
{
|
||||
JobCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
|
||||
public TaskCompletionSource JobCompleted { get; }
|
||||
}
|
||||
|
||||
private sealed class QueueBackedScanJobSource : IScanJobSource
|
||||
{
|
||||
private readonly IScanQueue _queue;
|
||||
private readonly ScannerQueueOptions _queueOptions;
|
||||
private readonly QueueBackedScanJobSourceDependencies _deps;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly string _consumerName = $"worker-smoke-{Guid.NewGuid():n}";
|
||||
|
||||
public QueueBackedScanJobSource(
|
||||
IScanQueue queue,
|
||||
ScannerQueueOptions queueOptions,
|
||||
QueueBackedScanJobSourceDependencies deps,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
|
||||
_queueOptions = queueOptions ?? throw new ArgumentNullException(nameof(queueOptions));
|
||||
_deps = deps ?? throw new ArgumentNullException(nameof(deps));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<IScanJobLease?> TryAcquireAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var request = new QueueLeaseRequest(_consumerName, 1, _queueOptions.DefaultLeaseDuration);
|
||||
var leases = await _queue.LeaseAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (leases.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new QueueBackedScanJobLease(
|
||||
leases[0],
|
||||
_queueOptions,
|
||||
_deps,
|
||||
_timeProvider.GetUtcNow());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class QueueBackedScanJobLease : IScanJobLease
|
||||
{
|
||||
private readonly IScanQueueLease _lease;
|
||||
private readonly ScannerQueueOptions _options;
|
||||
private readonly QueueBackedScanJobSourceDependencies _deps;
|
||||
private readonly DateTimeOffset _leasedAt;
|
||||
private readonly IReadOnlyDictionary<string, string> _metadata;
|
||||
|
||||
public QueueBackedScanJobLease(
|
||||
IScanQueueLease lease,
|
||||
ScannerQueueOptions options,
|
||||
QueueBackedScanJobSourceDependencies deps,
|
||||
DateTimeOffset leasedAt)
|
||||
{
|
||||
_lease = lease ?? throw new ArgumentNullException(nameof(lease));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_deps = deps ?? throw new ArgumentNullException(nameof(deps));
|
||||
_leasedAt = leasedAt;
|
||||
|
||||
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["queue"] = _options.Kind.ToString(),
|
||||
["queue.consumer"] = lease.Consumer,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(lease.IdempotencyKey))
|
||||
{
|
||||
metadata["job.idempotency"] = lease.IdempotencyKey;
|
||||
}
|
||||
|
||||
foreach (var attribute in lease.Attributes)
|
||||
{
|
||||
metadata[attribute.Key] = attribute.Value;
|
||||
}
|
||||
|
||||
_metadata = metadata;
|
||||
}
|
||||
|
||||
public string JobId => _lease.JobId;
|
||||
|
||||
public string ScanId => _metadata.TryGetValue("scanId", out var scanId) ? scanId : _lease.JobId;
|
||||
|
||||
public int Attempt => _lease.Attempt;
|
||||
|
||||
public DateTimeOffset EnqueuedAtUtc => _lease.EnqueuedAt;
|
||||
|
||||
public DateTimeOffset LeasedAtUtc => _leasedAt;
|
||||
|
||||
public TimeSpan LeaseDuration => _lease.LeaseExpiresAt - _leasedAt;
|
||||
|
||||
public IReadOnlyDictionary<string, string> Metadata => _metadata;
|
||||
|
||||
public async ValueTask RenewAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _lease.RenewAsync(_options.DefaultLeaseDuration, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask CompleteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _lease.AcknowledgeAsync(cancellationToken).ConfigureAwait(false);
|
||||
_deps.JobCompleted.TrySetResult();
|
||||
}
|
||||
|
||||
public async ValueTask AbandonAsync(string reason, CancellationToken cancellationToken)
|
||||
{
|
||||
await _lease.ReleaseAsync(QueueReleaseDisposition.Retry, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask PoisonAsync(string reason, CancellationToken cancellationToken)
|
||||
{
|
||||
await _lease.DeadLetterAsync(reason, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using StellaOps.Scanner.Worker.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Tests;
|
||||
|
||||
public sealed class ScannerWorkerOptionsValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenHeartbeatSafetyFactorBelowThree()
|
||||
{
|
||||
var options = new ScannerWorkerOptions();
|
||||
options.Queue.HeartbeatSafetyFactor = 2.5;
|
||||
|
||||
var validator = new ScannerWorkerOptionsValidator();
|
||||
var result = validator.Validate(string.Empty, options);
|
||||
|
||||
Assert.True(result.Failed, "Validation should fail when HeartbeatSafetyFactor < 3.");
|
||||
Assert.Contains(result.Failures, failure => failure.Contains("HeartbeatSafetyFactor", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WhenHeartbeatSafetyFactorAtLeastThree()
|
||||
{
|
||||
var options = new ScannerWorkerOptions();
|
||||
options.Queue.HeartbeatSafetyFactor = 3.5;
|
||||
|
||||
var validator = new ScannerWorkerOptionsValidator();
|
||||
var result = validator.Validate(string.Empty, options);
|
||||
|
||||
Assert.True(result.Succeeded, "Validation should succeed when HeartbeatSafetyFactor >= 3.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Scanner.Worker/StellaOps.Scanner.Worker.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Queue/StellaOps.Scanner.Queue.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Tests.TestInfrastructure;
|
||||
|
||||
public sealed class StaticOptionsMonitor<TOptions> : IOptionsMonitor<TOptions>
|
||||
where TOptions : class
|
||||
{
|
||||
private readonly TOptions _value;
|
||||
|
||||
public StaticOptionsMonitor(TOptions value)
|
||||
{
|
||||
_value = value ?? throw new ArgumentNullException(nameof(value));
|
||||
}
|
||||
|
||||
public TOptions CurrentValue => _value;
|
||||
|
||||
public TOptions Get(string? name) => _value;
|
||||
|
||||
public IDisposable OnChange(Action<TOptions, string?> listener) => NullDisposable.Instance;
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
{
|
||||
public static readonly NullDisposable Instance = new();
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Scanner.Worker.Diagnostics;
|
||||
using StellaOps.Scanner.Worker.Hosting;
|
||||
using StellaOps.Scanner.Worker.Options;
|
||||
using StellaOps.Scanner.Worker.Processing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Tests;
|
||||
|
||||
public sealed class WorkerBasicScanScenarioTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DelayAsync_CompletesAfterTimeAdvance()
|
||||
{
|
||||
var scheduler = new ControlledDelayScheduler();
|
||||
var delayTask = scheduler.DelayAsync(TimeSpan.FromSeconds(5), CancellationToken.None);
|
||||
scheduler.AdvanceBy(TimeSpan.FromSeconds(5));
|
||||
await delayTask.WaitAsync(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_CompletesJob_RecordsTelemetry_And_Heartbeats()
|
||||
{
|
||||
var fakeTime = new FakeTimeProvider();
|
||||
fakeTime.SetUtcNow(DateTimeOffset.UtcNow);
|
||||
|
||||
var options = new ScannerWorkerOptions
|
||||
{
|
||||
MaxConcurrentJobs = 1,
|
||||
};
|
||||
options.Telemetry.EnableTelemetry = false;
|
||||
options.Telemetry.EnableMetrics = true;
|
||||
|
||||
var optionsMonitor = new StaticOptionsMonitor<ScannerWorkerOptions>(options);
|
||||
var testLoggerProvider = new TestLoggerProvider();
|
||||
var lease = new TestJobLease(fakeTime);
|
||||
var jobSource = new TestJobSource(lease);
|
||||
var scheduler = new ControlledDelayScheduler();
|
||||
var analyzer = new TestAnalyzerDispatcher(scheduler);
|
||||
|
||||
using var listener = new WorkerMetricsListener();
|
||||
listener.Start();
|
||||
|
||||
using var services = new ServiceCollection()
|
||||
.AddLogging(builder =>
|
||||
{
|
||||
builder.ClearProviders();
|
||||
builder.AddProvider(testLoggerProvider);
|
||||
builder.SetMinimumLevel(LogLevel.Debug);
|
||||
})
|
||||
.AddSingleton(fakeTime)
|
||||
.AddSingleton<TimeProvider>(fakeTime)
|
||||
.AddSingleton<IOptionsMonitor<ScannerWorkerOptions>>(optionsMonitor)
|
||||
.AddSingleton<ScannerWorkerMetrics>()
|
||||
.AddSingleton<ScanProgressReporter>()
|
||||
.AddSingleton<ScanJobProcessor>()
|
||||
.AddSingleton<LeaseHeartbeatService>()
|
||||
.AddSingleton<IDelayScheduler>(scheduler)
|
||||
.AddSingleton<IScanJobSource>(_ => jobSource)
|
||||
.AddSingleton<IScanAnalyzerDispatcher>(analyzer)
|
||||
.AddSingleton<IEntryTraceExecutionService, NullEntryTraceExecutionService>()
|
||||
.AddSingleton<IScanStageExecutor, AnalyzerStageExecutor>()
|
||||
.AddSingleton<ScannerWorkerHostedService>()
|
||||
.BuildServiceProvider();
|
||||
|
||||
var worker = services.GetRequiredService<ScannerWorkerHostedService>();
|
||||
|
||||
await worker.StartAsync(CancellationToken.None);
|
||||
|
||||
await jobSource.LeaseIssued.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await Task.Yield();
|
||||
|
||||
var spin = 0;
|
||||
while (!lease.Completed.Task.IsCompleted && spin++ < 24)
|
||||
{
|
||||
fakeTime.Advance(TimeSpan.FromSeconds(15));
|
||||
scheduler.AdvanceBy(TimeSpan.FromSeconds(15));
|
||||
await Task.Delay(1);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await lease.Completed.Task.WaitAsync(TimeSpan.FromSeconds(30));
|
||||
}
|
||||
catch (TimeoutException ex)
|
||||
{
|
||||
var stageLogs = string.Join(Environment.NewLine, testLoggerProvider
|
||||
.GetEntriesForCategory(typeof(ScanProgressReporter).FullName!)
|
||||
.Select(entry => entry.ToFormattedString()));
|
||||
|
||||
throw new TimeoutException($"Worker did not complete within timeout. Logs:{Environment.NewLine}{stageLogs}", ex);
|
||||
}
|
||||
|
||||
await worker.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(lease.Completed.Task.IsCompletedSuccessfully, "Job should complete successfully.");
|
||||
Assert.Single(analyzer.Executions);
|
||||
Assert.True(lease.RenewalCount >= 1, "Lease should have been renewed at least once.");
|
||||
|
||||
var stageOrder = testLoggerProvider
|
||||
.GetEntriesForCategory(typeof(ScanProgressReporter).FullName!)
|
||||
.Where(entry => entry.EventId.Id == 1000)
|
||||
.Select(entry => entry.GetScopeProperty<string>("Stage"))
|
||||
.Where(stage => stage is not null)
|
||||
.Cast<string>()
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(ScanStageNames.Ordered, stageOrder);
|
||||
|
||||
var queueLatency = listener.Measurements.Where(m => m.InstrumentName == "scanner_worker_queue_latency_ms").ToArray();
|
||||
Assert.Single(queueLatency);
|
||||
Assert.True(queueLatency[0].Value > 0, "Queue latency should be positive.");
|
||||
|
||||
var jobDuration = listener.Measurements.Where(m => m.InstrumentName == "scanner_worker_job_duration_ms").ToArray();
|
||||
Assert.Single(jobDuration);
|
||||
Assert.True(jobDuration[0].Value > 0, "Job duration should be positive.");
|
||||
|
||||
var stageDurations = listener.Measurements.Where(m => m.InstrumentName == "scanner_worker_stage_duration_ms").ToArray();
|
||||
Assert.Contains(stageDurations, m => m.Tags.TryGetValue("stage", out var stage) && Equals(stage, ScanStageNames.ExecuteAnalyzers));
|
||||
}
|
||||
|
||||
private sealed class TestJobSource : IScanJobSource
|
||||
{
|
||||
private readonly TestJobLease _lease;
|
||||
private int _delivered;
|
||||
|
||||
public TestJobSource(TestJobLease lease)
|
||||
{
|
||||
_lease = lease;
|
||||
}
|
||||
|
||||
public TaskCompletionSource LeaseIssued { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public Task<IScanJobLease?> TryAcquireAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _delivered, 1) == 0)
|
||||
{
|
||||
LeaseIssued.TrySetResult();
|
||||
return Task.FromResult<IScanJobLease?>(_lease);
|
||||
}
|
||||
|
||||
return Task.FromResult<IScanJobLease?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestJobLease : IScanJobLease
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly Dictionary<string, string> _metadata = new()
|
||||
{
|
||||
{ "queue", "tests" },
|
||||
{ "job.kind", "basic" },
|
||||
};
|
||||
|
||||
public TestJobLease(FakeTimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider;
|
||||
EnqueuedAtUtc = _timeProvider.GetUtcNow() - TimeSpan.FromSeconds(5);
|
||||
LeasedAtUtc = _timeProvider.GetUtcNow();
|
||||
}
|
||||
|
||||
public string JobId { get; } = Guid.NewGuid().ToString("n");
|
||||
|
||||
public string ScanId { get; } = $"scan-{Guid.NewGuid():n}";
|
||||
|
||||
public int Attempt { get; } = 1;
|
||||
|
||||
public DateTimeOffset EnqueuedAtUtc { get; }
|
||||
|
||||
public DateTimeOffset LeasedAtUtc { get; }
|
||||
|
||||
public TimeSpan LeaseDuration { get; } = TimeSpan.FromSeconds(90);
|
||||
|
||||
public IReadOnlyDictionary<string, string> Metadata => _metadata;
|
||||
|
||||
public TaskCompletionSource Completed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public int RenewalCount => _renewalCount;
|
||||
|
||||
public ValueTask RenewAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Interlocked.Increment(ref _renewalCount);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask CompleteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Completed.TrySetResult();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask AbandonAsync(string reason, CancellationToken cancellationToken)
|
||||
{
|
||||
Completed.TrySetException(new InvalidOperationException($"Abandoned: {reason}"));
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask PoisonAsync(string reason, CancellationToken cancellationToken)
|
||||
{
|
||||
Completed.TrySetException(new InvalidOperationException($"Poisoned: {reason}"));
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
private int _renewalCount;
|
||||
}
|
||||
|
||||
private sealed class NullEntryTraceExecutionService : IEntryTraceExecutionService
|
||||
{
|
||||
public ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class TestAnalyzerDispatcher : IScanAnalyzerDispatcher
|
||||
{
|
||||
private readonly IDelayScheduler _scheduler;
|
||||
|
||||
public TestAnalyzerDispatcher(IDelayScheduler scheduler)
|
||||
{
|
||||
_scheduler = scheduler;
|
||||
}
|
||||
|
||||
public List<string> Executions { get; } = new();
|
||||
|
||||
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
Executions.Add(context.JobId);
|
||||
await _scheduler.DelayAsync(TimeSpan.FromSeconds(45), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ControlledDelayScheduler : IDelayScheduler
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly SortedDictionary<double, List<ScheduledDelay>> _scheduled = new();
|
||||
private double _currentMilliseconds;
|
||||
|
||||
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
|
||||
{
|
||||
if (delay <= TimeSpan.Zero)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var tcs = new TaskCompletionSource<object?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var scheduled = new ScheduledDelay(tcs, cancellationToken);
|
||||
lock (_lock)
|
||||
{
|
||||
var due = _currentMilliseconds + delay.TotalMilliseconds;
|
||||
if (!_scheduled.TryGetValue(due, out var list))
|
||||
{
|
||||
list = new List<ScheduledDelay>();
|
||||
_scheduled.Add(due, list);
|
||||
}
|
||||
|
||||
list.Add(scheduled);
|
||||
}
|
||||
|
||||
return scheduled.Task;
|
||||
}
|
||||
|
||||
public void AdvanceBy(TimeSpan delta)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_currentMilliseconds += delta.TotalMilliseconds;
|
||||
var dueKeys = _scheduled.Keys.Where(key => key <= _currentMilliseconds).ToList();
|
||||
foreach (var due in dueKeys)
|
||||
{
|
||||
foreach (var scheduled in _scheduled[due])
|
||||
{
|
||||
scheduled.Complete();
|
||||
}
|
||||
|
||||
_scheduled.Remove(due);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ScheduledDelay
|
||||
{
|
||||
private readonly TaskCompletionSource<object?> _tcs;
|
||||
private readonly CancellationTokenRegistration _registration;
|
||||
|
||||
public ScheduledDelay(TaskCompletionSource<object?> tcs, CancellationToken cancellationToken)
|
||||
{
|
||||
_tcs = tcs;
|
||||
if (cancellationToken.CanBeCanceled)
|
||||
{
|
||||
_registration = cancellationToken.Register(state =>
|
||||
{
|
||||
var source = (TaskCompletionSource<object?>)state!;
|
||||
source.TrySetCanceled(cancellationToken);
|
||||
}, tcs);
|
||||
}
|
||||
}
|
||||
|
||||
public Task Task => _tcs.Task;
|
||||
|
||||
public void Complete()
|
||||
{
|
||||
_registration.Dispose();
|
||||
_tcs.TrySetResult(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StaticOptionsMonitor<TOptions> : IOptionsMonitor<TOptions>
|
||||
where TOptions : class
|
||||
{
|
||||
private readonly TOptions _value;
|
||||
|
||||
public StaticOptionsMonitor(TOptions value)
|
||||
{
|
||||
_value = value;
|
||||
}
|
||||
|
||||
public TOptions CurrentValue => _value;
|
||||
|
||||
public TOptions Get(string? name) => _value;
|
||||
|
||||
public IDisposable OnChange(Action<TOptions, string?> listener) => NullDisposable.Instance;
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
{
|
||||
public static readonly NullDisposable Instance = new();
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class WorkerMetricsListener : IDisposable
|
||||
{
|
||||
private readonly MeterListener _listener;
|
||||
public ConcurrentBag<Measurement> Measurements { get; } = new();
|
||||
|
||||
public WorkerMetricsListener()
|
||||
{
|
||||
_listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (instrument, listener) =>
|
||||
{
|
||||
if (instrument.Meter.Name == ScannerWorkerInstrumentation.MeterName)
|
||||
{
|
||||
listener.EnableMeasurementEvents(instrument);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_listener.SetMeasurementEventCallback<double>((instrument, measurement, tags, state) =>
|
||||
{
|
||||
var tagDictionary = new Dictionary<string, object?>(tags.Length, StringComparer.Ordinal);
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
tagDictionary[tag.Key] = tag.Value;
|
||||
}
|
||||
|
||||
Measurements.Add(new Measurement(instrument.Name, measurement, tagDictionary));
|
||||
});
|
||||
}
|
||||
|
||||
public void Start() => _listener.Start();
|
||||
|
||||
public void Dispose() => _listener.Dispose();
|
||||
}
|
||||
|
||||
public sealed record Measurement(string InstrumentName, double Value, IReadOnlyDictionary<string, object?> Tags)
|
||||
{
|
||||
public object? this[string name] => Tags.TryGetValue(name, out var value) ? value : null;
|
||||
}
|
||||
|
||||
private sealed class TestLoggerProvider : ILoggerProvider
|
||||
{
|
||||
private readonly ConcurrentQueue<TestLogEntry> _entries = new();
|
||||
|
||||
public ILogger CreateLogger(string categoryName) => new TestLogger(categoryName, _entries);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
public IEnumerable<TestLogEntry> GetEntriesForCategory(string categoryName)
|
||||
=> _entries.Where(entry => entry.Category == categoryName);
|
||||
|
||||
private sealed class TestLogger : ILogger
|
||||
{
|
||||
private readonly string _category;
|
||||
private readonly ConcurrentQueue<TestLogEntry> _entries;
|
||||
|
||||
public TestLogger(string category, ConcurrentQueue<TestLogEntry> entries)
|
||||
{
|
||||
_category = category;
|
||||
_entries = entries;
|
||||
}
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => NullDisposable.Instance;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
_entries.Enqueue(new TestLogEntry(_category, logLevel, eventId, state, exception));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
{
|
||||
public static readonly NullDisposable Instance = new();
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record TestLogEntry(string Category, LogLevel Level, EventId EventId, object? State, Exception? Exception)
|
||||
{
|
||||
public T? GetScopeProperty<T>(string name)
|
||||
{
|
||||
if (State is not IEnumerable<KeyValuePair<string, object?>> state)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
foreach (var kvp in state)
|
||||
{
|
||||
if (string.Equals(kvp.Key, name, StringComparison.OrdinalIgnoreCase) && kvp.Value is T value)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
public string ToFormattedString()
|
||||
{
|
||||
var properties = State is IEnumerable<KeyValuePair<string, object?>> kvps
|
||||
? string.Join(", ", kvps.Select(kvp => $"{kvp.Key}={kvp.Value}"))
|
||||
: State?.ToString() ?? string.Empty;
|
||||
|
||||
var exceptionPart = Exception is null ? string.Empty : $" Exception={Exception.GetType().Name}: {Exception.Message}";
|
||||
return $"[{Level}] {Category} ({EventId.Id}) {properties}{exceptionPart}";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user