Initial commit (history squashed)
This commit is contained in:
483
src/StellaOps.Feedser.Core.Tests/JobCoordinatorTests.cs
Normal file
483
src/StellaOps.Feedser.Core.Tests/JobCoordinatorTests.cs
Normal file
@@ -0,0 +1,483 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Feedser.Core.Jobs;
|
||||
|
||||
namespace StellaOps.Feedser.Core.Tests;
|
||||
|
||||
public sealed class JobCoordinatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task TriggerAsync_RunCompletesSuccessfully()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddTransient<SuccessfulJob>();
|
||||
services.AddLogging();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var jobStore = new InMemoryJobStore();
|
||||
var leaseStore = new InMemoryLeaseStore();
|
||||
var jobOptions = new JobSchedulerOptions
|
||||
{
|
||||
DefaultLeaseDuration = TimeSpan.FromSeconds(5),
|
||||
DefaultTimeout = TimeSpan.FromSeconds(10),
|
||||
};
|
||||
|
||||
var definition = new JobDefinition(
|
||||
Kind: "test:run",
|
||||
JobType: typeof(SuccessfulJob),
|
||||
Timeout: TimeSpan.FromSeconds(5),
|
||||
LeaseDuration: TimeSpan.FromSeconds(2),
|
||||
CronExpression: null,
|
||||
Enabled: true);
|
||||
jobOptions.Definitions.Add(definition.Kind, definition);
|
||||
|
||||
using var diagnostics = new JobDiagnostics();
|
||||
var coordinator = new JobCoordinator(
|
||||
Options.Create(jobOptions),
|
||||
jobStore,
|
||||
leaseStore,
|
||||
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||
NullLogger<JobCoordinator>.Instance,
|
||||
NullLoggerFactory.Instance,
|
||||
new TestTimeProvider(),
|
||||
diagnostics);
|
||||
|
||||
var result = await coordinator.TriggerAsync(definition.Kind, new Dictionary<string, object?> { ["foo"] = "bar" }, "unit-test", CancellationToken.None);
|
||||
|
||||
Assert.Equal(JobTriggerOutcome.Accepted, result.Outcome);
|
||||
var completed = await jobStore.Completion.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
Assert.Equal(JobRunStatus.Succeeded, completed.Status);
|
||||
await leaseStore.WaitForReleaseAsync(TimeSpan.FromSeconds(1));
|
||||
Assert.True(leaseStore.ReleaseCount > 0);
|
||||
Assert.Equal("bar", completed.Parameters["foo"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerAsync_MarksRunFailed_WhenLeaseReleaseFails()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddTransient<SuccessfulJob>();
|
||||
services.AddLogging();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var jobStore = new InMemoryJobStore();
|
||||
var leaseStore = new FailingLeaseStore
|
||||
{
|
||||
ThrowOnRelease = true,
|
||||
};
|
||||
|
||||
var jobOptions = new JobSchedulerOptions
|
||||
{
|
||||
DefaultLeaseDuration = TimeSpan.FromSeconds(5),
|
||||
DefaultTimeout = TimeSpan.FromSeconds(10),
|
||||
};
|
||||
|
||||
var definition = new JobDefinition(
|
||||
Kind: "test:run",
|
||||
JobType: typeof(SuccessfulJob),
|
||||
Timeout: TimeSpan.FromSeconds(5),
|
||||
LeaseDuration: TimeSpan.FromSeconds(2),
|
||||
CronExpression: null,
|
||||
Enabled: true);
|
||||
jobOptions.Definitions.Add(definition.Kind, definition);
|
||||
|
||||
using var diagnostics = new JobDiagnostics();
|
||||
var coordinator = new JobCoordinator(
|
||||
Options.Create(jobOptions),
|
||||
jobStore,
|
||||
leaseStore,
|
||||
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||
NullLogger<JobCoordinator>.Instance,
|
||||
NullLoggerFactory.Instance,
|
||||
new TestTimeProvider(),
|
||||
diagnostics);
|
||||
|
||||
var result = await coordinator.TriggerAsync(definition.Kind, null, "unit-test", CancellationToken.None);
|
||||
|
||||
Assert.Equal(JobTriggerOutcome.Accepted, result.Outcome);
|
||||
var completed = await jobStore.Completion.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
Assert.Equal(JobRunStatus.Failed, completed.Status);
|
||||
Assert.NotNull(completed.Error);
|
||||
Assert.Contains("Failed to release lease", completed.Error!, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.True(leaseStore.ReleaseAttempts > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerAsync_MarksRunFailed_WhenLeaseHeartbeatFails()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddTransient<SlowJob>();
|
||||
services.AddLogging();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var jobStore = new InMemoryJobStore();
|
||||
var leaseStore = new FailingLeaseStore
|
||||
{
|
||||
ThrowOnHeartbeat = true,
|
||||
};
|
||||
|
||||
var jobOptions = new JobSchedulerOptions
|
||||
{
|
||||
DefaultLeaseDuration = TimeSpan.FromSeconds(2),
|
||||
DefaultTimeout = TimeSpan.FromSeconds(10),
|
||||
};
|
||||
|
||||
var definition = new JobDefinition(
|
||||
Kind: "test:heartbeat",
|
||||
JobType: typeof(SlowJob),
|
||||
Timeout: TimeSpan.FromSeconds(5),
|
||||
LeaseDuration: TimeSpan.FromSeconds(2),
|
||||
CronExpression: null,
|
||||
Enabled: true);
|
||||
jobOptions.Definitions.Add(definition.Kind, definition);
|
||||
|
||||
using var diagnostics = new JobDiagnostics();
|
||||
var coordinator = new JobCoordinator(
|
||||
Options.Create(jobOptions),
|
||||
jobStore,
|
||||
leaseStore,
|
||||
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||
NullLogger<JobCoordinator>.Instance,
|
||||
NullLoggerFactory.Instance,
|
||||
new TestTimeProvider(),
|
||||
diagnostics);
|
||||
|
||||
var result = await coordinator.TriggerAsync(definition.Kind, null, "unit-test", CancellationToken.None);
|
||||
|
||||
Assert.Equal(JobTriggerOutcome.Accepted, result.Outcome);
|
||||
var completed = await jobStore.Completion.Task.WaitAsync(TimeSpan.FromSeconds(6));
|
||||
Assert.Equal(JobRunStatus.Failed, completed.Status);
|
||||
Assert.NotNull(completed.Error);
|
||||
Assert.Contains("Failed to heartbeat lease", completed.Error!, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.True(leaseStore.HeartbeatCount > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerAsync_ReturnsAlreadyRunning_WhenLeaseUnavailable()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddTransient<SuccessfulJob>();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var jobStore = new InMemoryJobStore();
|
||||
var leaseStore = new InMemoryLeaseStore
|
||||
{
|
||||
NextLease = null,
|
||||
};
|
||||
var jobOptions = new JobSchedulerOptions();
|
||||
var definition = new JobDefinition(
|
||||
"test:run",
|
||||
typeof(SuccessfulJob),
|
||||
TimeSpan.FromSeconds(5),
|
||||
TimeSpan.FromSeconds(2),
|
||||
null,
|
||||
true);
|
||||
jobOptions.Definitions.Add(definition.Kind, definition);
|
||||
|
||||
using var diagnostics = new JobDiagnostics();
|
||||
var coordinator = new JobCoordinator(
|
||||
Options.Create(jobOptions),
|
||||
jobStore,
|
||||
leaseStore,
|
||||
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||
NullLogger<JobCoordinator>.Instance,
|
||||
NullLoggerFactory.Instance,
|
||||
new TestTimeProvider(),
|
||||
diagnostics);
|
||||
|
||||
var result = await coordinator.TriggerAsync(definition.Kind, null, "unit-test", CancellationToken.None);
|
||||
|
||||
Assert.Equal(JobTriggerOutcome.AlreadyRunning, result.Outcome);
|
||||
Assert.False(jobStore.CreatedRuns.Any());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerAsync_ReturnsInvalidParameters_ForUnsupportedPayload()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddTransient<SuccessfulJob>();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var jobStore = new InMemoryJobStore();
|
||||
var leaseStore = new InMemoryLeaseStore();
|
||||
var jobOptions = new JobSchedulerOptions();
|
||||
var definition = new JobDefinition(
|
||||
"test:run",
|
||||
typeof(SuccessfulJob),
|
||||
TimeSpan.FromSeconds(5),
|
||||
TimeSpan.FromSeconds(2),
|
||||
null,
|
||||
true);
|
||||
jobOptions.Definitions.Add(definition.Kind, definition);
|
||||
|
||||
using var diagnostics = new JobDiagnostics();
|
||||
var coordinator = new JobCoordinator(
|
||||
Options.Create(jobOptions),
|
||||
jobStore,
|
||||
leaseStore,
|
||||
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||
NullLogger<JobCoordinator>.Instance,
|
||||
NullLoggerFactory.Instance,
|
||||
new TestTimeProvider(),
|
||||
diagnostics);
|
||||
|
||||
var parameters = new Dictionary<string, object?>
|
||||
{
|
||||
["bad"] = new object(),
|
||||
};
|
||||
|
||||
var result = await coordinator.TriggerAsync(definition.Kind, parameters, "unit-test", CancellationToken.None);
|
||||
|
||||
Assert.Equal(JobTriggerOutcome.InvalidParameters, result.Outcome);
|
||||
Assert.Contains("unsupported", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.False(jobStore.CreatedRuns.Any());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerAsync_CancelsJobOnTimeout()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddTransient<TimeoutJob>();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var jobStore = new InMemoryJobStore();
|
||||
var leaseStore = new InMemoryLeaseStore();
|
||||
var jobOptions = new JobSchedulerOptions
|
||||
{
|
||||
DefaultLeaseDuration = TimeSpan.FromSeconds(5),
|
||||
DefaultTimeout = TimeSpan.FromMilliseconds(100),
|
||||
};
|
||||
|
||||
var definition = new JobDefinition(
|
||||
Kind: "test:timeout",
|
||||
JobType: typeof(TimeoutJob),
|
||||
Timeout: TimeSpan.FromMilliseconds(100),
|
||||
LeaseDuration: TimeSpan.FromSeconds(2),
|
||||
CronExpression: null,
|
||||
Enabled: true);
|
||||
jobOptions.Definitions.Add(definition.Kind, definition);
|
||||
|
||||
using var diagnostics = new JobDiagnostics();
|
||||
var coordinator = new JobCoordinator(
|
||||
Options.Create(jobOptions),
|
||||
jobStore,
|
||||
leaseStore,
|
||||
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||
NullLogger<JobCoordinator>.Instance,
|
||||
NullLoggerFactory.Instance,
|
||||
new TestTimeProvider(),
|
||||
diagnostics);
|
||||
|
||||
var result = await coordinator.TriggerAsync(definition.Kind, null, "unit-test", CancellationToken.None);
|
||||
Assert.Equal(JobTriggerOutcome.Accepted, result.Outcome);
|
||||
|
||||
var completed = await jobStore.Completion.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
Assert.Equal(JobRunStatus.Cancelled, completed.Status);
|
||||
await leaseStore.WaitForReleaseAsync(TimeSpan.FromSeconds(1));
|
||||
Assert.True(leaseStore.ReleaseCount > 0);
|
||||
}
|
||||
|
||||
private sealed class SuccessfulJob : IJob
|
||||
{
|
||||
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TimeoutJob : IJob
|
||||
{
|
||||
public async Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SlowJob : IJob
|
||||
{
|
||||
public async Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryJobStore : IJobStore
|
||||
{
|
||||
private readonly Dictionary<Guid, JobRunSnapshot> _runs = new();
|
||||
public TaskCompletionSource<JobRunSnapshot> Completion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public List<JobRunSnapshot> CreatedRuns { get; } = new();
|
||||
|
||||
public Task<JobRunSnapshot> CreateAsync(JobRunCreateRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var run = new JobRunSnapshot(
|
||||
Guid.NewGuid(),
|
||||
request.Kind,
|
||||
JobRunStatus.Pending,
|
||||
request.CreatedAt,
|
||||
null,
|
||||
null,
|
||||
request.Trigger,
|
||||
request.ParametersHash,
|
||||
null,
|
||||
request.Timeout,
|
||||
request.LeaseDuration,
|
||||
request.Parameters);
|
||||
_runs[run.RunId] = run;
|
||||
CreatedRuns.Add(run);
|
||||
return Task.FromResult(run);
|
||||
}
|
||||
|
||||
public Task<JobRunSnapshot?> TryStartAsync(Guid runId, DateTimeOffset startedAt, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_runs.TryGetValue(runId, out var run))
|
||||
{
|
||||
var updated = run with { Status = JobRunStatus.Running, StartedAt = startedAt };
|
||||
_runs[runId] = updated;
|
||||
return Task.FromResult<JobRunSnapshot?>(updated);
|
||||
}
|
||||
|
||||
return Task.FromResult<JobRunSnapshot?>(null);
|
||||
}
|
||||
|
||||
public Task<JobRunSnapshot?> TryCompleteAsync(Guid runId, JobRunCompletion completion, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_runs.TryGetValue(runId, out var run))
|
||||
{
|
||||
var updated = run with { Status = completion.Status, CompletedAt = completion.CompletedAt, Error = completion.Error };
|
||||
_runs[runId] = updated;
|
||||
Completion.TrySetResult(updated);
|
||||
return Task.FromResult<JobRunSnapshot?>(updated);
|
||||
}
|
||||
|
||||
return Task.FromResult<JobRunSnapshot?>(null);
|
||||
}
|
||||
|
||||
public Task<JobRunSnapshot?> FindAsync(Guid runId, CancellationToken cancellationToken)
|
||||
{
|
||||
_runs.TryGetValue(runId, out var run);
|
||||
return Task.FromResult<JobRunSnapshot?>(run);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<JobRunSnapshot>> GetRecentRunsAsync(string? kind, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = _runs.Values.AsEnumerable();
|
||||
if (!string.IsNullOrWhiteSpace(kind))
|
||||
{
|
||||
query = query.Where(r => r.Kind == kind);
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<JobRunSnapshot>>(query.OrderByDescending(r => r.CreatedAt).Take(limit).ToArray());
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<JobRunSnapshot>> GetActiveRunsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<JobRunSnapshot>>(_runs.Values.Where(r => r.Status is JobRunStatus.Pending or JobRunStatus.Running).ToArray());
|
||||
}
|
||||
|
||||
public Task<JobRunSnapshot?> GetLastRunAsync(string kind, CancellationToken cancellationToken)
|
||||
{
|
||||
var run = _runs.Values
|
||||
.Where(r => r.Kind == kind)
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
return Task.FromResult<JobRunSnapshot?>(run);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyDictionary<string, JobRunSnapshot>> GetLastRunsAsync(IEnumerable<string> kinds, CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new Dictionary<string, JobRunSnapshot>(StringComparer.Ordinal);
|
||||
foreach (var kind in kinds.Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
var run = _runs.Values
|
||||
.Where(r => r.Kind == kind)
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
if (run is not null)
|
||||
{
|
||||
results[kind] = run;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyDictionary<string, JobRunSnapshot>>(results);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryLeaseStore : ILeaseStore
|
||||
{
|
||||
public JobLease? NextLease { get; set; } = new JobLease("job:test:run", "holder", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, TimeSpan.FromSeconds(2), DateTimeOffset.UtcNow.AddSeconds(2));
|
||||
public int HeartbeatCount { get; private set; }
|
||||
public int ReleaseCount { get; private set; }
|
||||
private readonly TaskCompletionSource<bool> _released = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public Task<JobLease?> TryAcquireAsync(string key, string holder, TimeSpan leaseDuration, DateTimeOffset now, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(NextLease);
|
||||
}
|
||||
|
||||
public Task<JobLease?> HeartbeatAsync(string key, string holder, TimeSpan leaseDuration, DateTimeOffset now, CancellationToken cancellationToken)
|
||||
{
|
||||
HeartbeatCount++;
|
||||
NextLease = new JobLease(key, holder, now, now, leaseDuration, now.Add(leaseDuration));
|
||||
return Task.FromResult<JobLease?>(NextLease);
|
||||
}
|
||||
|
||||
public Task<bool> ReleaseAsync(string key, string holder, CancellationToken cancellationToken)
|
||||
{
|
||||
ReleaseCount++;
|
||||
_released.TrySetResult(true);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task WaitForReleaseAsync(TimeSpan timeout)
|
||||
=> _released.Task.WaitAsync(timeout);
|
||||
}
|
||||
|
||||
private sealed class FailingLeaseStore : ILeaseStore
|
||||
{
|
||||
public bool ThrowOnHeartbeat { get; set; }
|
||||
public bool ThrowOnRelease { get; set; }
|
||||
|
||||
public int HeartbeatCount { get; private set; }
|
||||
public int ReleaseAttempts { get; private set; }
|
||||
|
||||
public Task<JobLease?> TryAcquireAsync(string key, string holder, TimeSpan leaseDuration, DateTimeOffset now, CancellationToken cancellationToken)
|
||||
{
|
||||
var lease = new JobLease(key, holder, now, now, leaseDuration, now.Add(leaseDuration));
|
||||
return Task.FromResult<JobLease?>(lease);
|
||||
}
|
||||
|
||||
public Task<JobLease?> HeartbeatAsync(string key, string holder, TimeSpan leaseDuration, DateTimeOffset now, CancellationToken cancellationToken)
|
||||
{
|
||||
HeartbeatCount++;
|
||||
if (ThrowOnHeartbeat)
|
||||
{
|
||||
throw new InvalidOperationException("Lease heartbeat failed");
|
||||
}
|
||||
|
||||
var lease = new JobLease(key, holder, now, now, leaseDuration, now.Add(leaseDuration));
|
||||
return Task.FromResult<JobLease?>(lease);
|
||||
}
|
||||
|
||||
public Task<bool> ReleaseAsync(string key, string holder, CancellationToken cancellationToken)
|
||||
{
|
||||
ReleaseAttempts++;
|
||||
if (ThrowOnRelease)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to release lease");
|
||||
}
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = DateTimeOffset.Parse("2024-01-01T00:00:00Z");
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now = _now.AddMilliseconds(100);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Feedser.Core.Jobs;
|
||||
using StellaOps.Plugin.Hosting;
|
||||
|
||||
namespace StellaOps.Feedser.Core.Tests;
|
||||
|
||||
public sealed class JobPluginRegistrationExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void RegisterJobPluginRoutines_LoadsPluginsAndRegistersDefinitions()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddJobScheduler();
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["plugin:test:timeoutSeconds"] = "45",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var assemblyPath = typeof(JobPluginRegistrationExtensionsTests).Assembly.Location;
|
||||
var pluginDirectory = Path.GetDirectoryName(assemblyPath)!;
|
||||
var pluginFile = Path.GetFileName(assemblyPath);
|
||||
|
||||
var options = new PluginHostOptions
|
||||
{
|
||||
BaseDirectory = pluginDirectory,
|
||||
PluginsDirectory = pluginDirectory,
|
||||
EnsureDirectoryExists = false,
|
||||
RecursiveSearch = false,
|
||||
};
|
||||
options.SearchPatterns.Add(pluginFile);
|
||||
|
||||
services.RegisterJobPluginRoutines(configuration, options);
|
||||
|
||||
Assert.Contains(
|
||||
services,
|
||||
descriptor => descriptor.ServiceType == typeof(PluginHostResult));
|
||||
|
||||
Assert.Contains(
|
||||
services,
|
||||
descriptor => descriptor.ServiceType.FullName == typeof(PluginRoutineExecuted).FullName);
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var schedulerOptions = provider.GetRequiredService<IOptions<JobSchedulerOptions>>().Value;
|
||||
|
||||
Assert.True(schedulerOptions.Definitions.TryGetValue(PluginJob.JobKind, out var definition));
|
||||
Assert.NotNull(definition);
|
||||
Assert.Equal(PluginJob.JobKind, definition.Kind);
|
||||
Assert.Equal("StellaOps.Feedser.Core.Tests.PluginJob", definition.JobType.FullName);
|
||||
Assert.Equal(TimeSpan.FromSeconds(45), definition.Timeout);
|
||||
Assert.Equal(TimeSpan.FromSeconds(5), definition.LeaseDuration);
|
||||
Assert.Equal("*/10 * * * *", definition.CronExpression);
|
||||
}
|
||||
}
|
||||
70
src/StellaOps.Feedser.Core.Tests/JobSchedulerBuilderTests.cs
Normal file
70
src/StellaOps.Feedser.Core.Tests/JobSchedulerBuilderTests.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Feedser.Core.Jobs;
|
||||
|
||||
namespace StellaOps.Feedser.Core.Tests;
|
||||
|
||||
public sealed class JobSchedulerBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddJob_RegistersDefinitionWithExplicitMetadata()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var builder = services.AddJobScheduler();
|
||||
|
||||
builder.AddJob<TestJob>(
|
||||
kind: "jobs:test",
|
||||
cronExpression: "*/5 * * * *",
|
||||
timeout: TimeSpan.FromMinutes(42),
|
||||
leaseDuration: TimeSpan.FromMinutes(7),
|
||||
enabled: false);
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var options = provider.GetRequiredService<IOptions<JobSchedulerOptions>>().Value;
|
||||
|
||||
Assert.True(options.Definitions.TryGetValue("jobs:test", out var definition));
|
||||
Assert.NotNull(definition);
|
||||
Assert.Equal(typeof(TestJob), definition.JobType);
|
||||
Assert.Equal(TimeSpan.FromMinutes(42), definition.Timeout);
|
||||
Assert.Equal(TimeSpan.FromMinutes(7), definition.LeaseDuration);
|
||||
Assert.Equal("*/5 * * * *", definition.CronExpression);
|
||||
Assert.False(definition.Enabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddJob_UsesDefaults_WhenOptionalMetadataExcluded()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var builder = services.AddJobScheduler(options =>
|
||||
{
|
||||
options.DefaultTimeout = TimeSpan.FromSeconds(123);
|
||||
options.DefaultLeaseDuration = TimeSpan.FromSeconds(45);
|
||||
});
|
||||
|
||||
builder.AddJob<DefaultedJob>(kind: "jobs:defaults");
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var options = provider.GetRequiredService<IOptions<JobSchedulerOptions>>().Value;
|
||||
|
||||
Assert.True(options.Definitions.TryGetValue("jobs:defaults", out var definition));
|
||||
Assert.NotNull(definition);
|
||||
Assert.Equal(typeof(DefaultedJob), definition.JobType);
|
||||
Assert.Equal(TimeSpan.FromSeconds(123), definition.Timeout);
|
||||
Assert.Equal(TimeSpan.FromSeconds(45), definition.LeaseDuration);
|
||||
Assert.Null(definition.CronExpression);
|
||||
Assert.True(definition.Enabled);
|
||||
}
|
||||
|
||||
private sealed class TestJob : IJob
|
||||
{
|
||||
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class DefaultedJob : IJob
|
||||
{
|
||||
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
42
src/StellaOps.Feedser.Core.Tests/PluginRoutineFixtures.cs
Normal file
42
src/StellaOps.Feedser.Core.Tests/PluginRoutineFixtures.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Feedser.Core.Jobs;
|
||||
|
||||
namespace StellaOps.Feedser.Core.Tests;
|
||||
|
||||
public sealed class TestPluginRoutine : IDependencyInjectionRoutine
|
||||
{
|
||||
public IServiceCollection Register(IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
var builder = new JobSchedulerBuilder(services);
|
||||
var timeoutSeconds = configuration.GetValue<int?>("plugin:test:timeoutSeconds") ?? 30;
|
||||
|
||||
builder.AddJob<PluginJob>(
|
||||
PluginJob.JobKind,
|
||||
cronExpression: "*/10 * * * *",
|
||||
timeout: TimeSpan.FromSeconds(timeoutSeconds),
|
||||
leaseDuration: TimeSpan.FromSeconds(5));
|
||||
|
||||
services.AddSingleton<PluginRoutineExecuted>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PluginRoutineExecuted
|
||||
{
|
||||
}
|
||||
|
||||
public sealed class PluginJob : IJob
|
||||
{
|
||||
public const string JobKind = "plugin:test";
|
||||
|
||||
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Feedser.Core/StellaOps.Feedser.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user