feat: Add Go module and workspace test fixtures

- Created expected JSON files for Go modules and workspaces.
- Added go.mod and go.sum files for example projects.
- Implemented private module structure with expected JSON output.
- Introduced vendored dependencies with corresponding expected JSON.
- Developed PostgresGraphJobStore for managing graph jobs.
- Established SQL migration scripts for graph jobs schema.
- Implemented GraphJobRepository for CRUD operations on graph jobs.
- Created IGraphJobRepository interface for repository abstraction.
- Added unit tests for GraphJobRepository to ensure functionality.
This commit is contained in:
StellaOps Bot
2025-12-06 20:04:03 +02:00
parent a6f1406509
commit 05597616d6
178 changed files with 12022 additions and 4545 deletions

View File

@@ -1,244 +1,244 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Repositories;
using StellaOps.Scheduler.Worker.Graph;
using StellaOps.Scheduler.Worker.Graph.Cartographer;
using StellaOps.Scheduler.Worker.Graph.Scheduler;
using StellaOps.Scheduler.Worker.Options;
using StellaOps.Scheduler.Worker.Observability;
using Xunit;
namespace StellaOps.Scheduler.Worker.Tests;
public sealed class GraphBuildExecutionServiceTests
{
[Fact]
public async Task ExecuteAsync_Skips_WhenGraphDisabled()
{
var repository = new RecordingGraphJobRepository();
var cartographer = new StubCartographerBuildClient();
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = false
}
});
var service = new GraphBuildExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphBuildExecutionService>.Instance);
var job = CreateGraphJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphBuildExecutionResultType.Skipped, result.Type);
Assert.Equal("graph_processing_disabled", result.Reason);
Assert.Equal(0, repository.ReplaceCalls);
Assert.Equal(0, cartographer.CallCount);
Assert.Empty(completion.Notifications);
}
[Fact]
public async Task ExecuteAsync_CompletesJob_OnSuccess()
{
var repository = new RecordingGraphJobRepository();
var cartographer = new StubCartographerBuildClient
{
Result = new CartographerBuildResult(
GraphJobStatus.Completed,
CartographerJobId: "carto-1",
GraphSnapshotId: "graph_snap",
ResultUri: "oras://graph/result",
Error: null)
};
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = true,
MaxAttempts = 2,
RetryBackoff = TimeSpan.FromMilliseconds(10)
}
});
var service = new GraphBuildExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphBuildExecutionService>.Instance);
var job = CreateGraphJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphBuildExecutionResultType.Completed, result.Type);
Assert.Single(completion.Notifications);
var notification = completion.Notifications[0];
Assert.Equal(job.Id, notification.JobId);
Assert.Equal("Build", notification.JobType);
Assert.Equal(GraphJobStatus.Completed, notification.Status);
Assert.Equal("oras://graph/result", notification.ResultUri);
Assert.Equal("graph_snap", notification.GraphSnapshotId);
Assert.Null(notification.Error);
Assert.Equal(1, cartographer.CallCount);
Assert.True(repository.ReplaceCalls >= 1);
}
[Fact]
public async Task ExecuteAsync_Fails_AfterMaxAttempts()
{
var repository = new RecordingGraphJobRepository();
var cartographer = new StubCartographerBuildClient
{
ExceptionToThrow = new InvalidOperationException("network")
};
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = true,
MaxAttempts = 2,
RetryBackoff = TimeSpan.FromMilliseconds(1)
}
});
var service = new GraphBuildExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphBuildExecutionService>.Instance);
var job = CreateGraphJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphBuildExecutionResultType.Failed, result.Type);
Assert.Equal(2, cartographer.CallCount);
Assert.Single(completion.Notifications);
Assert.Equal(GraphJobStatus.Failed, completion.Notifications[0].Status);
Assert.Equal("network", completion.Notifications[0].Error);
}
[Fact]
public async Task ExecuteAsync_Skips_WhenConcurrencyConflict()
{
var repository = new RecordingGraphJobRepository
{
ShouldReplaceSucceed = false
};
var cartographer = new StubCartographerBuildClient();
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = true
}
});
var service = new GraphBuildExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphBuildExecutionService>.Instance);
var job = CreateGraphJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphBuildExecutionResultType.Skipped, result.Type);
Assert.Equal("concurrency_conflict", result.Reason);
Assert.Equal(0, cartographer.CallCount);
Assert.Empty(completion.Notifications);
}
private static GraphBuildJob CreateGraphJob() => new(
id: "gbj_1",
tenantId: "tenant-alpha",
sbomId: "sbom-1",
sbomVersionId: "sbom-1-v1",
sbomDigest: "sha256:" + new string('a', 64),
status: GraphJobStatus.Pending,
trigger: GraphBuildJobTrigger.SbomVersion,
createdAt: DateTimeOffset.UtcNow,
attempts: 0,
metadata: Array.Empty<KeyValuePair<string, string>>());
private sealed class RecordingGraphJobRepository : IGraphJobRepository
{
public int ReplaceCalls { get; private set; }
public bool ShouldReplaceSucceed { get; set; } = true;
public Task<bool> TryReplaceAsync(GraphBuildJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken = default)
{
if (!ShouldReplaceSucceed)
{
return Task.FromResult(false);
}
ReplaceCalls++;
return Task.FromResult(true);
}
public Task<GraphBuildJob> ReplaceAsync(GraphBuildJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphOverlayJob> ReplaceAsync(GraphOverlayJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task InsertAsync(GraphBuildJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task InsertAsync(GraphOverlayJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphBuildJob?> GetBuildJobAsync(string tenantId, string jobId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphOverlayJob?> GetOverlayJobAsync(string tenantId, string jobId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphBuildJob>> ListBuildJobsAsync(string tenantId, GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphBuildJob>> ListBuildJobsAsync(GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphOverlayJob>> ListOverlayJobsAsync(string tenantId, GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphOverlayJob>> ListOverlayJobsAsync(GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyCollection<GraphOverlayJob>> ListOverlayJobsAsync(string tenantId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<bool> TryReplaceOverlayAsync(GraphOverlayJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
}
private sealed class StubCartographerBuildClient : ICartographerBuildClient
{
public CartographerBuildResult Result { get; set; } = new(GraphJobStatus.Completed, null, null, null, null);
public Exception? ExceptionToThrow { get; set; }
public int CallCount { get; private set; }
public Task<CartographerBuildResult> StartBuildAsync(GraphBuildJob job, CancellationToken cancellationToken)
{
CallCount++;
if (ExceptionToThrow is not null)
{
throw ExceptionToThrow;
}
return Task.FromResult(Result);
}
}
private sealed class RecordingCompletionClient : IGraphJobCompletionClient
{
public List<GraphJobCompletionRequestDto> Notifications { get; } = new();
public Task NotifyAsync(GraphJobCompletionRequestDto request, CancellationToken cancellationToken)
{
Notifications.Add(request);
return Task.CompletedTask;
}
}
}
using Xunit;
namespace StellaOps.Scheduler.Worker.Tests;
public sealed class GraphBuildExecutionServiceTests
{
[Fact]
public async Task ExecuteAsync_Skips_WhenGraphDisabled()
{
var repository = new RecordingGraphJobRepository();
var cartographer = new StubCartographerBuildClient();
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = false
}
});
var service = new GraphBuildExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphBuildExecutionService>.Instance);
var job = CreateGraphJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphBuildExecutionResultType.Skipped, result.Type);
Assert.Equal("graph_processing_disabled", result.Reason);
Assert.Equal(0, repository.ReplaceCalls);
Assert.Equal(0, cartographer.CallCount);
Assert.Empty(completion.Notifications);
}
[Fact]
public async Task ExecuteAsync_CompletesJob_OnSuccess()
{
var repository = new RecordingGraphJobRepository();
var cartographer = new StubCartographerBuildClient
{
Result = new CartographerBuildResult(
GraphJobStatus.Completed,
CartographerJobId: "carto-1",
GraphSnapshotId: "graph_snap",
ResultUri: "oras://graph/result",
Error: null)
};
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = true,
MaxAttempts = 2,
RetryBackoff = TimeSpan.FromMilliseconds(10)
}
});
var service = new GraphBuildExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphBuildExecutionService>.Instance);
var job = CreateGraphJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphBuildExecutionResultType.Completed, result.Type);
Assert.Single(completion.Notifications);
var notification = completion.Notifications[0];
Assert.Equal(job.Id, notification.JobId);
Assert.Equal("Build", notification.JobType);
Assert.Equal(GraphJobStatus.Completed, notification.Status);
Assert.Equal("oras://graph/result", notification.ResultUri);
Assert.Equal("graph_snap", notification.GraphSnapshotId);
Assert.Null(notification.Error);
Assert.Equal(1, cartographer.CallCount);
Assert.True(repository.ReplaceCalls >= 1);
}
[Fact]
public async Task ExecuteAsync_Fails_AfterMaxAttempts()
{
var repository = new RecordingGraphJobRepository();
var cartographer = new StubCartographerBuildClient
{
ExceptionToThrow = new InvalidOperationException("network")
};
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = true,
MaxAttempts = 2,
RetryBackoff = TimeSpan.FromMilliseconds(1)
}
});
var service = new GraphBuildExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphBuildExecutionService>.Instance);
var job = CreateGraphJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphBuildExecutionResultType.Failed, result.Type);
Assert.Equal(2, cartographer.CallCount);
Assert.Single(completion.Notifications);
Assert.Equal(GraphJobStatus.Failed, completion.Notifications[0].Status);
Assert.Equal("network", completion.Notifications[0].Error);
}
[Fact]
public async Task ExecuteAsync_Skips_WhenConcurrencyConflict()
{
var repository = new RecordingGraphJobRepository
{
ShouldReplaceSucceed = false
};
var cartographer = new StubCartographerBuildClient();
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = true
}
});
var service = new GraphBuildExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphBuildExecutionService>.Instance);
var job = CreateGraphJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphBuildExecutionResultType.Skipped, result.Type);
Assert.Equal("concurrency_conflict", result.Reason);
Assert.Equal(0, cartographer.CallCount);
Assert.Empty(completion.Notifications);
}
private static GraphBuildJob CreateGraphJob() => new(
id: "gbj_1",
tenantId: "tenant-alpha",
sbomId: "sbom-1",
sbomVersionId: "sbom-1-v1",
sbomDigest: "sha256:" + new string('a', 64),
status: GraphJobStatus.Pending,
trigger: GraphBuildJobTrigger.SbomVersion,
createdAt: DateTimeOffset.UtcNow,
attempts: 0,
metadata: Array.Empty<KeyValuePair<string, string>>());
private sealed class RecordingGraphJobRepository : IGraphJobRepository
{
public int ReplaceCalls { get; private set; }
public bool ShouldReplaceSucceed { get; set; } = true;
public Task<bool> TryReplaceAsync(GraphBuildJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken = default)
{
if (!ShouldReplaceSucceed)
{
return Task.FromResult(false);
}
ReplaceCalls++;
return Task.FromResult(true);
}
public Task<GraphBuildJob> ReplaceAsync(GraphBuildJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphOverlayJob> ReplaceAsync(GraphOverlayJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task InsertAsync(GraphBuildJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task InsertAsync(GraphOverlayJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphBuildJob?> GetBuildJobAsync(string tenantId, string jobId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphOverlayJob?> GetOverlayJobAsync(string tenantId, string jobId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphBuildJob>> ListBuildJobsAsync(string tenantId, GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphBuildJob>> ListBuildJobsAsync(GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphOverlayJob>> ListOverlayJobsAsync(string tenantId, GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphOverlayJob>> ListOverlayJobsAsync(GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyCollection<GraphOverlayJob>> ListOverlayJobsAsync(string tenantId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<bool> TryReplaceOverlayAsync(GraphOverlayJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
}
private sealed class StubCartographerBuildClient : ICartographerBuildClient
{
public CartographerBuildResult Result { get; set; } = new(GraphJobStatus.Completed, null, null, null, null);
public Exception? ExceptionToThrow { get; set; }
public int CallCount { get; private set; }
public Task<CartographerBuildResult> StartBuildAsync(GraphBuildJob job, CancellationToken cancellationToken)
{
CallCount++;
if (ExceptionToThrow is not null)
{
throw ExceptionToThrow;
}
return Task.FromResult(Result);
}
}
private sealed class RecordingCompletionClient : IGraphJobCompletionClient
{
public List<GraphJobCompletionRequestDto> Notifications { get; } = new();
public Task NotifyAsync(GraphJobCompletionRequestDto request, CancellationToken cancellationToken)
{
Notifications.Add(request);
return Task.CompletedTask;
}
}
}

View File

@@ -1,238 +1,238 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Repositories;
using StellaOps.Scheduler.Worker.Graph;
using StellaOps.Scheduler.Worker.Graph.Cartographer;
using StellaOps.Scheduler.Worker.Graph.Scheduler;
using StellaOps.Scheduler.Worker.Options;
using StellaOps.Scheduler.Worker.Observability;
using Xunit;
namespace StellaOps.Scheduler.Worker.Tests;
public sealed class GraphOverlayExecutionServiceTests
{
[Fact]
public async Task ExecuteAsync_Skips_WhenGraphDisabled()
{
var repository = new RecordingGraphJobRepository();
var cartographer = new StubCartographerOverlayClient();
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = false
}
});
var service = new GraphOverlayExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphOverlayExecutionService>.Instance);
var job = CreateOverlayJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphOverlayExecutionResultType.Skipped, result.Type);
Assert.Equal("graph_processing_disabled", result.Reason);
Assert.Empty(completion.Notifications);
Assert.Equal(0, cartographer.CallCount);
}
[Fact]
public async Task ExecuteAsync_CompletesJob_OnSuccess()
{
var repository = new RecordingGraphJobRepository();
var cartographer = new StubCartographerOverlayClient
{
Result = new CartographerOverlayResult(
GraphJobStatus.Completed,
GraphSnapshotId: "graph_snap_2",
ResultUri: "oras://graph/overlay",
Error: null)
};
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = true,
MaxAttempts = 2,
RetryBackoff = TimeSpan.FromMilliseconds(5)
}
});
var service = new GraphOverlayExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphOverlayExecutionService>.Instance);
var job = CreateOverlayJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphOverlayExecutionResultType.Completed, result.Type);
Assert.Single(completion.Notifications);
var notification = completion.Notifications[0];
Assert.Equal("Overlay", notification.JobType);
Assert.Equal(GraphJobStatus.Completed, notification.Status);
Assert.Equal("oras://graph/overlay", notification.ResultUri);
Assert.Equal("graph_snap_2", notification.GraphSnapshotId);
}
[Fact]
public async Task ExecuteAsync_Fails_AfterRetries()
{
var repository = new RecordingGraphJobRepository();
var cartographer = new StubCartographerOverlayClient
{
ExceptionToThrow = new InvalidOperationException("overlay failed")
};
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = true,
MaxAttempts = 2,
RetryBackoff = TimeSpan.FromMilliseconds(1)
}
});
var service = new GraphOverlayExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphOverlayExecutionService>.Instance);
var job = CreateOverlayJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphOverlayExecutionResultType.Failed, result.Type);
Assert.Single(completion.Notifications);
Assert.Equal(GraphJobStatus.Failed, completion.Notifications[0].Status);
Assert.Equal("overlay failed", completion.Notifications[0].Error);
}
[Fact]
public async Task ExecuteAsync_Skips_WhenConcurrencyConflict()
{
var repository = new RecordingGraphJobRepository
{
ShouldReplaceSucceed = false
};
var cartographer = new StubCartographerOverlayClient();
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = true
}
});
var service = new GraphOverlayExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphOverlayExecutionService>.Instance);
var job = CreateOverlayJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphOverlayExecutionResultType.Skipped, result.Type);
Assert.Equal("concurrency_conflict", result.Reason);
Assert.Empty(completion.Notifications);
Assert.Equal(0, cartographer.CallCount);
}
private static GraphOverlayJob CreateOverlayJob() => new(
id: "goj_1",
tenantId: "tenant-alpha",
graphSnapshotId: "snap-1",
overlayKind: GraphOverlayKind.Policy,
overlayKey: "policy@1",
status: GraphJobStatus.Pending,
trigger: GraphOverlayJobTrigger.Policy,
createdAt: DateTimeOffset.UtcNow,
subjects: Array.Empty<string>(),
attempts: 0,
metadata: Array.Empty<KeyValuePair<string, string>>());
private sealed class RecordingGraphJobRepository : IGraphJobRepository
{
public bool ShouldReplaceSucceed { get; set; } = true;
public int RunningReplacements { get; private set; }
public Task<bool> TryReplaceOverlayAsync(GraphOverlayJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken = default)
{
if (!ShouldReplaceSucceed)
{
return Task.FromResult(false);
}
RunningReplacements++;
return Task.FromResult(true);
}
public Task<bool> TryReplaceAsync(GraphBuildJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphBuildJob> ReplaceAsync(GraphBuildJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphOverlayJob> ReplaceAsync(GraphOverlayJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task InsertAsync(GraphBuildJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task InsertAsync(GraphOverlayJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphBuildJob?> GetBuildJobAsync(string tenantId, string jobId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphOverlayJob?> GetOverlayJobAsync(string tenantId, string jobId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphBuildJob>> ListBuildJobsAsync(string tenantId, GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphBuildJob>> ListBuildJobsAsync(GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphOverlayJob>> ListOverlayJobsAsync(string tenantId, GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphOverlayJob>> ListOverlayJobsAsync(GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyCollection<GraphOverlayJob>> ListOverlayJobsAsync(string tenantId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
}
private sealed class StubCartographerOverlayClient : ICartographerOverlayClient
{
public CartographerOverlayResult Result { get; set; } = new(GraphJobStatus.Completed, null, null, null);
public Exception? ExceptionToThrow { get; set; }
public int CallCount { get; private set; }
public Task<CartographerOverlayResult> StartOverlayAsync(GraphOverlayJob job, CancellationToken cancellationToken)
{
CallCount++;
if (ExceptionToThrow is not null)
{
throw ExceptionToThrow;
}
return Task.FromResult(Result);
}
}
private sealed class RecordingCompletionClient : IGraphJobCompletionClient
{
public List<GraphJobCompletionRequestDto> Notifications { get; } = new();
public Task NotifyAsync(GraphJobCompletionRequestDto request, CancellationToken cancellationToken)
{
Notifications.Add(request);
return Task.CompletedTask;
}
}
}
using Xunit;
namespace StellaOps.Scheduler.Worker.Tests;
public sealed class GraphOverlayExecutionServiceTests
{
[Fact]
public async Task ExecuteAsync_Skips_WhenGraphDisabled()
{
var repository = new RecordingGraphJobRepository();
var cartographer = new StubCartographerOverlayClient();
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = false
}
});
var service = new GraphOverlayExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphOverlayExecutionService>.Instance);
var job = CreateOverlayJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphOverlayExecutionResultType.Skipped, result.Type);
Assert.Equal("graph_processing_disabled", result.Reason);
Assert.Empty(completion.Notifications);
Assert.Equal(0, cartographer.CallCount);
}
[Fact]
public async Task ExecuteAsync_CompletesJob_OnSuccess()
{
var repository = new RecordingGraphJobRepository();
var cartographer = new StubCartographerOverlayClient
{
Result = new CartographerOverlayResult(
GraphJobStatus.Completed,
GraphSnapshotId: "graph_snap_2",
ResultUri: "oras://graph/overlay",
Error: null)
};
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = true,
MaxAttempts = 2,
RetryBackoff = TimeSpan.FromMilliseconds(5)
}
});
var service = new GraphOverlayExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphOverlayExecutionService>.Instance);
var job = CreateOverlayJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphOverlayExecutionResultType.Completed, result.Type);
Assert.Single(completion.Notifications);
var notification = completion.Notifications[0];
Assert.Equal("Overlay", notification.JobType);
Assert.Equal(GraphJobStatus.Completed, notification.Status);
Assert.Equal("oras://graph/overlay", notification.ResultUri);
Assert.Equal("graph_snap_2", notification.GraphSnapshotId);
}
[Fact]
public async Task ExecuteAsync_Fails_AfterRetries()
{
var repository = new RecordingGraphJobRepository();
var cartographer = new StubCartographerOverlayClient
{
ExceptionToThrow = new InvalidOperationException("overlay failed")
};
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = true,
MaxAttempts = 2,
RetryBackoff = TimeSpan.FromMilliseconds(1)
}
});
var service = new GraphOverlayExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphOverlayExecutionService>.Instance);
var job = CreateOverlayJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphOverlayExecutionResultType.Failed, result.Type);
Assert.Single(completion.Notifications);
Assert.Equal(GraphJobStatus.Failed, completion.Notifications[0].Status);
Assert.Equal("overlay failed", completion.Notifications[0].Error);
}
[Fact]
public async Task ExecuteAsync_Skips_WhenConcurrencyConflict()
{
var repository = new RecordingGraphJobRepository
{
ShouldReplaceSucceed = false
};
var cartographer = new StubCartographerOverlayClient();
var completion = new RecordingCompletionClient();
using var metrics = new SchedulerWorkerMetrics();
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
{
Graph = new SchedulerWorkerOptions.GraphOptions
{
Enabled = true
}
});
var service = new GraphOverlayExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphOverlayExecutionService>.Instance);
var job = CreateOverlayJob();
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(GraphOverlayExecutionResultType.Skipped, result.Type);
Assert.Equal("concurrency_conflict", result.Reason);
Assert.Empty(completion.Notifications);
Assert.Equal(0, cartographer.CallCount);
}
private static GraphOverlayJob CreateOverlayJob() => new(
id: "goj_1",
tenantId: "tenant-alpha",
graphSnapshotId: "snap-1",
overlayKind: GraphOverlayKind.Policy,
overlayKey: "policy@1",
status: GraphJobStatus.Pending,
trigger: GraphOverlayJobTrigger.Policy,
createdAt: DateTimeOffset.UtcNow,
subjects: Array.Empty<string>(),
attempts: 0,
metadata: Array.Empty<KeyValuePair<string, string>>());
private sealed class RecordingGraphJobRepository : IGraphJobRepository
{
public bool ShouldReplaceSucceed { get; set; } = true;
public int RunningReplacements { get; private set; }
public Task<bool> TryReplaceOverlayAsync(GraphOverlayJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken = default)
{
if (!ShouldReplaceSucceed)
{
return Task.FromResult(false);
}
RunningReplacements++;
return Task.FromResult(true);
}
public Task<bool> TryReplaceAsync(GraphBuildJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphBuildJob> ReplaceAsync(GraphBuildJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphOverlayJob> ReplaceAsync(GraphOverlayJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task InsertAsync(GraphBuildJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task InsertAsync(GraphOverlayJob job, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphBuildJob?> GetBuildJobAsync(string tenantId, string jobId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<GraphOverlayJob?> GetOverlayJobAsync(string tenantId, string jobId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphBuildJob>> ListBuildJobsAsync(string tenantId, GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphBuildJob>> ListBuildJobsAsync(GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphOverlayJob>> ListOverlayJobsAsync(string tenantId, GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GraphOverlayJob>> ListOverlayJobsAsync(GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<IReadOnlyCollection<GraphOverlayJob>> ListOverlayJobsAsync(string tenantId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
}
private sealed class StubCartographerOverlayClient : ICartographerOverlayClient
{
public CartographerOverlayResult Result { get; set; } = new(GraphJobStatus.Completed, null, null, null);
public Exception? ExceptionToThrow { get; set; }
public int CallCount { get; private set; }
public Task<CartographerOverlayResult> StartOverlayAsync(GraphOverlayJob job, CancellationToken cancellationToken)
{
CallCount++;
if (ExceptionToThrow is not null)
{
throw ExceptionToThrow;
}
return Task.FromResult(Result);
}
}
private sealed class RecordingCompletionClient : IGraphJobCompletionClient
{
public List<GraphJobCompletionRequestDto> Notifications { get; } = new();
public Task NotifyAsync(GraphJobCompletionRequestDto request, CancellationToken cancellationToken)
{
Notifications.Add(request);
return Task.CompletedTask;
}
}
}

View File

@@ -6,9 +6,9 @@ using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Driver;
using StellaOps.Scheduler.Queue;
using StellaOps.Scheduler.Storage.Mongo.Projections;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Storage.Mongo.Services;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Projections;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Repositories;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Services;
using StellaOps.Scheduler.Worker.Options;
using StellaOps.Scheduler.Worker.Observability;
using StellaOps.Scheduler.Worker.Planning;

View File

@@ -5,9 +5,9 @@ using MongoDB.Driver;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Queue;
using StellaOps.Scheduler.Storage.Mongo.Projections;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Storage.Mongo.Services;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Projections;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Repositories;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Services;
using StellaOps.Scheduler.Worker.Options;
using StellaOps.Scheduler.Worker.Planning;
using StellaOps.Scheduler.Worker.Observability;

View File

@@ -6,7 +6,7 @@ using MongoDB.Driver;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Repositories;
using StellaOps.Scheduler.Worker.Options;
using StellaOps.Scheduler.Worker.Policy;
using StellaOps.Scheduler.Worker.Observability;

View File

@@ -1,80 +1,80 @@
using System;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Worker.Options;
using StellaOps.Scheduler.Worker.Observability;
using StellaOps.Scheduler.Worker.Policy;
using Xunit;
namespace StellaOps.Scheduler.Worker.Tests;
public sealed class PolicyRunExecutionServiceTests
{
private static readonly SchedulerWorkerOptions WorkerOptions = new()
{
Policy =
{
Dispatch =
{
LeaseOwner = "test-dispatch",
BatchSize = 1,
LeaseDuration = TimeSpan.FromMinutes(1),
IdleDelay = TimeSpan.FromMilliseconds(10),
MaxAttempts = 2,
RetryBackoff = TimeSpan.FromSeconds(30)
},
Api =
{
BaseAddress = new Uri("https://policy.example.com"),
RunsPath = "/api/policy/policies/{policyId}/runs",
SimulatePath = "/api/policy/policies/{policyId}/simulate"
}
}
};
[Fact]
public async Task ExecuteAsync_CancelsJob_WhenCancellationRequested()
{
var repository = new RecordingPolicyRunJobRepository();
var client = new StubPolicyRunClient();
var options = Microsoft.Extensions.Options.Options.Create(CloneOptions());
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-28T10:00:00Z"));
using var metrics = new SchedulerWorkerMetrics();
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Repositories;
using StellaOps.Scheduler.Worker.Options;
using StellaOps.Scheduler.Worker.Observability;
using StellaOps.Scheduler.Worker.Policy;
using Xunit;
namespace StellaOps.Scheduler.Worker.Tests;
public sealed class PolicyRunExecutionServiceTests
{
private static readonly SchedulerWorkerOptions WorkerOptions = new()
{
Policy =
{
Dispatch =
{
LeaseOwner = "test-dispatch",
BatchSize = 1,
LeaseDuration = TimeSpan.FromMinutes(1),
IdleDelay = TimeSpan.FromMilliseconds(10),
MaxAttempts = 2,
RetryBackoff = TimeSpan.FromSeconds(30)
},
Api =
{
BaseAddress = new Uri("https://policy.example.com"),
RunsPath = "/api/policy/policies/{policyId}/runs",
SimulatePath = "/api/policy/policies/{policyId}/simulate"
}
}
};
[Fact]
public async Task ExecuteAsync_CancelsJob_WhenCancellationRequested()
{
var repository = new RecordingPolicyRunJobRepository();
var client = new StubPolicyRunClient();
var options = Microsoft.Extensions.Options.Options.Create(CloneOptions());
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-28T10:00:00Z"));
using var metrics = new SchedulerWorkerMetrics();
var targeting = new StubPolicyRunTargetingService
{
OnEnsureTargets = job => PolicyRunTargetingResult.Unchanged(job)
};
var webhook = new RecordingPolicySimulationWebhookClient();
var service = new PolicyRunExecutionService(repository, client, options, timeProvider, metrics, targeting, webhook, NullLogger<PolicyRunExecutionService>.Instance);
var job = CreateJob(status: PolicyRunJobStatus.Dispatching) with
{
CancellationRequested = true,
LeaseOwner = "test-dispatch",
LeaseExpiresAt = timeProvider.GetUtcNow().AddMinutes(1)
};
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(PolicyRunExecutionResultType.Cancelled, result.Type);
Assert.Equal(PolicyRunJobStatus.Cancelled, result.UpdatedJob.Status);
var job = CreateJob(status: PolicyRunJobStatus.Dispatching) with
{
CancellationRequested = true,
LeaseOwner = "test-dispatch",
LeaseExpiresAt = timeProvider.GetUtcNow().AddMinutes(1)
};
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(PolicyRunExecutionResultType.Cancelled, result.Type);
Assert.Equal(PolicyRunJobStatus.Cancelled, result.UpdatedJob.Status);
Assert.True(repository.ReplaceCalled);
Assert.Equal("test-dispatch", repository.ExpectedLeaseOwner);
Assert.Single(webhook.Payloads);
Assert.Equal("cancelled", webhook.Payloads[0].Result);
}
[Fact]
public async Task ExecuteAsync_SubmitsJob_OnSuccess()
{
var repository = new RecordingPolicyRunJobRepository();
}
[Fact]
public async Task ExecuteAsync_SubmitsJob_OnSuccess()
{
var repository = new RecordingPolicyRunJobRepository();
var client = new StubPolicyRunClient
{
Result = PolicyRunSubmissionResult.Succeeded("run:P-7:2025", DateTimeOffset.Parse("2025-10-28T10:01:00Z"))
@@ -88,33 +88,33 @@ public sealed class PolicyRunExecutionServiceTests
};
var webhook = new RecordingPolicySimulationWebhookClient();
var service = new PolicyRunExecutionService(repository, client, options, timeProvider, metrics, targeting, webhook, NullLogger<PolicyRunExecutionService>.Instance);
var job = CreateJob(status: PolicyRunJobStatus.Dispatching) with
{
LeaseOwner = "test-dispatch",
LeaseExpiresAt = timeProvider.GetUtcNow().AddMinutes(1)
};
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(PolicyRunExecutionResultType.Submitted, result.Type);
var job = CreateJob(status: PolicyRunJobStatus.Dispatching) with
{
LeaseOwner = "test-dispatch",
LeaseExpiresAt = timeProvider.GetUtcNow().AddMinutes(1)
};
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(PolicyRunExecutionResultType.Submitted, result.Type);
Assert.Equal(PolicyRunJobStatus.Submitted, result.UpdatedJob.Status);
Assert.Equal("run:P-7:2025", result.UpdatedJob.RunId);
Assert.Equal(job.AttemptCount + 1, result.UpdatedJob.AttemptCount);
Assert.Null(result.UpdatedJob.LastError);
Assert.True(repository.ReplaceCalled);
Assert.Empty(webhook.Payloads);
}
[Fact]
public async Task ExecuteAsync_RetriesJob_OnFailure()
{
var repository = new RecordingPolicyRunJobRepository();
var client = new StubPolicyRunClient
{
Result = PolicyRunSubmissionResult.Failed("timeout")
};
var options = Microsoft.Extensions.Options.Options.Create(CloneOptions());
}
[Fact]
public async Task ExecuteAsync_RetriesJob_OnFailure()
{
var repository = new RecordingPolicyRunJobRepository();
var client = new StubPolicyRunClient
{
Result = PolicyRunSubmissionResult.Failed("timeout")
};
var options = Microsoft.Extensions.Options.Options.Create(CloneOptions());
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-28T10:00:00Z"));
using var metrics = new SchedulerWorkerMetrics();
var targeting = new StubPolicyRunTargetingService
@@ -123,35 +123,35 @@ public sealed class PolicyRunExecutionServiceTests
};
var webhook = new RecordingPolicySimulationWebhookClient();
var service = new PolicyRunExecutionService(repository, client, options, timeProvider, metrics, targeting, webhook, NullLogger<PolicyRunExecutionService>.Instance);
var job = CreateJob(status: PolicyRunJobStatus.Dispatching) with
{
LeaseOwner = "test-dispatch",
LeaseExpiresAt = timeProvider.GetUtcNow().AddMinutes(1)
};
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(PolicyRunExecutionResultType.Retrying, result.Type);
Assert.Equal(PolicyRunJobStatus.Pending, result.UpdatedJob.Status);
var job = CreateJob(status: PolicyRunJobStatus.Dispatching) with
{
LeaseOwner = "test-dispatch",
LeaseExpiresAt = timeProvider.GetUtcNow().AddMinutes(1)
};
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(PolicyRunExecutionResultType.Retrying, result.Type);
Assert.Equal(PolicyRunJobStatus.Pending, result.UpdatedJob.Status);
Assert.Equal(job.AttemptCount + 1, result.UpdatedJob.AttemptCount);
Assert.Equal("timeout", result.UpdatedJob.LastError);
Assert.True(result.UpdatedJob.AvailableAt > job.AvailableAt);
Assert.Empty(webhook.Payloads);
}
[Fact]
public async Task ExecuteAsync_MarksJobFailed_WhenAttemptsExceeded()
{
var repository = new RecordingPolicyRunJobRepository();
var client = new StubPolicyRunClient
{
Result = PolicyRunSubmissionResult.Failed("bad request")
};
var optionsValue = CloneOptions();
optionsValue.Policy.Dispatch.MaxAttempts = 1;
var options = Microsoft.Extensions.Options.Options.Create(optionsValue);
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-28T10:00:00Z"));
}
[Fact]
public async Task ExecuteAsync_MarksJobFailed_WhenAttemptsExceeded()
{
var repository = new RecordingPolicyRunJobRepository();
var client = new StubPolicyRunClient
{
Result = PolicyRunSubmissionResult.Failed("bad request")
};
var optionsValue = CloneOptions();
optionsValue.Policy.Dispatch.MaxAttempts = 1;
var options = Microsoft.Extensions.Options.Options.Create(optionsValue);
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-28T10:00:00Z"));
using var metrics = new SchedulerWorkerMetrics();
var targeting = new StubPolicyRunTargetingService
{
@@ -159,13 +159,13 @@ public sealed class PolicyRunExecutionServiceTests
};
var webhook = new RecordingPolicySimulationWebhookClient();
var service = new PolicyRunExecutionService(repository, client, options, timeProvider, metrics, targeting, webhook, NullLogger<PolicyRunExecutionService>.Instance);
var job = CreateJob(status: PolicyRunJobStatus.Dispatching, attemptCount: 0) with
{
LeaseOwner = "test-dispatch",
LeaseExpiresAt = timeProvider.GetUtcNow().AddMinutes(1)
};
var job = CreateJob(status: PolicyRunJobStatus.Dispatching, attemptCount: 0) with
{
LeaseOwner = "test-dispatch",
LeaseExpiresAt = timeProvider.GetUtcNow().AddMinutes(1)
};
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(PolicyRunExecutionResultType.Failed, result.Type);
@@ -173,100 +173,100 @@ public sealed class PolicyRunExecutionServiceTests
Assert.Equal("bad request", result.UpdatedJob.LastError);
Assert.Single(webhook.Payloads);
Assert.Equal("failed", webhook.Payloads[0].Result);
}
[Fact]
public async Task ExecuteAsync_NoWork_CompletesJob()
{
var repository = new RecordingPolicyRunJobRepository();
var client = new StubPolicyRunClient();
var options = Microsoft.Extensions.Options.Options.Create(CloneOptions());
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-28T10:00:00Z"));
using var metrics = new SchedulerWorkerMetrics();
}
[Fact]
public async Task ExecuteAsync_NoWork_CompletesJob()
{
var repository = new RecordingPolicyRunJobRepository();
var client = new StubPolicyRunClient();
var options = Microsoft.Extensions.Options.Options.Create(CloneOptions());
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-28T10:00:00Z"));
using var metrics = new SchedulerWorkerMetrics();
var targeting = new StubPolicyRunTargetingService
{
OnEnsureTargets = job => PolicyRunTargetingResult.NoWork(job, "empty")
};
var webhook = new RecordingPolicySimulationWebhookClient();
var service = new PolicyRunExecutionService(repository, client, options, timeProvider, metrics, targeting, webhook, NullLogger<PolicyRunExecutionService>.Instance);
var job = CreateJob(status: PolicyRunJobStatus.Dispatching, inputs: PolicyRunInputs.Empty) with
{
LeaseOwner = "test-dispatch",
LeaseExpiresAt = timeProvider.GetUtcNow().AddMinutes(1)
};
var result = await service.ExecuteAsync(job, CancellationToken.None);
var job = CreateJob(status: PolicyRunJobStatus.Dispatching, inputs: PolicyRunInputs.Empty) with
{
LeaseOwner = "test-dispatch",
LeaseExpiresAt = timeProvider.GetUtcNow().AddMinutes(1)
};
var result = await service.ExecuteAsync(job, CancellationToken.None);
Assert.Equal(PolicyRunExecutionResultType.NoOp, result.Type);
Assert.Equal(PolicyRunJobStatus.Completed, result.UpdatedJob.Status);
Assert.True(repository.ReplaceCalled);
Assert.Equal("test-dispatch", repository.ExpectedLeaseOwner);
Assert.Single(webhook.Payloads);
Assert.Equal("succeeded", webhook.Payloads[0].Result);
}
private static PolicyRunJob CreateJob(PolicyRunJobStatus status, int attemptCount = 0, PolicyRunInputs? inputs = null)
{
var resolvedInputs = inputs ?? new PolicyRunInputs(sbomSet: new[] { "sbom:S-42" }, captureExplain: true);
var metadata = ImmutableSortedDictionary.Create<string, string>(StringComparer.Ordinal);
return new PolicyRunJob(
SchemaVersion: SchedulerSchemaVersions.PolicyRunJob,
Id: "job_1",
TenantId: "tenant-alpha",
PolicyId: "P-7",
PolicyVersion: 4,
Mode: PolicyRunMode.Incremental,
Priority: PolicyRunPriority.Normal,
PriorityRank: -1,
RunId: "run:P-7:2025",
RequestedBy: "user:cli",
CorrelationId: "corr-1",
Metadata: metadata,
Inputs: resolvedInputs,
QueuedAt: DateTimeOffset.Parse("2025-10-28T09:59:00Z"),
Status: status,
AttemptCount: attemptCount,
LastAttemptAt: null,
LastError: null,
CreatedAt: DateTimeOffset.Parse("2025-10-28T09:58:00Z"),
UpdatedAt: DateTimeOffset.Parse("2025-10-28T09:58:00Z"),
AvailableAt: DateTimeOffset.Parse("2025-10-28T09:59:00Z"),
SubmittedAt: null,
CompletedAt: null,
LeaseOwner: null,
LeaseExpiresAt: null,
CancellationRequested: false,
CancellationRequestedAt: null,
CancellationReason: null,
CancelledAt: null);
}
private static SchedulerWorkerOptions CloneOptions()
{
return new SchedulerWorkerOptions
{
Policy = new SchedulerWorkerOptions.PolicyOptions
{
Enabled = WorkerOptions.Policy.Enabled,
Dispatch = new SchedulerWorkerOptions.PolicyOptions.DispatchOptions
{
LeaseOwner = WorkerOptions.Policy.Dispatch.LeaseOwner,
BatchSize = WorkerOptions.Policy.Dispatch.BatchSize,
LeaseDuration = WorkerOptions.Policy.Dispatch.LeaseDuration,
IdleDelay = WorkerOptions.Policy.Dispatch.IdleDelay,
MaxAttempts = WorkerOptions.Policy.Dispatch.MaxAttempts,
RetryBackoff = WorkerOptions.Policy.Dispatch.RetryBackoff
},
Api = new SchedulerWorkerOptions.PolicyOptions.ApiOptions
{
BaseAddress = WorkerOptions.Policy.Api.BaseAddress,
RunsPath = WorkerOptions.Policy.Api.RunsPath,
SimulatePath = WorkerOptions.Policy.Api.SimulatePath,
TenantHeader = WorkerOptions.Policy.Api.TenantHeader,
IdempotencyHeader = WorkerOptions.Policy.Api.IdempotencyHeader,
RequestTimeout = WorkerOptions.Policy.Api.RequestTimeout
},
}
private static PolicyRunJob CreateJob(PolicyRunJobStatus status, int attemptCount = 0, PolicyRunInputs? inputs = null)
{
var resolvedInputs = inputs ?? new PolicyRunInputs(sbomSet: new[] { "sbom:S-42" }, captureExplain: true);
var metadata = ImmutableSortedDictionary.Create<string, string>(StringComparer.Ordinal);
return new PolicyRunJob(
SchemaVersion: SchedulerSchemaVersions.PolicyRunJob,
Id: "job_1",
TenantId: "tenant-alpha",
PolicyId: "P-7",
PolicyVersion: 4,
Mode: PolicyRunMode.Incremental,
Priority: PolicyRunPriority.Normal,
PriorityRank: -1,
RunId: "run:P-7:2025",
RequestedBy: "user:cli",
CorrelationId: "corr-1",
Metadata: metadata,
Inputs: resolvedInputs,
QueuedAt: DateTimeOffset.Parse("2025-10-28T09:59:00Z"),
Status: status,
AttemptCount: attemptCount,
LastAttemptAt: null,
LastError: null,
CreatedAt: DateTimeOffset.Parse("2025-10-28T09:58:00Z"),
UpdatedAt: DateTimeOffset.Parse("2025-10-28T09:58:00Z"),
AvailableAt: DateTimeOffset.Parse("2025-10-28T09:59:00Z"),
SubmittedAt: null,
CompletedAt: null,
LeaseOwner: null,
LeaseExpiresAt: null,
CancellationRequested: false,
CancellationRequestedAt: null,
CancellationReason: null,
CancelledAt: null);
}
private static SchedulerWorkerOptions CloneOptions()
{
return new SchedulerWorkerOptions
{
Policy = new SchedulerWorkerOptions.PolicyOptions
{
Enabled = WorkerOptions.Policy.Enabled,
Dispatch = new SchedulerWorkerOptions.PolicyOptions.DispatchOptions
{
LeaseOwner = WorkerOptions.Policy.Dispatch.LeaseOwner,
BatchSize = WorkerOptions.Policy.Dispatch.BatchSize,
LeaseDuration = WorkerOptions.Policy.Dispatch.LeaseDuration,
IdleDelay = WorkerOptions.Policy.Dispatch.IdleDelay,
MaxAttempts = WorkerOptions.Policy.Dispatch.MaxAttempts,
RetryBackoff = WorkerOptions.Policy.Dispatch.RetryBackoff
},
Api = new SchedulerWorkerOptions.PolicyOptions.ApiOptions
{
BaseAddress = WorkerOptions.Policy.Api.BaseAddress,
RunsPath = WorkerOptions.Policy.Api.RunsPath,
SimulatePath = WorkerOptions.Policy.Api.SimulatePath,
TenantHeader = WorkerOptions.Policy.Api.TenantHeader,
IdempotencyHeader = WorkerOptions.Policy.Api.IdempotencyHeader,
RequestTimeout = WorkerOptions.Policy.Api.RequestTimeout
},
Targeting = new SchedulerWorkerOptions.PolicyOptions.TargetingOptions
{
Enabled = WorkerOptions.Policy.Targeting.Enabled,
@@ -284,15 +284,15 @@ public sealed class PolicyRunExecutionServiceTests
}
};
}
private sealed class StubPolicyRunTargetingService : IPolicyRunTargetingService
{
public Func<PolicyRunJob, PolicyRunTargetingResult>? OnEnsureTargets { get; set; }
public Task<PolicyRunTargetingResult> EnsureTargetsAsync(PolicyRunJob job, CancellationToken cancellationToken)
=> Task.FromResult(OnEnsureTargets?.Invoke(job) ?? PolicyRunTargetingResult.Unchanged(job));
}
private sealed class StubPolicyRunTargetingService : IPolicyRunTargetingService
{
public Func<PolicyRunJob, PolicyRunTargetingResult>? OnEnsureTargets { get; set; }
public Task<PolicyRunTargetingResult> EnsureTargetsAsync(PolicyRunJob job, CancellationToken cancellationToken)
=> Task.FromResult(OnEnsureTargets?.Invoke(job) ?? PolicyRunTargetingResult.Unchanged(job));
}
private sealed class RecordingPolicySimulationWebhookClient : IPolicySimulationWebhookClient
{
public List<PolicySimulationWebhookPayload> Payloads { get; } = new();
@@ -306,13 +306,13 @@ public sealed class PolicyRunExecutionServiceTests
private sealed class RecordingPolicyRunJobRepository : IPolicyRunJobRepository
{
public bool ReplaceCalled { get; private set; }
public string? ExpectedLeaseOwner { get; private set; }
public PolicyRunJob? LastJob { get; private set; }
public Task<PolicyRunJob?> GetAsync(string tenantId, string jobId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<PolicyRunJob?>(null);
public bool ReplaceCalled { get; private set; }
public string? ExpectedLeaseOwner { get; private set; }
public PolicyRunJob? LastJob { get; private set; }
public Task<PolicyRunJob?> GetAsync(string tenantId, string jobId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<PolicyRunJob?>(null);
public Task<PolicyRunJob?> GetByRunIdAsync(string tenantId, string runId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<PolicyRunJob?>(null);
@@ -327,38 +327,38 @@ public sealed class PolicyRunExecutionServiceTests
public Task<PolicyRunJob?> LeaseAsync(string leaseOwner, DateTimeOffset now, TimeSpan leaseDuration, int maxAttempts, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<PolicyRunJob?>(null);
public Task<bool> ReplaceAsync(PolicyRunJob job, string? expectedLeaseOwner = null, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
ReplaceCalled = true;
ExpectedLeaseOwner = expectedLeaseOwner;
LastJob = job;
return Task.FromResult(true);
}
public Task<IReadOnlyList<PolicyRunJob>> ListAsync(string tenantId, string? policyId = null, PolicyRunMode? mode = null, IReadOnlyCollection<PolicyRunJobStatus>? statuses = null, DateTimeOffset? queuedAfter = null, int limit = 50, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<PolicyRunJob>>(Array.Empty<PolicyRunJob>());
}
private sealed class StubPolicyRunClient : IPolicyRunClient
{
public PolicyRunSubmissionResult Result { get; set; } = PolicyRunSubmissionResult.Succeeded(null, null);
public Task<PolicyRunSubmissionResult> SubmitAsync(PolicyRunJob job, PolicyRunRequest request, CancellationToken cancellationToken)
=> Task.FromResult(Result);
}
private sealed class TestTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public TestTimeProvider(DateTimeOffset now)
{
_now = now;
}
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan delta) => _now = _now.Add(delta);
}
}
public Task<bool> ReplaceAsync(PolicyRunJob job, string? expectedLeaseOwner = null, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
ReplaceCalled = true;
ExpectedLeaseOwner = expectedLeaseOwner;
LastJob = job;
return Task.FromResult(true);
}
public Task<IReadOnlyList<PolicyRunJob>> ListAsync(string tenantId, string? policyId = null, PolicyRunMode? mode = null, IReadOnlyCollection<PolicyRunJobStatus>? statuses = null, DateTimeOffset? queuedAfter = null, int limit = 50, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<PolicyRunJob>>(Array.Empty<PolicyRunJob>());
}
private sealed class StubPolicyRunClient : IPolicyRunClient
{
public PolicyRunSubmissionResult Result { get; set; } = PolicyRunSubmissionResult.Succeeded(null, null);
public Task<PolicyRunSubmissionResult> SubmitAsync(PolicyRunJob job, PolicyRunRequest request, CancellationToken cancellationToken)
=> Task.FromResult(Result);
}
private sealed class TestTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public TestTimeProvider(DateTimeOffset now)
{
_now = now;
}
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan delta) => _now = _now.Add(delta);
}
}

View File

@@ -9,9 +9,9 @@ using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Driver;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Queue;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Storage.Mongo.Services;
using StellaOps.Scheduler.Storage.Mongo.Projections;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Repositories;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Services;
using StellaOps.Scheduler.Storage.Postgres.Repositories.Projections;
using StellaOps.Scheduler.Worker.Events;
using StellaOps.Scheduler.Worker.Execution;
using StellaOps.Scheduler.Worker.Observability;