From c03433854a4b002067573b037e98f6a896ad6592 Mon Sep 17 00:00:00 2001 From: Stuart Turner Date: Mon, 17 Aug 2026 19:23:21 -0500 Subject: [PATCH 1/4] Initial draft of further configuration API changes --- Directory.Packages.props | 8 +- samples/Aspire/Api/Program.cs | 12 +- .../AspireDashboardTelemetryExtensions.cs | 17 +- samples/Basic/Program.cs | 6 +- .../DashboardApiEndpointOperations.cs | 4 +- .../ImmediateJobsDashboardBuilder.cs | 98 +++++ .../ImmediateJobsDashboardOptions.cs | 82 ++-- ...obsDashboardServiceCollectionExtensions.cs | 43 +- ...rameworkCoreServiceCollectionExtensions.cs | 11 +- .../Immediate.Jobs.EntityFrameworkCore.csproj | 5 +- .../ServiceCollectionExtensions.sbntxt | 2 +- .../Immediate.Jobs.LinqToDB.csproj | 1 - .../LinqToDBJobStorage.cs | 185 +++++---- .../LinqToDBJobStorageOptions.cs | 10 + .../LinqToDBServiceCollectionExtensions.cs | 39 +- src/Immediate.Jobs.LinqToDB/Owned.cs | 82 ++++ .../Immediate.Jobs.Redis.csproj | 1 - .../ImmediateJobsRedisBuilder.cs | 61 +++ src/Immediate.Jobs.Redis/RedisJobStorage.cs | 120 ++---- .../RedisJobStorageOptions.cs | 20 +- .../RedisServiceCollectionExtensions.cs | 82 +--- .../Immediate.Jobs.Shared.csproj | 2 +- .../ImmediateJobsBuilder.cs | 377 ++++-------------- .../ImmediateJobsOptions.cs | 5 + .../ImmediateJobsStorageBuilder.cs | 212 ++++++++++ .../Internals/ImmediateJobsStorageOptions.cs | 6 + .../Internals/JobSchedulingService.cs | 23 +- .../ServiceCollectionExtensions.cs | 11 +- src/Immediate.Jobs.Testing/JobTestHarness.cs | 2 +- 29 files changed, 863 insertions(+), 664 deletions(-) rename src/Immediate.Jobs.Dashboard/{ => Endpoints}/DashboardApiEndpointOperations.cs (98%) create mode 100644 src/Immediate.Jobs.Dashboard/ImmediateJobsDashboardBuilder.cs create mode 100644 src/Immediate.Jobs.LinqToDB/LinqToDBJobStorageOptions.cs create mode 100644 src/Immediate.Jobs.LinqToDB/Owned.cs create mode 100644 src/Immediate.Jobs.Redis/ImmediateJobsRedisBuilder.cs create mode 100644 src/Immediate.Jobs.Shared/ImmediateJobsStorageBuilder.cs create mode 100644 src/Immediate.Jobs.Shared/Internals/ImmediateJobsStorageOptions.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index e1004d9..7fd3138 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -58,7 +58,7 @@ - + @@ -74,7 +74,7 @@ - + @@ -89,7 +89,7 @@ - + @@ -104,7 +104,7 @@ - + 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..a23973e --- /dev/null +++ b/src/Immediate.Jobs.Dashboard/ImmediateJobsDashboardBuilder.cs @@ -0,0 +1,98 @@ +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); + + /// + /// 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. + /// + 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 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/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..dfe0773 100644 --- a/src/Immediate.Jobs.LinqToDB/Immediate.Jobs.LinqToDB.csproj +++ b/src/Immediate.Jobs.LinqToDB/Immediate.Jobs.LinqToDB.csproj @@ -22,7 +22,6 @@ - diff --git a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.cs b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.cs index 46c2d03..85067aa 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,8 @@ 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(); + await using var scope = contextScope.GetScope(out var connection); + _ = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); try { @@ -62,7 +54,9 @@ public async ValueTask> GetIncomingEdgesAsync ) { 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) @@ -138,7 +132,7 @@ await EvaluateInitialDependenciesAsync( connection, jobEntities, edgeEntities, - _timeProvider.GetUtcNow().UtcTicks, + timeProvider.GetUtcNow().UtcTicks, cancellationToken ).ConfigureAwait(false); @@ -160,7 +154,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); @@ -181,7 +175,7 @@ public async ValueTask> AcquireDueJobsAsync( 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 +189,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 +229,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 +252,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 +426,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 +481,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 +606,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; @@ -692,15 +693,18 @@ public async ValueTask> AcquireJobsAsync( ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lease, TimeSpan.Zero); 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 +719,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 { @@ -820,11 +825,11 @@ public async ValueTask RenewLeaseAsync( CancellationToken cancellationToken = default ) { - ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lease, TimeSpan.Zero); - await using var connection = CreateConnection(); + 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); @@ -910,7 +915,8 @@ public async ValueTask UpsertRecurringAsync( ArgumentNullException.ThrowIfNull(schedule); 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 +959,8 @@ public async ValueTask RemoveObsoleteCodeDefinedRecurringAsync( CancellationToken cancellationToken = default ) { - ArgumentNullException.ThrowIfNull(activeScheduleNames); - await using var connection = CreateConnection(); + 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 +970,8 @@ public async ValueTask RemoveObsoleteCodeDefinedRecurringAsync( /// public async ValueTask RemoveRecurringAsync(string name, CancellationToken cancellationToken = default) { - ArgumentException.ThrowIfNullOrWhiteSpace(name); - await using var connection = CreateConnection(); + await using var scope = contextScope.GetScope(out var connection); + var removed = await Recurring(connection) .Where(schedule => schedule.Name == name && !schedule.IsCodeDefined) .DeleteAsync(cancellationToken) @@ -987,8 +993,8 @@ public ValueTask ResumeRecurringAsync(string name, CancellationToken cancellatio private async ValueTask SetRecurringPausedAsync(string name, bool paused, CancellationToken cancellationToken) { - ArgumentException.ThrowIfNullOrWhiteSpace(name); - await using var connection = CreateConnection(); + 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 +1012,8 @@ public async ValueTask> GetDueRecurringAsync CancellationToken cancellationToken = default ) { - ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(batchSize, 0); - await using var connection = CreateConnection(); + 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 +1031,8 @@ public async ValueTask MaterializeRecurringAsync( CancellationToken cancellationToken = default ) { - ArgumentNullException.ThrowIfNull(schedule); - ArgumentNullException.ThrowIfNull(job); - await using var connection = CreateConnection(); + await using var scope = contextScope.GetScope(out var connection); + _ = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); try { @@ -1073,7 +1078,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 +1104,8 @@ public async ValueTask GetMonitoringSnapshotAsync( CancellationToken cancellationToken = default ) { - await using var connection = CreateConnection(); + 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 +1119,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 +1127,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 +1147,8 @@ public async ValueTask> QueryJobsAsync( CancellationToken cancellationToken = default ) { - await using var connection = CreateConnection(); + 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 +1181,8 @@ public async ValueTask> QueryJobExecutionsAsyn CancellationToken cancellationToken = default ) { - await using var connection = CreateConnection(); + 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 +1234,8 @@ public async ValueTask> QueryJobExecutionsAsyn CancellationToken cancellationToken = default ) { - ArgumentException.ThrowIfNullOrWhiteSpace(batchId); - await using var connection = CreateConnection(); + 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 +1247,8 @@ public async ValueTask> QueryBatchesAsync( CancellationToken cancellationToken = default ) { - await using var connection = CreateConnection(); + 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 +1268,8 @@ public async ValueTask> QueryBatchMembersAsync( CancellationToken cancellationToken = default ) { - await using var connection = CreateConnection(); + 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 +1298,8 @@ public async ValueTask> QueryBatchMembersAsync( CancellationToken cancellationToken = default ) { - ArgumentException.ThrowIfNullOrWhiteSpace(batchId); - await using var connection = CreateConnection(); + 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 +1328,8 @@ public async ValueTask> QueryBatchMembersAsync( CancellationToken cancellationToken = default ) { - await using var connection = CreateConnection(); + 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) @@ -1369,7 +1381,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 +1435,8 @@ await PropagateTerminalAsync( /// public async ValueTask DeleteBatchAsync(string batchId, CancellationToken cancellationToken = default) { - ArgumentException.ThrowIfNullOrWhiteSpace(batchId); - await using var connection = CreateConnection(); + await using var scope = contextScope.GetScope(out var connection); + _ = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); try { @@ -1481,7 +1493,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) @@ -1546,7 +1558,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 +1575,8 @@ 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(); + await using var scope = contextScope.GetScope(out var connection); + _ = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); try { @@ -1615,8 +1627,9 @@ public async ValueTask PurgeJobsAsync( CancellationToken cancellationToken = default ) { - var now = _timeProvider.GetUtcNow(); - await using var connection = CreateConnection(); + await using var scope = contextScope.GetScope(out var connection); + + var now = timeProvider.GetUtcNow(); _ = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); try { @@ -1667,9 +1680,11 @@ public async ValueTask PurgeBatchesAsync( CancellationToken cancellationToken = default ) { - var now = _timeProvider.GetUtcNow(); - await using var connection = CreateConnection(); + 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 +1731,9 @@ 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; + 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) @@ -1759,7 +1774,8 @@ public async ValueTask IsHealthyAsync(CancellationToken cancellationToken { 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 +1824,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 +1874,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 +2418,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 +2481,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 +2601,6 @@ CancellationToken cancellationToken return updated != 0; } - private DataConnection CreateConnection() => new(_dataOptions); - private ITable Jobs(DataConnection connection) => WithSchema(connection.GetTable()); @@ -2605,11 +2622,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/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..5f9c059 --- /dev/null +++ b/src/Immediate.Jobs.Redis/ImmediateJobsRedisBuilder.cs @@ -0,0 +1,61 @@ +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 + ); +} + +internal sealed class ImmediateJobsRedisBuilder(IImmediateJobsStorageBuilder builder, OptionsBuilder optionsBuilder) : IImmediateJobsRedisBuilder +{ + public IImmediateJobsRedisBuilder ConfigureRedis(Action> configureRedis) + { + ArgumentNullException.ThrowIfNull(configureRedis); + + configureRedis(optionsBuilder); + 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..ae184b3 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,106 @@ 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.Enabled = 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."); - - if (_factory is null) - ImmediateJobException.Throw("Cannot select distributed operation mode when no durable storage provider has been provided."); + ArgumentNullException.ThrowIfNull(configureJobs); - _storageMode = JobStorageMode.Distributed; + OptionsBuilder.Configure(configureJobs); 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() { - UseStorage(durableStorageFactory); - UseDistributed(); + FairQueueOptionsBuilder.Configure(o => o.Enabled = true); return this; } - internal void ValidateAndRegister(IServiceCollection services) + public IImmediateJobsBuilder UseFairQueues( + Action> configureFairQueues + ) { - // 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(configureFairQueues); - return; - } + FairQueueOptionsBuilder.Configure(o => o.Enabled = true); + configureFairQueues(FairQueueOptionsBuilder); + return this; + } - if (_factory is null) - { - // none, with no factory is base-state; aka in-memory - if (_storageMode is JobStorageMode.None) - return; + public IImmediateJobsBuilder ConfigureStorage( + Action configure + ) + { + ArgumentNullException.ThrowIfNull(configure); - ImmediateJobException.Throw("Durable storage is required, but no durable storage provider has been provided."); - } + if (Services.Any(s => s.ServiceType == typeof(ImmediateJobsStorageBuilder))) + ImmediateJobException.Throw("Cannot configure storage multiple times."); - // only check for explicit distributed - if (_storageMode is JobStorageMode.Distributed) - { - services.Replace(ServiceDescriptor.Singleton(_factory)); - return; - } + StorageOptionsBuilder.Configure(o => o.Configured = true); - // none or explicit single-server are both single-server - services.Replace( - ServiceDescriptor.Singleton( - sp => new SingleServerJobStorage( - _factory(sp), - sp.GetRequiredService() - ) - ) - ); + var builder = new ImmediateJobsStorageBuilder(Services); + configure(builder); + builder.ValidateAndRegister(); + + return this; } } diff --git a/src/Immediate.Jobs.Shared/ImmediateJobsOptions.cs b/src/Immediate.Jobs.Shared/ImmediateJobsOptions.cs index 8dcd21e..1ffc231 100644 --- a/src/Immediate.Jobs.Shared/ImmediateJobsOptions.cs +++ b/src/Immediate.Jobs.Shared/ImmediateJobsOptions.cs @@ -8,6 +8,11 @@ namespace Immediate.Jobs.Shared; [Validate] public sealed partial class ImmediateJobsOptions : IValidationTarget { + /// + /// Controls whether workers are enabled. + /// + public bool Enabled { 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..7be5e75 --- /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. + /// + /// + /// This options instance. + /// + 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. + /// + /// + /// This options instance. + /// + 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. + /// + /// + /// This options instance. + /// + IImmediateJobsStorageBuilder UseStorage< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TJobStorage + >() where TJobStorage : class, IJobStorage; + + /// + /// Selects memory-primary, durable-replica operation for one scheduler server. + /// + /// + /// This options instance. + /// + IImmediateJobsStorageBuilder UseSingleServer(); + + /// + /// Selects memory-primary operation with the supplied durable replica. + /// + /// + /// The factory that creates the durable storage replica. + /// + /// + /// This options instance. + /// + IImmediateJobsStorageBuilder UseSingleServer(Func durableStorageFactory); + + /// + /// Selects durable-storage-primary operation for multiple scheduler servers. + /// + /// + /// This options instance. + /// + IImmediateJobsStorageBuilder UseDistributed(); + + /// + /// Selects durable-storage-primary operation for multiple scheduler servers. + /// + /// + /// The factory that creates the durable storage replica. + /// + /// + /// This options instance. + /// + 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..02b854a 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.Enabled) + 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 From da6178b4f39613cd47418cda964a18b40f6ad1a0 Mon Sep 17 00:00:00 2001 From: Stuart Turner Date: Mon, 17 Aug 2026 20:39:19 -0500 Subject: [PATCH 2/4] Finish building tests --- .github/FUNDING.yml | 3 + Directory.Packages.props | 9 +- .../ImmediateJobsDashboardBuilder.cs | 23 +- .../EntityFrameworkCoreJobStorage.cs | 221 +++++++++----- .../Immediate.Jobs.LinqToDB.csproj | 1 + .../LinqToDBJobStorage.cs | 154 +++++++--- .../LinqToDBSchemaExtensions.cs | 46 +-- .../ImmediateJobsRedisBuilder.cs | 20 ++ .../ImmediateJobsBuilder.cs | 2 +- .../ImmediateJobsOptions.cs | 6 +- .../Internals/JobSchedulingService.cs | 2 +- .../Storage/JobStorageConformanceSuite.cs | 7 + .../Storage/RecurringStorageConformance.cs | 5 - .../Immediate.Jobs.FunctionalTests.csproj | 9 +- .../Packages/DashboardPackageTests.cs | 196 +++++++------ .../QueueSchedulerTests.cs | 4 +- .../RecurringSchedulerTests.cs | 2 +- .../StorageCapabilityTests.cs | 2 +- .../ConformanceFixtures.cs | 272 ------------------ .../EntityFrameworkCoreConformanceTests.cs | 199 ++++++++++++- .../Immediate.Jobs.StorageTests.csproj | 33 ++- ...JobStorageConformanceTestCaseSerializer.cs | 33 +++ .../LinqToDBConformanceTests.cs | 156 +++++++++- .../OptionsPatternTests.cs | 47 --- .../RedisConformanceTests.cs | 79 +++++ ....ServiceCollectionExtensions.g.verified.cs | 2 +- ....ServiceCollectionExtensions.g.verified.cs | 2 +- ....ServiceCollectionExtensions.g.verified.cs | 2 +- ....ServiceCollectionExtensions.g.verified.cs | 2 +- ....ServiceCollectionExtensions.g.verified.cs | 2 +- ....ServiceCollectionExtensions.g.verified.cs | 2 +- ....ServiceCollectionExtensions.g.verified.cs | 2 +- ....ServiceCollectionExtensions.g.verified.cs | 2 +- ....ServiceCollectionExtensions.g.verified.cs | 2 +- ....ServiceCollectionExtensions.g.verified.cs | 2 +- ....ServiceCollectionExtensions.g.verified.cs | 2 +- ....ServiceCollectionExtensions.g.verified.cs | 2 +- 37 files changed, 961 insertions(+), 594 deletions(-) create mode 100644 .github/FUNDING.yml create mode 100644 tests/Immediate.Jobs.StorageTests/JobStorageConformanceTestCaseSerializer.cs delete mode 100644 tests/Immediate.Jobs.StorageTests/OptionsPatternTests.cs 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 7fd3138..54ad902 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,12 +14,12 @@ + - @@ -53,6 +53,7 @@ + @@ -60,6 +61,7 @@ + @@ -76,6 +78,7 @@ + @@ -84,6 +87,7 @@ + @@ -91,6 +95,7 @@ + @@ -99,6 +104,7 @@ + @@ -106,6 +112,7 @@ + diff --git a/src/Immediate.Jobs.Dashboard/ImmediateJobsDashboardBuilder.cs b/src/Immediate.Jobs.Dashboard/ImmediateJobsDashboardBuilder.cs index a23973e..9102058 100644 --- a/src/Immediate.Jobs.Dashboard/ImmediateJobsDashboardBuilder.cs +++ b/src/Immediate.Jobs.Dashboard/ImmediateJobsDashboardBuilder.cs @@ -19,7 +19,22 @@ public interface IImmediateJobsDashboardBuilder : IImmediateJobsBuilder /// /// The supplied builder. /// - IImmediateJobsDashboardBuilder ConfigureDashboard(Action> configureDashboard); + 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. @@ -70,6 +85,12 @@ 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) => 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.LinqToDB/Immediate.Jobs.LinqToDB.csproj b/src/Immediate.Jobs.LinqToDB/Immediate.Jobs.LinqToDB.csproj index dfe0773..9db1fbd 100644 --- a/src/Immediate.Jobs.LinqToDB/Immediate.Jobs.LinqToDB.csproj +++ b/src/Immediate.Jobs.LinqToDB/Immediate.Jobs.LinqToDB.csproj @@ -22,6 +22,7 @@ + diff --git a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.cs b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.cs index 85067aa..44f0f20 100644 --- a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.cs +++ b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.cs @@ -31,6 +31,8 @@ TimeProvider timeProvider /// public async ValueTask EnqueueAsync(JobRecord job, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + await using var scope = contextScope.GetScope(out var connection); _ = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); @@ -53,6 +55,8 @@ public async ValueTask> GetIncomingEdgesAsync CancellationToken cancellationToken = default ) { + cancellationToken.ThrowIfCancellationRequested(); + var ids = childJobIds.Distinct(StringComparer.Ordinal).ToArray(); await using var scope = contextScope.GetScope(out var connection); @@ -68,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( @@ -172,6 +173,8 @@ public async ValueTask> AcquireDueJobsAsync( CancellationToken cancellationToken = default ) { + cancellationToken.ThrowIfCancellationRequested(); + if (request.FairQueues is not null) return await AcquireDueJobsFairAsync(request, cancellationToken).ConfigureAwait(false); @@ -688,9 +691,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 []; var now = timeProvider.GetUtcNow().UtcTicks; @@ -789,6 +791,8 @@ public async ValueTask SetExecutionTelemetryAsync( CancellationToken cancellationToken = default ) { + cancellationToken.ThrowIfCancellationRequested(); + await RetryConcurrencyAsync(async connection => { var job = await Jobs(connection).SingleOrDefaultAsync( @@ -825,6 +829,8 @@ public async ValueTask RenewLeaseAsync( CancellationToken cancellationToken = default ) { + cancellationToken.ThrowIfCancellationRequested(); + await using var scope = contextScope.GetScope(out var connection); var updated = await Jobs(connection) @@ -838,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, @@ -854,8 +865,9 @@ public ValueTask CompleteWithContinuationsAsync( CancellationToken cancellationToken = default ) { - ArgumentNullException.ThrowIfNull(additions); - return MutateOwnedWithDependenciesAsync( + cancellationToken.ThrowIfCancellationRequested(); + + await MutateOwnedWithDependenciesAsync( jobId, executionNumber, workerId, @@ -868,7 +880,7 @@ public ValueTask CompleteWithContinuationsAsync( } /// - public ValueTask AddBatchJobAsync( + public async ValueTask AddBatchJobAsync( string currentJobId, int executionNumber, JobRecord job, @@ -876,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( @@ -912,7 +926,8 @@ public async ValueTask UpsertRecurringAsync( CancellationToken cancellationToken = default ) { - ArgumentNullException.ThrowIfNull(schedule); + cancellationToken.ThrowIfCancellationRequested(); + for (var attempt = 0; attempt < MaxConcurrencyAttempts; attempt++) { await using var scope = contextScope.GetScope(out var connection); @@ -959,6 +974,8 @@ public async ValueTask RemoveObsoleteCodeDefinedRecurringAsync( CancellationToken cancellationToken = default ) { + cancellationToken.ThrowIfCancellationRequested(); + await using var scope = contextScope.GetScope(out var connection); var schedules = Recurring(connection).Where(schedule => schedule.IsCodeDefined); @@ -984,15 +1001,25 @@ 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) { + cancellationToken.ThrowIfCancellationRequested(); + await using var scope = contextScope.GetScope(out var connection); var updated = await Recurring(connection) @@ -1012,6 +1039,8 @@ public async ValueTask> GetDueRecurringAsync CancellationToken cancellationToken = default ) { + cancellationToken.ThrowIfCancellationRequested(); + await using var scope = contextScope.GetScope(out var connection); var schedules = await Recurring(connection) @@ -1031,6 +1060,8 @@ public async ValueTask MaterializeRecurringAsync( CancellationToken cancellationToken = default ) { + cancellationToken.ThrowIfCancellationRequested(); + await using var scope = contextScope.GetScope(out var connection); _ = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); @@ -1147,6 +1178,8 @@ public async ValueTask> QueryJobsAsync( CancellationToken cancellationToken = default ) { + cancellationToken.ThrowIfCancellationRequested(); + await using var scope = contextScope.GetScope(out var connection); IQueryable jobs = Jobs(connection); @@ -1181,6 +1214,8 @@ public async ValueTask> QueryJobExecutionsAsyn CancellationToken cancellationToken = default ) { + cancellationToken.ThrowIfCancellationRequested(); + await using var scope = contextScope.GetScope(out var connection); var job = await Jobs(connection) @@ -1234,6 +1269,8 @@ public async ValueTask> QueryJobExecutionsAsyn CancellationToken cancellationToken = default ) { + cancellationToken.ThrowIfCancellationRequested(); + await using var scope = contextScope.GetScope(out var connection); var batch = await Batches(connection).SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken) @@ -1247,6 +1284,8 @@ public async ValueTask> QueryBatchesAsync( CancellationToken cancellationToken = default ) { + cancellationToken.ThrowIfCancellationRequested(); + await using var scope = contextScope.GetScope(out var connection); IQueryable batches = Batches(connection); @@ -1268,6 +1307,8 @@ public async ValueTask> QueryBatchMembersAsync( CancellationToken cancellationToken = default ) { + cancellationToken.ThrowIfCancellationRequested(); + await using var scope = contextScope.GetScope(out var connection); var jobs = Jobs(connection).Where(job => job.BatchId == batchId); @@ -1298,6 +1339,8 @@ public async ValueTask> QueryBatchMembersAsync( CancellationToken cancellationToken = default ) { + cancellationToken.ThrowIfCancellationRequested(); + await using var scope = contextScope.GetScope(out var connection); if (!await Batches(connection).AnyAsync(batch => batch.Id == batchId, cancellationToken).ConfigureAwait(false)) @@ -1328,6 +1371,8 @@ public async ValueTask> QueryBatchMembersAsync( CancellationToken cancellationToken = default ) { + cancellationToken.ThrowIfCancellationRequested(); + await using var scope = contextScope.GetScope(out var connection); var job = await Jobs(connection).SingleOrDefaultAsync(item => item.Id == jobId, cancellationToken) @@ -1360,7 +1405,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( @@ -1435,6 +1481,8 @@ await PropagateTerminalAsync( /// public async ValueTask DeleteBatchAsync(string batchId, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + await using var scope = contextScope.GetScope(out var connection); _ = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); @@ -1471,7 +1519,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), @@ -1519,10 +1568,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 ); @@ -1575,6 +1625,8 @@ private async Task RetryCoreAsync(DataConnection connection, string jobId, Cance /// public async ValueTask DeleteAsync(string jobId, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + await using var scope = contextScope.GetScope(out var connection); _ = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); @@ -1627,6 +1679,8 @@ public async ValueTask PurgeJobsAsync( CancellationToken cancellationToken = default ) { + cancellationToken.ThrowIfCancellationRequested(); + await using var scope = contextScope.GetScope(out var connection); var now = timeProvider.GetUtcNow(); @@ -1680,6 +1734,8 @@ public async ValueTask PurgeBatchesAsync( CancellationToken cancellationToken = default ) { + cancellationToken.ThrowIfCancellationRequested(); + await using var scope = contextScope.GetScope(out var connection); var now = timeProvider.GetUtcNow(); @@ -1731,6 +1787,8 @@ public async ValueTask PurgeBatchesAsync( /// public async ValueTask HeartbeatAsync(JobServerSnapshot server, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + await using var scope = contextScope.GetScope(out var connection); var cutoff = (timeProvider.GetUtcNow() - TimeSpan.FromMinutes(2)).UtcTicks; @@ -1772,6 +1830,8 @@ public async ValueTask HeartbeatAsync(JobServerSnapshot server, CancellationToke /// public async ValueTask IsHealthyAsync(CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + try { await using var scope = contextScope.GetScope(out var connection); 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.Redis/ImmediateJobsRedisBuilder.cs b/src/Immediate.Jobs.Redis/ImmediateJobsRedisBuilder.cs index 5f9c059..8ed6610 100644 --- a/src/Immediate.Jobs.Redis/ImmediateJobsRedisBuilder.cs +++ b/src/Immediate.Jobs.Redis/ImmediateJobsRedisBuilder.cs @@ -22,6 +22,18 @@ public interface IImmediateJobsRedisBuilder : IImmediateJobsStorageBuilder 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 @@ -34,6 +46,14 @@ public IImmediateJobsRedisBuilder ConfigureRedis(Action configureRedis) + { + ArgumentNullException.ThrowIfNull(configureRedis); + + optionsBuilder.Configure(configureRedis); + return this; + } + public IServiceCollection Services => builder.Services; public IImmediateJobsStorageBuilder UseDistributed() => diff --git a/src/Immediate.Jobs.Shared/ImmediateJobsBuilder.cs b/src/Immediate.Jobs.Shared/ImmediateJobsBuilder.cs index ae184b3..09c6ea8 100644 --- a/src/Immediate.Jobs.Shared/ImmediateJobsBuilder.cs +++ b/src/Immediate.Jobs.Shared/ImmediateJobsBuilder.cs @@ -150,7 +150,7 @@ public IImmediateJobsBuilder AddHealthCheck( public IImmediateJobsBuilder DisableWorkers() { - OptionsBuilder.PostConfigure(o => o.Enabled = false); + OptionsBuilder.PostConfigure(o => o.IsJobSchedulingServiceEnabled = false); return this; } diff --git a/src/Immediate.Jobs.Shared/ImmediateJobsOptions.cs b/src/Immediate.Jobs.Shared/ImmediateJobsOptions.cs index 1ffc231..9539a83 100644 --- a/src/Immediate.Jobs.Shared/ImmediateJobsOptions.cs +++ b/src/Immediate.Jobs.Shared/ImmediateJobsOptions.cs @@ -3,15 +3,15 @@ namespace Immediate.Jobs.Shared; /// -/// Global scheduler and worker options. +/// Process-wide scheduler and worker options. /// [Validate] public sealed partial class ImmediateJobsOptions : IValidationTarget { /// - /// Controls whether workers are enabled. + /// Controls whether the scheduling service and it's attendant workers are enabled. /// - public bool Enabled { get; set; } = true; + public bool IsJobSchedulingServiceEnabled { get; set; } = true; /// /// Maximum concurrently executing jobs on this node. diff --git a/src/Immediate.Jobs.Shared/Internals/JobSchedulingService.cs b/src/Immediate.Jobs.Shared/Internals/JobSchedulingService.cs index 02b854a..effc917 100644 --- a/src/Immediate.Jobs.Shared/Internals/JobSchedulingService.cs +++ b/src/Immediate.Jobs.Shared/Internals/JobSchedulingService.cs @@ -136,7 +136,7 @@ JobSchedulerState state /// protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - if (!_options.Enabled) + if (!_options.IsJobSchedulingServiceEnabled) return; await _storage.InitializeAsync(stoppingToken).ConfigureAwait(false); 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..03fc652 100644 --- a/tests/Immediate.Jobs.StorageTests/EntityFrameworkCoreConformanceTests.cs +++ b/tests/Immediate.Jobs.StorageTests/EntityFrameworkCoreConformanceTests.cs @@ -1,5 +1,15 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; +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; namespace Immediate.Jobs.StorageTests; @@ -25,13 +35,14 @@ JobStorageConformanceTestCase testCase ) { ArgumentNullException.ThrowIfNull(testCase); + await using var fixture = await RelationalConformanceFixture.CreateAsync( containers, database, - ConformanceAdapter.EntityFrameworkCore, TestContext.Current.CancellationToken, useDistributedTopology: topology == ConformanceTopology.Distributed ); + await testCase.RunAsync(fixture.Services, TestContext.Current.CancellationToken); } @@ -53,3 +64,189 @@ private static TheoryData _services + ?? throw new InvalidOperationException("The relational conformance fixture has not finished initializing."); + + internal static async ValueTask CreateAsync( + StorageContainers? containers, + ConformanceDatabase database, + 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 = 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 + { + 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); + } + + 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); + } +} + +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); +} 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..78788f5 100644 --- a/tests/Immediate.Jobs.StorageTests/LinqToDBConformanceTests.cs +++ b/tests/Immediate.Jobs.StorageTests/LinqToDBConformanceTests.cs @@ -1,5 +1,14 @@ +using System.Diagnostics.CodeAnalysis; +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; namespace Immediate.Jobs.StorageTests; @@ -28,7 +37,6 @@ JobStorageConformanceTestCase testCase await using var fixture = await RelationalConformanceFixture.CreateAsync( containers, database, - ConformanceAdapter.LinqToDB, TestContext.Current.CancellationToken, useDistributedTopology: topology == ConformanceTopology.Distributed ); @@ -53,3 +61,149 @@ private static TheoryData _services + ?? throw new InvalidOperationException("The relational conformance fixture has not finished initializing."); + + internal static async ValueTask CreateAsync( + StorageContainers? containers, + ConformanceDatabase database, + 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 dataOptions = database switch + { + 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 + { + 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, cancellationToken).ConfigureAwait(false); + + 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); + } +} + +[SuppressMessage("Performance", "CA1812", Justification = "Used via attribute")] +file sealed class ConformanceDbContext( + DataOptions dataOptions +) : DataConnection(dataOptions); 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..959f923 100644 --- a/tests/Immediate.Jobs.StorageTests/RedisConformanceTests.cs +++ b/tests/Immediate.Jobs.StorageTests/RedisConformanceTests.cs @@ -1,5 +1,9 @@ +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; namespace Immediate.Jobs.StorageTests; @@ -18,10 +22,85 @@ 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).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.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().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); + } +} 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 ) From 30e3ed796164a196129b8c75ea441c23f030bb28 Mon Sep 17 00:00:00 2001 From: Stuart Turner Date: Wed, 19 Aug 2026 10:11:52 -0500 Subject: [PATCH 3/4] Improve parallelization --- .editorconfig | 1 + .../EntityFrameworkCoreConformanceTests.cs | 205 +++++++++++------ .../LinqToDBConformanceTests.cs | 207 ++++++++++++------ .../RedisConformanceTests.cs | 28 ++- .../StorageContainers.cs | 47 ---- 5 files changed, 291 insertions(+), 197 deletions(-) delete mode 100644 tests/Immediate.Jobs.StorageTests/StorageContainers.cs 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/tests/Immediate.Jobs.StorageTests/EntityFrameworkCoreConformanceTests.cs b/tests/Immediate.Jobs.StorageTests/EntityFrameworkCoreConformanceTests.cs index 03fc652..d358837 100644 --- a/tests/Immediate.Jobs.StorageTests/EntityFrameworkCoreConformanceTests.cs +++ b/tests/Immediate.Jobs.StorageTests/EntityFrameworkCoreConformanceTests.cs @@ -1,5 +1,6 @@ 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; @@ -10,11 +11,12 @@ 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 | @@ -23,49 +25,90 @@ 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( + ConformanceDatabase.PostgreSql, + useDistributedTopology: topology == ConformanceTopology.Distributed, + container: container.PostgreSql + ); + await testCase.RunAsync(fixture.Services, TestContext.Current.CancellationToken); + } +} + +[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 + ) + { await using var fixture = await RelationalConformanceFixture.CreateAsync( - containers, - database, - TestContext.Current.CancellationToken, - useDistributedTopology: topology == ConformanceTopology.Distributed + ConformanceDatabase.SqlServer, + useDistributedTopology: topology == ConformanceTopology.Distributed, + container: container.SqlServer ); await testCase.RunAsync(fixture.Services, TestContext.Current.CancellationToken); } +} - private static TheoryData CreateCases() +[Collection("EntityFrameworkCore-SQLite")] +public sealed class EntityFrameworkCoreSQLiteConformanceTests +{ + [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()) - { - foreach (var topology in Enum.GetValues()) - { - var capabilities = topology == ConformanceTopology.Distributed - ? Capabilities - : Capabilities & ~StorageCapabilities.Replica; - foreach (var testCase in JobStorageConformanceSuite.GetCases(capabilities)) - data.Add(database, topology, testCase); - } - } + await using var fixture = await RelationalConformanceFixture.CreateAsync( + ConformanceDatabase.Sqlite, + useDistributedTopology: topology == ConformanceTopology.Distributed, + container: null + ); - return data; + await testCase.RunAsync(fixture.Services, TestContext.Current.CancellationToken); } } -file sealed class RelationalConformanceFixture : IAsyncDisposable +file sealed class RelationalConformanceFixture( + ConformanceDatabase database, + ServiceProvider services, + string connectionString, + string? schema, + string? sqlitePath +) : IAsyncDisposable { private static readonly string[] SqlServerTables = [ @@ -78,35 +121,12 @@ private static TheoryData _services - ?? throw new InvalidOperationException("The relational conformance fixture has not finished initializing."); + internal IServiceProvider Services => services; internal static async ValueTask CreateAsync( - StorageContainers? containers, ConformanceDatabase database, - CancellationToken cancellationToken, - bool useDistributedTopology = true + bool useDistributedTopology, + IDatabaseContainer? container ) { var schema = database == ConformanceDatabase.Sqlite ? null : "jobs_" + Guid.NewGuid().ToString("N"); @@ -117,8 +137,8 @@ internal static async ValueTask CreateAsync( var connectionString = database switch { ConformanceDatabase.Sqlite => $"Data Source={sqlitePath}", - ConformanceDatabase.PostgreSql => GetContainers(containers).PostgreSql.GetConnectionString(), - ConformanceDatabase.SqlServer => GetContainers(containers).SqlServer.GetConnectionString(), + ConformanceDatabase.PostgreSql when container is { } => container.GetConnectionString(), + ConformanceDatabase.SqlServer when container is { } => container.GetConnectionString(), _ => throw new ArgumentOutOfRangeException(nameof(database)), }; @@ -144,6 +164,7 @@ internal static async ValueTask CreateAsync( services.AddLogging(); services.AddSingleton(clock); services.AddSingleton(clock); + services.AddSingleton>(contextFactory); services.AddImmediateJobsCore() @@ -177,51 +198,48 @@ internal static async ValueTask CreateAsync( )) { if (!string.IsNullOrWhiteSpace(batch)) - await context.Database.ExecuteSqlRawAsync(batch, cancellationToken).ConfigureAwait(false); + await context.Database.ExecuteSqlRawAsync(batch, TestContext.Current.CancellationToken); } return fixture; } catch { - await fixture.DisposeAsync().ConfigureAwait(false); + await fixture.DisposeAsync(); 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 (services is not null) + await services.DisposeAsync(); - if (_sqlitePath is not null) + if (sqlitePath is not null) { SqliteConnection.ClearAllPools(); - File.Delete(_sqlitePath); + File.Delete(sqlitePath); return; } - var cleanupOptions = _database == ConformanceDatabase.PostgreSql - ? new DataOptions().UsePostgreSQL(_connectionString) - : new DataOptions().UseSqlServer(_connectionString); + var cleanupOptions = database == ConformanceDatabase.PostgreSql + ? new DataOptions().UsePostgreSQL(connectionString) + : new DataOptions().UseSqlServer(connectionString); await using var connection = new DataConnection(cleanupOptions); - if (_database == ConformanceDatabase.PostgreSql) + if (database == ConformanceDatabase.PostgreSql) { - await connection.ExecuteAsync($"DROP SCHEMA IF EXISTS \"{_schema}\" CASCADE").ConfigureAwait(false); + await connection.ExecuteAsync($"DROP SCHEMA IF EXISTS \"{schema}\" CASCADE"); return; } foreach (var table in SqlServerTables) - await connection.ExecuteAsync($"DROP TABLE IF EXISTS [{_schema}].[{table}]").ConfigureAwait(false); + 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}]')" - ).ConfigureAwait(false); + $"IF SCHEMA_ID(N'{schema}') IS NOT NULL EXEC(N'DROP SCHEMA [{schema}]')" + ); } } @@ -239,7 +257,6 @@ file sealed class ConformanceDbContext( ) : DbContext(options) { internal string? Schema { get; } = schema; - protected override void OnModelCreating(ModelBuilder modelBuilder) => _ = modelBuilder.AddImmediateJobs(Schema); } @@ -250,3 +267,47 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) => 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/LinqToDBConformanceTests.cs b/tests/Immediate.Jobs.StorageTests/LinqToDBConformanceTests.cs index 78788f5..2ac21ce 100644 --- a/tests/Immediate.Jobs.StorageTests/LinqToDBConformanceTests.cs +++ b/tests/Immediate.Jobs.StorageTests/LinqToDBConformanceTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using DotNet.Testcontainers.Containers; using Immediate.Jobs.LinqToDB; using Immediate.Jobs.Shared.Storage; using Immediate.Jobs.Testing; @@ -9,11 +10,12 @@ 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 | @@ -22,47 +24,90 @@ 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( - ConformanceDatabase database, ConformanceTopology topology, JobStorageConformanceTestCase testCase ) { - ArgumentNullException.ThrowIfNull(testCase); await using var fixture = await RelationalConformanceFixture.CreateAsync( - containers, - database, - 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(LinqToDBMsSQLFixtureGroup.Name)] +public sealed class LinqToDBMsSQLConformanceTests(LinqToDBMsSQLContainer container) +{ + [Theory] + [MemberData(nameof(LinqToDBConformanceTestCases.CreateCases), MemberType = typeof(LinqToDBConformanceTestCases))] + public async Task LinqToDBConforms( + ConformanceTopology topology, + JobStorageConformanceTestCase testCase + ) { - var data = new TheoryData(); - foreach (var database in Enum.GetValues()) - { - foreach (var topology in Enum.GetValues()) - { - var capabilities = topology == ConformanceTopology.Distributed - ? Capabilities - : Capabilities & ~StorageCapabilities.Replica; - foreach (var testCase in JobStorageConformanceSuite.GetCases(capabilities)) - data.Add(database, topology, testCase); - } - } + await using var fixture = await RelationalConformanceFixture.CreateAsync( + ConformanceDatabase.SqlServer, + useDistributedTopology: topology == ConformanceTopology.Distributed, + container: container.SqlServer + ); - return data; + 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( + 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 : IAsyncDisposable +file sealed class RelationalConformanceFixture( + ConformanceDatabase database, + ServiceProvider services, + string connectionString, + string? schema, + string? sqlitePath +) : IAsyncDisposable { private static readonly string[] SqlServerTables = [ @@ -75,35 +120,12 @@ private static TheoryData _services - ?? throw new InvalidOperationException("The relational conformance fixture has not finished initializing."); + internal IServiceProvider Services => services; internal static async ValueTask CreateAsync( - StorageContainers? containers, ConformanceDatabase database, - CancellationToken cancellationToken, - bool useDistributedTopology = true + bool useDistributedTopology, + IDatabaseContainer? container ) { var schema = database == ConformanceDatabase.Sqlite ? null : "jobs_" + Guid.NewGuid().ToString("N"); @@ -114,8 +136,8 @@ internal static async ValueTask CreateAsync( var connectionString = database switch { ConformanceDatabase.Sqlite => $"Data Source={sqlitePath}", - ConformanceDatabase.PostgreSql => GetContainers(containers).PostgreSql.GetConnectionString(), - ConformanceDatabase.SqlServer => GetContainers(containers).SqlServer.GetConnectionString(), + ConformanceDatabase.PostgreSql when container is { } => container.GetConnectionString(), + ConformanceDatabase.SqlServer when container is { } => container.GetConnectionString(), _ => throw new ArgumentOutOfRangeException(nameof(database)), }; @@ -156,50 +178,47 @@ internal static async ValueTask CreateAsync( try { await using (var context = new ConformanceDbContext(dataOptions)) - await context.CreateImmediateJobsSchemaAsync(schema, cancellationToken).ConfigureAwait(false); + await context.CreateImmediateJobsSchemaAsync(schema, TestContext.Current.CancellationToken); return fixture; } catch { - await fixture.DisposeAsync().ConfigureAwait(false); + await fixture.DisposeAsync(); 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 (services is not null) + await services.DisposeAsync(); - if (_sqlitePath is not null) + if (sqlitePath is not null) { SqliteConnection.ClearAllPools(); - File.Delete(_sqlitePath); + File.Delete(sqlitePath); return; } - var cleanupOptions = _database == ConformanceDatabase.PostgreSql - ? new DataOptions().UsePostgreSQL(_connectionString) - : new DataOptions().UseSqlServer(_connectionString); + var cleanupOptions = database == ConformanceDatabase.PostgreSql + ? new DataOptions().UsePostgreSQL(connectionString) + : new DataOptions().UseSqlServer(connectionString); await using var connection = new DataConnection(cleanupOptions); - if (_database == ConformanceDatabase.PostgreSql) + if (database == ConformanceDatabase.PostgreSql) { - _ = await connection.ExecuteAsync($"DROP SCHEMA IF EXISTS \"{_schema}\" CASCADE").ConfigureAwait(false); + await connection.ExecuteAsync($"DROP SCHEMA IF EXISTS \"{schema}\" CASCADE"); return; } foreach (var table in SqlServerTables) - _ = await connection.ExecuteAsync($"DROP TABLE IF EXISTS [{_schema}].[{table}]").ConfigureAwait(false); + 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}]')" - ).ConfigureAwait(false); + await connection.ExecuteAsync( + $"IF SCHEMA_ID(N'{schema}') IS NOT NULL EXEC(N'DROP SCHEMA [{schema}]')" + ); } } @@ -207,3 +226,47 @@ public async ValueTask DisposeAsync() 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/RedisConformanceTests.cs b/tests/Immediate.Jobs.StorageTests/RedisConformanceTests.cs index 959f923..0d217a6 100644 --- a/tests/Immediate.Jobs.StorageTests/RedisConformanceTests.cs +++ b/tests/Immediate.Jobs.StorageTests/RedisConformanceTests.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Time.Testing; using StackExchange.Redis; +using Testcontainers.Redis; namespace Immediate.Jobs.StorageTests; @@ -56,7 +57,7 @@ CancellationToken cancellationToken ) { cancellationToken.ThrowIfCancellationRequested(); - var connection = await ConnectionMultiplexer.ConnectAsync(connectionString).ConfigureAwait(false); + var connection = await ConnectionMultiplexer.ConnectAsync(connectionString); try { var keyPrefix = "immediate-jobs-conformance-" + Guid.NewGuid().ToString("N"); @@ -83,24 +84,39 @@ CancellationToken cancellationToken } catch { - await connection.DisposeAsync().ConfigureAwait(false); + await connection.DisposeAsync(); throw; } } public async ValueTask DisposeAsync() { - await ((ServiceProvider)Services).DisposeAsync().ConfigureAwait(false); + 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}}}:*").ConfigureAwait(false)) + await foreach (var key in server.KeysAsync(pattern: $"{{{_keyPrefix}}}:*")) keys.Add(key); if (keys.Count > 0) - _ = await _connection.GetDatabase().KeyDeleteAsync([.. keys], flags: CommandFlags.None).ConfigureAwait(false); + _ = await _connection.GetDatabase().KeyDeleteAsync([.. keys], flags: CommandFlags.None); } - await _connection.DisposeAsync().ConfigureAwait(false); + 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(); -} From 6e3d864a987da86fb24476bd6b2ff2a7fbe2426e Mon Sep 17 00:00:00 2001 From: Stuart Turner Date: Wed, 19 Aug 2026 10:43:52 -0500 Subject: [PATCH 4/4] Fix comments, missing cancellation checks --- .../ImmediateJobsDashboardBuilder.cs | 2 +- src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.cs | 4 ++++ src/Immediate.Jobs.Shared/ImmediateJobsBuilder.cs | 2 ++ .../ImmediateJobsStorageBuilder.cs | 14 +++++++------- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/Immediate.Jobs.Dashboard/ImmediateJobsDashboardBuilder.cs b/src/Immediate.Jobs.Dashboard/ImmediateJobsDashboardBuilder.cs index 9102058..15f6057 100644 --- a/src/Immediate.Jobs.Dashboard/ImmediateJobsDashboardBuilder.cs +++ b/src/Immediate.Jobs.Dashboard/ImmediateJobsDashboardBuilder.cs @@ -51,7 +51,7 @@ Action configureDashboard /// langword="null"/> when the link is not available, such as before a trace has been created. /// /// - /// This options instance. + /// The supplied builder. /// IImmediateJobsDashboardBuilder AddTelemetryLink( string label, diff --git a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.cs b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.cs index 44f0f20..a076262 100644 --- a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.cs +++ b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.cs @@ -987,6 +987,8 @@ public async ValueTask RemoveObsoleteCodeDefinedRecurringAsync( /// public async ValueTask RemoveRecurringAsync(string name, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + await using var scope = contextScope.GetScope(out var connection); var removed = await Recurring(connection) @@ -1135,6 +1137,8 @@ public async ValueTask GetMonitoringSnapshotAsync( CancellationToken cancellationToken = default ) { + cancellationToken.ThrowIfCancellationRequested(); + await using var scope = contextScope.GetScope(out var connection); var rawCounts = await Jobs(connection) diff --git a/src/Immediate.Jobs.Shared/ImmediateJobsBuilder.cs b/src/Immediate.Jobs.Shared/ImmediateJobsBuilder.cs index 09c6ea8..dc3d850 100644 --- a/src/Immediate.Jobs.Shared/ImmediateJobsBuilder.cs +++ b/src/Immediate.Jobs.Shared/ImmediateJobsBuilder.cs @@ -206,6 +206,8 @@ Action configure configure(builder); builder.ValidateAndRegister(); + Services.AddSingleton(builder); + return this; } } diff --git a/src/Immediate.Jobs.Shared/ImmediateJobsStorageBuilder.cs b/src/Immediate.Jobs.Shared/ImmediateJobsStorageBuilder.cs index 7be5e75..c8eb4b8 100644 --- a/src/Immediate.Jobs.Shared/ImmediateJobsStorageBuilder.cs +++ b/src/Immediate.Jobs.Shared/ImmediateJobsStorageBuilder.cs @@ -19,7 +19,7 @@ public interface IImmediateJobsStorageBuilder /// Selects the non-durable, single-node in-memory provider. /// /// - /// This options instance. + /// The supplied builder. /// IImmediateJobsStorageBuilder UseInMemory(); @@ -31,7 +31,7 @@ public interface IImmediateJobsStorageBuilder /// The factory that creates the durable storage provider. /// /// - /// This options instance. + /// The supplied builder. /// IImmediateJobsStorageBuilder UseStorage(Func factory); @@ -40,7 +40,7 @@ public interface IImmediateJobsStorageBuilder /// authoritative in-process store for a single scheduler server. /// /// - /// This options instance. + /// The supplied builder. /// IImmediateJobsStorageBuilder UseStorage< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TJobStorage @@ -50,7 +50,7 @@ IImmediateJobsStorageBuilder UseStorage< /// Selects memory-primary, durable-replica operation for one scheduler server. /// /// - /// This options instance. + /// The supplied builder. /// IImmediateJobsStorageBuilder UseSingleServer(); @@ -61,7 +61,7 @@ IImmediateJobsStorageBuilder UseStorage< /// The factory that creates the durable storage replica. /// /// - /// This options instance. + /// The supplied builder. /// IImmediateJobsStorageBuilder UseSingleServer(Func durableStorageFactory); @@ -69,7 +69,7 @@ IImmediateJobsStorageBuilder UseStorage< /// Selects durable-storage-primary operation for multiple scheduler servers. /// /// - /// This options instance. + /// The supplied builder. /// IImmediateJobsStorageBuilder UseDistributed(); @@ -80,7 +80,7 @@ IImmediateJobsStorageBuilder UseStorage< /// The factory that creates the durable storage replica. /// /// - /// This options instance. + /// The supplied builder. /// IImmediateJobsStorageBuilder UseDistributed(Func durableStorageFactory); }