diff --git a/.editorconfig b/.editorconfig
index 649aec2..3ff9029 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -342,6 +342,7 @@ dotnet_diagnostic.CA1812.severity = none # CA1812: Avoid uninstan
[tests/**.cs]
+dotnet_diagnostic.CA1062.severity = none # CA1062: Validate arguments of public methods
dotnet_diagnostic.CA1707.severity = none # CA1707: Identifiers should not contain underscores
dotnet_diagnostic.CA1724.severity = none # CA1724: Type names should not match namespaces
dotnet_diagnostic.CA1822.severity = none # CA1822: Mark members as static
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..c4a8cfb
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,3 @@
+# These are supported funding model platforms
+
+github: [viceroypenguin]
diff --git a/Directory.Packages.props b/Directory.Packages.props
index e1004d9..54ad902 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -14,12 +14,12 @@
+
-
@@ -53,13 +53,15 @@
+
-
+
+
@@ -74,8 +76,9 @@
-
+
+
@@ -84,13 +87,15 @@
+
-
+
+
@@ -99,13 +104,15 @@
+
-
+
+
diff --git a/samples/Aspire/Api/Program.cs b/samples/Aspire/Api/Program.cs
index 792efb8..6239f67 100644
--- a/samples/Aspire/Api/Program.cs
+++ b/samples/Aspire/Api/Program.cs
@@ -33,11 +33,7 @@
options.UseNpgsql(connectionString, npgsql => npgsql.EnableRetryOnFailure()));
builder.Services.AddAspireApiHandlers();
-builder.Services.AddImmediateJobsDashboard(options =>
-{
- if (aspireDashboardUrl is not null)
- _ = options.AddAspireTelemetryLinks(aspireDashboardUrl);
-});
+
builder.Services.AddAspireApiJobs()
.UseFairQueues()
.ConfigureStorage(
@@ -45,12 +41,14 @@
.UseEntityFrameworkCore()
.UseSingleServer()
)
- .Configure(o =>
+ .ConfigureWorkers(o =>
{
o.MaxParallelJobs = 4;
o.PollingInterval = TimeSpan.FromSeconds(5);
})
- .AddHealthCheck();
+ .AddHealthCheck()
+ .AddImmediateJobsDashboard()
+ .AddAspireTelemetryLinks(aspireDashboardUrl);
var app = builder.Build();
diff --git a/samples/Aspire/Api/Telemetry/AspireDashboardTelemetryExtensions.cs b/samples/Aspire/Api/Telemetry/AspireDashboardTelemetryExtensions.cs
index d20f3be..7ee482e 100644
--- a/samples/Aspire/Api/Telemetry/AspireDashboardTelemetryExtensions.cs
+++ b/samples/Aspire/Api/Telemetry/AspireDashboardTelemetryExtensions.cs
@@ -5,13 +5,16 @@ namespace Immediate.Jobs.Aspire.Api.Telemetry;
internal static class AspireDashboardTelemetryExtensions
{
- public static ImmediateJobsDashboardOptions AddAspireTelemetryLinks(
- this ImmediateJobsDashboardOptions options,
- Uri dashboardUrl
+ public static IImmediateJobsDashboardBuilder AddAspireTelemetryLinks(
+ this IImmediateJobsDashboardBuilder builder,
+ Uri? dashboardUrl
)
{
- ArgumentNullException.ThrowIfNull(options);
- ArgumentNullException.ThrowIfNull(dashboardUrl);
+ ArgumentNullException.ThrowIfNull(builder);
+
+ if (dashboardUrl is null)
+ return builder;
+
if (!dashboardUrl.IsAbsoluteUri ||
(!string.Equals(dashboardUrl.Scheme, Uri.UriSchemeHttp, StringComparison.Ordinal) && !string.Equals(dashboardUrl.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal)))
{
@@ -19,7 +22,7 @@ Uri dashboardUrl
}
var baseUrl = new Uri(dashboardUrl.AbsoluteUri.TrimEnd('/') + '/', UriKind.Absolute);
- return options
+ builder
.AddTelemetryLink(
"Aspire trace",
JobTelemetryLinkKind.Trace,
@@ -30,6 +33,8 @@ Uri dashboardUrl
JobTelemetryLinkKind.Logs,
context => CreateLogsUrl(baseUrl, context.Job)
);
+
+ return builder;
}
private static Uri? CreateTraceUrl(Uri baseUrl, JobRecord job)
diff --git a/samples/Basic/Program.cs b/samples/Basic/Program.cs
index 474ce42..c6bb857 100644
--- a/samples/Basic/Program.cs
+++ b/samples/Basic/Program.cs
@@ -5,12 +5,12 @@
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton();
-builder.Services.AddImmediateJobsDashboard();
builder.Services.AddBasicHandlers();
builder.Services.AddBasicJobs()
- .Configure(o => o.MaxParallelJobs = 4)
+ .ConfigureWorkers(o => o.MaxParallelJobs = 4)
.ConfigureStorage(options => options.UseInMemory())
- .AddHealthCheck();
+ .AddHealthCheck()
+ .AddImmediateJobsDashboard();
var app = builder.Build();
diff --git a/src/Immediate.Jobs.Dashboard/DashboardApiEndpointOperations.cs b/src/Immediate.Jobs.Dashboard/Endpoints/DashboardApiEndpointOperations.cs
similarity index 98%
rename from src/Immediate.Jobs.Dashboard/DashboardApiEndpointOperations.cs
rename to src/Immediate.Jobs.Dashboard/Endpoints/DashboardApiEndpointOperations.cs
index 9549208..f22db8b 100644
--- a/src/Immediate.Jobs.Dashboard/DashboardApiEndpointOperations.cs
+++ b/src/Immediate.Jobs.Dashboard/Endpoints/DashboardApiEndpointOperations.cs
@@ -4,9 +4,7 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
-#pragma warning disable CA1812 // Request types and route groups are activated by generated endpoints.
-
-namespace Immediate.Jobs.Dashboard;
+namespace Immediate.Jobs.Dashboard.Endpoints;
internal enum DashboardMutationStatus
{
diff --git a/src/Immediate.Jobs.Dashboard/ImmediateJobsDashboardBuilder.cs b/src/Immediate.Jobs.Dashboard/ImmediateJobsDashboardBuilder.cs
new file mode 100644
index 0000000..15f6057
--- /dev/null
+++ b/src/Immediate.Jobs.Dashboard/ImmediateJobsDashboardBuilder.cs
@@ -0,0 +1,119 @@
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Options;
+
+namespace Immediate.Jobs.Dashboard;
+
+///
+/// The fluent registration result returned by .
+///
+public interface IImmediateJobsDashboardBuilder : IImmediateJobsBuilder
+{
+ ///
+ /// Provides an extension point to configure the options using a user provided configuration method.
+ ///
+ ///
+ /// The configuration method used to set the options.
+ ///
+ ///
+ /// The supplied builder.
+ ///
+ IImmediateJobsDashboardBuilder ConfigureDashboard(
+ Action> configureDashboard
+ );
+
+ ///
+ /// Provides an extension point to configure the options using a user provided configuration method.
+ ///
+ ///
+ /// The configuration method used to set the options.
+ ///
+ ///
+ /// The supplied builder.
+ ///
+ IImmediateJobsDashboardBuilder ConfigureDashboard(
+ Action configureDashboard
+ );
+
+ ///
+ /// Adds a provider-specific link from job details to an external telemetry system.
+ ///
+ ///
+ /// User-facing action label.
+ ///
+ ///
+ /// Whether the link opens traces or logs.
+ ///
+ ///
+ /// Builds a URL from the job and optional exact execution. Exact-execution requests scope the job's attempt,
+ /// trace ID, span ID, and execution-started compatibility fields to that execution. Return when the link is not available, such as before a trace has been created.
+ ///
+ ///
+ /// The supplied builder.
+ ///
+ IImmediateJobsDashboardBuilder AddTelemetryLink(
+ string label,
+ JobTelemetryLinkKind kind,
+ Func createUrl
+ );
+}
+
+internal sealed class ImmediateJobsDashboardBuilder(IImmediateJobsBuilder builder, OptionsBuilder optionsBuilder) : IImmediateJobsDashboardBuilder
+{
+ public IImmediateJobsDashboardBuilder AddTelemetryLink(
+ string label,
+ JobTelemetryLinkKind kind,
+ Func createUrl
+ )
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(label);
+
+ if (!Enum.IsDefined(kind))
+ throw new ArgumentOutOfRangeException(nameof(kind));
+
+ ArgumentNullException.ThrowIfNull(createUrl);
+
+ optionsBuilder.Configure(o => o.TelemetryLinks.Add(new(label, kind, createUrl)));
+ return this;
+ }
+
+ public IImmediateJobsDashboardBuilder ConfigureDashboard(Action> configureDashboard)
+ {
+ configureDashboard(optionsBuilder);
+ return this;
+ }
+
+ public IImmediateJobsDashboardBuilder ConfigureDashboard(Action configureDashboard)
+ {
+ optionsBuilder.Configure(configureDashboard);
+ return this;
+ }
+
+ public IServiceCollection Services => builder.Services;
+
+ public IImmediateJobsBuilder AddHealthCheck(string name = "immediate-jobs", HealthStatus? failureStatus = null, IEnumerable? tags = null) =>
+ builder.AddHealthCheck(name, failureStatus, tags);
+
+ public IImmediateJobsBuilder ConfigureWorkers(Action configureJobs) =>
+ builder.ConfigureWorkers(configureJobs);
+
+ public IImmediateJobsBuilder ConfigureWorkers(Action> configureJobs) =>
+ builder.ConfigureWorkers(configureJobs);
+
+ public IImmediateJobsBuilder ConfigureStorage(Action configure) =>
+ builder.ConfigureStorage(configure);
+
+ public IImmediateJobsBuilder DisableWorkers() =>
+ builder.DisableWorkers();
+
+ public IImmediateJobsBuilder UseFairQueues() =>
+ builder.UseFairQueues();
+
+ public IImmediateJobsBuilder UseFairQueues(Action> configureFairQueues) =>
+ builder.UseFairQueues(configureFairQueues);
+
+ IImmediateJobsBuilder IImmediateJobsBuilder.UseIdGenerator<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TGenerator>() =>
+ builder.UseIdGenerator();
+}
diff --git a/src/Immediate.Jobs.Dashboard/ImmediateJobsDashboardOptions.cs b/src/Immediate.Jobs.Dashboard/ImmediateJobsDashboardOptions.cs
index 1ce0e5e..548dc38 100644
--- a/src/Immediate.Jobs.Dashboard/ImmediateJobsDashboardOptions.cs
+++ b/src/Immediate.Jobs.Dashboard/ImmediateJobsDashboardOptions.cs
@@ -1,61 +1,37 @@
+using Immediate.Validations.Shared;
+
namespace Immediate.Jobs.Dashboard;
-/// Configures the Immediate.Jobs dashboard and monitoring API.
-public sealed class ImmediateJobsDashboardOptions
+///
+/// Configures the Immediate.Jobs dashboard and monitoring API.
+///
+[Validate]
+public sealed partial class ImmediateJobsDashboardOptions : IValidationTarget
{
- private readonly List _telemetryLinks = [];
-
- /// The interval between server-sent monitoring snapshots.
- /// The interval between consecutive dashboard updates.
+ ///
+ /// The interval between server-sent monitoring snapshots.
+ ///
+ [GreaterThan(nameof(TimeSpan.Zero))]
public TimeSpan UpdateInterval { get; set; } = TimeSpan.FromSeconds(2);
- internal bool RestrictToDevelopmentEnvironment { get; private set; } = true;
- internal string? AuthorizationPolicy { get; private set; }
- internal IReadOnlyList TelemetryLinks => _telemetryLinks;
-
- /// Allows dashboard access without an authorization policy in any hosting environment.
- ///
- /// This disables the default development-environment restriction. Prefer
- /// when exposing the dashboard outside a trusted development environment.
- ///
- /// This options instance.
- public ImmediateJobsDashboardOptions AllowInAnyEnvironment()
- {
- RestrictToDevelopmentEnvironment = false;
- return this;
- }
-
- /// Requires the named ASP.NET Core authorization policy on every dashboard endpoint.
- /// The registered authorization policy name.
- /// This options instance.
- public ImmediateJobsDashboardOptions RequireAuthorization(string policy)
- {
- ArgumentException.ThrowIfNullOrWhiteSpace(policy);
- AuthorizationPolicy = policy;
- return this;
- }
+ ///
+ /// Determines whether the dashboard is disabled in non-Development environments.
+ ///
+ ///
+ /// When , dashboard will only be enabled when the current environment
+ /// is Development. Otherwise, dashboard will be enabled in all environments.
+ /// Default is .
+ ///
+ public bool RestrictToDevelopmentEnvironment { get; set; } = true;
- /// Adds a provider-specific link from job details to an external telemetry system.
- /// User-facing action label.
- /// Whether the link opens traces or logs.
- ///
- /// Builds a URL from the job and optional exact execution. Exact-execution requests scope the job's
- /// attempt, trace ID, span ID, and execution-started compatibility fields to that execution. Return
- /// when the link is not available, such as before a trace has been created.
- ///
- /// This options instance.
- public ImmediateJobsDashboardOptions AddTelemetryLink(
- string label,
- JobTelemetryLinkKind kind,
- Func createUrl
- )
- {
- ArgumentException.ThrowIfNullOrWhiteSpace(label);
- ArgumentNullException.ThrowIfNull(createUrl);
- if (!Enum.IsDefined(kind))
- throw new ArgumentOutOfRangeException(nameof(kind));
+ ///
+ /// Requires the named ASP.NET Core authorization policy on every dashboard endpoint.
+ ///
+ ///
+ /// The registered authorization policy name.
+ ///
+ [NotEmpty]
+ public string? AuthorizationPolicy { get; set; }
- _telemetryLinks.Add(new(label, kind, createUrl));
- return this;
- }
+ internal List TelemetryLinks { get; } = [];
}
diff --git a/src/Immediate.Jobs.Dashboard/ImmediateJobsDashboardServiceCollectionExtensions.cs b/src/Immediate.Jobs.Dashboard/ImmediateJobsDashboardServiceCollectionExtensions.cs
index d74a57d..3bb7ab6 100644
--- a/src/Immediate.Jobs.Dashboard/ImmediateJobsDashboardServiceCollectionExtensions.cs
+++ b/src/Immediate.Jobs.Dashboard/ImmediateJobsDashboardServiceCollectionExtensions.cs
@@ -1,34 +1,41 @@
+using Immediate.Validations.Shared;
using Microsoft.Extensions.DependencyInjection;
namespace Immediate.Jobs.Dashboard;
-/// Registers the Immediate.Jobs dashboard API handlers and validation pipeline.
+///
+/// Registers the Immediate.Jobs dashboard API handlers and validation pipeline.
+///
public static class ImmediateJobsDashboardServiceCollectionExtensions
{
- /// Adds the services required by MapImmediateJobsDashboard.
- /// The service collection to add dashboard services to.
- /// An optional callback that configures the dashboard.
- /// The service collection for further configuration.
- public static IServiceCollection AddImmediateJobsDashboard(
- this IServiceCollection services,
- Action? configure = null
+ ///
+ /// Adds the services required by MapImmediateJobsDashboard.
+ ///
+ ///
+ /// The builder used to configure Immediate.Jobs
+ ///
+ ///
+ /// The service collection for further configuration.
+ ///
+ public static IImmediateJobsDashboardBuilder AddImmediateJobsDashboard(
+ this IImmediateJobsBuilder builder
)
{
- ArgumentNullException.ThrowIfNull(services);
+ ArgumentNullException.ThrowIfNull(builder);
- var optionsBuilder = services
+ var optionsBuilder = builder.Services
.AddOptionsWithValidateOnStart()
.Validate(
- static options => options.UpdateInterval > TimeSpan.Zero,
- "The dashboard update interval must be positive."
+ o =>
+ {
+ ValidationException.ThrowIfInvalid(o, $@"Validation error for ""{nameof(ImmediateJobsDashboardOptions)}""");
+ return true;
+ }
);
- if (configure is not null)
- optionsBuilder.Configure(configure);
- _ = services.AddHttpContextAccessor();
- _ = services.AddImmediateJobsDashboardHandlers();
- _ = services.AddImmediateJobsCore();
+ _ = builder.Services.AddHttpContextAccessor();
+ _ = builder.Services.AddImmediateJobsDashboardHandlers();
- return services;
+ return new ImmediateJobsDashboardBuilder(builder, optionsBuilder);
}
}
diff --git a/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.cs b/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.cs
index d761d94..221207c 100644
--- a/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.cs
+++ b/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.cs
@@ -20,11 +20,13 @@ internal sealed class EntityFrameworkCoreJobStorage(
private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
///
- public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+ public async ValueTask DisposeAsync() { }
///
public async ValueTask InitializeAsync(CancellationToken cancellationToken = default)
{
+ cancellationToken.ThrowIfCancellationRequested();
+
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
_ = context.Model.FindEntityType(typeof(ImmediateJobEntity))
?? throw new ImmediateJobException("Immediate.Jobs entities are not configured. Call modelBuilder.AddImmediateJobs() from OnModelCreating.");
@@ -33,7 +35,8 @@ public async ValueTask InitializeAsync(CancellationToken cancellationToken = def
///
public async ValueTask EnqueueAsync(JobRecord job, CancellationToken cancellationToken = default)
{
- ArgumentNullException.ThrowIfNull(job);
+ cancellationToken.ThrowIfCancellationRequested();
+
await ExecuteWithStrategyAsync(
operationCancellationToken => EnqueueCoreAsync(job, operationCancellationToken),
cancellationToken
@@ -42,8 +45,11 @@ await ExecuteWithStrategyAsync(
private async Task EnqueueCoreAsync(JobRecord job, CancellationToken cancellationToken)
{
+ cancellationToken.ThrowIfCancellationRequested();
+
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
+
if (job.GroupId is { } groupId && !await HasLiveGroupJobsAsync(
context,
job.QueueName,
@@ -73,8 +79,8 @@ public async ValueTask EnqueueContinuationAsync(
CancellationToken cancellationToken = default
)
{
- ArgumentNullException.ThrowIfNull(job);
- ArgumentNullException.ThrowIfNull(edges);
+ cancellationToken.ThrowIfCancellationRequested();
+
await ExecuteGraphInsertAsync(batch: null, [job], edges, cancellationToken).ConfigureAwait(false);
}
@@ -86,11 +92,8 @@ public async ValueTask EnqueueBatchAsync(
CancellationToken cancellationToken = default
)
{
- ArgumentNullException.ThrowIfNull(batch);
- ArgumentNullException.ThrowIfNull(jobs);
- ArgumentNullException.ThrowIfNull(edges);
- if (jobs.Count == 0)
- throw new ImmediateJobException("An atomic batch cannot be committed without jobs.");
+ cancellationToken.ThrowIfCancellationRequested();
+
await ExecuteGraphInsertAsync(batch, jobs, edges, cancellationToken).ConfigureAwait(false);
}
@@ -172,6 +175,8 @@ public async ValueTask> AcquireDueJobsAsync(
CancellationToken cancellationToken = default
)
{
+ cancellationToken.ThrowIfCancellationRequested();
+
if (request.FairQueues is not null)
return await AcquireDueJobsFairAsync(request, cancellationToken).ConfigureAwait(false);
@@ -623,9 +628,8 @@ public async ValueTask> AcquireJobsAsync(
CancellationToken cancellationToken = default
)
{
- ArgumentNullException.ThrowIfNull(jobIds);
- ArgumentException.ThrowIfNullOrWhiteSpace(workerId);
- ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lease, TimeSpan.Zero);
+ cancellationToken.ThrowIfCancellationRequested();
+
if (jobIds.Count == 0)
return [];
@@ -718,7 +722,7 @@ CancellationToken cancellationToken
}
///
- public ValueTask SetExecutionTelemetryAsync(
+ public async ValueTask SetExecutionTelemetryAsync(
string jobId,
int executionNumber,
string workerId,
@@ -726,18 +730,29 @@ public ValueTask SetExecutionTelemetryAsync(
string? spanId,
DateTimeOffset startedAt,
CancellationToken cancellationToken = default
- ) => MutateOwnedAsync(jobId, executionNumber, workerId, (job, execution) =>
+ )
{
- job.ExecutionTraceId = traceId;
- job.ExecutionSpanId = spanId;
- job.ExecutionStartedAt = startedAt;
- execution.ExecutionTraceId = traceId;
- execution.ExecutionSpanId = spanId;
- execution.ExecutionStartedAt = startedAt;
- }, cancellationToken);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await MutateOwnedAsync(
+ jobId,
+ executionNumber,
+ workerId,
+ (job, execution) =>
+ {
+ job.ExecutionTraceId = traceId;
+ job.ExecutionSpanId = spanId;
+ job.ExecutionStartedAt = startedAt;
+ execution.ExecutionTraceId = traceId;
+ execution.ExecutionSpanId = spanId;
+ execution.ExecutionStartedAt = startedAt;
+ },
+ cancellationToken
+ );
+ }
///
- public ValueTask RenewLeaseAsync(
+ public async ValueTask RenewLeaseAsync(
string jobId,
int executionNumber,
string workerId,
@@ -745,8 +760,9 @@ public ValueTask RenewLeaseAsync(
CancellationToken cancellationToken = default
)
{
- ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lease, TimeSpan.Zero);
- return MutateOwnedAsync(
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await MutateOwnedAsync(
jobId,
executionNumber,
workerId,
@@ -756,15 +772,20 @@ public ValueTask RenewLeaseAsync(
}
///
- public ValueTask CompleteAsync(
+ public async ValueTask CompleteAsync(
string jobId,
int executionNumber,
string workerId,
CancellationToken cancellationToken = default
- ) => CompleteWithContinuationsAsync(jobId, executionNumber, workerId, [], cancellationToken);
+ )
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await CompleteWithContinuationsAsync(jobId, executionNumber, workerId, [], cancellationToken);
+ }
///
- public ValueTask CompleteWithContinuationsAsync(
+ public async ValueTask CompleteWithContinuationsAsync(
string jobId,
int executionNumber,
string workerId,
@@ -772,8 +793,9 @@ public ValueTask CompleteWithContinuationsAsync(
CancellationToken cancellationToken = default
)
{
- ArgumentNullException.ThrowIfNull(additions);
- return MutateOwnedWithDependenciesAsync(
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await MutateOwnedWithDependenciesAsync(
jobId,
executionNumber,
workerId,
@@ -786,7 +808,7 @@ public ValueTask CompleteWithContinuationsAsync(
}
///
- public ValueTask AddBatchJobAsync(
+ public async ValueTask AddBatchJobAsync(
string currentJobId,
int executionNumber,
JobRecord job,
@@ -794,10 +816,9 @@ public ValueTask AddBatchJobAsync(
CancellationToken cancellationToken = default
)
{
- ArgumentNullException.ThrowIfNull(job);
- if (options == ContinuationOptions.Detached)
- throw new ImmediateJobException("AddToBatchAsync cannot create a detached job.");
- return RetryConcurrencyAsync(
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await RetryConcurrencyAsync(
operationCancellationToken => AddBatchJobCoreAsync(
currentJobId,
executionNumber,
@@ -810,28 +831,34 @@ public ValueTask AddBatchJobAsync(
}
///
- public ValueTask FailAsync(
+ public async ValueTask FailAsync(
string jobId,
int executionNumber,
string workerId,
string error,
DateTimeOffset? nextRetryAt,
CancellationToken cancellationToken = default
- ) => MutateOwnedWithDependenciesAsync(
- jobId,
- executionNumber,
- workerId,
- error,
- nextRetryAt,
- succeeded: false,
- [],
- cancellationToken
- );
+ )
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await MutateOwnedWithDependenciesAsync(
+ jobId,
+ executionNumber,
+ workerId,
+ error,
+ nextRetryAt,
+ succeeded: false,
+ [],
+ cancellationToken
+ );
+ }
///
public async ValueTask UpsertRecurringAsync(RecurringJobSchedule schedule, CancellationToken cancellationToken = default)
{
- ArgumentNullException.ThrowIfNull(schedule);
+ cancellationToken.ThrowIfCancellationRequested();
+
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
if (await UpdateRecurringAsync(context, schedule, cancellationToken).ConfigureAwait(false) != 0)
return;
@@ -859,7 +886,8 @@ public async ValueTask RemoveObsoleteCodeDefinedRecurringAsync(
CancellationToken cancellationToken = default
)
{
- ArgumentNullException.ThrowIfNull(activeScheduleNames);
+ cancellationToken.ThrowIfCancellationRequested();
+
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var schedules = context.Set()
.Where(schedule => schedule.IsCodeDefined);
@@ -871,7 +899,8 @@ public async ValueTask RemoveObsoleteCodeDefinedRecurringAsync(
///
public async ValueTask RemoveRecurringAsync(string name, CancellationToken cancellationToken = default)
{
- ArgumentException.ThrowIfNullOrWhiteSpace(name);
+ cancellationToken.ThrowIfCancellationRequested();
+
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var removed = await context.Set()
.Where(schedule => schedule.Name == name && !schedule.IsCodeDefined)
@@ -890,17 +919,26 @@ public async ValueTask RemoveRecurringAsync(string name, CancellationToken cance
}
///
- public ValueTask PauseRecurringAsync(string name, CancellationToken cancellationToken = default)
- => MutateRecurringAsync(name, schedule => schedule.IsPaused = true, cancellationToken);
+ public async ValueTask PauseRecurringAsync(string name, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await MutateRecurringAsync(name, schedule => schedule.IsPaused = true, cancellationToken);
+ }
///
- public ValueTask ResumeRecurringAsync(string name, CancellationToken cancellationToken = default)
- => MutateRecurringAsync(name, schedule => schedule.IsPaused = false, cancellationToken);
+ public async ValueTask ResumeRecurringAsync(string name, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await MutateRecurringAsync(name, schedule => schedule.IsPaused = false, cancellationToken);
+ }
///
public async ValueTask> GetDueRecurringAsync(DateTimeOffset now, int batchSize, CancellationToken cancellationToken = default)
{
- ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(batchSize, 0);
+ cancellationToken.ThrowIfCancellationRequested();
+
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
return await context.Set()
.AsNoTracking()
@@ -930,8 +968,8 @@ public async ValueTask MaterializeRecurringAsync(
CancellationToken cancellationToken = default
)
{
- ArgumentNullException.ThrowIfNull(schedule);
- ArgumentNullException.ThrowIfNull(job);
+ cancellationToken.ThrowIfCancellationRequested();
+
await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var strategy = strategyContext.Database.CreateExecutionStrategy();
return await strategy.ExecuteAsync(
@@ -1019,6 +1057,8 @@ CancellationToken cancellationToken
///
public async ValueTask GetMonitoringSnapshotAsync(CancellationToken cancellationToken = default)
{
+ cancellationToken.ThrowIfCancellationRequested();
+
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var rawCounts = await context.Set()
.AsNoTracking()
@@ -1067,6 +1107,8 @@ public async ValueTask GetMonitoringSnapshotAsync(Cancell
///
public async ValueTask> QueryJobsAsync(JobQuery query, CancellationToken cancellationToken = default)
{
+ cancellationToken.ThrowIfCancellationRequested();
+
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var jobs = context.Set().AsNoTracking();
if (query.Id is { } id)
@@ -1101,6 +1143,8 @@ public async ValueTask> QueryJobExecutionsAsyn
CancellationToken cancellationToken = default
)
{
+ cancellationToken.ThrowIfCancellationRequested();
+
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var job = await context.Set()
.AsNoTracking()
@@ -1155,7 +1199,8 @@ public async ValueTask> QueryJobExecutionsAsyn
CancellationToken cancellationToken = default
)
{
- ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
+ cancellationToken.ThrowIfCancellationRequested();
+
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var batch = await context.Set()
.AsNoTracking()
@@ -1170,6 +1215,8 @@ public async ValueTask> GetIncomingEdgesAsync
CancellationToken cancellationToken = default
)
{
+ cancellationToken.ThrowIfCancellationRequested();
+
if (childJobIds.Count == 0)
return [];
@@ -1192,6 +1239,8 @@ public async ValueTask> QueryBatchesAsync(
CancellationToken cancellationToken = default
)
{
+ cancellationToken.ThrowIfCancellationRequested();
+
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var batches = context.Set().AsNoTracking();
if (query.State is { } state)
@@ -1212,6 +1261,8 @@ public async ValueTask> QueryBatchMembersAsync(
CancellationToken cancellationToken = default
)
{
+ cancellationToken.ThrowIfCancellationRequested();
+
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var jobs = context.Set()
.AsNoTracking()
@@ -1243,7 +1294,8 @@ public async ValueTask> QueryBatchMembersAsync(
CancellationToken cancellationToken = default
)
{
- ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
+ cancellationToken.ThrowIfCancellationRequested();
+
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
if (!await context.Set()
.AnyAsync(batch => batch.Id == batchId, cancellationToken)
@@ -1280,7 +1332,8 @@ public async ValueTask> QueryBatchMembersAsync(
CancellationToken cancellationToken = default
)
{
- ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
+ cancellationToken.ThrowIfCancellationRequested();
+
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var job = await context.Set()
.AsNoTracking()
@@ -1313,10 +1366,11 @@ public async ValueTask> QueryBatchMembersAsync(
}
///
- public ValueTask CancelBatchAsync(string batchId, CancellationToken cancellationToken = default)
+ public async ValueTask CancelBatchAsync(string batchId, CancellationToken cancellationToken = default)
{
- ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
- return RetryConcurrencyAsync(
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await RetryConcurrencyAsync(
operationCancellationToken => CancelBatchCoreAsync(batchId, operationCancellationToken),
cancellationToken
);
@@ -1370,10 +1424,11 @@ private async Task CancelBatchCoreAsync(string batchId, CancellationToken cancel
}
///
- public ValueTask DeleteBatchAsync(string batchId, CancellationToken cancellationToken = default)
+ public async ValueTask DeleteBatchAsync(string batchId, CancellationToken cancellationToken = default)
{
- ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
- return ExecuteWithStrategyAsync(
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await ExecuteWithStrategyAsync(
operationCancellationToken => DeleteBatchCoreAsync(batchId, operationCancellationToken),
cancellationToken
);
@@ -1410,10 +1465,11 @@ private async Task DeleteBatchCoreAsync(string batchId, CancellationToken cancel
}
///
- public ValueTask CancelAsync(string jobId, CancellationToken cancellationToken = default)
+ public async ValueTask CancelAsync(string jobId, CancellationToken cancellationToken = default)
{
- ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
- return RetryConcurrencyAsync(
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await RetryConcurrencyAsync(
operationCancellationToken => CancelCoreAsync(jobId, operationCancellationToken),
cancellationToken
);
@@ -1457,10 +1513,11 @@ private async Task CancelCoreAsync(string jobId, CancellationToken cancellationT
}
///
- public ValueTask RetryAsync(string jobId, CancellationToken cancellationToken = default)
+ public async ValueTask RetryAsync(string jobId, CancellationToken cancellationToken = default)
{
- ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
- return RetryConcurrencyAsync(
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await RetryConcurrencyAsync(
operationCancellationToken => RetryCoreAsync(jobId, operationCancellationToken),
cancellationToken
);
@@ -1532,10 +1589,11 @@ private async Task RetryCoreAsync(string jobId, CancellationToken cancellationTo
}
///
- public ValueTask DeleteAsync(string jobId, CancellationToken cancellationToken = default)
+ public async ValueTask DeleteAsync(string jobId, CancellationToken cancellationToken = default)
{
- ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
- return ExecuteWithStrategyAsync(
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await ExecuteWithStrategyAsync(
operationCancellationToken => DeleteCoreAsync(jobId, operationCancellationToken),
cancellationToken
);
@@ -1590,14 +1648,16 @@ private async Task DeleteCoreAsync(string jobId, CancellationToken cancellationT
}
///
- public ValueTask PurgeJobsAsync(
+ public async ValueTask PurgeJobsAsync(
TimeSpan succeededRetention,
TimeSpan failedRetention,
CancellationToken cancellationToken = default
)
{
+ cancellationToken.ThrowIfCancellationRequested();
+
var now = _timeProvider.GetUtcNow();
- return ExecuteWithStrategyAsync(
+ await ExecuteWithStrategyAsync(
operationCancellationToken => PurgeJobsCoreAsync(
now - succeededRetention,
now - failedRetention,
@@ -1608,14 +1668,16 @@ public ValueTask PurgeJobsAsync(
}
///
- public ValueTask PurgeBatchesAsync(
+ public async ValueTask PurgeBatchesAsync(
TimeSpan batchSucceededRetention,
TimeSpan batchFailedRetention,
CancellationToken cancellationToken = default
)
{
+ cancellationToken.ThrowIfCancellationRequested();
+
var now = _timeProvider.GetUtcNow();
- return ExecuteWithStrategyAsync(
+ await ExecuteWithStrategyAsync(
operationCancellationToken => PurgeBatchesCoreAsync(
now - batchSucceededRetention,
now - batchFailedRetention,
@@ -1699,7 +1761,8 @@ CancellationToken cancellationToken
///
public async ValueTask HeartbeatAsync(JobServerSnapshot server, CancellationToken cancellationToken = default)
{
- ArgumentNullException.ThrowIfNull(server);
+ cancellationToken.ThrowIfCancellationRequested();
+
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var cutoff = _timeProvider.GetUtcNow() - TimeSpan.FromMinutes(2);
_ = await context.Set()
@@ -1730,6 +1793,8 @@ public async ValueTask HeartbeatAsync(JobServerSnapshot server, CancellationToke
///
public async ValueTask IsHealthyAsync(CancellationToken cancellationToken = default)
{
+ cancellationToken.ThrowIfCancellationRequested();
+
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
diff --git a/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreServiceCollectionExtensions.cs b/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreServiceCollectionExtensions.cs
index 64cea30..526a712 100644
--- a/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreServiceCollectionExtensions.cs
+++ b/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreServiceCollectionExtensions.cs
@@ -1,5 +1,4 @@
using Microsoft.EntityFrameworkCore;
-using Microsoft.Extensions.DependencyInjection;
namespace Immediate.Jobs.EntityFrameworkCore;
@@ -21,17 +20,11 @@ public static class EntityFrameworkCoreServiceCollectionExtensions
///
/// The configured Immediate.Jobs options.
///
- public static ImmediateJobsStorageBuilder UseEntityFrameworkCore(this ImmediateJobsStorageBuilder builder)
+ public static IImmediateJobsStorageBuilder UseEntityFrameworkCore(this IImmediateJobsStorageBuilder builder)
where TContext : DbContext
{
ArgumentNullException.ThrowIfNull(builder);
- return builder.UseStorage(
- services =>
- new EntityFrameworkCoreJobStorage(
- services.GetRequiredService>(),
- services.GetService()
- )
- );
+ return builder.UseStorage>();
}
}
diff --git a/src/Immediate.Jobs.EntityFrameworkCore/Immediate.Jobs.EntityFrameworkCore.csproj b/src/Immediate.Jobs.EntityFrameworkCore/Immediate.Jobs.EntityFrameworkCore.csproj
index 12d8936..6ecc4e4 100644
--- a/src/Immediate.Jobs.EntityFrameworkCore/Immediate.Jobs.EntityFrameworkCore.csproj
+++ b/src/Immediate.Jobs.EntityFrameworkCore/Immediate.Jobs.EntityFrameworkCore.csproj
@@ -21,9 +21,8 @@
-
-
-
+
+
diff --git a/src/Immediate.Jobs.Generators/Templates/ServiceCollectionExtensions.sbntxt b/src/Immediate.Jobs.Generators/Templates/ServiceCollectionExtensions.sbntxt
index 49f2495..7e57947 100644
--- a/src/Immediate.Jobs.Generators/Templates/ServiceCollectionExtensions.sbntxt
+++ b/src/Immediate.Jobs.Generators/Templates/ServiceCollectionExtensions.sbntxt
@@ -10,7 +10,7 @@ namespace {{ namespace }};
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Immediate.Jobs", "{{ version }}")]
public static class ImmediateJobsGeneratedServiceCollectionExtensions
{
- public static global::Immediate.Jobs.Shared.ImmediateJobsBuilder Add{{ assembly_name }}Jobs(
+ public static global::Immediate.Jobs.Shared.IImmediateJobsBuilder Add{{ assembly_name }}Jobs(
this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services,
params {{ if language_version <= 1200 }}string[]{{ else }}global::System.ReadOnlySpan{{ end }} tags
)
diff --git a/src/Immediate.Jobs.LinqToDB/Immediate.Jobs.LinqToDB.csproj b/src/Immediate.Jobs.LinqToDB/Immediate.Jobs.LinqToDB.csproj
index cc0d059..9db1fbd 100644
--- a/src/Immediate.Jobs.LinqToDB/Immediate.Jobs.LinqToDB.csproj
+++ b/src/Immediate.Jobs.LinqToDB/Immediate.Jobs.LinqToDB.csproj
@@ -22,7 +22,7 @@
-
+
diff --git a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.cs b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.cs
index 46c2d03..a076262 100644
--- a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.cs
+++ b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.cs
@@ -4,31 +4,23 @@
using LinqToDB;
using LinqToDB.Async;
using LinqToDB.Data;
+using Microsoft.Extensions.Options;
namespace Immediate.Jobs.LinqToDB;
/// An optimistic-concurrency LinqToDB implementation of .
-internal sealed class LinqToDBJobStorage : IRecurringJobStorage, IJobGraphStorage, IFairQueueStorage, IJobStorageReplica, IJobGraphStorageReplica
+internal sealed class LinqToDBJobStorage(
+ Owned contextScope,
+ IOptions options,
+ TimeProvider timeProvider
+) : IRecurringJobStorage, IJobGraphStorage, IFairQueueStorage, IJobStorageReplica, IJobGraphStorageReplica
+ where T : DataConnection
{
private const int MaxContendedCompletionAttempts = 50;
private const int MaxConcurrencyAttempts = 5;
private const int MaxConsecutiveFailedFairClaims = 5;
- private readonly DataOptions _dataOptions;
- private readonly string? _schema;
- private readonly TimeProvider _timeProvider;
- /// Creates storage using immutable LinqToDB connection options.
- /// The immutable LinqToDB connection options.
- /// The database schema containing the Immediate.Jobs tables, or for the provider default.
- /// The clock used for storage timestamps, or to use the system clock.
- public LinqToDBJobStorage(DataOptions dataOptions, string? schema = null, TimeProvider? timeProvider = null)
- {
- ArgumentNullException.ThrowIfNull(dataOptions);
- LinqToDBSchemaExtensions.ValidateSchema(schema);
- _dataOptions = dataOptions;
- _schema = schema;
- _timeProvider = timeProvider ?? TimeProvider.System;
- }
+ private readonly string? _schema = options.Value.Schema;
///
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
@@ -39,8 +31,10 @@ public LinqToDBJobStorage(DataOptions dataOptions, string? schema = null, TimePr
///
public async ValueTask EnqueueAsync(JobRecord job, CancellationToken cancellationToken = default)
{
- ArgumentNullException.ThrowIfNull(job);
- await using var connection = CreateConnection();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await using var scope = contextScope.GetScope(out var connection);
+
_ = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
try
{
@@ -61,8 +55,12 @@ public async ValueTask> GetIncomingEdgesAsync
CancellationToken cancellationToken = default
)
{
+ cancellationToken.ThrowIfCancellationRequested();
+
var ids = childJobIds.Distinct(StringComparer.Ordinal).ToArray();
- await using var connection = CreateConnection();
+
+ await using var scope = contextScope.GetScope(out var connection);
+
var edges = await Continuations(connection)
.Where(edge => ids.Contains(edge.ChildJobId))
.OrderBy(edge => edge.ChildJobId)
@@ -74,31 +72,28 @@ public async ValueTask> GetIncomingEdgesAsync
}
///
- public ValueTask EnqueueContinuationAsync(
+ public async ValueTask EnqueueContinuationAsync(
JobRecord job,
IReadOnlyList edges,
CancellationToken cancellationToken = default
)
{
- ArgumentNullException.ThrowIfNull(job);
- ArgumentNullException.ThrowIfNull(edges);
- return ExecuteGraphInsertAsync(batch: null, [job], edges, cancellationToken);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await ExecuteGraphInsertAsync(batch: null, [job], edges, cancellationToken);
}
///
- public ValueTask EnqueueBatchAsync(
+ public async ValueTask EnqueueBatchAsync(
BatchRecord batch,
IReadOnlyList jobs,
IReadOnlyList edges,
CancellationToken cancellationToken = default
)
{
- ArgumentNullException.ThrowIfNull(batch);
- ArgumentNullException.ThrowIfNull(jobs);
- ArgumentNullException.ThrowIfNull(edges);
- if (jobs.Count == 0)
- throw new ImmediateJobException("An atomic batch cannot be committed without jobs.");
- return ExecuteGraphInsertAsync(batch, jobs, edges, cancellationToken);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await ExecuteGraphInsertAsync(batch, jobs, edges, cancellationToken);
}
private ValueTask ExecuteGraphInsertAsync(
@@ -138,7 +133,7 @@ await EvaluateInitialDependenciesAsync(
connection,
jobEntities,
edgeEntities,
- _timeProvider.GetUtcNow().UtcTicks,
+ timeProvider.GetUtcNow().UtcTicks,
cancellationToken
).ConfigureAwait(false);
@@ -160,7 +155,7 @@ await EvaluateInitialDependenciesAsync(
CancelledCount = cancelled,
SkippedCount = skipped,
StartedAt = Ticks(batch.StartedAt),
- CompletedAt = pending == 0 ? Ticks(batch.CompletedAt ?? _timeProvider.GetUtcNow()) : null,
+ CompletedAt = pending == 0 ? Ticks(batch.CompletedAt ?? timeProvider.GetUtcNow()) : null,
State = pending == 0 ? GetTerminalBatchState(failed, cancelled) : BatchState.Executing,
ConcurrencyStamp = Guid.NewGuid(),
}, cancellationToken).ConfigureAwait(false);
@@ -178,10 +173,12 @@ public async ValueTask> AcquireDueJobsAsync(
CancellationToken cancellationToken = default
)
{
+ cancellationToken.ThrowIfCancellationRequested();
+
if (request.FairQueues is not null)
return await AcquireDueJobsFairAsync(request, cancellationToken).ConfigureAwait(false);
- var now = _timeProvider.GetUtcNow().UtcTicks;
+ var now = timeProvider.GetUtcNow().UtcTicks;
var acquired = new List(request.BatchSize);
foreach (var queue in request.Queues)
{
@@ -195,7 +192,9 @@ public async ValueTask> AcquireDueJobsAsync(
var eligibleNames = jobCapacities.Where(static pair => pair.Value > 0).Select(static pair => pair.Key).ToArray();
if (eligibleNames.Length == 0)
break;
- await using var readConnection = CreateConnection();
+
+ await using var scope = contextScope.GetScope(out var readConnection);
+
var candidates = await Jobs(readConnection)
.Where(job => job.QueueName == queue.QueueName && eligibleNames.Contains(job.JobName) &&
(((job.State == JobState.Scheduled || job.State == JobState.Pending) && job.DueAt <= now)
@@ -233,7 +232,7 @@ private async ValueTask> AcquireDueJobsFairAsync(
CancellationToken cancellationToken
)
{
- var now = _timeProvider.GetUtcNow().UtcTicks;
+ var now = timeProvider.GetUtcNow().UtcTicks;
var acquired = new List(request.BatchSize);
foreach (var queue in request.Queues)
{
@@ -256,7 +255,8 @@ CancellationToken cancellationToken
if (eligibleNames.Length == 0)
break;
- await using var readConnection = CreateConnection();
+ await using var scope = contextScope.GetScope(out var readConnection);
+
var eligibleQuery = Jobs(readConnection)
.Where(job => job.QueueName == queue.QueueName && eligibleNames.Contains(job.JobName) &&
(((job.State == JobState.Scheduled || job.State == JobState.Pending) && job.DueAt <= now)
@@ -429,7 +429,8 @@ CancellationToken cancellationToken
if (eligibleNames.Length == 0)
break;
- await using var readConnection = CreateConnection();
+ await using var scope = contextScope.GetScope(out var readConnection);
+
var candidates = await Jobs(readConnection)
.Where(job => job.QueueName == queueName && eligibleNames.Contains(job.JobName) &&
(((job.State == JobState.Scheduled || job.State == JobState.Pending) && job.DueAt <= now)
@@ -483,7 +484,8 @@ CancellationToken cancellationToken
CancellationToken cancellationToken
)
{
- await using var connection = CreateConnection();
+ await using var scope = contextScope.GetScope(out var connection);
+
_ = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
Guid? observedCursorStamp = null;
var cursorWasMissing = false;
@@ -607,12 +609,14 @@ CancellationToken cancellationToken
if (groupId is null || (!cursorWasMissing && observedCursorStamp is null))
return false;
- await using var connection = CreateConnection();
+ await using var scope = contextScope.GetScope(out var connection);
+
var currentStamp = await FairQueueGroups(connection)
.Where(group => group.QueueName == queueName && group.GroupId == groupId)
.Select(static group => (Guid?)group.ConcurrencyStamp)
.SingleOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
+
return cursorWasMissing
? currentStamp is not null
: currentStamp != observedCursorStamp;
@@ -687,20 +691,22 @@ public async ValueTask> AcquireJobsAsync(
CancellationToken cancellationToken = default
)
{
- ArgumentNullException.ThrowIfNull(jobIds);
- ArgumentException.ThrowIfNullOrWhiteSpace(workerId);
- ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lease, TimeSpan.Zero);
+ cancellationToken.ThrowIfCancellationRequested();
+
if (jobIds.Count == 0)
return [];
- var now = _timeProvider.GetUtcNow().UtcTicks;
+ var now = timeProvider.GetUtcNow().UtcTicks;
var ids = jobIds.ToArray();
- await using var connection = CreateConnection();
+
+ await using var scope = contextScope.GetScope(out var connection);
+
var candidates = await Jobs(connection)
.Where(job => ids.Contains(job.Id) &&
(((job.State == JobState.Scheduled || job.State == JobState.Pending) && job.DueAt <= now)
|| (job.State == JobState.Active && job.LeaseExpiresAt <= now)))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
+
return await AcquireCandidatesAsync(candidates, workerId, lease, now, cancellationToken).ConfigureAwait(false);
}
@@ -715,7 +721,8 @@ CancellationToken cancellationToken
var acquired = new List(candidates.Count);
foreach (var candidate in candidates)
{
- await using var connection = CreateConnection();
+ await using var scope = contextScope.GetScope(out var connection);
+
_ = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
try
{
@@ -784,6 +791,8 @@ public async ValueTask SetExecutionTelemetryAsync(
CancellationToken cancellationToken = default
)
{
+ cancellationToken.ThrowIfCancellationRequested();
+
await RetryConcurrencyAsync(async connection =>
{
var job = await Jobs(connection).SingleOrDefaultAsync(
@@ -820,11 +829,13 @@ public async ValueTask RenewLeaseAsync(
CancellationToken cancellationToken = default
)
{
- ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lease, TimeSpan.Zero);
- await using var connection = CreateConnection();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await using var scope = contextScope.GetScope(out var connection);
+
var updated = await Jobs(connection)
.Where(job => job.Id == jobId && job.Attempt == executionNumber && job.State == JobState.Active && job.WorkerId == workerId)
- .Set(job => job.LeaseExpiresAt, _timeProvider.GetUtcNow().UtcTicks + lease.Ticks)
+ .Set(job => job.LeaseExpiresAt, timeProvider.GetUtcNow().UtcTicks + lease.Ticks)
.Set(job => job.ConcurrencyStamp, Guid.NewGuid())
.UpdateAsync(cancellationToken)
.ConfigureAwait(false);
@@ -833,15 +844,20 @@ public async ValueTask RenewLeaseAsync(
}
///
- public ValueTask CompleteAsync(
+ public async ValueTask CompleteAsync(
string jobId,
int executionNumber,
string workerId,
CancellationToken cancellationToken = default
- ) => CompleteWithContinuationsAsync(jobId, executionNumber, workerId, [], cancellationToken);
+ )
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await CompleteWithContinuationsAsync(jobId, executionNumber, workerId, [], cancellationToken);
+ }
///
- public ValueTask CompleteWithContinuationsAsync(
+ public async ValueTask CompleteWithContinuationsAsync(
string jobId,
int executionNumber,
string workerId,
@@ -849,8 +865,9 @@ public ValueTask CompleteWithContinuationsAsync(
CancellationToken cancellationToken = default
)
{
- ArgumentNullException.ThrowIfNull(additions);
- return MutateOwnedWithDependenciesAsync(
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await MutateOwnedWithDependenciesAsync(
jobId,
executionNumber,
workerId,
@@ -863,7 +880,7 @@ public ValueTask CompleteWithContinuationsAsync(
}
///
- public ValueTask AddBatchJobAsync(
+ public async ValueTask AddBatchJobAsync(
string currentJobId,
int executionNumber,
JobRecord job,
@@ -871,35 +888,37 @@ public ValueTask AddBatchJobAsync(
CancellationToken cancellationToken = default
)
{
- ArgumentNullException.ThrowIfNull(job);
- if (options == ContinuationOptions.Detached)
- throw new ImmediateJobException("AddToBatchAsync cannot create a detached job.");
- if (options is not (ContinuationOptions.BesideContinuations or ContinuationOptions.BeforeContinuations))
- throw new ArgumentOutOfRangeException(nameof(options));
- return RetryConcurrencyAsync(
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await RetryConcurrencyAsync(
connection => AddBatchJobCoreAsync(connection, currentJobId, executionNumber, job, options, cancellationToken),
cancellationToken
);
}
///
- public ValueTask FailAsync(
+ public async ValueTask FailAsync(
string jobId,
int executionNumber,
string workerId,
string error,
DateTimeOffset? nextRetryAt,
CancellationToken cancellationToken = default
- ) => MutateOwnedWithDependenciesAsync(
- jobId,
- executionNumber,
- workerId,
- error,
- nextRetryAt,
- succeeded: false,
- [],
- cancellationToken
- );
+ )
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await MutateOwnedWithDependenciesAsync(
+ jobId,
+ executionNumber,
+ workerId,
+ error,
+ nextRetryAt,
+ succeeded: false,
+ [],
+ cancellationToken
+ );
+ }
///
public async ValueTask UpsertRecurringAsync(
@@ -907,10 +926,12 @@ public async ValueTask UpsertRecurringAsync(
CancellationToken cancellationToken = default
)
{
- ArgumentNullException.ThrowIfNull(schedule);
+ cancellationToken.ThrowIfCancellationRequested();
+
for (var attempt = 0; attempt < MaxConcurrencyAttempts; attempt++)
{
- await using var connection = CreateConnection();
+ await using var scope = contextScope.GetScope(out var connection);
+
var existing = await Recurring(connection).SingleOrDefaultAsync(item => item.Name == schedule.Name, cancellationToken)
.ConfigureAwait(false);
if (existing is null)
@@ -953,8 +974,10 @@ public async ValueTask RemoveObsoleteCodeDefinedRecurringAsync(
CancellationToken cancellationToken = default
)
{
- ArgumentNullException.ThrowIfNull(activeScheduleNames);
- await using var connection = CreateConnection();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await using var scope = contextScope.GetScope(out var connection);
+
var schedules = Recurring(connection).Where(schedule => schedule.IsCodeDefined);
if (activeScheduleNames.Count != 0)
schedules = schedules.Where(schedule => !activeScheduleNames.Contains(schedule.Name));
@@ -964,8 +987,10 @@ public async ValueTask RemoveObsoleteCodeDefinedRecurringAsync(
///
public async ValueTask RemoveRecurringAsync(string name, CancellationToken cancellationToken = default)
{
- ArgumentException.ThrowIfNullOrWhiteSpace(name);
- await using var connection = CreateConnection();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await using var scope = contextScope.GetScope(out var connection);
+
var removed = await Recurring(connection)
.Where(schedule => schedule.Name == name && !schedule.IsCodeDefined)
.DeleteAsync(cancellationToken)
@@ -978,17 +1003,27 @@ public async ValueTask RemoveRecurringAsync(string name, CancellationToken cance
}
///
- public ValueTask PauseRecurringAsync(string name, CancellationToken cancellationToken = default) =>
- SetRecurringPausedAsync(name, paused: true, cancellationToken);
+ public async ValueTask PauseRecurringAsync(string name, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await SetRecurringPausedAsync(name, paused: true, cancellationToken);
+ }
///
- public ValueTask ResumeRecurringAsync(string name, CancellationToken cancellationToken = default) =>
- SetRecurringPausedAsync(name, paused: false, cancellationToken);
+ public async ValueTask ResumeRecurringAsync(string name, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await SetRecurringPausedAsync(name, paused: false, cancellationToken).ConfigureAwait(false);
+ }
private async ValueTask SetRecurringPausedAsync(string name, bool paused, CancellationToken cancellationToken)
{
- ArgumentException.ThrowIfNullOrWhiteSpace(name);
- await using var connection = CreateConnection();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await using var scope = contextScope.GetScope(out var connection);
+
var updated = await Recurring(connection)
.Where(schedule => schedule.Name == name)
.Set(schedule => schedule.IsPaused, paused)
@@ -1006,8 +1041,10 @@ public async ValueTask> GetDueRecurringAsync
CancellationToken cancellationToken = default
)
{
- ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(batchSize, 0);
- await using var connection = CreateConnection();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await using var scope = contextScope.GetScope(out var connection);
+
var schedules = await Recurring(connection)
.Where(schedule => !schedule.IsPaused && schedule.NextRunAt <= now.UtcTicks)
.OrderBy(schedule => schedule.NextRunAt)
@@ -1025,9 +1062,10 @@ public async ValueTask MaterializeRecurringAsync(
CancellationToken cancellationToken = default
)
{
- ArgumentNullException.ThrowIfNull(schedule);
- ArgumentNullException.ThrowIfNull(job);
- await using var connection = CreateConnection();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await using var scope = contextScope.GetScope(out var connection);
+
_ = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
try
{
@@ -1073,7 +1111,8 @@ private async ValueTask AdvanceRecurringAfterDedupeAsync(
CancellationToken cancellationToken
)
{
- await using var connection = CreateConnection();
+ await using var scope = contextScope.GetScope(out var connection);
+
if (!await Jobs(connection)
.AnyAsync(job => job.RecurringKey == recurringKey, cancellationToken)
.ConfigureAwait(false))
@@ -1098,7 +1137,10 @@ public async ValueTask GetMonitoringSnapshotAsync(
CancellationToken cancellationToken = default
)
{
- await using var connection = CreateConnection();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await using var scope = contextScope.GetScope(out var connection);
+
var rawCounts = await Jobs(connection)
.GroupBy(job => job.State)
.Select(group => new { State = group.Key, Count = group.LongCount() })
@@ -1112,7 +1154,7 @@ public async ValueTask GetMonitoringSnapshotAsync(
.OrderBy(schedule => schedule.Name)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
- var cutoff = (_timeProvider.GetUtcNow() - TimeSpan.FromMinutes(2)).UtcTicks;
+ var cutoff = (timeProvider.GetUtcNow() - TimeSpan.FromMinutes(2)).UtcTicks;
var serverEntities = await Servers(connection)
.Where(server => server.LastHeartbeat >= cutoff)
.OrderBy(server => server.WorkerId)
@@ -1120,7 +1162,7 @@ public async ValueTask GetMonitoringSnapshotAsync(
.ConfigureAwait(false);
return new JobMonitoringSnapshot
{
- CapturedAt = _timeProvider.GetUtcNow(),
+ CapturedAt = timeProvider.GetUtcNow(),
Counts = counts,
Recurring = [.. recurringEntities.Select(ToRecord)],
Servers = [.. serverEntities.Select(server => new JobServerSnapshot
@@ -1140,7 +1182,10 @@ public async ValueTask> QueryJobsAsync(
CancellationToken cancellationToken = default
)
{
- await using var connection = CreateConnection();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await using var scope = contextScope.GetScope(out var connection);
+
IQueryable jobs = Jobs(connection);
if (query.Id is { } id)
jobs = jobs.Where(job => job.Id == id);
@@ -1173,7 +1218,10 @@ public async ValueTask> QueryJobExecutionsAsyn
CancellationToken cancellationToken = default
)
{
- await using var connection = CreateConnection();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await using var scope = contextScope.GetScope(out var connection);
+
var job = await Jobs(connection)
.SingleOrDefaultAsync(item => item.Id == query.JobId, cancellationToken)
.ConfigureAwait(false);
@@ -1225,8 +1273,10 @@ public async ValueTask> QueryJobExecutionsAsyn
CancellationToken cancellationToken = default
)
{
- ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
- await using var connection = CreateConnection();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await using var scope = contextScope.GetScope(out var connection);
+
var batch = await Batches(connection).SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
.ConfigureAwait(false);
return batch is null ? null : ToStatus(batch);
@@ -1238,7 +1288,10 @@ public async ValueTask> QueryBatchesAsync(
CancellationToken cancellationToken = default
)
{
- await using var connection = CreateConnection();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await using var scope = contextScope.GetScope(out var connection);
+
IQueryable batches = Batches(connection);
if (query.State is { } state)
batches = batches.Where(batch => batch.State == state);
@@ -1258,7 +1311,10 @@ public async ValueTask> QueryBatchMembersAsync(
CancellationToken cancellationToken = default
)
{
- await using var connection = CreateConnection();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await using var scope = contextScope.GetScope(out var connection);
+
var jobs = Jobs(connection).Where(job => job.BatchId == batchId);
if (query.State is { } state)
jobs = jobs.Where(job => job.State == state);
@@ -1287,8 +1343,10 @@ public async ValueTask> QueryBatchMembersAsync(
CancellationToken cancellationToken = default
)
{
- ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
- await using var connection = CreateConnection();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await using var scope = contextScope.GetScope(out var connection);
+
if (!await Batches(connection).AnyAsync(batch => batch.Id == batchId, cancellationToken).ConfigureAwait(false))
return null;
var entities = await Jobs(connection)
@@ -1317,7 +1375,10 @@ public async ValueTask> QueryBatchMembersAsync(
CancellationToken cancellationToken = default
)
{
- await using var connection = CreateConnection();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await using var scope = contextScope.GetScope(out var connection);
+
var job = await Jobs(connection).SingleOrDefaultAsync(item => item.Id == jobId, cancellationToken)
.ConfigureAwait(false);
if (job is null)
@@ -1348,7 +1409,8 @@ public async ValueTask> QueryBatchMembersAsync(
///
public async ValueTask CancelBatchAsync(string batchId, CancellationToken cancellationToken = default)
{
- ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
+ cancellationToken.ThrowIfCancellationRequested();
+
var terminalGroups = new HashSet<(string QueueName, string GroupId)>();
await RetryConcurrencyAsync(
connection => CancelBatchCoreAsync(
@@ -1369,7 +1431,7 @@ private async Task CancelBatchCoreAsync(
CancellationToken cancellationToken
)
{
- var now = _timeProvider.GetUtcNow().UtcTicks;
+ var now = timeProvider.GetUtcNow().UtcTicks;
var batch = await Batches(connection).SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
.ConfigureAwait(false)
?? throw new KeyNotFoundException($"Batch '{batchId}' was not found.");
@@ -1423,8 +1485,10 @@ await PropagateTerminalAsync(
///
public async ValueTask DeleteBatchAsync(string batchId, CancellationToken cancellationToken = default)
{
- ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
- await using var connection = CreateConnection();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await using var scope = contextScope.GetScope(out var connection);
+
_ = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
try
{
@@ -1459,7 +1523,8 @@ public async ValueTask DeleteBatchAsync(string batchId, CancellationToken cancel
///
public async ValueTask CancelAsync(string jobId, CancellationToken cancellationToken = default)
{
- ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
+ cancellationToken.ThrowIfCancellationRequested();
+
var terminalGroups = new HashSet<(string QueueName, string GroupId)>();
await RetryConcurrencyAsync(
connection => CancelCoreAsync(connection, jobId, terminalGroups, cancellationToken),
@@ -1481,7 +1546,7 @@ CancellationToken cancellationToken
if (IsTerminal(job.State))
throw new ImmediateJobException("Only a non-terminal job can be cancelled.");
- var now = _timeProvider.GetUtcNow().UtcTicks;
+ var now = timeProvider.GetUtcNow().UtcTicks;
if (job.State == JobState.Active)
{
_ = await GetOrMaterializeExecutionAsync(connection, job, cancellationToken).ConfigureAwait(false)
@@ -1507,10 +1572,11 @@ CancellationToken cancellationToken
}
///
- public ValueTask RetryAsync(string jobId, CancellationToken cancellationToken = default)
+ public async ValueTask RetryAsync(string jobId, CancellationToken cancellationToken = default)
{
- ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
- return RetryConcurrencyAsync(
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await RetryConcurrencyAsync(
connection => RetryCoreAsync(connection, jobId, cancellationToken),
cancellationToken
);
@@ -1546,7 +1612,7 @@ private async Task RetryCoreAsync(DataConnection connection, string jobId, Cance
var oldStamp = job.ConcurrencyStamp;
job.State = JobState.Pending;
- job.DueAt = _timeProvider.GetUtcNow().UtcTicks;
+ job.DueAt = timeProvider.GetUtcNow().UtcTicks;
job.WorkerId = null;
job.LeaseExpiresAt = null;
if (wasFailed)
@@ -1563,8 +1629,10 @@ private async Task RetryCoreAsync(DataConnection connection, string jobId, Cance
///
public async ValueTask DeleteAsync(string jobId, CancellationToken cancellationToken = default)
{
- ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
- await using var connection = CreateConnection();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await using var scope = contextScope.GetScope(out var connection);
+
_ = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
try
{
@@ -1615,8 +1683,11 @@ public async ValueTask PurgeJobsAsync(
CancellationToken cancellationToken = default
)
{
- var now = _timeProvider.GetUtcNow();
- await using var connection = CreateConnection();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await using var scope = contextScope.GetScope(out var connection);
+
+ var now = timeProvider.GetUtcNow();
_ = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
try
{
@@ -1667,9 +1738,13 @@ public async ValueTask PurgeBatchesAsync(
CancellationToken cancellationToken = default
)
{
- var now = _timeProvider.GetUtcNow();
- await using var connection = CreateConnection();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await using var scope = contextScope.GetScope(out var connection);
+
+ var now = timeProvider.GetUtcNow();
_ = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
+
try
{
var batchIds = await Batches(connection)
@@ -1716,9 +1791,11 @@ public async ValueTask PurgeBatchesAsync(
///
public async ValueTask HeartbeatAsync(JobServerSnapshot server, CancellationToken cancellationToken = default)
{
- ArgumentNullException.ThrowIfNull(server);
- await using var connection = CreateConnection();
- var cutoff = (_timeProvider.GetUtcNow() - TimeSpan.FromMinutes(2)).UtcTicks;
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await using var scope = contextScope.GetScope(out var connection);
+
+ var cutoff = (timeProvider.GetUtcNow() - TimeSpan.FromMinutes(2)).UtcTicks;
_ = await Servers(connection)
.Where(entity => entity.LastHeartbeat < cutoff)
.DeleteAsync(cancellationToken)
@@ -1757,9 +1834,12 @@ public async ValueTask HeartbeatAsync(JobServerSnapshot server, CancellationToke
///
public async ValueTask IsHealthyAsync(CancellationToken cancellationToken = default)
{
+ cancellationToken.ThrowIfCancellationRequested();
+
try
{
- await using var connection = CreateConnection();
+ await using var scope = contextScope.GetScope(out var connection);
+
_ = await connection.ExecuteAsync("SELECT 1", cancellationToken).ConfigureAwait(false);
return true;
}
@@ -1808,7 +1888,8 @@ private async Task CleanupFairQueueGroupsAsync(
{
try
{
- await using var connection = CreateConnection();
+ await using var scope = contextScope.GetScope(out var connection);
+
if (await Jobs(connection)
.AnyAsync(
item => item.QueueName == queueName
@@ -1857,7 +1938,7 @@ CancellationToken cancellationToken
)
.ConfigureAwait(false) ?? throw new ImmediateJobException($"Worker '{workerId}' does not own active job '{jobId}'.");
var oldStamp = job.ConcurrencyStamp;
- var now = _timeProvider.GetUtcNow().UtcTicks;
+ var now = timeProvider.GetUtcNow().UtcTicks;
_ = await GetOrMaterializeExecutionAsync(connection, job, cancellationToken).ConfigureAwait(false)
?? throw new ImmediateJobException($"Active job '{job.Id}' has no execution ordinal.");
var executionUpdated = await Executions(connection)
@@ -2401,7 +2482,8 @@ private async ValueTask RetryConcurrencyAsync(
var concurrencyAttempt = 0;
while (true)
{
- await using var connection = CreateConnection();
+ await using var scope = contextScope.GetScope(out var connection);
+
_ = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
try
{
@@ -2463,7 +2545,8 @@ CancellationToken cancellationToken
{
try
{
- await using var connection = CreateConnection();
+ await using var scope = contextScope.GetScope(out var connection);
+
return await Executions(connection)
.AnyAsync(
execution => execution.JobId == jobId && execution.Attempt == attempt,
@@ -2582,8 +2665,6 @@ CancellationToken cancellationToken
return updated != 0;
}
- private DataConnection CreateConnection() => new(_dataOptions);
-
private ITable Jobs(DataConnection connection) =>
WithSchema(connection.GetTable());
@@ -2605,11 +2686,11 @@ private ITable Recurring(DataConnection connection)
private ITable Servers(DataConnection connection) =>
WithSchema(connection.GetTable());
- private ITable WithSchema(ITable table)
- where T : notnull => _schema is null ? table : table.SchemaName(_schema);
+ private ITable WithSchema(ITable table)
+ where TTable : notnull => _schema is null ? table : table.SchemaName(_schema);
- private Task InsertAsync(DataConnection connection, T entity, CancellationToken cancellationToken)
- where T : notnull => connection.InsertAsync(entity, schemaName: _schema, token: cancellationToken);
+ private Task InsertAsync(DataConnection connection, TTable entity, CancellationToken cancellationToken)
+ where TTable : notnull => connection.InsertAsync(entity, schemaName: _schema, token: cancellationToken);
private static bool IsTerminal(JobState state) =>
state is JobState.Succeeded or JobState.Failed or JobState.Cancelled or JobState.Skipped;
diff --git a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorageOptions.cs b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorageOptions.cs
new file mode 100644
index 0000000..0a0464e
--- /dev/null
+++ b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorageOptions.cs
@@ -0,0 +1,10 @@
+using Immediate.Validations.Shared;
+
+namespace Immediate.Jobs.LinqToDB;
+
+[Validate]
+internal sealed partial class LinqToDBJobStorageOptions : IValidationTarget
+{
+ [NotEmpty]
+ public string? Schema { get; set; }
+}
diff --git a/src/Immediate.Jobs.LinqToDB/LinqToDBSchemaExtensions.cs b/src/Immediate.Jobs.LinqToDB/LinqToDBSchemaExtensions.cs
index 37c17fa..6e31773 100644
--- a/src/Immediate.Jobs.LinqToDB/LinqToDBSchemaExtensions.cs
+++ b/src/Immediate.Jobs.LinqToDB/LinqToDBSchemaExtensions.cs
@@ -8,72 +8,80 @@ public static class LinqToDBSchemaExtensions
{
/// Creates the Immediate.Jobs tables and indexes when they do not already exist.
/// This helper bootstraps fresh storage only; it does not perform production schema upgrades.
- /// The LinqToDB connection options used to create the schema.
+ /// A LinqToDB Data Connection.
/// The database schema to create objects in, or for the provider default.
/// A token that can cancel the operation.
/// A task that represents the asynchronous schema creation operation.
- public static async Task CreateImmediateJobsSchemaAsync(
- this DataOptions dataOptions,
+ public static async Task CreateImmediateJobsSchemaAsync(
+ this TContext context,
string? schema = null,
CancellationToken cancellationToken = default
- )
+ ) where TContext : DataConnection
{
- ArgumentNullException.ThrowIfNull(dataOptions);
+ ArgumentNullException.ThrowIfNull(context);
ValidateSchema(schema);
- await using var connection = new DataConnection(dataOptions);
- var provider = connection.DataProvider.Name;
+
+ var provider = context.DataProvider.Name;
if (schema is not null && provider.Contains("SQLite", StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("SQLite does not support named schemas.", nameof(schema));
if (provider.Contains("SQLite", StringComparison.OrdinalIgnoreCase))
{
- _ = await connection.ExecuteAsync(SqliteSchema, cancellationToken).ConfigureAwait(false);
- await CreateIndexesAsync(connection, provider, schema, cancellationToken).ConfigureAwait(false);
+ _ = await context.ExecuteAsync(SqliteSchema, cancellationToken).ConfigureAwait(false);
+ await CreateIndexesAsync(context, provider, schema, cancellationToken).ConfigureAwait(false);
return;
}
if (schema is not null)
- _ = await CreateSchemaAsync(connection, provider, schema, cancellationToken).ConfigureAwait(false);
+ _ = await CreateSchemaAsync(context, provider, schema, cancellationToken).ConfigureAwait(false);
const TableOptions CreateIfMissing = TableOptions.CreateIfNotExists;
- _ = await connection.CreateTableAsync(
+ _ = await context.CreateTableAsync(
schemaName: schema,
tableOptions: CreateIfMissing,
token: cancellationToken
).ConfigureAwait(false);
- _ = await connection.CreateTableAsync(
+
+ _ = await context.CreateTableAsync(
schemaName: schema,
tableOptions: CreateIfMissing,
token: cancellationToken
).ConfigureAwait(false);
- _ = await connection.CreateTableAsync(
+
+ _ = await context.CreateTableAsync(
schemaName: schema,
tableOptions: CreateIfMissing,
token: cancellationToken
).ConfigureAwait(false);
- _ = await connection.CreateTableAsync(
+
+ _ = await context.CreateTableAsync(
schemaName: schema,
tableOptions: CreateIfMissing,
token: cancellationToken
).ConfigureAwait(false);
- _ = await connection.CreateTableAsync(
+
+ _ = await context.CreateTableAsync(
schemaName: schema,
tableOptions: CreateIfMissing,
token: cancellationToken
).ConfigureAwait(false);
- _ = await connection.CreateTableAsync(
+
+ _ = await context.CreateTableAsync(
schemaName: schema,
tableOptions: CreateIfMissing,
token: cancellationToken
).ConfigureAwait(false);
- _ = await connection.CreateTableAsync(
+
+ _ = await context.CreateTableAsync(
schemaName: schema,
tableOptions: CreateIfMissing,
token: cancellationToken
).ConfigureAwait(false);
- _ = await CreateConstraintsAndDefaultsAsync(connection, provider, schema, cancellationToken)
+
+ _ = await CreateConstraintsAndDefaultsAsync(context, provider, schema, cancellationToken)
.ConfigureAwait(false);
- await CreateIndexesAsync(connection, provider, schema, cancellationToken).ConfigureAwait(false);
+
+ await CreateIndexesAsync(context, provider, schema, cancellationToken).ConfigureAwait(false);
}
private const string SqliteSchema = """
diff --git a/src/Immediate.Jobs.LinqToDB/LinqToDBServiceCollectionExtensions.cs b/src/Immediate.Jobs.LinqToDB/LinqToDBServiceCollectionExtensions.cs
index f861d31..f800248 100644
--- a/src/Immediate.Jobs.LinqToDB/LinqToDBServiceCollectionExtensions.cs
+++ b/src/Immediate.Jobs.LinqToDB/LinqToDBServiceCollectionExtensions.cs
@@ -1,4 +1,6 @@
+using Immediate.Validations.Shared;
using LinqToDB;
+using LinqToDB.Data;
using Microsoft.Extensions.DependencyInjection;
namespace Immediate.Jobs.LinqToDB;
@@ -16,31 +18,36 @@ public static class LinqToDBServiceCollectionExtensions
///
/// The Immediate.Jobs storage options builder to configure.
///
- ///
- /// The immutable LinqToDB connection options.
- ///
///
/// The database schema containing the Immediate.Jobs tables, or for the provider default.
///
///
/// The configured Immediate.Jobs options.
///
- public static ImmediateJobsStorageBuilder UseLinqToDB(
- this ImmediateJobsStorageBuilder builder,
- DataOptions dataOptions,
+ public static IImmediateJobsStorageBuilder UseLinqToDB(
+ this IImmediateJobsStorageBuilder builder,
string? schema = null
- )
+ ) where T : DataConnection
{
ArgumentNullException.ThrowIfNull(builder);
- ArgumentNullException.ThrowIfNull(dataOptions);
- return builder.UseStorage(
- services =>
- new LinqToDBJobStorage(
- dataOptions,
- schema,
- services.GetService()
- )
- );
+ builder.Services.AddSingleton>();
+
+ var optionsBuilder = builder.Services
+ .AddOptionsWithValidateOnStart()
+ .Validate(
+ o =>
+ {
+ ValidationException.ThrowIfInvalid(o, $@"Validation error for ""{nameof(LinqToDBJobStorageOptions)}""");
+ return true;
+ }
+ );
+
+ if (!string.IsNullOrWhiteSpace(schema))
+ optionsBuilder.Configure(o => o.Schema = schema);
+
+ builder.UseStorage>();
+
+ return builder;
}
}
diff --git a/src/Immediate.Jobs.LinqToDB/Owned.cs b/src/Immediate.Jobs.LinqToDB/Owned.cs
new file mode 100644
index 0000000..6a9d8d2
--- /dev/null
+++ b/src/Immediate.Jobs.LinqToDB/Owned.cs
@@ -0,0 +1,82 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Immediate.Jobs.LinqToDB;
+
+///
+/// Represents a container for a scope and a scoped service that is rooted by the scope.
+///
+///
+/// The type of the service contained by the scope.
+///
+internal sealed class OwnedScope : IAsyncDisposable
+{
+ internal OwnedScope(
+ T service,
+ IAsyncDisposable disposable
+ )
+ {
+ Service = service;
+ _disposable = disposable;
+ }
+
+ ///
+ /// The instance of the service contained by the scope.
+ ///
+ public T Service { get; }
+
+ private readonly IAsyncDisposable _disposable;
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ await _disposable.DisposeAsync().ConfigureAwait(false);
+ }
+}
+
+///
+/// A factory for creating a scope containing a strong-type service as it's root.
+///
+///
+/// The type of the service that should be created at the root of the scope.
+///
+///
+/// A used to create the scope for the service.
+///
+internal sealed class Owned(
+ IServiceScopeFactory serviceScopeFactory
+) where T : class
+{
+ ///
+ /// Creates a temporary scope and gets an instance of the service from that scope.
+ ///
+ ///
+ /// An containing both the scope and the service, so that the scope can be disposed
+ /// at the appropriate time.
+ ///
+ public OwnedScope GetScope() => GetScope(out _);
+
+ ///
+ /// Creates a temporary scope and gets an instance of the service from that scope.
+ ///
+ ///
+ /// The instance of the service created from the scope.
+ ///
+ ///
+ /// An containing both the scope and the service, so that the scope can be disposed
+ /// at the appropriate time.
+ ///
+ public OwnedScope GetScope(out T service)
+ {
+ var scope = serviceScopeFactory.CreateAsyncScope();
+ try
+ {
+ service = scope.ServiceProvider.GetRequiredService();
+ return new(service, scope);
+ }
+ catch
+ {
+ scope.Dispose();
+ throw;
+ }
+ }
+}
diff --git a/src/Immediate.Jobs.Redis/Immediate.Jobs.Redis.csproj b/src/Immediate.Jobs.Redis/Immediate.Jobs.Redis.csproj
index 07d06fb..dde411f 100644
--- a/src/Immediate.Jobs.Redis/Immediate.Jobs.Redis.csproj
+++ b/src/Immediate.Jobs.Redis/Immediate.Jobs.Redis.csproj
@@ -21,7 +21,6 @@
-
diff --git a/src/Immediate.Jobs.Redis/ImmediateJobsRedisBuilder.cs b/src/Immediate.Jobs.Redis/ImmediateJobsRedisBuilder.cs
new file mode 100644
index 0000000..8ed6610
--- /dev/null
+++ b/src/Immediate.Jobs.Redis/ImmediateJobsRedisBuilder.cs
@@ -0,0 +1,81 @@
+using System.Diagnostics.CodeAnalysis;
+using Immediate.Jobs.Shared.Storage;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+namespace Immediate.Jobs.Redis;
+
+///
+/// The fluent registration result returned by .
+///
+public interface IImmediateJobsRedisBuilder : IImmediateJobsStorageBuilder
+{
+ ///
+ /// Provides an extension point to configure the options using a user provided configuration method.
+ ///
+ ///
+ /// The configuration method used to set the options.
+ ///
+ ///
+ /// The supplied builder.
+ ///
+ IImmediateJobsRedisBuilder ConfigureRedis(
+ Action> configureRedis
+ );
+ ///
+ /// Provides an extension point to configure the options using a user provided configuration method.
+ ///
+ ///
+ /// The configuration method used to set the options.
+ ///
+ ///
+ /// The supplied builder.
+ ///
+ IImmediateJobsRedisBuilder ConfigureRedis(
+ Action configureRedis
+ );
+}
+
+internal sealed class ImmediateJobsRedisBuilder(IImmediateJobsStorageBuilder builder, OptionsBuilder optionsBuilder) : IImmediateJobsRedisBuilder
+{
+ public IImmediateJobsRedisBuilder ConfigureRedis(Action> configureRedis)
+ {
+ ArgumentNullException.ThrowIfNull(configureRedis);
+
+ configureRedis(optionsBuilder);
+ return this;
+ }
+
+ public IImmediateJobsRedisBuilder ConfigureRedis(Action configureRedis)
+ {
+ ArgumentNullException.ThrowIfNull(configureRedis);
+
+ optionsBuilder.Configure(configureRedis);
+ return this;
+ }
+
+ public IServiceCollection Services => builder.Services;
+
+ public IImmediateJobsStorageBuilder UseDistributed() =>
+ builder.UseDistributed();
+
+ public IImmediateJobsStorageBuilder UseDistributed(Func durableStorageFactory) =>
+ builder.UseDistributed(durableStorageFactory);
+
+ public IImmediateJobsStorageBuilder UseInMemory() =>
+ builder.UseInMemory();
+
+ public IImmediateJobsStorageBuilder UseSingleServer() =>
+ builder.UseSingleServer();
+
+ public IImmediateJobsStorageBuilder UseSingleServer(Func durableStorageFactory) =>
+ builder.UseSingleServer(durableStorageFactory);
+
+ public IImmediateJobsStorageBuilder UseStorage(Func factory) =>
+ builder.UseStorage(factory);
+
+ IImmediateJobsStorageBuilder IImmediateJobsStorageBuilder.UseStorage<
+ [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TJobStorage
+ >() =>
+ builder.UseStorage();
+}
diff --git a/src/Immediate.Jobs.Redis/RedisJobStorage.cs b/src/Immediate.Jobs.Redis/RedisJobStorage.cs
index 374965d..deb08f1 100644
--- a/src/Immediate.Jobs.Redis/RedisJobStorage.cs
+++ b/src/Immediate.Jobs.Redis/RedisJobStorage.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text.Json;
using Immediate.Jobs.Shared.Apis;
@@ -11,7 +12,11 @@ namespace Immediate.Jobs.Redis;
/// Distributed Redis storage for ordinary queue jobs and recurring schedules.
/// Batches and continuations require a graph-capable SQL provider.
///
-internal sealed class RedisJobStorage : IRecurringJobStorage, IDisposable
+internal sealed class RedisJobStorage(
+ IConnectionMultiplexer connection,
+ IOptions options,
+ TimeProvider timeProvider
+) : IRecurringJobStorage
{
private const int QueryWindowSize = 256;
private const int MaximumQueryTake = 1000;
@@ -45,54 +50,19 @@ internal sealed class RedisJobStorage : IRecurringJobStorage, IDisposable
"synthetic",
];
- private readonly IConnectionMultiplexer _connection;
- private readonly IDatabase _database;
- private readonly TimeProvider _timeProvider;
- private readonly string _root;
- private readonly bool _ownsConnection;
- private readonly Lock _disposeGate = new();
- private Task? _disposeTask;
-
- /// Creates storage over an existing Redis connection.
- /// The application-owned Redis connection.
- /// The Redis storage options, or to use defaults.
- /// The clock used for storage timestamps, or to use the system clock.
- public RedisJobStorage(
- IConnectionMultiplexer connection,
- RedisJobStorageOptions? options = null,
- TimeProvider? timeProvider = null
- ) : this(connection, Options.Create(options ?? new()), timeProvider, ownsConnection: false)
- {
- }
+ [SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", Justification = "Owned by DI")]
+ private readonly IConnectionMultiplexer _connection = connection;
- internal RedisJobStorage(
- IConnectionMultiplexer connection,
- IOptions options,
- TimeProvider? timeProvider,
- bool ownsConnection
- )
- {
-#pragma warning disable MA0015 // Specify the parameter name in ArgumentException
- ArgumentNullException.ThrowIfNull(connection);
- ArgumentNullException.ThrowIfNull(options);
- var storageOptions = options.Value;
- ArgumentException.ThrowIfNullOrWhiteSpace(storageOptions.KeyPrefix);
-#pragma warning restore MA0015 // Specify the parameter name in ArgumentException
-
- if (storageOptions.KeyPrefix.IndexOfAny(['{', '}']) >= 0)
- throw new ArgumentException("The Redis key prefix cannot contain '{' or '}'.", nameof(options));
-
- _connection = connection;
- _database = connection.GetDatabase(storageOptions.Database);
- _timeProvider = timeProvider ?? TimeProvider.System;
- _root = $"{{{storageOptions.KeyPrefix}}}:";
- _ownsConnection = ownsConnection;
- }
+ private readonly RedisJobStorageOptions _storageOptions = options.Value;
+ private readonly TimeProvider _timeProvider = timeProvider;
+ private readonly string _root = $"{{{options.Value.KeyPrefix}}}:";
+
+ private IDatabase Database => _connection.GetDatabase(_storageOptions.Database);
///
public async ValueTask InitializeAsync(CancellationToken cancellationToken = default)
{
- _ = await _database.PingAsync().WaitAsync(cancellationToken).ConfigureAwait(false);
+ _ = await Database.PingAsync().WaitAsync(cancellationToken).ConfigureAwait(false);
}
///
@@ -155,7 +125,7 @@ public async ValueTask> AcquireDueJobsAsync(
}
}
- var result = await _database.ScriptEvaluateAsync(
+ var result = await Database.ScriptEvaluateAsync(
RedisScripts.Acquire,
[.. keys],
[.. values]
@@ -285,7 +255,7 @@ public async ValueTask GetMonitoringSnapshotAsync(
{
var states = Enum.GetValues();
var countTasks = states
- .Select(state => _database.SetLengthAsync(StateKey(state)))
+ .Select(state => Database.SetLengthAsync(StateKey(state)))
.ToArray();
var recurringTask = ReadAllRecurringAsync(cancellationToken);
var serversTask = ReadLiveServersAsync(cancellationToken);
@@ -365,7 +335,7 @@ public async ValueTask> QueryJobExecutionsAsyn
var synthetic = JobExecutionRecord.CreateSynthetic(job);
var syntheticMissing = synthetic is not null
&& (query.Attempt is null || query.Attempt == synthetic.Attempt)
- && !await _database.HashExistsAsync(
+ && !await Database.HashExistsAsync(
ExecutionDataKey(query.JobId),
ExecutionField(synthetic.Attempt, "state")
).WaitAsync(cancellationToken).ConfigureAwait(false);
@@ -388,7 +358,7 @@ public async ValueTask> QueryJobExecutionsAsyn
RedisValue[] attempts;
if (query.Attempt is { } attempt)
{
- var exists = await _database.HashExistsAsync(
+ var exists = await Database.HashExistsAsync(
ExecutionDataKey(query.JobId),
ExecutionField(attempt, "state")
).WaitAsync(cancellationToken).ConfigureAwait(false);
@@ -396,7 +366,7 @@ public async ValueTask> QueryJobExecutionsAsyn
}
else
{
- attempts = await _database.SortedSetRangeByRankAsync(
+ attempts = await Database.SortedSetRangeByRankAsync(
ExecutionIndexKey(query.JobId),
skip,
skip + take - 1,
@@ -535,7 +505,7 @@ public async ValueTask IsHealthyAsync(CancellationToken cancellationToken
{
try
{
- _ = await _database.PingAsync().WaitAsync(cancellationToken).ConfigureAwait(false);
+ _ = await Database.PingAsync().WaitAsync(cancellationToken).ConfigureAwait(false);
return true;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
@@ -624,7 +594,7 @@ public async ValueTask> GetDueRecurringAsync
CancellationToken cancellationToken = default
)
{
- var values = await _database.SortedSetRangeByScoreAsync(
+ var values = await Database.SortedSetRangeByScoreAsync(
RecurringDueKey,
stop: Score(now),
take: batchSize
@@ -655,7 +625,7 @@ public async ValueTask> GetDueRecurringAsync
if (stale.Count != 0)
{
- _ = await _database.SortedSetRemoveAsync(RecurringDueKey, [.. stale])
+ _ = await Database.SortedSetRemoveAsync(RecurringDueKey, [.. stale])
.WaitAsync(cancellationToken).ConfigureAwait(false);
}
@@ -693,29 +663,7 @@ public async ValueTask MaterializeRecurringAsync(
}
///
- public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
-
- ///
- public ValueTask DisposeAsync()
- {
- lock (_disposeGate)
- return new(_disposeTask ??= DisposeCoreAsync());
- }
-
- private async Task DisposeCoreAsync()
- {
- if (_ownsConnection)
- {
- try
- {
- await _connection.CloseAsync().ConfigureAwait(false);
- }
- finally
- {
- _connection.Dispose();
- }
- }
- }
+ public async ValueTask DisposeAsync() { }
private async ValueTask SetRecurringPausedAsync(
string name,
@@ -742,7 +690,7 @@ CancellationToken cancellationToken
{
while (true)
{
- var ids = await _database.SortedSetRangeByScoreAsync(
+ var ids = await Database.SortedSetRangeByScoreAsync(
CompletedKey(state),
stop: Score(cutoff),
exclude: Exclude.Stop,
@@ -774,19 +722,19 @@ CancellationToken cancellationToken
private async Task> ReadLiveServersAsync(CancellationToken cancellationToken)
{
var cutoff = _timeProvider.GetUtcNow() - TimeSpan.FromMinutes(2);
- var stale = await _database.SortedSetRangeByScoreAsync(
+ var stale = await Database.SortedSetRangeByScoreAsync(
ServersKey,
stop: Score(cutoff),
exclude: Exclude.Stop
).WaitAsync(cancellationToken).ConfigureAwait(false);
if (stale.Length != 0)
- _ = await _database.SortedSetRemoveAsync(ServersKey, stale).WaitAsync(cancellationToken).ConfigureAwait(false);
- var ids = await _database.SortedSetRangeByScoreAsync(
+ _ = await Database.SortedSetRemoveAsync(ServersKey, stale).WaitAsync(cancellationToken).ConfigureAwait(false);
+ var ids = await Database.SortedSetRangeByScoreAsync(
ServersKey,
start: Score(cutoff)
).WaitAsync(cancellationToken).ConfigureAwait(false);
var tasks = ids
- .Select(id => _database.HashGetAsync(ServerKey((string)id!), ["last", "active", "max"]))
+ .Select(id => Database.HashGetAsync(ServerKey((string)id!), ["last", "active", "max"]))
.ToArray();
_ = await Task.WhenAll(tasks).WaitAsync(cancellationToken).ConfigureAwait(false);
return
@@ -806,7 +754,7 @@ .. tasks
private async Task> ReadAllRecurringAsync(CancellationToken cancellationToken)
{
- var names = await _database.SetMembersAsync(RecurringNamesKey)
+ var names = await Database.SetMembersAsync(RecurringNamesKey)
.WaitAsync(cancellationToken)
.ConfigureAwait(false);
return await ReadRecurringAsync(
@@ -830,7 +778,7 @@ CancellationToken cancellationToken
CancellationToken cancellationToken
)
{
- var values = await _database.HashGetAsync(RecurringKey(name), RecurringMutableFields)
+ var values = await Database.HashGetAsync(RecurringKey(name), RecurringMutableFields)
.WaitAsync(cancellationToken)
.ConfigureAwait(false);
if (values[0].IsNull)
@@ -877,7 +825,7 @@ CancellationToken cancellationToken
}
}
- var allValues = await _database.HashGetAsync(ExecutionDataKey(jobId), fields)
+ var allValues = await Database.HashGetAsync(ExecutionDataKey(jobId), fields)
.WaitAsync(cancellationToken)
.ConfigureAwait(false);
@@ -912,7 +860,7 @@ private async Task> ReadJobIdsByRankAsync(
CancellationToken cancellationToken
)
{
- var values = await _database.SortedSetRangeByRankAsync(
+ var values = await Database.SortedSetRangeByRankAsync(
AllJobsKey,
start,
start + count - 1,
@@ -936,7 +884,7 @@ private static bool MatchesQuery(JobRecord job, JobQuery query) =>
private async ValueTask ReadJobAsync(string id, CancellationToken cancellationToken)
{
- var values = await _database.HashGetAsync(JobKey(id), JobMutableFields)
+ var values = await Database.HashGetAsync(JobKey(id), JobMutableFields)
.WaitAsync(cancellationToken)
.ConfigureAwait(false);
if (values[0].IsNull)
@@ -967,7 +915,7 @@ private async ValueTask EvaluateInt64Async(
CancellationToken cancellationToken
)
{
- var result = await _database.ScriptEvaluateAsync(script, keys, values)
+ var result = await Database.ScriptEvaluateAsync(script, keys, values)
.WaitAsync(cancellationToken)
.ConfigureAwait(false);
return (long)result;
diff --git a/src/Immediate.Jobs.Redis/RedisJobStorageOptions.cs b/src/Immediate.Jobs.Redis/RedisJobStorageOptions.cs
index 7496767..cb4d9cd 100644
--- a/src/Immediate.Jobs.Redis/RedisJobStorageOptions.cs
+++ b/src/Immediate.Jobs.Redis/RedisJobStorageOptions.cs
@@ -1,7 +1,10 @@
+using Immediate.Validations.Shared;
+
namespace Immediate.Jobs.Redis;
/// Configures Redis key placement for Immediate.Jobs.
-public sealed class RedisJobStorageOptions
+[Validate]
+public sealed partial class RedisJobStorageOptions : IValidationTarget
{
/// The logical Redis database. The server default is used when this is negative.
/// The zero-based logical database number, or a negative value to use the server default.
@@ -12,5 +15,20 @@ public sealed class RedisJobStorageOptions
/// Cluster hash tag that keeps each atomic Lua operation in one slot.
///
/// The prefix prepended to provider keys.
+ [NotEmpty]
public string KeyPrefix { get; set; } = "immediate-jobs";
+
+ private static void AdditionalValidations(ValidationResult errors, RedisJobStorageOptions target)
+ {
+ if (target.KeyPrefix.ContainsAny('{', '}'))
+ {
+ errors.Add(
+ new()
+ {
+ PropertyName = nameof(KeyPrefix),
+ ErrorMessage = "The Redis key prefix cannot contain '{' or '}'.",
+ }
+ );
+ }
+ }
}
diff --git a/src/Immediate.Jobs.Redis/RedisServiceCollectionExtensions.cs b/src/Immediate.Jobs.Redis/RedisServiceCollectionExtensions.cs
index ca645b9..407d492 100644
--- a/src/Immediate.Jobs.Redis/RedisServiceCollectionExtensions.cs
+++ b/src/Immediate.Jobs.Redis/RedisServiceCollectionExtensions.cs
@@ -1,6 +1,5 @@
+using Immediate.Validations.Shared;
using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Options;
-using StackExchange.Redis;
namespace Immediate.Jobs.Redis;
@@ -18,80 +17,29 @@ public static class RedisServiceCollectionExtensions
///
/// The Immediate.Jobs storage options builder to configure.
///
- ///
- /// The Redis configuration string.
- ///
- ///
- /// An optional callback that configures Redis key placement.
- ///
///
/// The configured Immediate.Jobs options.
///
- public static ImmediateJobsStorageBuilder UseRedis(
- this ImmediateJobsStorageBuilder builder,
- string configuration,
- Action? configure = null
+ public static IImmediateJobsRedisBuilder UseRedis(
+ this IImmediateJobsStorageBuilder builder
)
{
ArgumentNullException.ThrowIfNull(builder);
- ArgumentException.ThrowIfNullOrWhiteSpace(configuration);
- ConfigureOptions(builder, configure);
- return builder
- .UseStorage(services => new RedisJobStorage(
- ConnectionMultiplexer.Connect(configuration),
- services.GetRequiredService>(),
- services.GetService(),
- ownsConnection: true
- ))
- .UseDistributed();
- }
- /// Selects an application-owned Redis connection as the distributed job provider.
- ///
- /// Redis does not implement graph storage, so batches and continuations require a SQL provider.
- /// The supplied connection is not disposed by the job provider.
- ///
- /// The Immediate.Jobs options to configure.
- /// The application-owned Redis connection.
- /// An optional callback that configures Redis key placement.
- /// The configured Immediate.Jobs options.
- public static ImmediateJobsStorageBuilder UseRedis(
- this ImmediateJobsStorageBuilder jobs,
- IConnectionMultiplexer connection,
- Action? configure = null
- )
- {
- ArgumentNullException.ThrowIfNull(jobs);
- ArgumentNullException.ThrowIfNull(connection);
- ConfigureOptions(jobs, configure);
- return jobs
- .UseStorage(services => new RedisJobStorage(
- connection,
- services.GetRequiredService>(),
- services.GetService(),
- ownsConnection: false
- ))
+ builder
+ .UseStorage()
.UseDistributed();
- }
- private static void ConfigureOptions(
- ImmediateJobsStorageBuilder builder,
- Action? configure
- )
- {
- var optionsBuilder = builder.Services.AddOptions();
- if (configure is not null)
- optionsBuilder.Configure(configure);
-
- optionsBuilder
- .Validate(
- static options => !string.IsNullOrWhiteSpace(options.KeyPrefix),
- "The Redis key prefix cannot be empty."
- )
+ var optionsBuilder = builder.Services
+ .AddOptionsWithValidateOnStart()
.Validate(
- static options => options.KeyPrefix.IndexOfAny(['{', '}']) < 0,
- "The Redis key prefix cannot contain '{' or '}'."
- )
- .ValidateOnStart();
+ o =>
+ {
+ ValidationException.ThrowIfInvalid(o, $@"Validation error for ""{nameof(RedisJobStorageOptions)}""");
+ return true;
+ }
+ );
+
+ return new ImmediateJobsRedisBuilder(builder, optionsBuilder);
}
}
diff --git a/src/Immediate.Jobs.Shared/Immediate.Jobs.Shared.csproj b/src/Immediate.Jobs.Shared/Immediate.Jobs.Shared.csproj
index 628f7f6..e1e20fd 100644
--- a/src/Immediate.Jobs.Shared/Immediate.Jobs.Shared.csproj
+++ b/src/Immediate.Jobs.Shared/Immediate.Jobs.Shared.csproj
@@ -17,7 +17,7 @@
-
+
diff --git a/src/Immediate.Jobs.Shared/ImmediateJobsBuilder.cs b/src/Immediate.Jobs.Shared/ImmediateJobsBuilder.cs
index 6ad215b..dc3d850 100644
--- a/src/Immediate.Jobs.Shared/ImmediateJobsBuilder.cs
+++ b/src/Immediate.Jobs.Shared/ImmediateJobsBuilder.cs
@@ -1,8 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using Immediate.Jobs.Shared.Interfaces;
using Immediate.Jobs.Shared.Internals;
-using Immediate.Jobs.Shared.Storage;
-using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Diagnostics.HealthChecks;
@@ -13,40 +11,12 @@ namespace Immediate.Jobs.Shared;
///
/// The fluent registration result returned by generated AddImmediateJobs methods.
///
-public sealed class ImmediateJobsBuilder
+public interface IImmediateJobsBuilder
{
- internal ImmediateJobsBuilder(
- IServiceCollection services,
- OptionsBuilder optionsBuilder,
- OptionsBuilder fairQueueOptionsBuilder
- )
- {
- Services = services;
- OptionsBuilder = optionsBuilder;
- FairQueueOptionsBuilder = fairQueueOptionsBuilder;
- }
-
- internal IServiceCollection Services { get; }
- internal OptionsBuilder OptionsBuilder { get; }
- internal OptionsBuilder FairQueueOptionsBuilder { get; }
-
///
- /// Replaces the default GUID job and batch identifier generator.
+ /// The service collection being configured.
///
- ///
- /// The identifier generator implementation.
- ///
- ///
- /// The supplied builder.
- ///
- public ImmediateJobsBuilder UseIdGenerator<
- [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TGenerator
- >()
- where TGenerator : class, IIdGenerator
- {
- Services.Replace(ServiceDescriptor.Singleton());
- return this;
- }
+ IServiceCollection Services { get; }
///
/// Adds scheduler liveness and storage connectivity to the health-check system.
@@ -63,73 +33,37 @@ public ImmediateJobsBuilder UseIdGenerator<
///
/// The supplied builder.
///
- public ImmediateJobsBuilder AddHealthCheck(
- string name = "immediate-jobs",
- HealthStatus? failureStatus = null,
- IEnumerable? tags = null
- )
- {
- Services.AddHealthChecks().AddCheck(name, failureStatus, tags ?? []);
- return this;
- }
+ IImmediateJobsBuilder AddHealthCheck(string name = "immediate-jobs", HealthStatus? failureStatus = null, IEnumerable? tags = null);
///
- /// Registers the dependency injection container to bind against
- /// the obtained from the DI service provider.
+ /// Provides an extension point to configure the options using a user provided configuration method.
///
- ///
- /// The name of the configuration section to bind from.
+ ///
+ /// The configuration method used to set the options.
///
///
/// The supplied builder.
///
- public ImmediateJobsBuilder Configure(
- string configurationSectionPath
- )
- {
- ArgumentException.ThrowIfNullOrWhiteSpace(configurationSectionPath);
-
- OptionsBuilder.BindConfiguration(configurationSectionPath);
- return this;
- }
+ IImmediateJobsBuilder ConfigureWorkers(Action> configureJobs);
///
- /// Registers a configuration instance which will bind against.
+ /// Provides an extension point to configure the options using a user provided configuration method.
///
- ///
- /// The configuration being bound.
- ///
+ ///
+ /// The configuration method used to set the options.
+ ///
///
/// The supplied builder.
///
- public ImmediateJobsBuilder Configure(
- IConfiguration configurationSection
- )
- {
- ArgumentNullException.ThrowIfNull(configurationSection);
-
- OptionsBuilder.Bind(configurationSection);
- return this;
- }
+ IImmediateJobsBuilder ConfigureWorkers(Action configureJobs);
///
- /// Registers an action used to configure an .
+ /// Disables workers from running in this application.
///
- ///
- /// The action used to configure the options.
- ///
///
/// The supplied builder.
///
- public ImmediateJobsBuilder Configure(
- Action configureOptions
- )
- {
- ArgumentNullException.ThrowIfNull(configureOptions);
-
- OptionsBuilder.Configure(configureOptions);
- return this;
- }
+ IImmediateJobsBuilder DisableWorkers();
///
/// Enables Fair Queues.
@@ -137,78 +71,31 @@ Action configureOptions
///
/// The supplied builder.
///
- public ImmediateJobsBuilder UseFairQueues()
- {
- FairQueueOptionsBuilder.PostConfigure(o => o.Enabled = true);
- return this;
- }
-
- ///
- /// Enables Fair Queues and registers the dependency injection container to bind against
- /// the obtained from the DI service provider.
- ///
- ///
- /// The name of the configuration section to bind from.
- ///
- ///
- /// The supplied builder.
- ///
- public ImmediateJobsBuilder UseFairQueues(
- string configurationSectionPath
- )
- {
- ArgumentException.ThrowIfNullOrWhiteSpace(configurationSectionPath);
-
- FairQueueOptionsBuilder
- .BindConfiguration(configurationSectionPath)
- .PostConfigure(o => o.Enabled = true);
-
- return this;
- }
+ IImmediateJobsBuilder UseFairQueues();
///
- /// Enables Fair Queues and registers a configuration instance which will bind against.
+ /// Enables Fair Queues.
///
- ///
- /// The configuration being bound.
+ ///
+ /// A configuration method used to configure the fair options policy.
///
///
/// The supplied builder.
///
- public ImmediateJobsBuilder UseFairQueues(
- IConfiguration configurationSection
- )
- {
- ArgumentNullException.ThrowIfNull(configurationSection);
-
- FairQueueOptionsBuilder
- .Bind(configurationSection)
- .PostConfigure(o => o.Enabled = true);
-
- return this;
- }
+ IImmediateJobsBuilder UseFairQueues(Action> configureFairQueues);
///
- /// Enables Fair Queues and registers an action used to configure an .
+ /// Replaces the default GUID job and batch identifier generator.
///
- ///
- /// The action used to configure the options.
- ///
+ ///
+ /// The identifier generator implementation.
+ ///
///
/// The supplied builder.
///
- public ImmediateJobsBuilder UseFairQueues(
- Action configureOptions
- )
- {
- ArgumentNullException.ThrowIfNull(configureOptions);
-
- FairQueueOptionsBuilder
- .Configure(configureOptions)
- .PostConfigure(o => o.Enabled = true);
-
- return this;
- }
+ IImmediateJobsBuilder UseIdGenerator<
+ [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TGenerator
+ >() where TGenerator : class, IIdGenerator;
///
/// Configures the job storage used by Immediate.Jobs.
@@ -219,194 +106,108 @@ Action configureOptions
///
/// The supplied builder.
///
- public ImmediateJobsBuilder ConfigureStorage(
- Action configure
- )
- {
- ArgumentNullException.ThrowIfNull(configure);
-
- if (Services.Any(s => s.ServiceType == typeof(ImmediateJobsStorageBuilder)))
- ImmediateJobException.Throw("Cannot configure storage multiple times.");
-
- var builder = new ImmediateJobsStorageBuilder(Services);
- Services.AddSingleton(builder);
- configure(builder);
-
- builder.ValidateAndRegister(Services);
-
- return this;
- }
+ IImmediateJobsBuilder ConfigureStorage(Action configure);
}
-///
-/// The fluent registration object used to configure job storage.
-///
-public sealed class ImmediateJobsStorageBuilder
+internal sealed class ImmediateJobsBuilder : IImmediateJobsBuilder
{
- private enum JobStorageMode
- {
- None,
- InMemory,
- SingleServer,
- Distributed,
- }
-
- private JobStorageMode _storageMode;
- private Func? _factory;
-
- internal ImmediateJobsStorageBuilder(IServiceCollection services)
+ internal ImmediateJobsBuilder(
+ IServiceCollection services,
+ OptionsBuilder optionsBuilder,
+ OptionsBuilder storageOptionsBuilder,
+ OptionsBuilder fairQueueOptionsBuilder
+ )
{
Services = services;
+ OptionsBuilder = optionsBuilder;
+ StorageOptionsBuilder = storageOptionsBuilder;
+ FairQueueOptionsBuilder = fairQueueOptionsBuilder;
}
- ///
- /// The service collection being configured.
- ///
public IServiceCollection Services { get; }
- ///
- /// Selects the non-durable, single-node in-memory provider.
- ///
- ///
- /// This options instance.
- ///
- public ImmediateJobsStorageBuilder UseInMemory()
- {
- if (_storageMode is not (JobStorageMode.None or JobStorageMode.InMemory))
- ImmediateJobException.Throw("Cannot select in-memory job storage when other job storage options have been selected.");
+ internal OptionsBuilder OptionsBuilder { get; }
+ internal OptionsBuilder StorageOptionsBuilder { get; }
+ internal OptionsBuilder FairQueueOptionsBuilder { get; }
- _storageMode = JobStorageMode.InMemory;
+ public IImmediateJobsBuilder UseIdGenerator<
+ [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TGenerator
+ >() where TGenerator : class, IIdGenerator
+ {
+ Services.Replace(ServiceDescriptor.Singleton());
return this;
}
- ///
- /// Selects a durable storage provider. By default, it is used as a write-through replica of the
- /// authoritative in-process store for a single scheduler server.
- ///
- ///
- /// The factory that creates the durable storage provider.
- ///
- ///
- /// This options instance.
- ///
- public ImmediateJobsStorageBuilder UseStorage(Func factory)
+ public IImmediateJobsBuilder AddHealthCheck(
+ string name = "immediate-jobs",
+ HealthStatus? failureStatus = null,
+ IEnumerable? tags = null
+ )
{
- ArgumentNullException.ThrowIfNull(factory);
-
- if (_storageMode is JobStorageMode.InMemory)
- ImmediateJobException.Throw("Cannot provide a durable storage provider when in-memory job storage has already been selected.");
-
- if (_factory is { })
- ImmediateJobException.Throw("A durable storage provider has already been provided.");
-
- _factory = factory;
+ Services.AddHealthChecks().AddCheck(name, failureStatus, tags ?? []);
return this;
}
- ///
- /// Selects memory-primary, durable-replica operation for one scheduler server.
- ///
- ///
- /// This options instance.
- ///
- public ImmediateJobsStorageBuilder UseSingleServer()
+ public IImmediateJobsBuilder DisableWorkers()
{
- if (_storageMode is not (JobStorageMode.None or JobStorageMode.SingleServer))
- ImmediateJobException.Throw("Cannot select single-server operation mode when other job storage options have been selected.");
-
- if (_factory is null)
- ImmediateJobException.Throw("Cannot select single-server operation mode when no durable storage provider has been provided.");
-
- _storageMode = JobStorageMode.SingleServer;
+ OptionsBuilder.PostConfigure(o => o.IsJobSchedulingServiceEnabled = false);
return this;
}
- ///
- /// Selects memory-primary operation with the supplied durable replica.
- ///
- ///
- /// The factory that creates the durable storage replica.
- ///
- ///
- /// This options instance.
- ///
- public ImmediateJobsStorageBuilder UseSingleServer(Func durableStorageFactory)
+ public IImmediateJobsBuilder ConfigureWorkers(
+ Action> configureJobs
+ )
{
- UseStorage(durableStorageFactory);
- UseSingleServer();
+ ArgumentNullException.ThrowIfNull(configureJobs);
+
+ configureJobs(OptionsBuilder);
return this;
}
- ///
- /// Selects durable-storage-primary operation for multiple scheduler servers.
- ///
- ///
- /// This options instance.
- ///
- public ImmediateJobsStorageBuilder UseDistributed()
+ public IImmediateJobsBuilder ConfigureWorkers(
+ Action configureJobs
+ )
{
- if (_storageMode is not (JobStorageMode.None or JobStorageMode.Distributed))
- ImmediateJobException.Throw("Cannot select distributed operation mode when other job storage options have been selected.");
+ ArgumentNullException.ThrowIfNull(configureJobs);
- if (_factory is null)
- ImmediateJobException.Throw("Cannot select distributed operation mode when no durable storage provider has been provided.");
+ OptionsBuilder.Configure(configureJobs);
+ return this;
+ }
- _storageMode = JobStorageMode.Distributed;
+ public IImmediateJobsBuilder UseFairQueues()
+ {
+ FairQueueOptionsBuilder.Configure(o => o.Enabled = true);
return this;
}
- ///
- /// Selects durable-storage-primary operation for multiple scheduler servers.
- ///
- ///
- /// The factory that creates the durable storage replica.
- ///
- ///
- /// This options instance.
- ///
- public ImmediateJobsStorageBuilder UseDistributed(Func durableStorageFactory)
+ public IImmediateJobsBuilder UseFairQueues(
+ Action> configureFairQueues
+ )
{
- UseStorage(durableStorageFactory);
- UseDistributed();
+ ArgumentNullException.ThrowIfNull(configureFairQueues);
+
+ FairQueueOptionsBuilder.Configure(o => o.Enabled = true);
+ configureFairQueues(FairQueueOptionsBuilder);
return this;
}
- internal void ValidateAndRegister(IServiceCollection services)
+ public IImmediateJobsBuilder ConfigureStorage(
+ Action configure
+ )
{
- // explicit in-memory
- if (_storageMode is JobStorageMode.InMemory)
- {
- // error should be thrown earlier, but just in case...
- if (_factory is { })
- ImmediateJobException.Throw("Cannot provide a durable storage provider when in-memory job storage has already been selected.");
+ ArgumentNullException.ThrowIfNull(configure);
- return;
- }
+ if (Services.Any(s => s.ServiceType == typeof(ImmediateJobsStorageBuilder)))
+ ImmediateJobException.Throw("Cannot configure storage multiple times.");
- if (_factory is null)
- {
- // none, with no factory is base-state; aka in-memory
- if (_storageMode is JobStorageMode.None)
- return;
+ StorageOptionsBuilder.Configure(o => o.Configured = true);
- ImmediateJobException.Throw("Durable storage is required, but no durable storage provider has been provided.");
- }
+ var builder = new ImmediateJobsStorageBuilder(Services);
+ configure(builder);
+ builder.ValidateAndRegister();
- // only check for explicit distributed
- if (_storageMode is JobStorageMode.Distributed)
- {
- services.Replace(ServiceDescriptor.Singleton(_factory));
- return;
- }
+ Services.AddSingleton(builder);
- // none or explicit single-server are both single-server
- services.Replace(
- ServiceDescriptor.Singleton(
- sp => new SingleServerJobStorage(
- _factory(sp),
- sp.GetRequiredService()
- )
- )
- );
+ return this;
}
}
diff --git a/src/Immediate.Jobs.Shared/ImmediateJobsOptions.cs b/src/Immediate.Jobs.Shared/ImmediateJobsOptions.cs
index 8dcd21e..9539a83 100644
--- a/src/Immediate.Jobs.Shared/ImmediateJobsOptions.cs
+++ b/src/Immediate.Jobs.Shared/ImmediateJobsOptions.cs
@@ -3,11 +3,16 @@
namespace Immediate.Jobs.Shared;
///
-/// Global scheduler and worker options.
+/// Process-wide scheduler and worker options.
///
[Validate]
public sealed partial class ImmediateJobsOptions : IValidationTarget
{
+ ///
+ /// Controls whether the scheduling service and it's attendant workers are enabled.
+ ///
+ public bool IsJobSchedulingServiceEnabled { get; set; } = true;
+
///
/// Maximum concurrently executing jobs on this node.
///
diff --git a/src/Immediate.Jobs.Shared/ImmediateJobsStorageBuilder.cs b/src/Immediate.Jobs.Shared/ImmediateJobsStorageBuilder.cs
new file mode 100644
index 0000000..c8eb4b8
--- /dev/null
+++ b/src/Immediate.Jobs.Shared/ImmediateJobsStorageBuilder.cs
@@ -0,0 +1,212 @@
+using System.Diagnostics.CodeAnalysis;
+using Immediate.Jobs.Shared.Storage;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace Immediate.Jobs.Shared;
+
+///
+/// The fluent registration object used to configure job storage.
+///
+public interface IImmediateJobsStorageBuilder
+{
+ ///
+ /// The service collection being configured.
+ ///
+ IServiceCollection Services { get; }
+
+ ///
+ /// Selects the non-durable, single-node in-memory provider.
+ ///
+ ///
+ /// The supplied builder.
+ ///
+ IImmediateJobsStorageBuilder UseInMemory();
+
+ ///
+ /// Selects a durable storage provider. By default, it is used as a write-through replica of the
+ /// authoritative in-process store for a single scheduler server.
+ ///
+ ///
+ /// The factory that creates the durable storage provider.
+ ///
+ ///
+ /// The supplied builder.
+ ///
+ IImmediateJobsStorageBuilder UseStorage(Func factory);
+
+ ///
+ /// Selects a durable storage provider. By default, it is used as a write-through replica of the
+ /// authoritative in-process store for a single scheduler server.
+ ///
+ ///
+ /// The supplied builder.
+ ///
+ IImmediateJobsStorageBuilder UseStorage<
+ [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TJobStorage
+ >() where TJobStorage : class, IJobStorage;
+
+ ///
+ /// Selects memory-primary, durable-replica operation for one scheduler server.
+ ///
+ ///
+ /// The supplied builder.
+ ///
+ IImmediateJobsStorageBuilder UseSingleServer();
+
+ ///
+ /// Selects memory-primary operation with the supplied durable replica.
+ ///
+ ///
+ /// The factory that creates the durable storage replica.
+ ///
+ ///
+ /// The supplied builder.
+ ///
+ IImmediateJobsStorageBuilder UseSingleServer(Func durableStorageFactory);
+
+ ///
+ /// Selects durable-storage-primary operation for multiple scheduler servers.
+ ///
+ ///
+ /// The supplied builder.
+ ///
+ IImmediateJobsStorageBuilder UseDistributed();
+
+ ///
+ /// Selects durable-storage-primary operation for multiple scheduler servers.
+ ///
+ ///
+ /// The factory that creates the durable storage replica.
+ ///
+ ///
+ /// The supplied builder.
+ ///
+ IImmediateJobsStorageBuilder UseDistributed(Func durableStorageFactory);
+}
+
+internal sealed class ImmediateJobsStorageBuilder : IImmediateJobsStorageBuilder
+{
+ private enum JobStorageMode
+ {
+ None,
+ InMemory,
+ SingleServer,
+ Distributed,
+ }
+
+ private JobStorageMode _storageMode;
+ private Func? _factory;
+
+ internal ImmediateJobsStorageBuilder(IServiceCollection services)
+ {
+ Services = services;
+ }
+
+ public IServiceCollection Services { get; }
+
+ public IImmediateJobsStorageBuilder UseInMemory()
+ {
+ if (_storageMode is not (JobStorageMode.None or JobStorageMode.InMemory))
+ ImmediateJobException.Throw("Cannot select in-memory job storage when other job storage options have been selected.");
+
+ _storageMode = JobStorageMode.InMemory;
+ return this;
+ }
+
+ public IImmediateJobsStorageBuilder UseStorage(Func factory)
+ {
+ ArgumentNullException.ThrowIfNull(factory);
+
+ if (_storageMode is JobStorageMode.InMemory)
+ ImmediateJobException.Throw("Cannot provide a durable storage provider when in-memory job storage has already been selected.");
+
+ if (_factory is { })
+ ImmediateJobException.Throw("A durable storage provider has already been provided.");
+
+ _factory = factory;
+ return this;
+ }
+
+ public IImmediateJobsStorageBuilder UseStorage<
+ [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TJobStorage
+ >() where TJobStorage : class, IJobStorage
+ {
+ if (_storageMode is JobStorageMode.InMemory)
+ ImmediateJobException.Throw("Cannot provide a durable storage provider when in-memory job storage has already been selected.");
+
+ if (_factory is { })
+ ImmediateJobException.Throw("A durable storage provider has already been provided.");
+
+ Services.AddSingleton();
+ _factory = sp => sp.GetRequiredService();
+ return this;
+ }
+
+ public IImmediateJobsStorageBuilder UseSingleServer()
+ {
+ if (_storageMode is not (JobStorageMode.None or JobStorageMode.SingleServer))
+ ImmediateJobException.Throw("Cannot select single-server operation mode when other job storage options have been selected.");
+
+ _storageMode = JobStorageMode.SingleServer;
+ return this;
+ }
+
+ public IImmediateJobsStorageBuilder UseSingleServer(Func durableStorageFactory)
+ {
+ UseStorage(durableStorageFactory);
+ UseSingleServer();
+ return this;
+ }
+
+ public IImmediateJobsStorageBuilder UseDistributed()
+ {
+ if (_storageMode is not (JobStorageMode.None or JobStorageMode.Distributed))
+ ImmediateJobException.Throw("Cannot select distributed operation mode when other job storage options have been selected.");
+
+ _storageMode = JobStorageMode.Distributed;
+ return this;
+ }
+
+ public IImmediateJobsStorageBuilder UseDistributed(Func durableStorageFactory)
+ {
+ UseStorage(durableStorageFactory);
+ UseDistributed();
+ return this;
+ }
+
+ internal void ValidateAndRegister()
+ {
+ switch (_storageMode)
+ {
+ case JobStorageMode.InMemory:
+ if (_factory is { })
+ throw new ImmediateJobException("Cannot provide a durable storage provider when in-memory job storage has already been selected.");
+ break;
+
+ case JobStorageMode.Distributed:
+ if (_factory is null)
+ throw new ImmediateJobException("Durable storage is required, but no durable storage provider has been provided.");
+
+ Services.Replace(ServiceDescriptor.Singleton(_factory));
+ break;
+
+ case JobStorageMode.None:
+ case JobStorageMode.SingleServer:
+ default:
+ if (_factory is null)
+ throw new ImmediateJobException("Durable storage is required, but no durable storage provider has been provided.");
+
+ // none or explicit single-server are both single-server
+ Services.Replace(
+ ServiceDescriptor.Singleton(
+ sp => new SingleServerJobStorage(
+ _factory(sp),
+ sp.GetRequiredService()
+ )
+ )
+ );
+ break;
+ }
+ }
+}
diff --git a/src/Immediate.Jobs.Shared/Internals/ImmediateJobsStorageOptions.cs b/src/Immediate.Jobs.Shared/Internals/ImmediateJobsStorageOptions.cs
new file mode 100644
index 0000000..090faf0
--- /dev/null
+++ b/src/Immediate.Jobs.Shared/Internals/ImmediateJobsStorageOptions.cs
@@ -0,0 +1,6 @@
+namespace Immediate.Jobs.Shared.Internals;
+
+internal sealed class ImmediateJobsStorageOptions
+{
+ public bool Configured { get; set; }
+}
diff --git a/src/Immediate.Jobs.Shared/Internals/JobSchedulingService.cs b/src/Immediate.Jobs.Shared/Internals/JobSchedulingService.cs
index 6c210ff..effc917 100644
--- a/src/Immediate.Jobs.Shared/Internals/JobSchedulingService.cs
+++ b/src/Immediate.Jobs.Shared/Internals/JobSchedulingService.cs
@@ -101,15 +101,8 @@ JobSchedulerState state
_storage = storage;
_recurringStorage = storage as IRecurringJobStorage;
_graphStorage = storage as IJobGraphStorage;
- _options = options.Value;
- _fairQueueOptions = fairQueueOptions.Value;
- _timeProvider = timeProvider;
- _idGenerator = idGenerator;
- _logger = logger;
- _state = state;
- if (_graphStorage is null)
- GraphFeaturesDisabled(_logger, storage.GetType().Name);
_definitions = definitions.ToDictionary(x => x.Name, StringComparer.Ordinal);
+
_queues = queueDefinitions
.Concat(_definitions.Values.Select(static definition => definition.Queue))
.Append(JobQueueDefinition.Default)
@@ -119,6 +112,14 @@ JobSchedulerState state
static group => group.Distinct().Single(),
StringComparer.Ordinal
);
+
+ _options = options.Value;
+ _fairQueueOptions = fairQueueOptions.Value;
+ _timeProvider = timeProvider;
+ _idGenerator = idGenerator;
+ _logger = logger;
+ _state = state;
+
// Reservation accounting in BuildAcquisitionRequest is the admission control, so the channel is
// only a handoff buffer. A bounded channel would add a second, redundant limit whose sole effect
// is to block the scheduler loop -- and with it the heartbeat -- if the two ever disagree.
@@ -127,11 +128,17 @@ JobSchedulerState state
SingleWriter = true,
SingleReader = _options.MaxParallelJobs == 1,
});
+
+ if (_graphStorage is null)
+ GraphFeaturesDisabled(_logger, storage.GetType().Name);
}
///
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
+ if (!_options.IsJobSchedulingServiceEnabled)
+ return;
+
await _storage.InitializeAsync(stoppingToken).ConfigureAwait(false);
await EnsureCodeSchedulesAsync(stoppingToken).ConfigureAwait(false);
_state.MarkStarted(_timeProvider.GetUtcNow());
diff --git a/src/Immediate.Jobs.Shared/ServiceCollectionExtensions.cs b/src/Immediate.Jobs.Shared/ServiceCollectionExtensions.cs
index d5556aa..e199d01 100644
--- a/src/Immediate.Jobs.Shared/ServiceCollectionExtensions.cs
+++ b/src/Immediate.Jobs.Shared/ServiceCollectionExtensions.cs
@@ -23,7 +23,7 @@ public static class ImmediateJobsRuntimeServiceCollectionExtensions
///
/// A builder for selecting storage and adding runtime integrations.
///
- public static ImmediateJobsBuilder AddImmediateJobsCore(
+ public static IImmediateJobsBuilder AddImmediateJobsCore(
this IServiceCollection services
)
{
@@ -39,6 +39,13 @@ this IServiceCollection services
}
);
+ var storageOptionsBuilder = services
+ .AddOptionsWithValidateOnStart()
+ .Validate(
+ o => o.Configured,
+ "Storage must be configured via `.ConfigureStorage()`"
+ );
+
var fairQueueOptionsBuilder = services
.AddOptionsWithValidateOnStart()
.Validate(
@@ -70,6 +77,6 @@ this IServiceCollection services
)
);
- return new(services, optionsBuilder, fairQueueOptionsBuilder);
+ return new ImmediateJobsBuilder(services, optionsBuilder, storageOptionsBuilder, fairQueueOptionsBuilder);
}
}
diff --git a/src/Immediate.Jobs.Testing/JobTestHarness.cs b/src/Immediate.Jobs.Testing/JobTestHarness.cs
index 50f34af..5945792 100644
--- a/src/Immediate.Jobs.Testing/JobTestHarness.cs
+++ b/src/Immediate.Jobs.Testing/JobTestHarness.cs
@@ -49,7 +49,7 @@ public JobTestHarness(
_ = services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
_ = services.AddImmediateJobsCore()
- .Configure(o => o.MaxParallelJobs = 1)
+ .ConfigureWorkers(o => o.MaxParallelJobs = 1)
.ConfigureStorage(o => o.UseInMemory());
_serviceProvider = services.BuildServiceProvider(new ServiceProviderOptions
diff --git a/src/Immediate.Jobs.Testing/Storage/JobStorageConformanceSuite.cs b/src/Immediate.Jobs.Testing/Storage/JobStorageConformanceSuite.cs
index e499cd3..e7eca05 100644
--- a/src/Immediate.Jobs.Testing/Storage/JobStorageConformanceSuite.cs
+++ b/src/Immediate.Jobs.Testing/Storage/JobStorageConformanceSuite.cs
@@ -44,6 +44,13 @@ public static IReadOnlyList GetCases(StorageCapab
);
}
+ ///
+ /// A map of all known cases by their case name.
+ ///
+ public static IReadOnlyDictionary AllCasesByName { get; } =
+ GetCases(KnownCapabilities)
+ .ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase);
+
private static void AddOptionalCases(
List destination,
StorageCapabilities advertisedCapabilities,
diff --git a/src/Immediate.Jobs.Testing/Storage/RecurringStorageConformance.cs b/src/Immediate.Jobs.Testing/Storage/RecurringStorageConformance.cs
index 5de1472..639a193 100644
--- a/src/Immediate.Jobs.Testing/Storage/RecurringStorageConformance.cs
+++ b/src/Immediate.Jobs.Testing/Storage/RecurringStorageConformance.cs
@@ -403,11 +403,6 @@ CancellationToken cancellationToken
ExceptionsName,
"removing a code-defined schedule must throw ImmediateJobException"
).ConfigureAwait(false);
- _ = await ConformanceAssert.ThrowsAsync(
- () => recurring.PauseRecurringAsync("", cancellationToken),
- ExceptionsName,
- "a blank schedule name must be rejected before a dashboard mutation"
- ).ConfigureAwait(false);
}
private static IRecurringJobStorage Recurring(IJobStorage storage, string caseName) =>
diff --git a/tests/Immediate.Jobs.FunctionalTests/Immediate.Jobs.FunctionalTests.csproj b/tests/Immediate.Jobs.FunctionalTests/Immediate.Jobs.FunctionalTests.csproj
index 7283a4a..3c30a24 100644
--- a/tests/Immediate.Jobs.FunctionalTests/Immediate.Jobs.FunctionalTests.csproj
+++ b/tests/Immediate.Jobs.FunctionalTests/Immediate.Jobs.FunctionalTests.csproj
@@ -1,17 +1,18 @@
-
+
+
Exe
- <_SkipUpgradeNetAnalyzersNuGetWarning>true
- $(NoWarn);CA1050;CA1812
runtime-async=on
+
+
@@ -19,6 +20,7 @@
+
+
diff --git a/tests/Immediate.Jobs.FunctionalTests/Packages/DashboardPackageTests.cs b/tests/Immediate.Jobs.FunctionalTests/Packages/DashboardPackageTests.cs
index 09697d5..d1b936d 100644
--- a/tests/Immediate.Jobs.FunctionalTests/Packages/DashboardPackageTests.cs
+++ b/tests/Immediate.Jobs.FunctionalTests/Packages/DashboardPackageTests.cs
@@ -9,7 +9,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
-using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
namespace Immediate.Jobs.FunctionalTests.Packages;
@@ -22,8 +21,8 @@ private static void ConfigureDashboardTestServices(
FakeTimeProvider timeProvider
)
{
- _ = services.Replace(ServiceDescriptor.Singleton(storage));
- _ = services.Replace(ServiceDescriptor.Singleton(timeProvider));
+ services.Replace(ServiceDescriptor.Singleton(storage));
+ services.Replace(ServiceDescriptor.Singleton(timeProvider));
services.RemoveAll();
}
@@ -37,35 +36,6 @@ public void PackageEmbedsCompleteSpaAssetSet()
Assert.Contains("Immediate.Jobs.Dashboard.Assets.app.js", resources);
}
- [Fact]
- public void AuthorizationPolicyRejectsBlankNames()
- {
- var options = new ImmediateJobsDashboardOptions();
-
- _ = Assert.Throws(() => options.RequireAuthorization(" "));
- _ = Assert.Throws(() => options.AddTelemetryLink(
- " ",
- JobTelemetryLinkKind.Trace,
- static _ => null
- ));
- }
-
- [Fact]
- public void DashboardConfigurationUsesOptionsPattern()
- {
- var services = new ServiceCollection();
- _ = services.AddImmediateJobsDashboard(options =>
- {
- options.UpdateInterval = TimeSpan.FromSeconds(5);
- });
-
- using var provider = services.BuildServiceProvider();
- var options = provider.GetRequiredService>().Value;
-
- Assert.Equal(TimeSpan.FromSeconds(5), options.UpdateInterval);
- Assert.Null(provider.GetService());
- }
-
[Theory]
[InlineData("Development", false, HttpStatusCode.Redirect)]
[InlineData("Local", false, HttpStatusCode.Forbidden)]
@@ -85,12 +55,15 @@ HttpStatusCode expectedStatus
{
EnvironmentName = environmentName,
});
- _ = builder.WebHost.UseTestServer();
- _ = builder.Services.AddImmediateJobsDashboard(options =>
- {
- if (allowInAnyEnvironment)
- _ = options.AllowInAnyEnvironment();
- });
+
+ builder.WebHost.UseTestServer();
+
+ builder.Services
+ .AddImmediateJobsCore()
+ .DisableWorkers()
+ .ConfigureStorage(o => o.UseInMemory())
+ .AddImmediateJobsDashboard()
+ .ConfigureDashboard(o => o.RestrictToDevelopmentEnvironment = !allowInAnyEnvironment);
ConfigureDashboardTestServices(builder.Services, storage, timeProvider);
@@ -134,17 +107,20 @@ await storage.EnqueueAsync(new()
EnvironmentName = Environments.Development,
});
- _ = builder.WebHost.UseTestServer();
- _ = builder.Services.AddImmediateJobsDashboard(options =>
- {
- _ = options.AddTelemetryLink(
+ builder.WebHost.UseTestServer();
+ builder.Services
+ .AddImmediateJobsCore()
+ .DisableWorkers()
+ .ConfigureStorage(o => o.UseInMemory())
+ .AddImmediateJobsDashboard()
+ .AddTelemetryLink(
"View execution trace",
JobTelemetryLinkKind.Trace,
context => context.Execution?.ExecutionTraceId is { } traceId
? new($"https://traces.example/trace/{traceId}")
: null
- );
- _ = options.AddTelemetryLink(
+ )
+ .AddTelemetryLink(
"View execution logs",
JobTelemetryLinkKind.Logs,
context => context.Execution is { } execution
@@ -153,15 +129,14 @@ await storage.EnqueueAsync(new()
$"https://logs.example/search?jobId={Uri.EscapeDataString(context.Job.Id)}&attempt={execution.Attempt}"
))
: null
- );
- _ = options.AddTelemetryLink(
+ )
+ .AddTelemetryLink(
"View all retry logs",
JobTelemetryLinkKind.Logs,
context => context.Execution is null
? new($"https://logs.example/search?jobId={Uri.EscapeDataString(context.Job.Id)}")
: null
);
- });
ConfigureDashboardTestServices(builder.Services, storage, timeProvider);
@@ -278,17 +253,22 @@ await storage.SetExecutionTelemetryAsync(
{
EnvironmentName = Environments.Development,
});
- _ = builder.WebHost.UseTestServer();
- _ = builder.Services.AddImmediateJobsDashboard(options =>
- {
- _ = options.AddTelemetryLink(
+
+ builder.WebHost.UseTestServer();
+
+ builder.Services
+ .AddImmediateJobsCore()
+ .DisableWorkers()
+ .ConfigureStorage(o => o.UseInMemory())
+ .AddImmediateJobsDashboard()
+ .AddTelemetryLink(
"Legacy trace callback",
JobTelemetryLinkKind.Trace,
context => context.Job.ExecutionTraceId is { } traceId
? new($"https://traces.example/trace/{traceId}")
: null
- );
- _ = options.AddTelemetryLink(
+ )
+ .AddTelemetryLink(
"Legacy attempt callback",
JobTelemetryLinkKind.Logs,
context => new(string.Create(
@@ -296,7 +276,6 @@ await storage.SetExecutionTelemetryAsync(
$"https://logs.example/search?attempt={context.Job.Attempt}&span={context.Job.ExecutionSpanId}"
))
);
- });
ConfigureDashboardTestServices(builder.Services, storage, timeProvider);
@@ -335,7 +314,6 @@ await storage.SetExecutionTelemetryAsync(
public async Task ExecutionApiValidatesPagingAndMissingResources(string path, HttpStatusCode expectedStatus)
{
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 7, 21, 12, 0, 0, TimeSpan.Zero));
- var now = timeProvider.GetUtcNow();
await using var storage = new InMemoryJobStorage(timeProvider);
@@ -348,12 +326,19 @@ await storage.EnqueueAsync(new()
DueAt = DateTimeOffset.UnixEpoch,
CreatedAt = DateTimeOffset.UnixEpoch,
}, TestContext.Current.CancellationToken);
+
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
EnvironmentName = Environments.Development,
});
- _ = builder.WebHost.UseTestServer();
- _ = builder.Services.AddImmediateJobsDashboard();
+
+ builder.WebHost.UseTestServer();
+
+ builder.Services
+ .AddImmediateJobsCore()
+ .DisableWorkers()
+ .ConfigureStorage(o => o.UseInMemory())
+ .AddImmediateJobsDashboard();
ConfigureDashboardTestServices(builder.Services, storage, timeProvider);
@@ -382,7 +367,6 @@ await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)
public async Task QueueOnlyStorageReportsCapabilitiesAndDisablesBatchApi()
{
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 7, 21, 12, 0, 0, TimeSpan.Zero));
- var now = timeProvider.GetUtcNow();
await using var storage = new StorageCapabilityTests.QueueOnlyStorage(timeProvider);
@@ -390,8 +374,14 @@ public async Task QueueOnlyStorageReportsCapabilitiesAndDisablesBatchApi()
{
EnvironmentName = Environments.Development,
});
- _ = builder.WebHost.UseTestServer();
- _ = builder.Services.AddImmediateJobsDashboard();
+
+ builder.WebHost.UseTestServer();
+
+ builder.Services
+ .AddImmediateJobsCore()
+ .DisableWorkers()
+ .ConfigureStorage(o => o.UseInMemory())
+ .AddImmediateJobsDashboard();
ConfigureDashboardTestServices(builder.Services, storage, timeProvider);
@@ -426,7 +416,6 @@ await overviewResponse.Content.ReadAsStringAsync(TestContext.Current.Cancellatio
public async Task InMemoryDashboardReturnsNotFoundForMissingMutationTargets(string method, string path)
{
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 7, 21, 12, 0, 0, TimeSpan.Zero));
- var now = timeProvider.GetUtcNow();
await using var storage = new InMemoryJobStorage(timeProvider);
@@ -434,8 +423,14 @@ public async Task InMemoryDashboardReturnsNotFoundForMissingMutationTargets(stri
{
EnvironmentName = Environments.Development,
});
- _ = builder.WebHost.UseTestServer();
- _ = builder.Services.AddImmediateJobsDashboard();
+
+ builder.WebHost.UseTestServer();
+
+ builder.Services
+ .AddImmediateJobsCore()
+ .DisableWorkers()
+ .ConfigureStorage(o => o.UseInMemory())
+ .AddImmediateJobsDashboard();
ConfigureDashboardTestServices(builder.Services, storage, timeProvider);
@@ -467,12 +462,19 @@ await storage.EnqueueAsync(new()
DueAt = now,
CreatedAt = now,
}, cancellationToken);
+
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
EnvironmentName = Environments.Development,
});
- _ = builder.WebHost.UseTestServer();
- _ = builder.Services.AddImmediateJobsDashboard();
+
+ builder.WebHost.UseTestServer();
+
+ builder.Services
+ .AddImmediateJobsCore()
+ .DisableWorkers()
+ .ConfigureStorage(o => o.UseInMemory())
+ .AddImmediateJobsDashboard();
ConfigureDashboardTestServices(builder.Services, storage, timeProvider);
@@ -503,7 +505,6 @@ await storage.EnqueueAsync(new()
public async Task SpaRoutesAreUnambiguous(string path)
{
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 7, 21, 12, 0, 0, TimeSpan.Zero));
- var now = timeProvider.GetUtcNow();
await using var storage = new InMemoryJobStorage(timeProvider);
@@ -511,8 +512,14 @@ public async Task SpaRoutesAreUnambiguous(string path)
{
EnvironmentName = Environments.Development,
});
- _ = builder.WebHost.UseTestServer();
- _ = builder.Services.AddImmediateJobsDashboard();
+
+ builder.WebHost.UseTestServer();
+
+ builder.Services
+ .AddImmediateJobsCore()
+ .DisableWorkers()
+ .ConfigureStorage(o => o.UseInMemory())
+ .AddImmediateJobsDashboard();
ConfigureDashboardTestServices(builder.Services, storage, timeProvider);
@@ -535,7 +542,6 @@ await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken),
public async Task CustomDashboardPrefixIsInjectedIntoSpaBase()
{
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 7, 21, 12, 0, 0, TimeSpan.Zero));
- var now = timeProvider.GetUtcNow();
await using var storage = new InMemoryJobStorage(timeProvider);
@@ -543,8 +549,14 @@ public async Task CustomDashboardPrefixIsInjectedIntoSpaBase()
{
EnvironmentName = Environments.Development,
});
- _ = builder.WebHost.UseTestServer();
- _ = builder.Services.AddImmediateJobsDashboard();
+
+ builder.WebHost.UseTestServer();
+
+ builder.Services
+ .AddImmediateJobsCore()
+ .DisableWorkers()
+ .ConfigureStorage(o => o.UseInMemory())
+ .AddImmediateJobsDashboard();
ConfigureDashboardTestServices(builder.Services, storage, timeProvider);
@@ -569,7 +581,6 @@ await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken),
public async Task RequestPathBaseIsIncludedInSpaBase()
{
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 7, 21, 12, 0, 0, TimeSpan.Zero));
- var now = timeProvider.GetUtcNow();
await using var storage = new InMemoryJobStorage(timeProvider);
@@ -577,8 +588,14 @@ public async Task RequestPathBaseIsIncludedInSpaBase()
{
EnvironmentName = Environments.Development,
});
- _ = builder.WebHost.UseTestServer();
- _ = builder.Services.AddImmediateJobsDashboard();
+
+ builder.WebHost.UseTestServer();
+
+ builder.Services
+ .AddImmediateJobsCore()
+ .DisableWorkers()
+ .ConfigureStorage(o => o.UseInMemory())
+ .AddImmediateJobsDashboard();
ConfigureDashboardTestServices(builder.Services, storage, timeProvider);
@@ -604,7 +621,6 @@ await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken),
public async Task DashboardRootRedirectsToTrailingSlash()
{
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 7, 21, 12, 0, 0, TimeSpan.Zero));
- var now = timeProvider.GetUtcNow();
await using var storage = new InMemoryJobStorage(timeProvider);
@@ -612,8 +628,14 @@ public async Task DashboardRootRedirectsToTrailingSlash()
{
EnvironmentName = Environments.Development,
});
- _ = builder.WebHost.UseTestServer();
- _ = builder.Services.AddImmediateJobsDashboard();
+
+ builder.WebHost.UseTestServer();
+
+ builder.Services
+ .AddImmediateJobsCore()
+ .DisableWorkers()
+ .ConfigureStorage(o => o.UseInMemory())
+ .AddImmediateJobsDashboard();
ConfigureDashboardTestServices(builder.Services, storage, timeProvider);
@@ -654,8 +676,14 @@ await storage.EnqueueAsync(new()
{
EnvironmentName = Environments.Development,
});
- _ = builder.WebHost.UseTestServer();
- _ = builder.Services.AddImmediateJobsDashboard();
+
+ builder.WebHost.UseTestServer();
+
+ builder.Services
+ .AddImmediateJobsCore()
+ .DisableWorkers()
+ .ConfigureStorage(o => o.UseInMemory())
+ .AddImmediateJobsDashboard();
ConfigureDashboardTestServices(builder.Services, storage, timeProvider);
@@ -717,8 +745,14 @@ await storage.EnqueueAsync(new()
{
EnvironmentName = Environments.Development,
});
- _ = builder.WebHost.UseTestServer();
- _ = builder.Services.AddImmediateJobsDashboard();
+
+ builder.WebHost.UseTestServer();
+
+ builder.Services
+ .AddImmediateJobsCore()
+ .DisableWorkers()
+ .ConfigureStorage(o => o.UseInMemory())
+ .AddImmediateJobsDashboard();
ConfigureDashboardTestServices(builder.Services, storage, timeProvider);
diff --git a/tests/Immediate.Jobs.FunctionalTests/QueueSchedulerTests.cs b/tests/Immediate.Jobs.FunctionalTests/QueueSchedulerTests.cs
index e97e855..ae61478 100644
--- a/tests/Immediate.Jobs.FunctionalTests/QueueSchedulerTests.cs
+++ b/tests/Immediate.Jobs.FunctionalTests/QueueSchedulerTests.cs
@@ -43,7 +43,7 @@ public async Task SchedulerAppliesQueueAndJobLimitsBeforeDispatch()
_ = services.AddLogging();
_ = services.AddSingleton(timeProvider);
_ = services.AddImmediateJobsCore()
- .Configure(o =>
+ .ConfigureWorkers(o =>
{
o.MaxParallelJobs = 3;
o.PollingInterval = TimeSpan.FromMilliseconds(10);
@@ -137,7 +137,7 @@ public async ValueTask InvokeAsync(IServiceProvider scopedServices, JobExecution
}
}
- private sealed class OtherHostedService : IHostedService
+ public sealed class OtherHostedService : IHostedService
{
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
diff --git a/tests/Immediate.Jobs.FunctionalTests/RecurringSchedulerTests.cs b/tests/Immediate.Jobs.FunctionalTests/RecurringSchedulerTests.cs
index f5c586e..13b6f98 100644
--- a/tests/Immediate.Jobs.FunctionalTests/RecurringSchedulerTests.cs
+++ b/tests/Immediate.Jobs.FunctionalTests/RecurringSchedulerTests.cs
@@ -260,7 +260,7 @@ private static JobSchedulingService BuildScheduler(
_ = services.AddLogging();
_ = services.AddSingleton(clock);
_ = services.AddImmediateJobsCore()
- .Configure(o => o.MaxParallelJobs = maxParallelJobs)
+ .ConfigureWorkers(o => o.MaxParallelJobs = maxParallelJobs)
.ConfigureStorage(o => o.UseStorage(_ => storage).UseDistributed());
_ = services.AddSingleton(new JobDefinition
diff --git a/tests/Immediate.Jobs.FunctionalTests/StorageCapabilityTests.cs b/tests/Immediate.Jobs.FunctionalTests/StorageCapabilityTests.cs
index dccf6c6..11eb2ab 100644
--- a/tests/Immediate.Jobs.FunctionalTests/StorageCapabilityTests.cs
+++ b/tests/Immediate.Jobs.FunctionalTests/StorageCapabilityTests.cs
@@ -79,7 +79,7 @@ public async Task QueueOnlySchedulerUsesPlainCompletionAndSkipsRecurring()
_ = services.AddLogging();
_ = services.AddSingleton(timeProvider);
_ = services.AddImmediateJobsCore()
- .Configure(o => o.MaxParallelJobs = 1)
+ .ConfigureWorkers(o => o.MaxParallelJobs = 1)
.ConfigureStorage(o => o.UseStorage(_ => storage).UseDistributed());
_ = services.AddSingleton(new JobDefinition
diff --git a/tests/Immediate.Jobs.StorageTests/ConformanceFixtures.cs b/tests/Immediate.Jobs.StorageTests/ConformanceFixtures.cs
index 8e2f07f..4de30c9 100644
--- a/tests/Immediate.Jobs.StorageTests/ConformanceFixtures.cs
+++ b/tests/Immediate.Jobs.StorageTests/ConformanceFixtures.cs
@@ -1,16 +1,3 @@
-using System.Text.RegularExpressions;
-using Immediate.Jobs.EntityFrameworkCore;
-using Immediate.Jobs.LinqToDB;
-using Immediate.Jobs.Redis;
-using LinqToDB;
-using LinqToDB.Data;
-using Microsoft.Data.Sqlite;
-using Microsoft.EntityFrameworkCore;
-using Microsoft.EntityFrameworkCore.Infrastructure;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Time.Testing;
-using StackExchange.Redis;
-
namespace Immediate.Jobs.StorageTests;
public enum ConformanceDatabase
@@ -20,267 +7,8 @@ public enum ConformanceDatabase
SqlServer,
}
-public enum ConformanceAdapter
-{
- EntityFrameworkCore,
- LinqToDB,
-}
-
public enum ConformanceTopology
{
Distributed,
SingleServer,
}
-
-internal sealed class RelationalConformanceFixture : IAsyncDisposable
-{
- private static readonly string[] SqlServerTables =
- [
- "immediate_job_continuations",
- "immediate_job_executions",
- "immediate_fair_queue_groups",
- "immediate_jobs",
- "immediate_job_batches",
- "immediate_recurring_jobs",
- "immediate_job_servers",
- ];
-
- private readonly ConformanceDatabase _database;
- private readonly string _connectionString;
- private readonly string? _schema;
- private readonly string? _sqlitePath;
- private ServiceProvider? _services;
-
- private RelationalConformanceFixture(
- ConformanceDatabase database,
- string connectionString,
- string? schema,
- string? sqlitePath
- )
- {
- _database = database;
- _connectionString = connectionString;
- _schema = schema;
- _sqlitePath = sqlitePath;
- }
-
- internal IServiceProvider Services => _services
- ?? throw new InvalidOperationException("The relational conformance fixture has not finished initializing.");
-
- internal static async ValueTask CreateAsync(
- StorageContainers? containers,
- ConformanceDatabase database,
- ConformanceAdapter adapter,
- CancellationToken cancellationToken,
- bool useDistributedTopology = true
- )
- {
- var schema = database == ConformanceDatabase.Sqlite ? null : "jobs_" + Guid.NewGuid().ToString("N");
- var sqlitePath = database == ConformanceDatabase.Sqlite
- ? Path.Combine(Path.GetTempPath(), $"immediate-jobs-conformance-{Guid.NewGuid():N}.db")
- : null;
- var connectionString = database switch
- {
- ConformanceDatabase.Sqlite => $"Data Source={sqlitePath}",
- ConformanceDatabase.PostgreSql => GetContainers(containers).PostgreSql.GetConnectionString(),
- ConformanceDatabase.SqlServer => GetContainers(containers).SqlServer.GetConnectionString(),
- _ => throw new ArgumentOutOfRangeException(nameof(database)),
- };
- var contextOptions = new DbContextOptionsBuilder();
- DataOptions dataOptions;
- switch (database)
- {
- case ConformanceDatabase.Sqlite:
- dataOptions = new DataOptions().UseSQLite(connectionString);
- _ = contextOptions.UseSqlite(connectionString);
- break;
- case ConformanceDatabase.PostgreSql:
- dataOptions = new DataOptions().UsePostgreSQL(connectionString);
- _ = contextOptions.UseNpgsql(connectionString);
- break;
- case ConformanceDatabase.SqlServer:
- dataOptions = new DataOptions().UseSqlServer(connectionString);
- _ = contextOptions.UseSqlServer(connectionString);
- break;
- default:
- throw new ArgumentOutOfRangeException(nameof(database));
- }
-
- _ = contextOptions.ReplaceService();
- var contextFactory = new ConformanceDbContextFactory(contextOptions.Options, schema);
- var fixture = new RelationalConformanceFixture(database, connectionString, schema, sqlitePath);
- try
- {
- if (adapter == ConformanceAdapter.LinqToDB)
- {
- await dataOptions.CreateImmediateJobsSchemaAsync(schema, cancellationToken).ConfigureAwait(false);
- }
- else
- {
- await using var context = contextFactory.CreateDbContext();
- var script = context.Database.GenerateCreateScript();
- foreach (var batch in Regex.Split(
- script,
- @"^\s*GO\s*$",
- RegexOptions.Multiline | RegexOptions.IgnoreCase,
- TimeSpan.FromSeconds(1)
- ))
- {
- if (!string.IsNullOrWhiteSpace(batch))
- _ = await context.Database.ExecuteSqlRawAsync(batch, cancellationToken).ConfigureAwait(false);
- }
- }
-
- var clock = new FakeTimeProvider(new DateTimeOffset(2026, 8, 8, 10, 0, 0, TimeSpan.Zero));
- var serviceCollection = new ServiceCollection();
- _ = serviceCollection.AddLogging();
- _ = serviceCollection.AddSingleton(clock);
- _ = serviceCollection.AddSingleton(clock);
- _ = serviceCollection.AddSingleton>(contextFactory);
- _ = serviceCollection.AddImmediateJobsCore()
- .ConfigureStorage(options =>
- {
- _ = adapter == ConformanceAdapter.EntityFrameworkCore
- ? options.UseEntityFrameworkCore()
- : options.UseLinqToDB(dataOptions, schema);
- if (useDistributedTopology)
- _ = options.UseDistributed();
- });
- fixture._services = serviceCollection.BuildServiceProvider(new ServiceProviderOptions
- {
- ValidateOnBuild = true,
- ValidateScopes = true,
- });
- return fixture;
- }
- catch
- {
- await fixture.DisposeAsync().ConfigureAwait(false);
- throw;
- }
- }
-
- private static StorageContainers GetContainers(StorageContainers? containers) =>
- containers ?? throw new InvalidOperationException("A container fixture is required for server databases.");
-
- public async ValueTask DisposeAsync()
- {
- if (_services is not null)
- await _services.DisposeAsync().ConfigureAwait(false);
- if (_sqlitePath is not null)
- {
- SqliteConnection.ClearAllPools();
- File.Delete(_sqlitePath);
- return;
- }
-
- var cleanupOptions = _database == ConformanceDatabase.PostgreSql
- ? new DataOptions().UsePostgreSQL(_connectionString)
- : new DataOptions().UseSqlServer(_connectionString);
- await using var connection = new DataConnection(cleanupOptions);
- if (_database == ConformanceDatabase.PostgreSql)
- {
- _ = await connection.ExecuteAsync($"DROP SCHEMA IF EXISTS \"{_schema}\" CASCADE").ConfigureAwait(false);
- return;
- }
-
- foreach (var table in SqlServerTables)
- _ = await connection.ExecuteAsync($"DROP TABLE IF EXISTS [{_schema}].[{table}]").ConfigureAwait(false);
- _ = await connection.ExecuteAsync(
- $"IF SCHEMA_ID(N'{_schema}') IS NOT NULL EXEC(N'DROP SCHEMA [{_schema}]')"
- ).ConfigureAwait(false);
- }
-}
-
-internal sealed class RedisConformanceFixture : IAsyncDisposable
-{
- private readonly IConnectionMultiplexer _connection;
- private readonly string _keyPrefix;
-
- private RedisConformanceFixture(
- IConnectionMultiplexer connection,
- string keyPrefix,
- ServiceProvider services
- )
- {
- _connection = connection;
- _keyPrefix = keyPrefix;
- Services = services;
- }
-
- internal IServiceProvider Services { get; }
-
- internal static async ValueTask CreateAsync(
- string connectionString,
- CancellationToken cancellationToken
- )
- {
- cancellationToken.ThrowIfCancellationRequested();
- var connection = await ConnectionMultiplexer.ConnectAsync(connectionString).ConfigureAwait(false);
- try
- {
- var keyPrefix = "immediate-jobs-conformance-" + Guid.NewGuid().ToString("N");
- var clock = new FakeTimeProvider(new DateTimeOffset(2026, 8, 8, 10, 0, 0, TimeSpan.Zero));
- var serviceCollection = new ServiceCollection();
- _ = serviceCollection.AddLogging();
- _ = serviceCollection.AddSingleton(clock);
- _ = serviceCollection.AddSingleton(clock);
- _ = serviceCollection.AddImmediateJobsCore()
- .ConfigureStorage(options =>
- _ = options.UseRedis(connection, storage => storage.KeyPrefix = keyPrefix)
- );
- var services = serviceCollection.BuildServiceProvider(new ServiceProviderOptions
- {
- ValidateOnBuild = true,
- ValidateScopes = true,
- });
- return new(connection, keyPrefix, services);
- }
- catch
- {
- await connection.DisposeAsync().ConfigureAwait(false);
- throw;
- }
- }
-
- public async ValueTask DisposeAsync()
- {
- await ((ServiceProvider)Services).DisposeAsync().ConfigureAwait(false);
- foreach (var endpoint in _connection.GetEndPoints())
- {
- var server = _connection.GetServer(endpoint);
- var keys = new List();
- await foreach (var key in server.KeysAsync(pattern: $"{{{_keyPrefix}}}:*").ConfigureAwait(false))
- keys.Add(key);
- if (keys.Count > 0)
- _ = await _connection.GetDatabase().KeyDeleteAsync([.. keys], flags: CommandFlags.None).ConfigureAwait(false);
- }
-
- await _connection.DisposeAsync().ConfigureAwait(false);
- }
-}
-
-internal sealed class ConformanceDbContextFactory(
- DbContextOptions options,
- string? schema
-) : IDbContextFactory
-{
- public ConformanceDbContext CreateDbContext() => new(options, schema);
-}
-
-internal sealed class ConformanceDbContext(
- DbContextOptions options,
- string? schema
-) : DbContext(options)
-{
- internal string? Schema { get; } = schema;
-
- protected override void OnModelCreating(ModelBuilder modelBuilder) =>
- _ = modelBuilder.AddImmediateJobs(Schema);
-}
-
-internal sealed class ConformanceSchemaModelCacheKeyFactory : IModelCacheKeyFactory
-{
- public object Create(DbContext context, bool designTime) =>
- (context.GetType(), ((ConformanceDbContext)context).Schema, designTime);
-}
diff --git a/tests/Immediate.Jobs.StorageTests/EntityFrameworkCoreConformanceTests.cs b/tests/Immediate.Jobs.StorageTests/EntityFrameworkCoreConformanceTests.cs
index 5a9345b..d358837 100644
--- a/tests/Immediate.Jobs.StorageTests/EntityFrameworkCoreConformanceTests.cs
+++ b/tests/Immediate.Jobs.StorageTests/EntityFrameworkCoreConformanceTests.cs
@@ -1,10 +1,22 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.RegularExpressions;
+using DotNet.Testcontainers.Containers;
+using Immediate.Jobs.EntityFrameworkCore;
using Immediate.Jobs.Shared.Storage;
using Immediate.Jobs.Testing;
+using LinqToDB;
+using LinqToDB.Data;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Time.Testing;
+using Testcontainers.MsSql;
+using Testcontainers.PostgreSql;
namespace Immediate.Jobs.StorageTests;
-[Collection(StorageContainerFixtureGroup.Name)]
-public sealed class EntityFrameworkCoreConformanceTests(StorageContainers containers)
+public static class EntityFrameworkCoreConformanceTestCases
{
private const StorageCapabilities Capabilities =
StorageCapabilities.Queue |
@@ -13,43 +25,289 @@ public sealed class EntityFrameworkCoreConformanceTests(StorageContainers contai
StorageCapabilities.FairQueues |
StorageCapabilities.Replica;
- public static TheoryData Cases =>
- CreateCases();
+ public static TheoryData CreateCases()
+ {
+ var data = new TheoryData();
+ foreach (var topology in Enum.GetValues())
+ {
+ var capabilities = topology == ConformanceTopology.Distributed
+ ? Capabilities
+ : Capabilities & ~StorageCapabilities.Replica;
+
+ foreach (var testCase in JobStorageConformanceSuite.GetCases(capabilities))
+ data.Add(topology, testCase);
+ }
+
+ return data;
+ }
+}
+[Collection(EntityFrameworkCorePgSQLFixtureGroup.Name)]
+public sealed class EntityFrameworkCorePgSQLConformanceTests(EntityFrameworkCorePgSQLContainer container)
+{
[Theory]
- [MemberData(nameof(Cases))]
+ [MemberData(nameof(EntityFrameworkCoreConformanceTestCases.CreateCases), MemberType = typeof(EntityFrameworkCoreConformanceTestCases))]
public async Task EntityFrameworkCoreConforms(
- ConformanceDatabase database,
ConformanceTopology topology,
JobStorageConformanceTestCase testCase
)
{
- ArgumentNullException.ThrowIfNull(testCase);
await using var fixture = await RelationalConformanceFixture.CreateAsync(
- containers,
- database,
- ConformanceAdapter.EntityFrameworkCore,
- TestContext.Current.CancellationToken,
- useDistributedTopology: topology == ConformanceTopology.Distributed
+ ConformanceDatabase.PostgreSql,
+ useDistributedTopology: topology == ConformanceTopology.Distributed,
+ container: container.PostgreSql
);
+
await testCase.RunAsync(fixture.Services, TestContext.Current.CancellationToken);
}
+}
- private static TheoryData CreateCases()
+[Collection(EntityFrameworkCoreMsSQLFixtureGroup.Name)]
+public sealed class EntityFrameworkCoreMsSQLConformanceTests(EntityFrameworkCoreMsSQLContainer container)
+{
+ [Theory]
+ [MemberData(nameof(EntityFrameworkCoreConformanceTestCases.CreateCases), MemberType = typeof(EntityFrameworkCoreConformanceTestCases))]
+ public async Task EntityFrameworkCoreConforms(
+ ConformanceTopology topology,
+ JobStorageConformanceTestCase testCase
+ )
{
- var data = new TheoryData();
- foreach (var database in Enum.GetValues())
+ await using var fixture = await RelationalConformanceFixture.CreateAsync(
+ ConformanceDatabase.SqlServer,
+ useDistributedTopology: topology == ConformanceTopology.Distributed,
+ container: container.SqlServer
+ );
+
+ await testCase.RunAsync(fixture.Services, TestContext.Current.CancellationToken);
+ }
+}
+
+[Collection("EntityFrameworkCore-SQLite")]
+public sealed class EntityFrameworkCoreSQLiteConformanceTests
+{
+ [Theory]
+ [MemberData(nameof(EntityFrameworkCoreConformanceTestCases.CreateCases), MemberType = typeof(EntityFrameworkCoreConformanceTestCases))]
+ public async Task EntityFrameworkCoreConforms(
+ ConformanceTopology topology,
+ JobStorageConformanceTestCase testCase
+ )
+ {
+ await using var fixture = await RelationalConformanceFixture.CreateAsync(
+ ConformanceDatabase.Sqlite,
+ useDistributedTopology: topology == ConformanceTopology.Distributed,
+ container: null
+ );
+
+ await testCase.RunAsync(fixture.Services, TestContext.Current.CancellationToken);
+ }
+}
+
+file sealed class RelationalConformanceFixture(
+ ConformanceDatabase database,
+ ServiceProvider services,
+ string connectionString,
+ string? schema,
+ string? sqlitePath
+) : IAsyncDisposable
+{
+ private static readonly string[] SqlServerTables =
+ [
+ "immediate_job_continuations",
+ "immediate_job_executions",
+ "immediate_fair_queue_groups",
+ "immediate_jobs",
+ "immediate_job_batches",
+ "immediate_recurring_jobs",
+ "immediate_job_servers",
+ ];
+
+ internal IServiceProvider Services => services;
+
+ internal static async ValueTask CreateAsync(
+ ConformanceDatabase database,
+ bool useDistributedTopology,
+ IDatabaseContainer? container
+ )
+ {
+ var schema = database == ConformanceDatabase.Sqlite ? null : "jobs_" + Guid.NewGuid().ToString("N");
+ var sqlitePath = database == ConformanceDatabase.Sqlite
+ ? Path.Combine(Path.GetTempPath(), $"immediate-jobs-conformance-{Guid.NewGuid():N}.db")
+ : null;
+
+ var connectionString = database switch
+ {
+ ConformanceDatabase.Sqlite => $"Data Source={sqlitePath}",
+ ConformanceDatabase.PostgreSql when container is { } => container.GetConnectionString(),
+ ConformanceDatabase.SqlServer when container is { } => container.GetConnectionString(),
+ _ => throw new ArgumentOutOfRangeException(nameof(database)),
+ };
+
+ var contextOptions = database switch
+ {
+ ConformanceDatabase.Sqlite =>
+ new DbContextOptionsBuilder().UseSqlite(connectionString),
+
+ ConformanceDatabase.PostgreSql =>
+ new DbContextOptionsBuilder().UseNpgsql(connectionString),
+
+ ConformanceDatabase.SqlServer =>
+ new DbContextOptionsBuilder().UseSqlServer(connectionString),
+
+ _ => throw new ArgumentOutOfRangeException(nameof(database)),
+ };
+
+ contextOptions.ReplaceService();
+ var contextFactory = new ConformanceDbContextFactory(contextOptions.Options, schema);
+
+ var clock = new FakeTimeProvider(new DateTimeOffset(2026, 8, 8, 10, 0, 0, TimeSpan.Zero));
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddSingleton(clock);
+ services.AddSingleton(clock);
+
+ services.AddSingleton>(contextFactory);
+
+ services.AddImmediateJobsCore()
+ .ConfigureStorage(options =>
+ {
+ options.UseEntityFrameworkCore();
+ if (useDistributedTopology)
+ _ = options.UseDistributed();
+ });
+
+ var servicesProvider = services.BuildServiceProvider(
+ new ServiceProviderOptions
+ {
+ ValidateOnBuild = true,
+ ValidateScopes = true,
+ }
+ );
+
+ var fixture = new RelationalConformanceFixture(database, servicesProvider, connectionString, schema, sqlitePath);
+
+ try
{
- foreach (var topology in Enum.GetValues())
+ await using var context = contextFactory.CreateDbContext();
+ var script = context.Database.GenerateCreateScript();
+
+ foreach (var batch in Regex.Split(
+ script,
+ @"^\s*GO\s*$",
+ RegexOptions.Multiline | RegexOptions.IgnoreCase,
+ TimeSpan.FromSeconds(1)
+ ))
{
- var capabilities = topology == ConformanceTopology.Distributed
- ? Capabilities
- : Capabilities & ~StorageCapabilities.Replica;
- foreach (var testCase in JobStorageConformanceSuite.GetCases(capabilities))
- data.Add(database, topology, testCase);
+ if (!string.IsNullOrWhiteSpace(batch))
+ await context.Database.ExecuteSqlRawAsync(batch, TestContext.Current.CancellationToken);
}
+
+ return fixture;
}
+ catch
+ {
+ await fixture.DisposeAsync();
+ throw;
+ }
+ }
- return data;
+ public async ValueTask DisposeAsync()
+ {
+ if (services is not null)
+ await services.DisposeAsync();
+
+ if (sqlitePath is not null)
+ {
+ SqliteConnection.ClearAllPools();
+ File.Delete(sqlitePath);
+ return;
+ }
+
+ var cleanupOptions = database == ConformanceDatabase.PostgreSql
+ ? new DataOptions().UsePostgreSQL(connectionString)
+ : new DataOptions().UseSqlServer(connectionString);
+
+ await using var connection = new DataConnection(cleanupOptions);
+
+ if (database == ConformanceDatabase.PostgreSql)
+ {
+ await connection.ExecuteAsync($"DROP SCHEMA IF EXISTS \"{schema}\" CASCADE");
+ return;
+ }
+
+ foreach (var table in SqlServerTables)
+ await connection.ExecuteAsync($"DROP TABLE IF EXISTS [{schema}].[{table}]");
+
+ await connection.ExecuteAsync(
+ $"IF SCHEMA_ID(N'{schema}') IS NOT NULL EXEC(N'DROP SCHEMA [{schema}]')"
+ );
+ }
+}
+
+file sealed class ConformanceDbContextFactory(
+ DbContextOptions options,
+ string? schema
+) : IDbContextFactory
+{
+ public ConformanceDbContext CreateDbContext() => new(options, schema);
+}
+
+file sealed class ConformanceDbContext(
+ DbContextOptions options,
+ string? schema
+) : DbContext(options)
+{
+ internal string? Schema { get; } = schema;
+ protected override void OnModelCreating(ModelBuilder modelBuilder) =>
+ _ = modelBuilder.AddImmediateJobs(Schema);
+}
+
+[SuppressMessage("Performance", "CA1812", Justification = "Used via attribute")]
+file sealed class ConformanceSchemaModelCacheKeyFactory : IModelCacheKeyFactory
+{
+ public object Create(DbContext context, bool designTime) =>
+ (context.GetType(), ((ConformanceDbContext)context).Schema, designTime);
+}
+
+[CollectionDefinition(Name)]
+public sealed class EntityFrameworkCorePgSQLFixtureGroup : ICollectionFixture
+{
+ public const string Name = "EntityFrameworkCore-PgSQL";
+}
+
+public sealed class EntityFrameworkCorePgSQLContainer : IAsyncLifetime
+{
+ public PostgreSqlContainer PostgreSql { get; } = new PostgreSqlBuilder("postgres:18-alpine").Build();
+
+ public async ValueTask InitializeAsync()
+ {
+ await PostgreSql.StartAsync();
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await PostgreSql.DisposeAsync();
+ }
+}
+
+[CollectionDefinition(Name)]
+public sealed class EntityFrameworkCoreMsSQLFixtureGroup : ICollectionFixture
+{
+ public const string Name = "EntityFrameworkCore-MsSQL";
+}
+
+public sealed class EntityFrameworkCoreMsSQLContainer : IAsyncLifetime
+{
+ public MsSqlContainer SqlServer { get; } = new MsSqlBuilder(
+ "mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04"
+ ).Build();
+
+ public async ValueTask InitializeAsync()
+ {
+ await SqlServer.StartAsync();
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await SqlServer.DisposeAsync();
}
}
diff --git a/tests/Immediate.Jobs.StorageTests/Immediate.Jobs.StorageTests.csproj b/tests/Immediate.Jobs.StorageTests/Immediate.Jobs.StorageTests.csproj
index b8256f9..336c908 100644
--- a/tests/Immediate.Jobs.StorageTests/Immediate.Jobs.StorageTests.csproj
+++ b/tests/Immediate.Jobs.StorageTests/Immediate.Jobs.StorageTests.csproj
@@ -1,40 +1,39 @@
+
- net8.0;net9.0;net10.0;net11.0
+
+ net8.0;net9.0;net10.0
Exe
- <_SkipUpgradeNetAnalyzersNuGetWarning>true
- $(NoWarn);CA1050;CA1812;CS8892
+
+
+ runtime-async=on
+
+
+
-
-
-
-
-
+
+
+
+
-
-
-
-
+
+
-
-
-
-
-
+
diff --git a/tests/Immediate.Jobs.StorageTests/JobStorageConformanceTestCaseSerializer.cs b/tests/Immediate.Jobs.StorageTests/JobStorageConformanceTestCaseSerializer.cs
new file mode 100644
index 0000000..ef9831d
--- /dev/null
+++ b/tests/Immediate.Jobs.StorageTests/JobStorageConformanceTestCaseSerializer.cs
@@ -0,0 +1,33 @@
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using Immediate.Jobs.StorageTests;
+using Immediate.Jobs.Testing;
+using Xunit.Sdk;
+
+[assembly: RegisterXunitSerializer(typeof(JobStorageConformanceTestCaseSerializer), typeof(JobStorageConformanceTestCase))]
+
+namespace Immediate.Jobs.StorageTests;
+
+[SuppressMessage("Performance", "CA1812", Justification = "Used via attribute")]
+internal sealed class JobStorageConformanceTestCaseSerializer : IXunitSerializer
+{
+ public object Deserialize(Type type, string serializedValue) =>
+ JobStorageConformanceSuite.AllCasesByName[serializedValue];
+
+ public bool IsSerializable(Type type, object? value, [NotNullWhen(false)] out string? failureReason)
+ {
+ if (type == typeof(JobStorageConformanceTestCase))
+ {
+ failureReason = null;
+ return true;
+ }
+
+ failureReason = "Unknown type.";
+ return false;
+ }
+
+ public string Serialize(object value) =>
+ value is JobStorageConformanceTestCase { Name: { } name }
+ ? name
+ : throw new UnreachableException();
+}
diff --git a/tests/Immediate.Jobs.StorageTests/LinqToDBConformanceTests.cs b/tests/Immediate.Jobs.StorageTests/LinqToDBConformanceTests.cs
index e09a0f9..2ac21ce 100644
--- a/tests/Immediate.Jobs.StorageTests/LinqToDBConformanceTests.cs
+++ b/tests/Immediate.Jobs.StorageTests/LinqToDBConformanceTests.cs
@@ -1,10 +1,21 @@
+using System.Diagnostics.CodeAnalysis;
+using DotNet.Testcontainers.Containers;
+using Immediate.Jobs.LinqToDB;
using Immediate.Jobs.Shared.Storage;
using Immediate.Jobs.Testing;
+using LinqToDB;
+using LinqToDB.Data;
+using LinqToDB.Extensions.DependencyInjection;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Time.Testing;
+using Testcontainers.MsSql;
+using Testcontainers.PostgreSql;
namespace Immediate.Jobs.StorageTests;
-[Collection(StorageContainerFixtureGroup.Name)]
-public sealed class LinqToDBConformanceTests(StorageContainers containers)
+public static class LinqToDBConformanceTestCases
{
private const StorageCapabilities Capabilities =
StorageCapabilities.Queue |
@@ -13,43 +24,249 @@ public sealed class LinqToDBConformanceTests(StorageContainers containers)
StorageCapabilities.FairQueues |
StorageCapabilities.Replica;
- public static TheoryData Cases =>
- CreateCases();
+ public static TheoryData CreateCases()
+ {
+ var data = new TheoryData();
+ foreach (var topology in Enum.GetValues())
+ {
+ var capabilities = topology == ConformanceTopology.Distributed
+ ? Capabilities
+ : Capabilities & ~StorageCapabilities.Replica;
+
+ foreach (var testCase in JobStorageConformanceSuite.GetCases(capabilities))
+ data.Add(topology, testCase);
+ }
+ return data;
+ }
+}
+
+[Collection(LinqToDBPgSQLFixtureGroup.Name)]
+public sealed class LinqToDBPgSQLConformanceTests(LinqToDBPgSQLContainer container)
+{
[Theory]
- [MemberData(nameof(Cases))]
+ [MemberData(nameof(LinqToDBConformanceTestCases.CreateCases), MemberType = typeof(LinqToDBConformanceTestCases))]
+ public async Task LinqToDBConforms(
+ ConformanceTopology topology,
+ JobStorageConformanceTestCase testCase
+ )
+ {
+ await using var fixture = await RelationalConformanceFixture.CreateAsync(
+ ConformanceDatabase.PostgreSql,
+ useDistributedTopology: topology == ConformanceTopology.Distributed,
+ container: container.PostgreSql
+ );
+
+ await testCase.RunAsync(fixture.Services, TestContext.Current.CancellationToken);
+ }
+}
+
+[Collection(LinqToDBMsSQLFixtureGroup.Name)]
+public sealed class LinqToDBMsSQLConformanceTests(LinqToDBMsSQLContainer container)
+{
+ [Theory]
+ [MemberData(nameof(LinqToDBConformanceTestCases.CreateCases), MemberType = typeof(LinqToDBConformanceTestCases))]
+ public async Task LinqToDBConforms(
+ ConformanceTopology topology,
+ JobStorageConformanceTestCase testCase
+ )
+ {
+ await using var fixture = await RelationalConformanceFixture.CreateAsync(
+ ConformanceDatabase.SqlServer,
+ useDistributedTopology: topology == ConformanceTopology.Distributed,
+ container: container.SqlServer
+ );
+
+ await testCase.RunAsync(fixture.Services, TestContext.Current.CancellationToken);
+ }
+}
+
+[Collection("LinqToDB-SQLite")]
+public sealed class LinqToDBSQLiteConformanceTests
+{
+ [Theory]
+ [MemberData(nameof(LinqToDBConformanceTestCases.CreateCases), MemberType = typeof(LinqToDBConformanceTestCases))]
public async Task LinqToDBConforms(
- ConformanceDatabase database,
ConformanceTopology topology,
JobStorageConformanceTestCase testCase
)
{
- ArgumentNullException.ThrowIfNull(testCase);
await using var fixture = await RelationalConformanceFixture.CreateAsync(
- containers,
- database,
- ConformanceAdapter.LinqToDB,
- TestContext.Current.CancellationToken,
- useDistributedTopology: topology == ConformanceTopology.Distributed
+ ConformanceDatabase.Sqlite,
+ useDistributedTopology: topology == ConformanceTopology.Distributed,
+ container: null
);
+
await testCase.RunAsync(fixture.Services, TestContext.Current.CancellationToken);
}
+}
+
+file sealed class RelationalConformanceFixture(
+ ConformanceDatabase database,
+ ServiceProvider services,
+ string connectionString,
+ string? schema,
+ string? sqlitePath
+) : IAsyncDisposable
+{
+ private static readonly string[] SqlServerTables =
+ [
+ "immediate_job_continuations",
+ "immediate_job_executions",
+ "immediate_fair_queue_groups",
+ "immediate_jobs",
+ "immediate_job_batches",
+ "immediate_recurring_jobs",
+ "immediate_job_servers",
+ ];
+
+ internal IServiceProvider Services => services;
- private static TheoryData CreateCases()
+ internal static async ValueTask CreateAsync(
+ ConformanceDatabase database,
+ bool useDistributedTopology,
+ IDatabaseContainer? container
+ )
{
- var data = new TheoryData();
- foreach (var database in Enum.GetValues())
+ var schema = database == ConformanceDatabase.Sqlite ? null : "jobs_" + Guid.NewGuid().ToString("N");
+ var sqlitePath = database == ConformanceDatabase.Sqlite
+ ? Path.Combine(Path.GetTempPath(), $"immediate-jobs-conformance-{Guid.NewGuid():N}.db")
+ : null;
+
+ var connectionString = database switch
+ {
+ ConformanceDatabase.Sqlite => $"Data Source={sqlitePath}",
+ ConformanceDatabase.PostgreSql when container is { } => container.GetConnectionString(),
+ ConformanceDatabase.SqlServer when container is { } => container.GetConnectionString(),
+ _ => throw new ArgumentOutOfRangeException(nameof(database)),
+ };
+
+ var dataOptions = database switch
{
- foreach (var topology in Enum.GetValues())
+ ConformanceDatabase.Sqlite => new DataOptions().UseSQLite(connectionString),
+ ConformanceDatabase.PostgreSql => new DataOptions().UsePostgreSQL(connectionString),
+ ConformanceDatabase.SqlServer => new DataOptions().UseSqlServer(connectionString),
+ _ => throw new ArgumentOutOfRangeException(nameof(database)),
+ };
+
+ var clock = new FakeTimeProvider(new DateTimeOffset(2026, 8, 8, 10, 0, 0, TimeSpan.Zero));
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddSingleton(clock);
+ services.AddSingleton(clock);
+
+ services.AddLinqToDBContext(() => dataOptions);
+
+ services.AddImmediateJobsCore()
+ .ConfigureStorage(options =>
+ {
+ options.UseLinqToDB(schema);
+ if (useDistributedTopology)
+ _ = options.UseDistributed();
+ });
+
+ var servicesProvider = services.BuildServiceProvider(
+ new ServiceProviderOptions
{
- var capabilities = topology == ConformanceTopology.Distributed
- ? Capabilities
- : Capabilities & ~StorageCapabilities.Replica;
- foreach (var testCase in JobStorageConformanceSuite.GetCases(capabilities))
- data.Add(database, topology, testCase);
+ ValidateOnBuild = true,
+ ValidateScopes = true,
}
+ );
+
+ var fixture = new RelationalConformanceFixture(database, servicesProvider, connectionString, schema, sqlitePath);
+
+ try
+ {
+ await using (var context = new ConformanceDbContext(dataOptions))
+ await context.CreateImmediateJobsSchemaAsync(schema, TestContext.Current.CancellationToken);
+
+ return fixture;
+ }
+ catch
+ {
+ await fixture.DisposeAsync();
+ throw;
}
+ }
- return data;
+ public async ValueTask DisposeAsync()
+ {
+ if (services is not null)
+ await services.DisposeAsync();
+
+ if (sqlitePath is not null)
+ {
+ SqliteConnection.ClearAllPools();
+ File.Delete(sqlitePath);
+ return;
+ }
+
+ var cleanupOptions = database == ConformanceDatabase.PostgreSql
+ ? new DataOptions().UsePostgreSQL(connectionString)
+ : new DataOptions().UseSqlServer(connectionString);
+
+ await using var connection = new DataConnection(cleanupOptions);
+
+ if (database == ConformanceDatabase.PostgreSql)
+ {
+ await connection.ExecuteAsync($"DROP SCHEMA IF EXISTS \"{schema}\" CASCADE");
+ return;
+ }
+
+ foreach (var table in SqlServerTables)
+ await connection.ExecuteAsync($"DROP TABLE IF EXISTS [{schema}].[{table}]");
+
+ await connection.ExecuteAsync(
+ $"IF SCHEMA_ID(N'{schema}') IS NOT NULL EXEC(N'DROP SCHEMA [{schema}]')"
+ );
+ }
+}
+
+[SuppressMessage("Performance", "CA1812", Justification = "Used via attribute")]
+file sealed class ConformanceDbContext(
+ DataOptions dataOptions
+) : DataConnection(dataOptions);
+
+[CollectionDefinition(Name)]
+public sealed class LinqToDBPgSQLFixtureGroup : ICollectionFixture
+{
+ public const string Name = "LinqToDB-PgSQL";
+}
+
+public sealed class LinqToDBPgSQLContainer : IAsyncLifetime
+{
+ public PostgreSqlContainer PostgreSql { get; } = new PostgreSqlBuilder("postgres:18-alpine").Build();
+
+ public async ValueTask InitializeAsync()
+ {
+ await PostgreSql.StartAsync();
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await PostgreSql.DisposeAsync();
+ }
+}
+
+[CollectionDefinition(Name)]
+public sealed class LinqToDBMsSQLFixtureGroup : ICollectionFixture
+{
+ public const string Name = "LinqToDB-MsSQL";
+}
+
+public sealed class LinqToDBMsSQLContainer : IAsyncLifetime
+{
+ public MsSqlContainer SqlServer { get; } = new MsSqlBuilder(
+ "mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04"
+ ).Build();
+
+ public async ValueTask InitializeAsync()
+ {
+ await SqlServer.StartAsync();
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await SqlServer.DisposeAsync();
}
}
diff --git a/tests/Immediate.Jobs.StorageTests/OptionsPatternTests.cs b/tests/Immediate.Jobs.StorageTests/OptionsPatternTests.cs
deleted file mode 100644
index 59c93e2..0000000
--- a/tests/Immediate.Jobs.StorageTests/OptionsPatternTests.cs
+++ /dev/null
@@ -1,47 +0,0 @@
-using Immediate.Jobs.Redis;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Options;
-using Microsoft.Extensions.Time.Testing;
-
-namespace Immediate.Jobs.StorageTests;
-
-public sealed class OptionsPatternTests
-{
- [Fact]
- public void RedisConfigurationUsesOptionsPattern()
- {
- var timeProvider = new FakeTimeProvider(DateTimeOffset.UnixEpoch);
- var services = new ServiceCollection();
- _ = services.AddSingleton(timeProvider);
- _ = services.AddImmediateJobsCore().ConfigureStorage(storage =>
- storage.UseRedis("unused", options =>
- {
- options.Database = 4;
- options.KeyPrefix = "configured";
- })
- );
-
- using var provider = services.BuildServiceProvider();
- var options = provider.GetRequiredService>().Value;
-
- Assert.Equal(4, options.Database);
- Assert.Equal("configured", options.KeyPrefix);
- }
-
- [Fact]
- public void RedisOptionsRejectInvalidKeyPrefixes()
- {
- var timeProvider = new FakeTimeProvider(DateTimeOffset.UnixEpoch);
- var services = new ServiceCollection();
- _ = services.AddSingleton(timeProvider);
- _ = services.AddImmediateJobsCore().ConfigureStorage(storage =>
- storage.UseRedis("unused", options => options.KeyPrefix = "{invalid}")
- );
-
- using var provider = services.BuildServiceProvider();
-
- _ = Assert.Throws(
- () => provider.GetRequiredService>().Value
- );
- }
-}
diff --git a/tests/Immediate.Jobs.StorageTests/RedisConformanceTests.cs b/tests/Immediate.Jobs.StorageTests/RedisConformanceTests.cs
index 8ec6aa9..0d217a6 100644
--- a/tests/Immediate.Jobs.StorageTests/RedisConformanceTests.cs
+++ b/tests/Immediate.Jobs.StorageTests/RedisConformanceTests.cs
@@ -1,5 +1,10 @@
+using Immediate.Jobs.Redis;
using Immediate.Jobs.Shared.Storage;
using Immediate.Jobs.Testing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Time.Testing;
+using StackExchange.Redis;
+using Testcontainers.Redis;
namespace Immediate.Jobs.StorageTests;
@@ -18,10 +23,100 @@ public sealed class RedisConformanceTests(RedisStorageFixture redis)
public async Task RedisConforms(JobStorageConformanceTestCase testCase)
{
ArgumentNullException.ThrowIfNull(testCase);
+
await using var fixture = await RedisConformanceFixture.CreateAsync(
redis.Container.GetConnectionString(),
TestContext.Current.CancellationToken
);
+
await testCase.RunAsync(fixture.Services, TestContext.Current.CancellationToken);
}
}
+
+file sealed class RedisConformanceFixture : IAsyncDisposable
+{
+ private readonly IConnectionMultiplexer _connection;
+ private readonly string _keyPrefix;
+
+ private RedisConformanceFixture(
+ IConnectionMultiplexer connection,
+ string keyPrefix,
+ ServiceProvider services
+ )
+ {
+ _connection = connection;
+ _keyPrefix = keyPrefix;
+ Services = services;
+ }
+
+ internal IServiceProvider Services { get; }
+
+ internal static async ValueTask CreateAsync(
+ string connectionString,
+ CancellationToken cancellationToken
+ )
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var connection = await ConnectionMultiplexer.ConnectAsync(connectionString);
+ try
+ {
+ var keyPrefix = "immediate-jobs-conformance-" + Guid.NewGuid().ToString("N");
+ var clock = new FakeTimeProvider(new DateTimeOffset(2026, 8, 8, 10, 0, 0, TimeSpan.Zero));
+ var serviceCollection = new ServiceCollection();
+ serviceCollection.AddLogging();
+ serviceCollection.AddSingleton(clock);
+ serviceCollection.AddSingleton(clock);
+ serviceCollection.AddSingleton(connection);
+
+ serviceCollection
+ .AddImmediateJobsCore()
+ .ConfigureStorage(options =>
+ options.UseRedis()
+ .ConfigureRedis(storage => storage.KeyPrefix = keyPrefix)
+ );
+
+ var services = serviceCollection.BuildServiceProvider(new ServiceProviderOptions
+ {
+ ValidateOnBuild = true,
+ ValidateScopes = true,
+ });
+ return new(connection, keyPrefix, services);
+ }
+ catch
+ {
+ await connection.DisposeAsync();
+ throw;
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await ((ServiceProvider)Services).DisposeAsync();
+ foreach (var endpoint in _connection.GetEndPoints())
+ {
+ var server = _connection.GetServer(endpoint);
+ var keys = new List();
+ await foreach (var key in server.KeysAsync(pattern: $"{{{_keyPrefix}}}:*"))
+ keys.Add(key);
+ if (keys.Count > 0)
+ _ = await _connection.GetDatabase().KeyDeleteAsync([.. keys], flags: CommandFlags.None);
+ }
+
+ await _connection.DisposeAsync();
+ }
+}
+
+[CollectionDefinition(Name)]
+public sealed class RedisContainerFixtureGroup : ICollectionFixture
+{
+ public const string Name = "Redis storage";
+}
+
+public sealed class RedisStorageFixture : IAsyncLifetime
+{
+ public RedisContainer Container { get; } = new RedisBuilder("redis:8-alpine").Build();
+
+ public ValueTask InitializeAsync() => new(Container.StartAsync());
+
+ public ValueTask DisposeAsync() => Container.DisposeAsync();
+}
diff --git a/tests/Immediate.Jobs.StorageTests/StorageContainers.cs b/tests/Immediate.Jobs.StorageTests/StorageContainers.cs
deleted file mode 100644
index 150cffd..0000000
--- a/tests/Immediate.Jobs.StorageTests/StorageContainers.cs
+++ /dev/null
@@ -1,47 +0,0 @@
-using Testcontainers.MsSql;
-using Testcontainers.PostgreSql;
-using Testcontainers.Redis;
-
-namespace Immediate.Jobs.StorageTests;
-
-[CollectionDefinition(Name)]
-public sealed class StorageContainerFixtureGroup : ICollectionFixture
-{
- public const string Name = "Storage containers";
-}
-
-public sealed class StorageContainers : IAsyncLifetime
-{
- public PostgreSqlContainer PostgreSql { get; } = new PostgreSqlBuilder("postgres:18-alpine").Build();
- public MsSqlContainer SqlServer { get; } = new MsSqlBuilder(
- "mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04"
- ).Build();
-
- public async ValueTask InitializeAsync()
- {
- await Task.WhenAll(PostgreSql.StartAsync(), SqlServer.StartAsync());
- }
-
- public async ValueTask DisposeAsync()
- {
- await Task.WhenAll(
- PostgreSql.DisposeAsync().AsTask(),
- SqlServer.DisposeAsync().AsTask()
- );
- }
-}
-
-[CollectionDefinition(Name)]
-public sealed class RedisContainerFixtureGroup : ICollectionFixture
-{
- public const string Name = "Redis storage";
-}
-
-public sealed class RedisStorageFixture : IAsyncLifetime
-{
- public RedisContainer Container { get; } = new RedisBuilder("redis:8-alpine").Build();
-
- public ValueTask InitializeAsync() => new(Container.StartAsync());
-
- public ValueTask DisposeAsync() => Container.DisposeAsync();
-}
diff --git a/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ServiceCollectionExtensionsUsesQueuesAndTaggedRegistrations_framework=net10.0#IJ.ServiceCollectionExtensions.g.verified.cs b/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ServiceCollectionExtensionsUsesQueuesAndTaggedRegistrations_framework=net10.0#IJ.ServiceCollectionExtensions.g.verified.cs
index 5d6540f..df5d66f 100644
--- a/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ServiceCollectionExtensionsUsesQueuesAndTaggedRegistrations_framework=net10.0#IJ.ServiceCollectionExtensions.g.verified.cs
+++ b/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ServiceCollectionExtensionsUsesQueuesAndTaggedRegistrations_framework=net10.0#IJ.ServiceCollectionExtensions.g.verified.cs
@@ -8,7 +8,7 @@ namespace Immediate.Jobs.Testing;
public static class ImmediateJobsGeneratedServiceCollectionExtensions
{
- public static global::Immediate.Jobs.Shared.ImmediateJobsBuilder AddTestsJobs(
+ public static global::Immediate.Jobs.Shared.IImmediateJobsBuilder AddTestsJobs(
this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services,
params global::System.ReadOnlySpan tags
)
diff --git a/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ServiceCollectionExtensionsUsesQueuesAndTaggedRegistrations_framework=net11.0#IJ.ServiceCollectionExtensions.g.verified.cs b/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ServiceCollectionExtensionsUsesQueuesAndTaggedRegistrations_framework=net11.0#IJ.ServiceCollectionExtensions.g.verified.cs
index 5d6540f..df5d66f 100644
--- a/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ServiceCollectionExtensionsUsesQueuesAndTaggedRegistrations_framework=net11.0#IJ.ServiceCollectionExtensions.g.verified.cs
+++ b/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ServiceCollectionExtensionsUsesQueuesAndTaggedRegistrations_framework=net11.0#IJ.ServiceCollectionExtensions.g.verified.cs
@@ -8,7 +8,7 @@ namespace Immediate.Jobs.Testing;
public static class ImmediateJobsGeneratedServiceCollectionExtensions
{
- public static global::Immediate.Jobs.Shared.ImmediateJobsBuilder AddTestsJobs(
+ public static global::Immediate.Jobs.Shared.IImmediateJobsBuilder AddTestsJobs(
this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services,
params global::System.ReadOnlySpan tags
)
diff --git a/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ServiceCollectionExtensionsUsesQueuesAndTaggedRegistrations_framework=net8.0#IJ.ServiceCollectionExtensions.g.verified.cs b/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ServiceCollectionExtensionsUsesQueuesAndTaggedRegistrations_framework=net8.0#IJ.ServiceCollectionExtensions.g.verified.cs
index 4ecdd2e..ba5f93e 100644
--- a/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ServiceCollectionExtensionsUsesQueuesAndTaggedRegistrations_framework=net8.0#IJ.ServiceCollectionExtensions.g.verified.cs
+++ b/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ServiceCollectionExtensionsUsesQueuesAndTaggedRegistrations_framework=net8.0#IJ.ServiceCollectionExtensions.g.verified.cs
@@ -8,7 +8,7 @@ namespace Immediate.Jobs.Testing;
public static class ImmediateJobsGeneratedServiceCollectionExtensions
{
- public static global::Immediate.Jobs.Shared.ImmediateJobsBuilder AddTestsJobs(
+ public static global::Immediate.Jobs.Shared.IImmediateJobsBuilder AddTestsJobs(
this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services,
params string[] tags
)
diff --git a/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ServiceCollectionExtensionsUsesQueuesAndTaggedRegistrations_framework=net9.0#IJ.ServiceCollectionExtensions.g.verified.cs b/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ServiceCollectionExtensionsUsesQueuesAndTaggedRegistrations_framework=net9.0#IJ.ServiceCollectionExtensions.g.verified.cs
index 5d6540f..df5d66f 100644
--- a/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ServiceCollectionExtensionsUsesQueuesAndTaggedRegistrations_framework=net9.0#IJ.ServiceCollectionExtensions.g.verified.cs
+++ b/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ServiceCollectionExtensionsUsesQueuesAndTaggedRegistrations_framework=net9.0#IJ.ServiceCollectionExtensions.g.verified.cs
@@ -8,7 +8,7 @@ namespace Immediate.Jobs.Testing;
public static class ImmediateJobsGeneratedServiceCollectionExtensions
{
- public static global::Immediate.Jobs.Shared.ImmediateJobsBuilder AddTestsJobs(
+ public static global::Immediate.Jobs.Shared.IImmediateJobsBuilder AddTestsJobs(
this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services,
params global::System.ReadOnlySpan tags
)
diff --git a/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ValidAddJobsMethod_framework=net10.0#IJ.ServiceCollectionExtensions.g.verified.cs b/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ValidAddJobsMethod_framework=net10.0#IJ.ServiceCollectionExtensions.g.verified.cs
index f58a13e..b15e54e 100644
--- a/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ValidAddJobsMethod_framework=net10.0#IJ.ServiceCollectionExtensions.g.verified.cs
+++ b/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ValidAddJobsMethod_framework=net10.0#IJ.ServiceCollectionExtensions.g.verified.cs
@@ -8,7 +8,7 @@ namespace Immediate.Jobs.Testing;
public static class ImmediateJobsGeneratedServiceCollectionExtensions
{
- public static global::Immediate.Jobs.Shared.ImmediateJobsBuilder AddTestsJobs(
+ public static global::Immediate.Jobs.Shared.IImmediateJobsBuilder AddTestsJobs(
this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services,
params global::System.ReadOnlySpan tags
)
diff --git a/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ValidAddJobsMethod_framework=net11.0#IJ.ServiceCollectionExtensions.g.verified.cs b/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ValidAddJobsMethod_framework=net11.0#IJ.ServiceCollectionExtensions.g.verified.cs
index f58a13e..b15e54e 100644
--- a/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ValidAddJobsMethod_framework=net11.0#IJ.ServiceCollectionExtensions.g.verified.cs
+++ b/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ValidAddJobsMethod_framework=net11.0#IJ.ServiceCollectionExtensions.g.verified.cs
@@ -8,7 +8,7 @@ namespace Immediate.Jobs.Testing;
public static class ImmediateJobsGeneratedServiceCollectionExtensions
{
- public static global::Immediate.Jobs.Shared.ImmediateJobsBuilder AddTestsJobs(
+ public static global::Immediate.Jobs.Shared.IImmediateJobsBuilder AddTestsJobs(
this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services,
params global::System.ReadOnlySpan tags
)
diff --git a/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ValidAddJobsMethod_framework=net8.0#IJ.ServiceCollectionExtensions.g.verified.cs b/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ValidAddJobsMethod_framework=net8.0#IJ.ServiceCollectionExtensions.g.verified.cs
index a09064e..124f5e8 100644
--- a/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ValidAddJobsMethod_framework=net8.0#IJ.ServiceCollectionExtensions.g.verified.cs
+++ b/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ValidAddJobsMethod_framework=net8.0#IJ.ServiceCollectionExtensions.g.verified.cs
@@ -8,7 +8,7 @@ namespace Immediate.Jobs.Testing;
public static class ImmediateJobsGeneratedServiceCollectionExtensions
{
- public static global::Immediate.Jobs.Shared.ImmediateJobsBuilder AddTestsJobs(
+ public static global::Immediate.Jobs.Shared.IImmediateJobsBuilder AddTestsJobs(
this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services,
params string[] tags
)
diff --git a/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ValidAddJobsMethod_framework=net9.0#IJ.ServiceCollectionExtensions.g.verified.cs b/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ValidAddJobsMethod_framework=net9.0#IJ.ServiceCollectionExtensions.g.verified.cs
index f58a13e..b15e54e 100644
--- a/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ValidAddJobsMethod_framework=net9.0#IJ.ServiceCollectionExtensions.g.verified.cs
+++ b/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/AddJobsTests.ValidAddJobsMethod_framework=net9.0#IJ.ServiceCollectionExtensions.g.verified.cs
@@ -8,7 +8,7 @@ namespace Immediate.Jobs.Testing;
public static class ImmediateJobsGeneratedServiceCollectionExtensions
{
- public static global::Immediate.Jobs.Shared.ImmediateJobsBuilder AddTestsJobs(
+ public static global::Immediate.Jobs.Shared.IImmediateJobsBuilder AddTestsJobs(
this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services,
params global::System.ReadOnlySpan tags
)
diff --git a/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/ImmediateAssemblyIdentifierTests.ImmediateAssemblyIdentifierOverridesAssemblyName_framework=net10.0#IJ.ServiceCollectionExtensions.g.verified.cs b/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/ImmediateAssemblyIdentifierTests.ImmediateAssemblyIdentifierOverridesAssemblyName_framework=net10.0#IJ.ServiceCollectionExtensions.g.verified.cs
index 492200e..9dc496d 100644
--- a/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/ImmediateAssemblyIdentifierTests.ImmediateAssemblyIdentifierOverridesAssemblyName_framework=net10.0#IJ.ServiceCollectionExtensions.g.verified.cs
+++ b/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/ImmediateAssemblyIdentifierTests.ImmediateAssemblyIdentifierOverridesAssemblyName_framework=net10.0#IJ.ServiceCollectionExtensions.g.verified.cs
@@ -8,7 +8,7 @@ namespace Immediate.Jobs.Testing;
public static class ImmediateJobsGeneratedServiceCollectionExtensions
{
- public static global::Immediate.Jobs.Shared.ImmediateJobsBuilder AddCustomJobs(
+ public static global::Immediate.Jobs.Shared.IImmediateJobsBuilder AddCustomJobs(
this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services,
params global::System.ReadOnlySpan tags
)
diff --git a/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/ImmediateAssemblyIdentifierTests.ImmediateAssemblyIdentifierOverridesAssemblyName_framework=net11.0#IJ.ServiceCollectionExtensions.g.verified.cs b/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/ImmediateAssemblyIdentifierTests.ImmediateAssemblyIdentifierOverridesAssemblyName_framework=net11.0#IJ.ServiceCollectionExtensions.g.verified.cs
index 492200e..9dc496d 100644
--- a/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/ImmediateAssemblyIdentifierTests.ImmediateAssemblyIdentifierOverridesAssemblyName_framework=net11.0#IJ.ServiceCollectionExtensions.g.verified.cs
+++ b/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/ImmediateAssemblyIdentifierTests.ImmediateAssemblyIdentifierOverridesAssemblyName_framework=net11.0#IJ.ServiceCollectionExtensions.g.verified.cs
@@ -8,7 +8,7 @@ namespace Immediate.Jobs.Testing;
public static class ImmediateJobsGeneratedServiceCollectionExtensions
{
- public static global::Immediate.Jobs.Shared.ImmediateJobsBuilder AddCustomJobs(
+ public static global::Immediate.Jobs.Shared.IImmediateJobsBuilder AddCustomJobs(
this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services,
params global::System.ReadOnlySpan tags
)
diff --git a/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/ImmediateAssemblyIdentifierTests.ImmediateAssemblyIdentifierOverridesAssemblyName_framework=net8.0#IJ.ServiceCollectionExtensions.g.verified.cs b/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/ImmediateAssemblyIdentifierTests.ImmediateAssemblyIdentifierOverridesAssemblyName_framework=net8.0#IJ.ServiceCollectionExtensions.g.verified.cs
index 7eef141..89db354 100644
--- a/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/ImmediateAssemblyIdentifierTests.ImmediateAssemblyIdentifierOverridesAssemblyName_framework=net8.0#IJ.ServiceCollectionExtensions.g.verified.cs
+++ b/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/ImmediateAssemblyIdentifierTests.ImmediateAssemblyIdentifierOverridesAssemblyName_framework=net8.0#IJ.ServiceCollectionExtensions.g.verified.cs
@@ -8,7 +8,7 @@ namespace Immediate.Jobs.Testing;
public static class ImmediateJobsGeneratedServiceCollectionExtensions
{
- public static global::Immediate.Jobs.Shared.ImmediateJobsBuilder AddCustomJobs(
+ public static global::Immediate.Jobs.Shared.IImmediateJobsBuilder AddCustomJobs(
this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services,
params string[] tags
)
diff --git a/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/ImmediateAssemblyIdentifierTests.ImmediateAssemblyIdentifierOverridesAssemblyName_framework=net9.0#IJ.ServiceCollectionExtensions.g.verified.cs b/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/ImmediateAssemblyIdentifierTests.ImmediateAssemblyIdentifierOverridesAssemblyName_framework=net9.0#IJ.ServiceCollectionExtensions.g.verified.cs
index 492200e..9dc496d 100644
--- a/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/ImmediateAssemblyIdentifierTests.ImmediateAssemblyIdentifierOverridesAssemblyName_framework=net9.0#IJ.ServiceCollectionExtensions.g.verified.cs
+++ b/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/ImmediateAssemblyIdentifierTests.ImmediateAssemblyIdentifierOverridesAssemblyName_framework=net9.0#IJ.ServiceCollectionExtensions.g.verified.cs
@@ -8,7 +8,7 @@ namespace Immediate.Jobs.Testing;
public static class ImmediateJobsGeneratedServiceCollectionExtensions
{
- public static global::Immediate.Jobs.Shared.ImmediateJobsBuilder AddCustomJobs(
+ public static global::Immediate.Jobs.Shared.IImmediateJobsBuilder AddCustomJobs(
this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services,
params global::System.ReadOnlySpan tags
)