From f9d98b3ede1166e6ab8e0788edf29b78f82656c4 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 9 Oct 2026 14:53:07 +0000 Subject: [PATCH 1/7] Persist Job Definitions --- docs/architecture.md | 7 +- docs/conventions.md | 8 +- docs/recurring-jobs.md | 8 +- docs/storage.md | 36 +- docs/testing.md | 2 +- readme.md | 9 + ...261009144316_DefinitionCatalog.Designer.cs | 452 ++++++++++++++++++ .../20261009144316_DefinitionCatalog.cs | 67 +++ .../Migrations/JobsDbContextModelSnapshot.cs | 36 +- src/Common/Utility.cs | 3 + .../DuplicateElementsAnalyzer.cs | 2 +- .../JobClassAnalyzer.cs | 9 +- .../EntityFrameworkCoreJobStorage.Acquire.cs | 4 +- ...ameworkCoreJobStorage.DefinitionCatalog.cs | 124 +++++ .../EntityFrameworkCoreJobStorage.Query.cs | 4 +- .../EntityFrameworkCoreJobStorage.Trigger.cs | 2 +- ...ntityFrameworkCoreJsonSerializerContext.cs | 1 + .../ImmediateJobsModelBuilderExtensions.cs | 41 +- .../LibraryEventIds.cs | 2 + .../ImmediateJobsGenerator.Transform.cs | 3 +- .../LibraryEventIds.cs | 2 + .../LinqToDBEntities.cs | 22 + .../LinqToDBJobStorage.Acquire.cs | 4 +- .../LinqToDBJobStorage.Database.cs | 6 + .../LinqToDBJobStorage.DefinitionCatalog.cs | 106 ++++ .../LinqToDBJobStorage.Query.cs | 4 +- .../LinqToDBJobStorage.Trigger.cs | 2 +- .../LinqToDBJsonSerializerContext.cs | 1 + .../LinqToDBSchemaExtensions.cs | 54 +++ src/Immediate.Jobs.Redis/LibraryEventIds.cs | 2 + .../RedisJobStorage.Acquire.cs | 2 +- .../RedisJobStorage.DefinitionCatalog.cs | 109 +++++ .../RedisJobStorage.Enqueue.cs | 2 +- .../RedisJobStorage.Keys.cs | 4 + .../RedisJobStorage.Query.cs | 4 +- .../RedisJobStorage.Recurring.cs | 2 +- .../RedisJobStorage.Trigger.cs | 4 +- .../RedisJsonSerializerContext.cs | 2 + src/Immediate.Jobs.Redis/RedisScripts.cs | 40 ++ .../Apis/JobDefinitionRecord.cs | 72 +++ .../Internals/JobSchedulingService.cs | 31 +- src/Immediate.Jobs.Shared/JobAttribute.cs | 1 + src/Immediate.Jobs.Shared/LibraryEventIds.cs | 4 + .../Storage/IJobStorage.cs | 30 ++ .../Storage/InMemoryJobStorage.Acquire.cs | 4 +- .../InMemoryJobStorage.DefinitionCatalog.cs | 55 +++ .../Storage/InMemoryJobStorage.Query.cs | 4 +- .../Storage/InMemoryJobStorage.Trigger.cs | 2 +- .../Storage/JobDefinitionReconciliation.cs | 122 +++++ .../Storage/JobDefinitionRegistration.cs | 19 + ...ingleServerJobStorage.DefinitionCatalog.cs | 55 +++ .../CapturingJobStorage.cs | 18 + .../DefinitionCatalogStorageConformance.cs | 125 +++++ .../Storage/JobStorageConformanceSuite.cs | 1 + .../JobDefinitionTests.cs | 87 ++++ .../StorageCapabilityTests.cs | 6 + .../EntityFrameworkCoreConformanceTests.cs | 18 + .../LinqToDBConformanceTests.cs | 17 + .../PostgreSqlJobSearchAssertions.cs | 35 ++ .../StorageConformanceInfrastructureTests.cs | 2 +- .../DuplicateElementsAnalyzerTests.cs | 10 +- .../AnalyzerTests/JobClassAnalyzerTests.cs | 45 ++ .../GeneratorTests/UnnameableJobsTests.cs | 30 ++ 63 files changed, 1932 insertions(+), 53 deletions(-) create mode 100644 samples/DistributedAspire/DistributedAspire.Shared/Migrations/20261009144316_DefinitionCatalog.Designer.cs create mode 100644 samples/DistributedAspire/DistributedAspire.Shared/Migrations/20261009144316_DefinitionCatalog.cs create mode 100644 src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.DefinitionCatalog.cs create mode 100644 src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.DefinitionCatalog.cs create mode 100644 src/Immediate.Jobs.Redis/RedisJobStorage.DefinitionCatalog.cs create mode 100644 src/Immediate.Jobs.Shared/Apis/JobDefinitionRecord.cs create mode 100644 src/Immediate.Jobs.Shared/Storage/InMemoryJobStorage.DefinitionCatalog.cs create mode 100644 src/Immediate.Jobs.Shared/Storage/JobDefinitionReconciliation.cs create mode 100644 src/Immediate.Jobs.Shared/Storage/JobDefinitionRegistration.cs create mode 100644 src/Immediate.Jobs.Shared/Storage/SingleServerJobStorage.DefinitionCatalog.cs create mode 100644 src/Immediate.Jobs.Testing/Storage/DefinitionCatalogStorageConformance.cs create mode 100644 tests/Immediate.Jobs.FunctionalTests/JobDefinitionTests.cs create mode 100644 tests/Immediate.Jobs.StorageTests/PostgreSqlJobSearchAssertions.cs diff --git a/docs/architecture.md b/docs/architecture.md index b7a15f57..4b0bbb0b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -32,7 +32,7 @@ For each `[Job]` class (see `Templates/Job.sbntxt`), nested in the user's partia - **`Invoker`**: a singleton `IJobInvoker` that deserializes the payload and runs the handler through the Immediate.Handlers pipeline. - **`JobDefinition`**: the runtime description (name, queue, attempts, timeout, backoff, cron, - overlap and misfire policies, concurrency). + overlap and misfire policies, and concurrency). - **`PayloadJsonContext`**: hand-emitted System.Text.Json metadata for the payload and context types, so serialization never uses reflection. @@ -44,8 +44,11 @@ configuration surface (`ConfigureStorage`, `ConfigureWorkers`, `UseFairQueues`, `JobSchedulingService` (`Internals/JobSchedulingService.cs`) is one hosted service per process: +- **Startup.** Initializes storage and submits the complete definition catalogue and code-defined + schedules through `MergeJobDefinitionsListAsync`, even when workers are disabled. The storage + reconciles the complete catalogue. Metadata has no invoker or CLR type. - **Polling loop.** Each iteration materializes due recurring schedules, builds a - `JobAcquisitionRequest` from free worker capacity and queue/job concurrency limits, calls + `JobAcquisitionRequest` from local definitions, free worker capacity, and queue/job concurrency limits, calls `IJobStorage.AcquireDueJobsAsync`, and writes the claimed records to an unbounded channel. On `PurgeInterval` it also purges job and batch history. It then waits `PollingInterval`. - **Workers.** `WorkerCount` tasks read the channel and run `ExecuteJobAsync`: a new DI scope, a diff --git a/docs/conventions.md b/docs/conventions.md index 72a9d636..a08594bc 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -47,10 +47,10 @@ assembly's `LibraryEventIds`: | Assembly | Range in use | | --- | --- | -| `Immediate.Jobs.Shared` | 11000–11093 (scheduler, in-memory, single-server) | -| `Immediate.Jobs.EntityFrameworkCore` | 11500–11542 | -| `Immediate.Jobs.LinqToDB` | 11600–11642 | -| `Immediate.Jobs.Redis` | 11700–11728 | +| `Immediate.Jobs.Shared` | 11000–11097 (scheduler, in-memory, single-server) | +| `Immediate.Jobs.EntityFrameworkCore` | 11500–11544 | +| `Immediate.Jobs.LinqToDB` | 11600–11644 | +| `Immediate.Jobs.Redis` | 11700–11730 | Append new ids after the current maximum of the assembly's range; never reuse or renumber an id, because users filter on them. `EventName` is `Immediate.Jobs.{Assembly}.{Name}`. diff --git a/docs/recurring-jobs.md b/docs/recurring-jobs.md index 4b1b55e3..0096fba4 100644 --- a/docs/recurring-jobs.md +++ b/docs/recurring-jobs.md @@ -17,9 +17,11 @@ A `RecurringJobSchedule` has a unique `Name`, the `JobName` it materializes, a ` `TimeZoneInfo.FindSystemTimeZoneById`. The analyzers validate code-defined expressions at compile time (`IJOB0007`). -At startup `JobSchedulingService.InitializeAsync` calls `MergeRecurringSchedulesListAsync` with every -code-defined schedule. Storage replaces the code-defined set (removing schedules for deleted jobs) -and leaves dynamic schedules untouched. +At startup `JobSchedulingService.InitializeAsync` submits all definitions and their code-defined schedules +through `MergeJobDefinitionsListAsync`. Storage atomically reconciles the catalogue, removing code-defined schedules when a definition disappears or loses its cron expression. It preserves +dynamic schedules. Unchanged cron/time zone retains the next occurrence; updates +preserve the pause state and last occurrence. The older `MergeRecurringSchedulesListAsync` operation +still performs an explicit global replacement and is not used by application startup. ## Materialization diff --git a/docs/storage.md b/docs/storage.md index 2862b734..f53babaf 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -5,7 +5,7 @@ Two interfaces in `src/Immediate.Jobs.Shared/Storage`: - **`IJobStorage`**: queueing, acquisition (including fair-queue policies), leases, completion and - failure, recurring schedules, wait-for-trigger updates and triggers, queries, monitoring snapshots, + failure, definition catalogues, recurring schedules, wait-for-trigger updates and triggers, queries, monitoring snapshots, heartbeats, purging. Every provider implements all of it. - **`IJobGraphStorage : IJobStorage`**: batches, continuations, dynamic additions from running jobs, batch triggers, batch reads and purging, plus the two single-server recovery reads @@ -30,6 +30,33 @@ monitoring snapshots and health-check data. is preserved through settlement and dynamic additions, and `BatchState.WaitingForTrigger` is non-terminal. +## Definition reconciliation + +`MergeJobDefinitionsListAsync(JobDefinitionRegistration)` receives the complete local definition list, +and all code-defined schedules. It inserts or updates supplied definitions and removes stored +definitions absent from that list. An empty list clears the catalogue. +Definition deletion never cascades to invocations, running leases, or execution history. + +Definition names are case-insensitive identities and retain their readable spelling. In-memory +and single-server use `OrdinalIgnoreCase`; SQL Server inherits database collation; PostgreSQL uses +a nondeterministic ICU collation (`und-u-ks-level2`); SQLite uses `NOCASE` (ASCII-only database +comparison). EF tracking uses an ordinal case-insensitive key comparer. EF applications with custom +provider conventions can pass `definitionNameCollation` to `AddImmediateJobs`. +Definition names must not have leading or trailing whitespace: the job analyzer reports `IJOB0008`, +and storage rejects invalid manually supplied names before modifying the catalogue. + +Concurrent startup snapshots commit atomically. In-memory uses its gate; relational providers take +an early write lock on a singleton catalogue row; Redis compares a version and applies metadata and +schedule changes in one Lua script, recomputing after a competing startup. Single-server delegates +metadata reads and reconciliation to durable storage, then refreshes the primary's code-defined +schedules. Pause and unchanged schedule progress survive startup. + +Each application's list is authoritative for the catalogue. Concurrent applications with inconsistent +lists can remove or replace each other's definitions; the last successful reconciliation wins. + +Custom providers must implement the catalogue operations. Relational users must add the two catalogue tables through their normal +schema update process; the bootstrap helpers do not upgrade existing production databases. + ## Providers | Provider | Location | Concurrency idiom | @@ -51,7 +78,8 @@ back a job transition. Tables: `immediate_jobs`, `immediate_job_executions`, `immediate_job_continuations`, `immediate_job_batches`, `immediate_recurring_jobs`, `immediate_job_servers`, and -`immediate_fair_queue_groups`. +`immediate_fair_queue_groups`, `immediate_job_definition_metadata`, and +`immediate_job_definition_catalog`. - **EF Core**: applications call `modelBuilder.AddImmediateJobs()` and own their migrations. A schema change is a breaking change for every EF Core user, and needs a migration note in the pull request. @@ -75,7 +103,9 @@ may build keys from the root): | `leases` | sorted set | Active job ids by lease expiry. | | `executions:index:{id}` / `executions:data:{id}` | sorted set / hash | Execution history. | | `recurring:*` | various | Schedules, due index, names, materialization de-duplication. | -| `server:{worker}` / `servers` | hash / sorted set | Heartbeats. | +| `server:{worker}` / `servers` | hash / sorted set | Scheduler heartbeats. | +| `definition-metadata` | hash | All definition metadata keyed by stable job name. | +| `definition-catalog-version` | string | Version used to atomically reconcile a complete catalogue. | | `fair:*` | various | Fair-queue indexes, active counts, and cursors; see [fair queues](fair-queues.md). | The payload lives only inside `record`. Lua must not re-encode it with `cjson` (key order and escaping diff --git a/docs/testing.md b/docs/testing.md index 70c625ea..da804fde 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -25,7 +25,7 @@ `src/Immediate.Jobs.Testing/Storage` ships in the `Immediate.Jobs.Testing` package, so custom providers can run it too. -- **Case files.** `QueueStorageConformance`, `RecurringStorageConformance`, +- **Case files.** `QueueStorageConformance`, `DefinitionCatalogStorageConformance`, `RecurringStorageConformance`, `FairQueueStorageConformance`, `TriggerStorageConformance`, `GraphStorageConformance`, and `ReplicaStorageConformance`. - **Case shape.** Each case is `new(Name, RequiredCapabilities, Scenario[, PersistedJobState])`. Names diff --git a/readme.md b/readme.md index 5b83c072..88ab4103 100644 --- a/readme.md +++ b/readme.md @@ -52,6 +52,15 @@ public sealed class SignupService(SendWelcomeEmail.Scheduler welcomeEmail) Register the generated handlers and jobs methods in `Program.cs`, in that order. For an assembly named `MyApp`, these are `AddMyAppHandlers()` and `AddMyAppJobs()`. +## Persisted definitions + +Startup provides the complete local catalogue to storage, which inserts, updates, and removes job +definitions and code-defined schedules. The dashboard shows every stored definition, including those +absent from its local registrations. + +Job names match case-insensitively. Explicit job names must not have leading or trailing whitespace; the analyzer reports `IJOB0008` +for invalid names. Storage also rejects such names in manually supplied definition catalogues. + ## Scheduling and handles Generated schedulers use one method name for relative and absolute scheduling. Pass a `TimeSpan` for a delay or a diff --git a/samples/DistributedAspire/DistributedAspire.Shared/Migrations/20261009144316_DefinitionCatalog.Designer.cs b/samples/DistributedAspire/DistributedAspire.Shared/Migrations/20261009144316_DefinitionCatalog.Designer.cs new file mode 100644 index 00000000..f8f62c9d --- /dev/null +++ b/samples/DistributedAspire/DistributedAspire.Shared/Migrations/20261009144316_DefinitionCatalog.Designer.cs @@ -0,0 +1,452 @@ +// +using System; +using Immediate.Jobs.DistributedAspire.Shared.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Immediate.Jobs.DistributedAspire.Shared.Migrations +{ + [DbContext(typeof(JobsDbContext))] + [Migration("20261009144316_DefinitionCatalog")] + partial class DefinitionCatalog + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:immediate_jobs_case_insensitive", "und-u-ks-level2,und-u-ks-level2,icu,False") + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Immediate.Jobs.DistributedAspire.Shared.Data.BatchEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("Id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("CreatedOn"); + + b.Property("Data") + .HasColumnType("uuid") + .HasColumnName("Data"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("Version"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .IsUnique(); + + b.ToTable("BatchableRows"); + }); + + modelBuilder.Entity("Immediate.Jobs.EntityFrameworkCore.ImmediateFairQueueGroupEntity", b => + { + b.Property("QueueName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GroupId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("LastServedSequence") + .HasColumnType("bigint"); + + b.HasKey("QueueName", "GroupId"); + + b.ToTable("immediate_fair_queue_groups", (string)null); + }); + + modelBuilder.Entity("Immediate.Jobs.EntityFrameworkCore.ImmediateJobBatchEntity", b => + { + b.Property("Id") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CancelledCount") + .HasColumnType("integer"); + + b.Property("CompletedAt") + .HasColumnType("bigint"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("bigint"); + + b.Property("FailedCount") + .HasColumnType("integer"); + + b.Property("PendingCount") + .HasColumnType("integer"); + + b.Property("SkippedCount") + .HasColumnType("integer"); + + b.Property("StartedAt") + .HasColumnType("bigint"); + + b.Property("State") + .HasColumnType("smallint"); + + b.Property("SucceededCount") + .HasColumnType("integer"); + + b.Property("TotalJobs") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("State", "CompletedAt"); + + b.ToTable("immediate_job_batches", (string)null); + }); + + modelBuilder.Entity("Immediate.Jobs.EntityFrameworkCore.ImmediateJobContinuationEntity", b => + { + b.Property("ChildJobHandle") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ParentKind") + .HasColumnType("smallint"); + + b.Property("ParentId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Delay") + .HasMaxLength(32) + .HasColumnType("bigint"); + + b.Property("ParentOutcome") + .HasColumnType("smallint"); + + b.Property("Trigger") + .HasColumnType("smallint"); + + b.HasKey("ChildJobHandle", "ParentKind", "ParentId"); + + b.HasIndex("ParentKind", "ParentId"); + + b.ToTable("immediate_job_continuations", (string)null); + }); + + modelBuilder.Entity("Immediate.Jobs.EntityFrameworkCore.ImmediateJobDefinitionCatalogEntity", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("immediate_job_definition_catalog", (string)null); + }); + + modelBuilder.Entity("Immediate.Jobs.EntityFrameworkCore.ImmediateJobDefinitionMetadataEntity", b => + { + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .UseCollation("immediate_jobs_case_insensitive"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Name"); + + b.ToTable("immediate_job_definition_metadata", (string)null); + }); + + modelBuilder.Entity("Immediate.Jobs.EntityFrameworkCore.ImmediateJobEntity", b => + { + b.Property("Id") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Attempt") + .HasColumnType("integer"); + + b.Property("BatchHandle") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CompletedAt") + .HasColumnType("bigint"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("Context") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("bigint"); + + b.Property("DueAt") + .HasColumnType("bigint"); + + b.Property("ExecutionSpanId") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ExecutionStartedAt") + .HasColumnType("bigint"); + + b.Property("ExecutionTraceId") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("FailedDependencies") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("JobName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .UseCollation("immediate_jobs_case_insensitive"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("LeaseExpiresAt") + .HasColumnType("bigint"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text"); + + b.Property("QueueName") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasDefaultValue("default"); + + b.Property("RecurringKey") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RemainingDependencies") + .HasColumnType("integer"); + + b.Property("State") + .HasColumnType("smallint"); + + b.Property("TraceParent") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TraceState") + .HasColumnType("text"); + + b.Property("WorkerId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("BatchHandle"); + + b.HasIndex("RecurringKey") + .IsUnique(); + + b.HasIndex("State", "CreatedAt"); + + b.HasIndex("State", "DueAt"); + + b.HasIndex("QueueName", "State", "GroupId"); + + b.HasIndex("QueueName", "State", "DueAt", "CreatedAt"); + + b.ToTable("immediate_jobs", (string)null); + }); + + modelBuilder.Entity("Immediate.Jobs.EntityFrameworkCore.ImmediateJobExecutionEntity", b => + { + b.Property("JobHandle") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Attempt") + .HasColumnType("integer"); + + b.Property("AcquiredAt") + .HasColumnType("bigint"); + + b.Property("CompletedAt") + .HasColumnType("bigint"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("ExecutionSpanId") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ExecutionStartedAt") + .HasColumnType("bigint"); + + b.Property("ExecutionTraceId") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("IsSynthetic") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("State") + .HasColumnType("smallint"); + + b.Property("WorkerId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("JobHandle", "Attempt"); + + b.ToTable("immediate_job_executions", (string)null); + }); + + modelBuilder.Entity("Immediate.Jobs.EntityFrameworkCore.ImmediateJobServerEntity", b => + { + b.Property("WorkerId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ActiveWorkers") + .HasColumnType("integer"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("bigint"); + + b.Property("LastHeartbeat") + .HasColumnType("bigint"); + + b.Property("MaxWorkers") + .HasColumnType("integer"); + + b.HasKey("WorkerId"); + + b.HasIndex("ExpiresAt"); + + b.ToTable("immediate_job_servers", (string)null); + }); + + modelBuilder.Entity("Immediate.Jobs.EntityFrameworkCore.ImmediateRecurringJobEntity", b => + { + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("Cron") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("IsCodeDefined") + .HasColumnType("boolean"); + + b.Property("IsPaused") + .HasColumnType("boolean"); + + b.Property("JobName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .UseCollation("immediate_jobs_case_insensitive"); + + b.Property("LastRunAt") + .HasColumnType("bigint"); + + b.Property("NextRunAt") + .HasColumnType("bigint"); + + b.Property("QueueName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TimeZone") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Name"); + + b.HasIndex("IsPaused", "NextRunAt"); + + b.ToTable("immediate_recurring_jobs", (string)null); + }); + + modelBuilder.Entity("Immediate.Jobs.EntityFrameworkCore.ImmediateJobContinuationEntity", b => + { + b.HasOne("Immediate.Jobs.EntityFrameworkCore.ImmediateJobEntity", null) + .WithMany() + .HasForeignKey("ChildJobHandle") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Immediate.Jobs.EntityFrameworkCore.ImmediateJobEntity", b => + { + b.HasOne("Immediate.Jobs.EntityFrameworkCore.ImmediateJobBatchEntity", null) + .WithMany() + .HasForeignKey("BatchHandle") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Immediate.Jobs.EntityFrameworkCore.ImmediateJobExecutionEntity", b => + { + b.HasOne("Immediate.Jobs.EntityFrameworkCore.ImmediateJobEntity", null) + .WithMany() + .HasForeignKey("JobHandle") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/samples/DistributedAspire/DistributedAspire.Shared/Migrations/20261009144316_DefinitionCatalog.cs b/samples/DistributedAspire/DistributedAspire.Shared/Migrations/20261009144316_DefinitionCatalog.cs new file mode 100644 index 00000000..e108427d --- /dev/null +++ b/samples/DistributedAspire/DistributedAspire.Shared/Migrations/20261009144316_DefinitionCatalog.cs @@ -0,0 +1,67 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Immediate.Jobs.DistributedAspire.Shared.Migrations; + +/// +[SuppressMessage("Design", "CA1062:Validate arguments of public methods")] +public sealed partial class DefinitionCatalog : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:CollationDefinition:immediate_jobs_case_insensitive", "und-u-ks-level2,und-u-ks-level2,icu,False"); + + migrationBuilder.AlterColumn( + name: "JobName", table: "immediate_jobs", type: "character varying(256)", + maxLength: 256, nullable: false, collation: "immediate_jobs_case_insensitive", + oldClrType: typeof(string), oldType: "character varying(256)", oldMaxLength: 256); + + migrationBuilder.AlterColumn( + name: "JobName", table: "immediate_recurring_jobs", type: "character varying(256)", + maxLength: 256, nullable: false, collation: "immediate_jobs_case_insensitive", + oldClrType: typeof(string), oldType: "character varying(256)", oldMaxLength: 256); + + migrationBuilder.CreateTable( + name: "immediate_job_definition_catalog", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false), + ConcurrencyStamp = table.Column(type: "uuid", nullable: false), + }, + constraints: table => table.PrimaryKey("PK_immediate_job_definition_catalog", x => x.Id)); + + migrationBuilder.CreateTable( + name: "immediate_job_definition_metadata", + columns: table => new + { + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false, collation: "immediate_jobs_case_insensitive"), + Metadata = table.Column(type: "text", nullable: false), + }, + constraints: table => table.PrimaryKey("PK_immediate_job_definition_metadata", x => x.Name)); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "JobName", table: "immediate_jobs", type: "character varying(256)", + maxLength: 256, nullable: false, + oldClrType: typeof(string), oldType: "character varying(256)", oldMaxLength: 256, + oldCollation: "immediate_jobs_case_insensitive"); + + migrationBuilder.AlterColumn( + name: "JobName", table: "immediate_recurring_jobs", type: "character varying(256)", + maxLength: 256, nullable: false, + oldClrType: typeof(string), oldType: "character varying(256)", oldMaxLength: 256, + oldCollation: "immediate_jobs_case_insensitive"); + + migrationBuilder.DropTable(name: "immediate_job_definition_catalog"); + migrationBuilder.DropTable(name: "immediate_job_definition_metadata"); + migrationBuilder.AlterDatabase() + .OldAnnotation("Npgsql:CollationDefinition:immediate_jobs_case_insensitive", "und-u-ks-level2,und-u-ks-level2,icu,False"); + } +} diff --git a/samples/DistributedAspire/DistributedAspire.Shared/Migrations/JobsDbContextModelSnapshot.cs b/samples/DistributedAspire/DistributedAspire.Shared/Migrations/JobsDbContextModelSnapshot.cs index 0f8aea7b..3fa59d61 100644 --- a/samples/DistributedAspire/DistributedAspire.Shared/Migrations/JobsDbContextModelSnapshot.cs +++ b/samples/DistributedAspire/DistributedAspire.Shared/Migrations/JobsDbContextModelSnapshot.cs @@ -17,6 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:immediate_jobs_case_insensitive", "und-u-ks-level2,und-u-ks-level2,icu,False") .HasAnnotation("ProductVersion", "10.0.12") .HasAnnotation("Relational:MaxIdentifierLength", 63); @@ -154,6 +155,35 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("immediate_job_continuations", (string)null); }); + modelBuilder.Entity("Immediate.Jobs.EntityFrameworkCore.ImmediateJobDefinitionCatalogEntity", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("immediate_job_definition_catalog", (string)null); + }); + + modelBuilder.Entity("Immediate.Jobs.EntityFrameworkCore.ImmediateJobDefinitionMetadataEntity", b => + { + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .UseCollation("immediate_jobs_case_insensitive"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Name"); + + b.ToTable("immediate_job_definition_metadata", (string)null); + }); + modelBuilder.Entity("Immediate.Jobs.EntityFrameworkCore.ImmediateJobEntity", b => { b.Property("Id") @@ -204,7 +234,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("JobName") .IsRequired() .HasMaxLength(256) - .HasColumnType("character varying(256)"); + .HasColumnType("character varying(256)") + .UseCollation("immediate_jobs_case_insensitive"); b.Property("LastError") .HasColumnType("text"); @@ -361,7 +392,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("JobName") .IsRequired() .HasMaxLength(256) - .HasColumnType("character varying(256)"); + .HasColumnType("character varying(256)") + .UseCollation("immediate_jobs_case_insensitive"); b.Property("LastRunAt") .HasColumnType("bigint"); diff --git a/src/Common/Utility.cs b/src/Common/Utility.cs index ea3fd1a2..6ff1413c 100644 --- a/src/Common/Utility.cs +++ b/src/Common/Utility.cs @@ -31,6 +31,9 @@ public bool HasNameContent() public string AsCSharpLiteral() => SymbolDisplay.FormatLiteral(str, quote: true); + public bool HasLeadingOrTrailingWhitespace() => + str.Length > 0 && (char.IsWhiteSpace(str[0]) || char.IsWhiteSpace(str[^1])); + public string EscapeIdentifier() => SyntaxFacts.GetKeywordKind(str) == SyntaxKind.None && SyntaxFacts.GetContextualKeywordKind(str) == SyntaxKind.None diff --git a/src/Immediate.Jobs.Analyzers/DuplicateElementsAnalyzer.cs b/src/Immediate.Jobs.Analyzers/DuplicateElementsAnalyzer.cs index eb3c4516..f20ceab8 100644 --- a/src/Immediate.Jobs.Analyzers/DuplicateElementsAnalyzer.cs +++ b/src/Immediate.Jobs.Analyzers/DuplicateElementsAnalyzer.cs @@ -157,7 +157,7 @@ Lock @lock private static void AnalyzeDuplicateJobNames(CompilationAnalysisContext context, List jobs) { - foreach (var jobGroup in jobs.GroupBy(x => x.JobName, StringComparer.Ordinal).Where(g => g.Skip(1).Any())) + foreach (var jobGroup in jobs.GroupBy(x => x.JobName, StringComparer.OrdinalIgnoreCase).Where(g => g.Skip(1).Any())) { var classes = string.Join(", ", jobGroup.Select(l => l.ClassName)); diff --git a/src/Immediate.Jobs.Analyzers/JobClassAnalyzer.cs b/src/Immediate.Jobs.Analyzers/JobClassAnalyzer.cs index cfa50532..d3776496 100644 --- a/src/Immediate.Jobs.Analyzers/JobClassAnalyzer.cs +++ b/src/Immediate.Jobs.Analyzers/JobClassAnalyzer.cs @@ -53,7 +53,7 @@ public sealed class JobClassAnalyzer : DiagnosticAnalyzer category: "ImmediateJobs", defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true, - description: "A job must have a name that can identify it in storage.", + description: "A job name must contain a letter or digit and must not have leading or trailing whitespace.", customTags: [WellKnownDiagnosticTags.NotConfigurable] ); @@ -130,7 +130,8 @@ private static void AnalyzeJobName(SymbolAnalysisContext context, AttributeData { context.CancellationToken.ThrowIfCancellationRequested(); - if (jobAttribute.GetJobName(className: context.Symbol.Name).HasNameContent()) + var name = jobAttribute.GetJobName(className: context.Symbol.Name); + if (name.HasNameContent() && !name.HasLeadingOrTrailingWhitespace()) return; var explicitName = jobAttribute.NamedArguments.GetStringValue("Name"); @@ -142,7 +143,9 @@ private static void AnalyzeJobName(SymbolAnalysisContext context, AttributeData context.Symbol.Name, explicitName is null ? $"a name cannot be derived from the class name `{context.Symbol.Name}`; rename the class or set `Name`" - : "`Name` must contain at least one letter or digit" + : !name.HasNameContent() + ? "`Name` must contain at least one letter or digit" + : "`Name` must not have leading or trailing whitespace" ) ); } diff --git a/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.Acquire.cs b/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.Acquire.cs index eb073d27..ab92344f 100644 --- a/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.Acquire.cs +++ b/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.Acquire.cs @@ -30,7 +30,7 @@ public async ValueTask> AcquireDueJobsAsync( if (queueCapacity <= 0) continue; - var jobCapacities = queue.JobCapacities.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal); + var jobCapacities = queue.JobCapacities.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.OrdinalIgnoreCase); while (queueCapacity > 0) { var eligibleNames = jobCapacities.Where(static pair => pair.Value > 0).Select(static pair => pair.Key).ToList(); @@ -54,7 +54,7 @@ public async ValueTask> AcquireDueJobsAsync( break; var selected = new List(candidates.Count); - var selectionCapacities = new Dictionary(jobCapacities, StringComparer.Ordinal); + var selectionCapacities = new Dictionary(jobCapacities, StringComparer.OrdinalIgnoreCase); foreach (var candidate in candidates) { if (selectionCapacities[candidate.JobName] <= 0) diff --git a/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.DefinitionCatalog.cs b/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.DefinitionCatalog.cs new file mode 100644 index 00000000..82c450b9 --- /dev/null +++ b/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.DefinitionCatalog.cs @@ -0,0 +1,124 @@ +using System.Text.Json; +using Immediate.Jobs.Shared.Apis; +using Immediate.Jobs.Shared.Storage; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Immediate.Jobs.EntityFrameworkCore; + +internal sealed partial class EntityFrameworkCoreJobStorage + where TContext : DbContext +{ + /// + public async ValueTask MergeJobDefinitionsListAsync(JobDefinitionRegistration registration, CancellationToken cancellationToken = default) + { + MergeJobDefinitionsListAsyncCalled(); + cancellationToken.ThrowIfCancellationRequested(); + await TaskScheduler.Yield(); + // Validate before taking a write lock or creating the catalogue sentinel. + _ = JobDefinitionReconciliation.Create(registration, [], []); + await RetryConcurrencyAsync(async token => + { + await EnsureDefinitionCatalogAsync(token); + await using var context = await contextFactory.CreateDbContextAsync(token); + await using var transaction = await context.Database.BeginTransactionAsync(token); + // A singleton row serializes the complete read/merge/write across application instances. + var stamp = Guid.NewGuid(); + _ = await context.Set().Where(item => item.Id == 1) + .ExecuteUpdateAsync(update => update.SetProperty(item => item.ConcurrencyStamp, stamp), token); + var definitions = await context.Set() + .ToDictionaryAsync(static item => item.Name, StringComparer.OrdinalIgnoreCase, token); + var schedules = await context.Set() + .ToDictionaryAsync(static item => item.Name, StringComparer.Ordinal, token); + var changes = JobDefinitionReconciliation.Create(registration, + definitions.Values.Select(static item => JsonSerializer.Deserialize(item.Metadata, EntityFrameworkCoreJsonSerializerContext.Default.JobDefinitionRecord)!).ToList(), + schedules.Values.Select(ToDefinitionSchedule).ToList()); + foreach (var name in changes.RemovedDefinitionNames) + context.Remove(definitions[name]); + foreach (var definition in changes.Definitions) + { + var metadata = JsonSerializer.Serialize(definition, EntityFrameworkCoreJsonSerializerContext.Default.JobDefinitionRecord); + if (definitions.TryGetValue(definition.Name, out var current)) + current.Metadata = metadata; + else + _ = context.Add(new ImmediateJobDefinitionMetadataEntity { Name = definition.Name, Metadata = metadata }); + } + + foreach (var name in changes.RemovedScheduleNames) + context.Remove(schedules[name]); + foreach (var schedule in changes.Schedules) + { + if (schedules.TryGetValue(schedule.Name, out var current)) + context.Entry(current).CurrentValues.SetValues(ToEntity(schedule)); + else + _ = context.Add(ToEntity(schedule)); + } + + _ = await context.SaveChangesAsync(token); + await transaction.CommitAsync(token); + }, cancellationToken); + } + + /// + public async ValueTask> GetJobDefinitionsAsync(CancellationToken cancellationToken = default) + { + GetJobDefinitionsAsyncCalled(); + cancellationToken.ThrowIfCancellationRequested(); + await TaskScheduler.Yield(); + return await ReadWithStrategyAsync(async (context, token) => + { + var metadata = await context.Set().AsNoTracking() + .Select(static item => item.Metadata).ToListAsync(token); + return (IReadOnlyList)[.. metadata + .Select(static value => JsonSerializer.Deserialize(value, EntityFrameworkCoreJsonSerializerContext.Default.JobDefinitionRecord)!) + .OrderBy(static definition => definition.Name, StringComparer.OrdinalIgnoreCase)]; + }, cancellationToken); + } + + private async Task EnsureDefinitionCatalogAsync(CancellationToken cancellationToken) + { + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + if (await context.Set().AnyAsync(item => item.Id == 1, cancellationToken)) + return; + _ = context.Add(new ImmediateJobDefinitionCatalogEntity { Id = 1, ConcurrencyStamp = Guid.NewGuid() }); + try + { + _ = await context.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException) + { + await using var verification = await contextFactory.CreateDbContextAsync(cancellationToken); + if (!await verification.Set().AnyAsync(item => item.Id == 1, cancellationToken)) + throw; + } + } + + private static RecurringJobSchedule ToDefinitionSchedule(ImmediateRecurringJobEntity entity) => new() + { + Name = entity.Name, + JobName = entity.JobName, + QueueName = entity.QueueName, + Cron = entity.Cron, + TimeZone = entity.TimeZone, + IsCodeDefined = entity.IsCodeDefined, + IsPaused = entity.IsPaused, + NextRunAt = entity.NextRunAt, + LastRunAt = entity.LastRunAt, + }; + + [LoggerMessage( + EventId = LibraryEventIds.MergeJobDefinitionsListAsyncCalled, + EventName = "Immediate.Jobs.EntityFrameworkCore.MergeJobDefinitionsListAsyncCalled", + Level = LogLevel.Debug, + Message = "MergeJobDefinitionsListAsyncCalled" + )] + private partial void MergeJobDefinitionsListAsyncCalled(); + + [LoggerMessage( + EventId = LibraryEventIds.GetJobDefinitionsAsyncCalled, + EventName = "Immediate.Jobs.EntityFrameworkCore.GetJobDefinitionsAsyncCalled", + Level = LogLevel.Debug, + Message = "GetJobDefinitionsAsyncCalled" + )] + private partial void GetJobDefinitionsAsyncCalled(); +} diff --git a/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.Query.cs b/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.Query.cs index 0aa5e5cc..ef31c2ad 100644 --- a/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.Query.cs +++ b/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.Query.cs @@ -87,7 +87,9 @@ public async ValueTask> QueryJobsAsync(JobQuery query, var search = query.Search.ToUpperInvariant(); // The parameterless form is intentionally used because relational providers translate it to SQL. #pragma warning disable CA1304, CA1311, CA1862, MA0011 - jobs = jobs.Where(job => job.JobName.ToUpper().Contains(search)); + jobs = context.Database.ProviderName!.Contains("Npgsql", StringComparison.Ordinal) + ? jobs.Where(job => EF.Functions.Collate(job.JobName, "und-x-icu").ToUpper().Contains(search)) + : jobs.Where(job => job.JobName.ToUpper().Contains(search)); #pragma warning restore CA1304, CA1311, CA1862, MA0011 } diff --git a/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.Trigger.cs b/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.Trigger.cs index 487cafeb..0b5e73f5 100644 --- a/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.Trigger.cs +++ b/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.Trigger.cs @@ -124,7 +124,7 @@ CancellationToken cancellationToken var job = await context.Set() .SingleOrDefaultAsync(item => item.Id == jobHandle.Value, cancellationToken) ?? throw new KeyNotFoundException($"Job '{jobHandle}' was not found."); - if (!string.Equals(job.JobName, expectedJobName, StringComparison.Ordinal)) + if (!string.Equals(job.JobName, expectedJobName, StringComparison.OrdinalIgnoreCase)) throw new ImmediateJobException($"Job '{jobHandle}' is not a '{expectedJobName}' job."); return job; diff --git a/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJsonSerializerContext.cs b/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJsonSerializerContext.cs index 47d75c2e..369e55e5 100644 --- a/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJsonSerializerContext.cs +++ b/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJsonSerializerContext.cs @@ -4,4 +4,5 @@ namespace Immediate.Jobs.EntityFrameworkCore; [JsonSerializable(typeof(JobServerSnapshot))] +[JsonSerializable(typeof(JobDefinitionRecord))] internal sealed partial class EntityFrameworkCoreJsonSerializerContext : JsonSerializerContext; diff --git a/src/Immediate.Jobs.EntityFrameworkCore/ImmediateJobsModelBuilderExtensions.cs b/src/Immediate.Jobs.EntityFrameworkCore/ImmediateJobsModelBuilderExtensions.cs index 28297bff..0e031a6a 100644 --- a/src/Immediate.Jobs.EntityFrameworkCore/ImmediateJobsModelBuilderExtensions.cs +++ b/src/Immediate.Jobs.EntityFrameworkCore/ImmediateJobsModelBuilderExtensions.cs @@ -1,6 +1,7 @@ using Immediate.Jobs.Shared.Apis; using Immediate.Jobs.Shared.Internals; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Immediate.Jobs.EntityFrameworkCore; @@ -11,8 +12,9 @@ public static class ImmediateJobsModelBuilderExtensions /// Configures the entities required by the Immediate.Jobs EF Core storage provider. /// The model builder to configure. /// The database schema for the Immediate.Jobs tables, or for the provider default. + /// An optional case-insensitive collation for definition names; defaults to the supported provider's case-insensitive collation. /// The configured model builder. - public static ModelBuilder AddImmediateJobs(this ModelBuilder modelBuilder, string? schema = null) + public static ModelBuilder AddImmediateJobs(this ModelBuilder modelBuilder, string? schema = null, string? definitionNameCollation = null) { ArgumentNullException.ThrowIfNull(modelBuilder); ConfigureBatches(modelBuilder.Entity(), schema); @@ -22,6 +24,31 @@ public static ModelBuilder AddImmediateJobs(this ModelBuilder modelBuilder, stri ConfigureContinuations(modelBuilder.Entity(), schema); ConfigureRecurring(modelBuilder.Entity(), schema); ConfigureServers(modelBuilder.Entity(), schema); + var definitions = modelBuilder.Entity(); + _ = definitions.ToTable("immediate_job_definition_metadata", schema); + _ = definitions.HasKey(definition => definition.Name); + var isPostgreSql = modelBuilder.Model.FindAnnotation("Npgsql:ValueGenerationStrategy") is not null; + var postgresCollation = schema is null ? "immediate_jobs_case_insensitive" : schema + "_jobs_ci"; + definitionNameCollation ??= isPostgreSql ? postgresCollation + : modelBuilder.Model.FindAnnotation("SqlServer:ValueGenerationStrategy") is not null ? null : "NOCASE"; + if (isPostgreSql && string.Equals(definitionNameCollation, postgresCollation, StringComparison.Ordinal)) + { + // Npgsql's public collation metadata is stored as this annotation (EF Core 8, 10, and 11). + _ = modelBuilder.HasAnnotation("Npgsql:CollationDefinition:" + postgresCollation, "und-u-ks-level2,und-u-ks-level2,icu,False"); + } + + _ = modelBuilder.Entity().Property(job => job.JobName).UseCollation(definitionNameCollation); + _ = modelBuilder.Entity().Property(schedule => schedule.JobName).UseCollation(definitionNameCollation); + var name = definitions.Property(definition => definition.Name).HasMaxLength(256).UseCollation(definitionNameCollation); + name.Metadata.SetValueComparer(new ValueComparer( + (left, right) => string.Equals(left, right, StringComparison.OrdinalIgnoreCase), + value => StringComparer.OrdinalIgnoreCase.GetHashCode(value), + value => value)); + _ = definitions.Property(definition => definition.Metadata).IsRequired(); + var catalogue = modelBuilder.Entity(); + _ = catalogue.ToTable("immediate_job_definition_catalog", schema); + _ = catalogue.HasKey(item => item.Id); + _ = catalogue.Property(item => item.Id).ValueGeneratedNever(); return modelBuilder; } @@ -310,3 +337,15 @@ internal sealed class ImmediateJobServerEntity public int MaxWorkers { get; set; } public string Details { get; set; } = null!; } + +internal sealed class ImmediateJobDefinitionMetadataEntity +{ + public string Name { get; set; } = null!; + public string Metadata { get; set; } = null!; +} + +internal sealed class ImmediateJobDefinitionCatalogEntity +{ + public int Id { get; set; } + public Guid ConcurrencyStamp { get; set; } +} diff --git a/src/Immediate.Jobs.EntityFrameworkCore/LibraryEventIds.cs b/src/Immediate.Jobs.EntityFrameworkCore/LibraryEventIds.cs index 0e44c77a..3a5a6fde 100644 --- a/src/Immediate.Jobs.EntityFrameworkCore/LibraryEventIds.cs +++ b/src/Immediate.Jobs.EntityFrameworkCore/LibraryEventIds.cs @@ -45,4 +45,6 @@ internal static class LibraryEventIds public const int UpdatePayloadAsyncCalled = 11540; public const int TryTriggerAsyncCalled = 11541; public const int TryTriggerBatchAsyncCalled = 11542; + public const int MergeJobDefinitionsListAsyncCalled = 11543; + public const int GetJobDefinitionsAsyncCalled = 11544; } diff --git a/src/Immediate.Jobs.Generators/ImmediateJobsGenerator.Transform.cs b/src/Immediate.Jobs.Generators/ImmediateJobsGenerator.Transform.cs index 839f3218..bd247328 100644 --- a/src/Immediate.Jobs.Generators/ImmediateJobsGenerator.Transform.cs +++ b/src/Immediate.Jobs.Generators/ImmediateJobsGenerator.Transform.cs @@ -31,7 +31,7 @@ CancellationToken cancellationToken var attribute = context.Attributes[0]; var jobName = attribute.GetJobName(className: symbol.Name); - if (!jobName.HasNameContent()) + if (!jobName.HasNameContent() || jobName.HasLeadingOrTrailingWhitespace()) return null; var arguments = attribute.NamedArguments; @@ -122,7 +122,6 @@ when queueSymbol.GetAttributes().QueueDefinitionAttribute is { } queueDefinition return null; var tags = handlerAttribute?.NamedArguments.GetStringArray("Tags"); - var extractors = attributes.GetContextExtractors().ToList(); if (extractors.Any(e => !PayloadValidation.CanSerializeToJson(e.ContextType, reportError: null))) diff --git a/src/Immediate.Jobs.LinqToDB/LibraryEventIds.cs b/src/Immediate.Jobs.LinqToDB/LibraryEventIds.cs index 7fb539f5..b498a481 100644 --- a/src/Immediate.Jobs.LinqToDB/LibraryEventIds.cs +++ b/src/Immediate.Jobs.LinqToDB/LibraryEventIds.cs @@ -45,4 +45,6 @@ internal static class LibraryEventIds public const int UpdatePayloadAsyncCalled = 11640; public const int TryTriggerAsyncCalled = 11641; public const int TryTriggerBatchAsyncCalled = 11642; + public const int MergeJobDefinitionsListAsyncCalled = 11643; + public const int GetJobDefinitionsAsyncCalled = 11644; } diff --git a/src/Immediate.Jobs.LinqToDB/LinqToDBEntities.cs b/src/Immediate.Jobs.LinqToDB/LinqToDBEntities.cs index 4be7261c..8168ec68 100644 --- a/src/Immediate.Jobs.LinqToDB/LinqToDBEntities.cs +++ b/src/Immediate.Jobs.LinqToDB/LinqToDBEntities.cs @@ -56,6 +56,8 @@ internal sealed class ImmediateJobEntity [Column(Length = 256, CanBeNull = false)] public string QueueName { get; set; } = JobQueueDefinition.DefaultName; [Column(Length = 256, CanBeNull = false)] + [Column(Configuration = ProviderName.PostgreSQL, DbType = "varchar(256) COLLATE \"immediate_jobs_case_insensitive\"", CanBeNull = false)] + [Column(Configuration = ProviderName.SQLite, DbType = "TEXT COLLATE NOCASE", CanBeNull = false)] public string JobName { get; set; } = null!; [Column(Length = 128, CanBeNull = true)] public string? GroupId { get; set; } @@ -164,6 +166,8 @@ internal sealed class ImmediateRecurringJobEntity [PrimaryKey, Column(Length = 256, CanBeNull = false)] public string Name { get; set; } = null!; [Column(Length = 256, CanBeNull = false)] + [Column(Configuration = ProviderName.PostgreSQL, DbType = "varchar(256) COLLATE \"immediate_jobs_case_insensitive\"", CanBeNull = false)] + [Column(Configuration = ProviderName.SQLite, DbType = "TEXT COLLATE NOCASE", CanBeNull = false)] public string JobName { get; set; } = null!; [Column(Length = 256, CanBeNull = false)] public string QueueName { get; set; } = null!; @@ -199,3 +203,21 @@ internal sealed class ImmediateJobServerEntity [Column(DataType = DataType.Text)] public string Details { get; set; } = null!; } + +[Table(Name = "immediate_job_definition_metadata")] +internal sealed class ImmediateJobDefinitionMetadataEntity +{ + [PrimaryKey, Column(Length = 256, CanBeNull = false)] + public string Name { get; set; } = null!; + [Column(DataType = DataType.Text, CanBeNull = false)] + public string Metadata { get; set; } = null!; +} + +[Table(Name = "immediate_job_definition_catalog")] +internal sealed class ImmediateJobDefinitionCatalogEntity +{ + [PrimaryKey, Column] + public int Id { get; set; } + [Column] + public Guid ConcurrencyStamp { get; set; } +} diff --git a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.Acquire.cs b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.Acquire.cs index 9b5f2b90..6ea387d8 100644 --- a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.Acquire.cs +++ b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.Acquire.cs @@ -31,7 +31,7 @@ public async ValueTask> AcquireDueJobsAsync( if (queueCapacity <= 0) continue; - var jobCapacities = queue.JobCapacities.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal); + var jobCapacities = queue.JobCapacities.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.OrdinalIgnoreCase); while (queueCapacity > 0) { var eligibleNames = jobCapacities.Where(static pair => pair.Value > 0).Select(static pair => pair.Key).ToList(); @@ -52,7 +52,7 @@ public async ValueTask> AcquireDueJobsAsync( if (candidates.Count == 0) break; - var selectionCapacities = new Dictionary(jobCapacities, StringComparer.Ordinal); + var selectionCapacities = new Dictionary(jobCapacities, StringComparer.OrdinalIgnoreCase); var selected = candidates.Where(candidate => selectionCapacities[candidate.JobName]-- > 0).ToList(); var claimed = await AcquireCandidatesAsync(selected, request.WorkerId, request.Lease, now, cancellationToken); foreach (var job in claimed) diff --git a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.Database.cs b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.Database.cs index 56249933..7c25265d 100644 --- a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.Database.cs +++ b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.Database.cs @@ -27,6 +27,12 @@ private ITable Recurring(DataConnection connection) private ITable Servers(DataConnection connection) => WithSchema(connection.GetTable()); + private ITable Definitions(DataConnection connection) => + WithSchema(connection.GetTable()); + + private ITable DefinitionCatalog(DataConnection connection) => + WithSchema(connection.GetTable()); + private ITable WithSchema(ITable table) where TTable : notnull => _schema is null ? table : table.SchemaName(_schema); diff --git a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.DefinitionCatalog.cs b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.DefinitionCatalog.cs new file mode 100644 index 00000000..e8e134a9 --- /dev/null +++ b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.DefinitionCatalog.cs @@ -0,0 +1,106 @@ +using System.Data.Common; +using System.Text.Json; +using Immediate.Jobs.Shared.Apis; +using Immediate.Jobs.Shared.Storage; +using LinqToDB; +using LinqToDB.Async; +using LinqToDB.Data; +using Microsoft.Extensions.Logging; + +namespace Immediate.Jobs.LinqToDB; + +internal sealed partial class LinqToDBJobStorage + where T : DataConnection +{ + /// + public async ValueTask MergeJobDefinitionsListAsync(JobDefinitionRegistration registration, CancellationToken cancellationToken = default) + { + MergeJobDefinitionsListAsyncCalled(); + cancellationToken.ThrowIfCancellationRequested(); + await TaskScheduler.Yield(); + _ = JobDefinitionReconciliation.Create(registration, [], []); + await EnsureDefinitionCatalogAsync(cancellationToken); + await RetryConcurrencyAsync(async connection => + { + var stamp = Guid.NewGuid(); + _ = await DefinitionCatalog(connection).Where(item => item.Id == 1) + .Set(item => item.ConcurrencyStamp, stamp).UpdateAsync(cancellationToken); + var definitions = await Definitions(connection) + .ToDictionaryAsync(static item => item.Name, StringComparer.OrdinalIgnoreCase, cancellationToken); + var schedules = await Recurring(connection) + .ToDictionaryAsync(static item => item.Name, StringComparer.Ordinal, cancellationToken); + var changes = JobDefinitionReconciliation.Create(registration, + definitions.Values.Select(static item => JsonSerializer.Deserialize(item.Metadata, LinqToDBJsonSerializerContext.Default.JobDefinitionRecord)!).ToList(), + schedules.Values.Select(ToRecord).ToList()); + foreach (var name in changes.RemovedDefinitionNames) + _ = await Definitions(connection).Where(item => item.Name == name).DeleteAsync(cancellationToken); + foreach (var definition in changes.Definitions) + { + var metadata = JsonSerializer.Serialize(definition, LinqToDBJsonSerializerContext.Default.JobDefinitionRecord); + _ = definitions.ContainsKey(definition.Name) + ? await Definitions(connection).Where(item => item.Name == definition.Name) + .Set(item => item.Metadata, metadata).UpdateAsync(cancellationToken) + : await InsertAsync(connection, new ImmediateJobDefinitionMetadataEntity { Name = definition.Name, Metadata = metadata }, cancellationToken); + } + + foreach (var name in changes.RemovedScheduleNames) + _ = await Recurring(connection).Where(item => item.Name == name).DeleteAsync(cancellationToken); + foreach (var schedule in changes.Schedules) + { + var entity = ToEntity(schedule); + if (schedules.TryGetValue(schedule.Name, out var current)) + { + if (!await UpdateRecurringAsync(connection, entity, current.ConcurrencyStamp, cancellationToken)) + throw new LostRaceException(); + } + else + _ = await InsertAsync(connection, entity, cancellationToken); + } + }, cancellationToken); + } + + /// + public async ValueTask> GetJobDefinitionsAsync(CancellationToken cancellationToken = default) + { + GetJobDefinitionsAsyncCalled(); + cancellationToken.ThrowIfCancellationRequested(); + await TaskScheduler.Yield(); + await using var scope = contextScope.GetScope(out var connection); + var metadata = await Definitions(connection).Select(static item => item.Metadata).ToListAsync(cancellationToken); + return [.. metadata.Select(static value => JsonSerializer.Deserialize(value, LinqToDBJsonSerializerContext.Default.JobDefinitionRecord)!) + .OrderBy(static definition => definition.Name, StringComparer.OrdinalIgnoreCase)]; + } + + private async Task EnsureDefinitionCatalogAsync(CancellationToken cancellationToken) + { + await using var scope = contextScope.GetScope(out var connection); + if (await DefinitionCatalog(connection).AnyAsync(item => item.Id == 1, cancellationToken)) + return; + try + { + _ = await InsertAsync(connection, new ImmediateJobDefinitionCatalogEntity { Id = 1, ConcurrencyStamp = Guid.NewGuid() }, cancellationToken); + } + catch (DbException) + { + await using var verificationScope = contextScope.GetScope(out var verification); + if (!await DefinitionCatalog(verification).AnyAsync(item => item.Id == 1, cancellationToken)) + throw; + } + } + + [LoggerMessage( + EventId = LibraryEventIds.MergeJobDefinitionsListAsyncCalled, + EventName = "Immediate.Jobs.LinqToDB.MergeJobDefinitionsListAsyncCalled", + Level = LogLevel.Debug, + Message = "MergeJobDefinitionsListAsyncCalled" + )] + private partial void MergeJobDefinitionsListAsyncCalled(); + + [LoggerMessage( + EventId = LibraryEventIds.GetJobDefinitionsAsyncCalled, + EventName = "Immediate.Jobs.LinqToDB.GetJobDefinitionsAsyncCalled", + Level = LogLevel.Debug, + Message = "GetJobDefinitionsAsyncCalled" + )] + private partial void GetJobDefinitionsAsyncCalled(); +} diff --git a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.Query.cs b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.Query.cs index 12a5af73..a99a30e5 100644 --- a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.Query.cs +++ b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.Query.cs @@ -102,7 +102,9 @@ public async ValueTask> QueryJobsAsync( { var search = query.Search.ToUpperInvariant(); #pragma warning disable CA1304, CA1311, CA1862, MA0011 - jobs = jobs.Where(job => job.JobName.ToUpper().Contains(search)); + jobs = connection.DataProvider.Name.Contains("PostgreSQL", StringComparison.OrdinalIgnoreCase) + ? jobs.Where(job => Sql.Collate(job.JobName, "und-x-icu").ToUpper().Contains(search)) + : jobs.Where(job => job.JobName.ToUpper().Contains(search)); #pragma warning restore CA1304, CA1311, CA1862, MA0011 } diff --git a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.Trigger.cs b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.Trigger.cs index ae956c4a..8532465f 100644 --- a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.Trigger.cs +++ b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.Trigger.cs @@ -131,7 +131,7 @@ CancellationToken cancellationToken { var job = await Jobs(connection).SingleOrDefaultAsync(item => item.Id == jobHandle.Value, cancellationToken) ?? throw new KeyNotFoundException($"Job '{jobHandle}' was not found."); - if (!string.Equals(job.JobName, expectedJobName, StringComparison.Ordinal)) + if (!string.Equals(job.JobName, expectedJobName, StringComparison.OrdinalIgnoreCase)) throw new ImmediateJobException($"Job '{jobHandle}' is not a '{expectedJobName}' job."); return job; diff --git a/src/Immediate.Jobs.LinqToDB/LinqToDBJsonSerializerContext.cs b/src/Immediate.Jobs.LinqToDB/LinqToDBJsonSerializerContext.cs index 4d1087f1..3f393798 100644 --- a/src/Immediate.Jobs.LinqToDB/LinqToDBJsonSerializerContext.cs +++ b/src/Immediate.Jobs.LinqToDB/LinqToDBJsonSerializerContext.cs @@ -4,4 +4,5 @@ namespace Immediate.Jobs.LinqToDB; [JsonSerializable(typeof(JobServerSnapshot))] +[JsonSerializable(typeof(JobDefinitionRecord))] internal sealed partial class LinqToDBJsonSerializerContext : JsonSerializerContext; diff --git a/src/Immediate.Jobs.LinqToDB/LinqToDBSchemaExtensions.cs b/src/Immediate.Jobs.LinqToDB/LinqToDBSchemaExtensions.cs index 2bdbac6a..d0bfbc14 100644 --- a/src/Immediate.Jobs.LinqToDB/LinqToDBSchemaExtensions.cs +++ b/src/Immediate.Jobs.LinqToDB/LinqToDBSchemaExtensions.cs @@ -30,6 +30,8 @@ public static async Task CreateImmediateJobsSchemaAsync( if (schema is not null) await CreateSchemaAsync(context, provider, schema, cancellationToken); + await CreateDefinitionMetadataTableAsync(context, provider, schema, cancellationToken); + await context.CreateTableAsync( schemaName: schema, tableOptions: TableOptions.CreateIfNotExists, @@ -72,6 +74,8 @@ await context.CreateTableAsync( token: cancellationToken ); + await context.CreateTableAsync(schemaName: schema, tableOptions: TableOptions.CreateIfNotExists, token: cancellationToken); + await CreateConstraintsAndDefaultsAsync(context, provider, schema, cancellationToken); await CreateIndexesAsync(context, provider, schema, cancellationToken); @@ -88,6 +92,56 @@ internal static void ValidateSchema(string? schema) } } + private static Task CreateDefinitionMetadataTableAsync( + DataConnection connection, + string provider, + string? schema, + CancellationToken cancellationToken + ) + { + if (provider.Contains("SqlServer", StringComparison.OrdinalIgnoreCase)) + { + var qualified = schema is null ? "[dbo].[immediate_job_definition_metadata]" : $"[{schema}].[immediate_job_definition_metadata]"; + return connection.ExecuteAsync($$""" + IF OBJECT_ID(N'{{qualified}}', N'U') IS NULL + CREATE TABLE {{qualified}} ( + [Name] nvarchar(256) NOT NULL PRIMARY KEY, + [Metadata] nvarchar(max) NOT NULL + ); + """, cancellationToken); + } + + if (provider.Contains("PostgreSQL", StringComparison.OrdinalIgnoreCase)) + { + var qualified = schema is null ? "\"immediate_job_definition_metadata\"" : $"\"{schema}\".\"immediate_job_definition_metadata\""; + return connection.ExecuteAsync($$""" + DO $collation$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_collation WHERE collname = 'immediate_jobs_case_insensitive') THEN + CREATE COLLATION "immediate_jobs_case_insensitive" (provider = icu, locale = 'und-u-ks-level2', deterministic = false); + END IF; + EXCEPTION WHEN duplicate_object THEN NULL; + END $collation$; + CREATE TABLE IF NOT EXISTS {{qualified}} ( + "Name" character varying(256) COLLATE "immediate_jobs_case_insensitive" NOT NULL PRIMARY KEY, + "Metadata" text NOT NULL + ); + """, cancellationToken); + } + + if (provider.Contains("SQLite", StringComparison.OrdinalIgnoreCase)) + { + return connection.ExecuteAsync(""" + CREATE TABLE IF NOT EXISTS "immediate_job_definition_metadata" ( + "Name" nvarchar(256) COLLATE NOCASE NOT NULL PRIMARY KEY, + "Metadata" text NOT NULL + ); + """, cancellationToken); + } + + throw new NotSupportedException($"Immediate.Jobs schema bootstrap does not support provider '{provider}'."); + } + private static Task CreateSchemaAsync( DataConnection connection, string provider, diff --git a/src/Immediate.Jobs.Redis/LibraryEventIds.cs b/src/Immediate.Jobs.Redis/LibraryEventIds.cs index 207426eb..9867ba92 100644 --- a/src/Immediate.Jobs.Redis/LibraryEventIds.cs +++ b/src/Immediate.Jobs.Redis/LibraryEventIds.cs @@ -31,4 +31,6 @@ internal static class LibraryEventIds public const int MaterializeRecurringAsyncCalledWithDependencies = 11726; public const int UpdatePayloadAsyncCalled = 11727; public const int TryTriggerAsyncCalled = 11728; + public const int MergeJobDefinitionsListAsyncCalled = 11729; + public const int GetJobDefinitionsAsyncCalled = 11730; } diff --git a/src/Immediate.Jobs.Redis/RedisJobStorage.Acquire.cs b/src/Immediate.Jobs.Redis/RedisJobStorage.Acquire.cs index c6cc6618..38ceb6ef 100644 --- a/src/Immediate.Jobs.Redis/RedisJobStorage.Acquire.cs +++ b/src/Immediate.Jobs.Redis/RedisJobStorage.Acquire.cs @@ -49,7 +49,7 @@ public async ValueTask> AcquireDueJobsAsync( values.Add(queue.JobCapacities.Count); foreach (var capacity in queue.JobCapacities) { - values.Add(capacity.Key); + values.Add(capacity.Key.ToUpperInvariant()); values.Add(Math.Max(0, capacity.Value)); } } diff --git a/src/Immediate.Jobs.Redis/RedisJobStorage.DefinitionCatalog.cs b/src/Immediate.Jobs.Redis/RedisJobStorage.DefinitionCatalog.cs new file mode 100644 index 00000000..eeabb98d --- /dev/null +++ b/src/Immediate.Jobs.Redis/RedisJobStorage.DefinitionCatalog.cs @@ -0,0 +1,109 @@ +using System.Text.Json; +using Immediate.Jobs.Shared.Apis; +using Immediate.Jobs.Shared.Storage; +using Microsoft.Extensions.Logging; + +namespace Immediate.Jobs.Redis; + +internal sealed partial class RedisJobStorage +{ + /// + public async ValueTask MergeJobDefinitionsListAsync(JobDefinitionRegistration registration, CancellationToken cancellationToken = default) + { + MergeJobDefinitionsListAsyncCalled(); + cancellationToken.ThrowIfCancellationRequested(); + await TaskScheduler.Yield(); + _ = JobDefinitionReconciliation.Create(registration, [], []); + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var version = await Database.StringGetAsync(DefinitionCatalogVersionKey).WaitAsync(cancellationToken); + var definitions = await GetJobDefinitionsAsync(cancellationToken); + var schedules = await ReadAllRecurringAsync(cancellationToken); + var changes = JobDefinitionReconciliation.Create(registration, definitions, schedules); + var payload = new RedisDefinitionCatalogChanges + { + Definitions = changes.Definitions.Select(static definition => new RedisDefinitionMetadata + { + Name = definition.Name.ToUpperInvariant(), + Metadata = JsonSerializer.Serialize(definition, RedisJsonSerializerContext.Default.JobDefinitionRecord), + }).ToList(), + RemovedDefinitions = changes.RemovedDefinitionNames.Select(static name => name.ToUpperInvariant()).ToList(), + RemovedSchedules = changes.RemovedScheduleNames, + Schedules = changes.Schedules.Select(static schedule => new RedisDefinitionSchedule + { + Name = schedule.Name, + Record = JsonSerializer.Serialize(schedule, RedisJsonSerializerContext.Default.RecurringJobSchedule), + Cron = schedule.Cron, + TimeZone = schedule.TimeZone, + Next = Ticks(schedule.NextRunAt), + Last = NullableTicks(schedule.LastRunAt), + DueScore = Score(schedule.NextRunAt), + DueMember = RecurringDueMember(schedule.NextRunAt, schedule.Name), + Paused = schedule.IsPaused ? "1" : "0", + }).ToList(), + }; + var result = await EvaluateInt64Async(RedisScripts.MergeDefinitionCatalog, + [DefinitionMetadataKey, DefinitionCatalogVersionKey, RecurringNamesKey, RecurringDueKey], + [version.IsNull ? "0" : version, _root, JsonSerializer.Serialize(payload, RedisJsonSerializerContext.Default.RedisDefinitionCatalogChanges)], + cancellationToken); + if (result == 1) + return; + // A competing startup committed. Recompute the entire scoped change from its new snapshot. + } + } + + /// + public async ValueTask> GetJobDefinitionsAsync(CancellationToken cancellationToken = default) + { + GetJobDefinitionsAsyncCalled(); + cancellationToken.ThrowIfCancellationRequested(); + await TaskScheduler.Yield(); + var metadata = await Database.HashGetAllAsync(DefinitionMetadataKey).WaitAsync(cancellationToken); + return [.. metadata.Select(static entry => JsonSerializer.Deserialize((string)entry.Value!, RedisJsonSerializerContext.Default.JobDefinitionRecord)!) + .OrderBy(static definition => definition.Name, StringComparer.OrdinalIgnoreCase)]; + } + + [LoggerMessage( + EventId = LibraryEventIds.MergeJobDefinitionsListAsyncCalled, + EventName = "Immediate.Jobs.Redis.MergeJobDefinitionsListAsyncCalled", + Level = LogLevel.Debug, + Message = "MergeJobDefinitionsListAsyncCalled" + )] + private partial void MergeJobDefinitionsListAsyncCalled(); + + [LoggerMessage( + EventId = LibraryEventIds.GetJobDefinitionsAsyncCalled, + EventName = "Immediate.Jobs.Redis.GetJobDefinitionsAsyncCalled", + Level = LogLevel.Debug, + Message = "GetJobDefinitionsAsyncCalled" + )] + private partial void GetJobDefinitionsAsyncCalled(); +} + +internal sealed record RedisDefinitionMetadata +{ + public required string Name { get; init; } + public required string Metadata { get; init; } +} + +internal sealed record RedisDefinitionSchedule +{ + public required string Name { get; init; } + public required string Record { get; init; } + public required string Cron { get; init; } + public required string TimeZone { get; init; } + public required string Next { get; init; } + public required string Last { get; init; } + public required long DueScore { get; init; } + public required string DueMember { get; init; } + public required string Paused { get; init; } +} + +internal sealed record RedisDefinitionCatalogChanges +{ + public required IReadOnlyList Definitions { get; init; } + public required IReadOnlyList RemovedDefinitions { get; init; } + public required IReadOnlyList Schedules { get; init; } + public required IReadOnlyList RemovedSchedules { get; init; } +} diff --git a/src/Immediate.Jobs.Redis/RedisJobStorage.Enqueue.cs b/src/Immediate.Jobs.Redis/RedisJobStorage.Enqueue.cs index 3a418b64..5582a91a 100644 --- a/src/Immediate.Jobs.Redis/RedisJobStorage.Enqueue.cs +++ b/src/Immediate.Jobs.Redis/RedisJobStorage.Enqueue.cs @@ -39,7 +39,7 @@ private RedisValue[] CreateEnqueueArguments(JobRecord job) => job.ExecutionSpanId ?? "", NullableTicks(job.ExecutionStartedAt), job.QueueName, - job.JobName, + job.JobName.ToUpperInvariant(), Score(job.CreatedAt), job.JobHandle.Value, Score(job.DueAt), diff --git a/src/Immediate.Jobs.Redis/RedisJobStorage.Keys.cs b/src/Immediate.Jobs.Redis/RedisJobStorage.Keys.cs index 946eb217..47116206 100644 --- a/src/Immediate.Jobs.Redis/RedisJobStorage.Keys.cs +++ b/src/Immediate.Jobs.Redis/RedisJobStorage.Keys.cs @@ -16,6 +16,10 @@ internal sealed partial class RedisJobStorage private RedisKey RecurringDedupeKey => _root + "recurring:dedupe"; + private RedisKey DefinitionMetadataKey => _root + "definition-metadata"; + + private RedisKey DefinitionCatalogVersionKey => _root + "definition-catalog-version"; + private RedisKey ServersKey => _root + "servers"; private RedisKey JobKey(JobHandle id) => _root + "job:" + id.Value; diff --git a/src/Immediate.Jobs.Redis/RedisJobStorage.Query.cs b/src/Immediate.Jobs.Redis/RedisJobStorage.Query.cs index b8953dd7..b245de33 100644 --- a/src/Immediate.Jobs.Redis/RedisJobStorage.Query.cs +++ b/src/Immediate.Jobs.Redis/RedisJobStorage.Query.cs @@ -121,7 +121,7 @@ public async ValueTask> QueryNonCompletedJobsAsync( var jobs = await ReadJobsAsync(ids, cancellationToken); return [ - .. jobs.Where(job => string.Equals(job.JobName, jobName, StringComparison.Ordinal) + .. jobs.Where(job => string.Equals(job.JobName, jobName, StringComparison.OrdinalIgnoreCase) && job.State is (JobState.AwaitingContinuation or JobState.WaitingForTrigger or JobState.Scheduled or JobState.Pending or JobState.Active)), ]; @@ -338,7 +338,7 @@ private static bool MatchesQuery(JobRecord job, JobQuery query) => (query.State is not { } state || job.State == state) && (query.CreatedBefore is not { } createdBefore || job.CreatedAt < createdBefore) && (string.IsNullOrWhiteSpace(query.QueueName) || string.Equals(job.QueueName, query.QueueName, StringComparison.Ordinal)) && - (string.IsNullOrWhiteSpace(query.JobName) || string.Equals(job.JobName, query.JobName, StringComparison.Ordinal)) && + (string.IsNullOrWhiteSpace(query.JobName) || string.Equals(job.JobName, query.JobName, StringComparison.OrdinalIgnoreCase)) && (string.IsNullOrWhiteSpace(query.Search) || job.JobName.Contains(query.Search, StringComparison.OrdinalIgnoreCase)); diff --git a/src/Immediate.Jobs.Redis/RedisJobStorage.Recurring.cs b/src/Immediate.Jobs.Redis/RedisJobStorage.Recurring.cs index 6112317e..4b9e8719 100644 --- a/src/Immediate.Jobs.Redis/RedisJobStorage.Recurring.cs +++ b/src/Immediate.Jobs.Redis/RedisJobStorage.Recurring.cs @@ -300,7 +300,7 @@ DateTimeOffset now job.ExecutionSpanId ?? "", NullableTicks(job.ExecutionStartedAt), job.QueueName, - job.JobName, + job.JobName.ToUpperInvariant(), Score(job.CreatedAt), Ticks(nextRunAt), Score(nextRunAt), diff --git a/src/Immediate.Jobs.Redis/RedisJobStorage.Trigger.cs b/src/Immediate.Jobs.Redis/RedisJobStorage.Trigger.cs index 6ea74626..c135f2ef 100644 --- a/src/Immediate.Jobs.Redis/RedisJobStorage.Trigger.cs +++ b/src/Immediate.Jobs.Redis/RedisJobStorage.Trigger.cs @@ -23,7 +23,7 @@ public async ValueTask UpdatePayloadAsync( .WaitAsync(cancellationToken); if (values[0].IsNull) throw new KeyNotFoundException($"Job '{jobHandle}' was not found."); - if (!string.Equals(values[2], expectedJobName, StringComparison.Ordinal)) + if (!string.Equals(values[2], expectedJobName, StringComparison.OrdinalIgnoreCase)) throw new ImmediateJobException($"Job '{jobHandle}' is not a '{expectedJobName}' job."); if ((int)values[1] != (int)JobState.WaitingForTrigger) throw new ImmediateJobException($"Job '{jobHandle}' is not waiting for a trigger."); @@ -58,7 +58,7 @@ public async ValueTask TryTriggerAsync( var result = await EvaluateInt64Async( RedisScripts.Trigger, [JobKey(jobHandle)], - [expectedJobName, Ticks(dueAt), Score(dueAt), Score(now), _root, jobHandle.Value], + [expectedJobName.ToUpperInvariant(), Ticks(dueAt), Score(dueAt), Score(now), _root, jobHandle.Value], cancellationToken ); if (result == 0) diff --git a/src/Immediate.Jobs.Redis/RedisJsonSerializerContext.cs b/src/Immediate.Jobs.Redis/RedisJsonSerializerContext.cs index cdece3bb..464c7354 100644 --- a/src/Immediate.Jobs.Redis/RedisJsonSerializerContext.cs +++ b/src/Immediate.Jobs.Redis/RedisJsonSerializerContext.cs @@ -6,4 +6,6 @@ namespace Immediate.Jobs.Redis; [JsonSerializable(typeof(JobRecord))] [JsonSerializable(typeof(RecurringJobSchedule))] [JsonSerializable(typeof(JobServerSnapshot))] +[JsonSerializable(typeof(JobDefinitionRecord))] +[JsonSerializable(typeof(RedisDefinitionCatalogChanges))] internal sealed partial class RedisJsonSerializerContext : JsonSerializerContext; diff --git a/src/Immediate.Jobs.Redis/RedisScripts.cs b/src/Immediate.Jobs.Redis/RedisScripts.cs index bae19061..5614dd21 100644 --- a/src/Immediate.Jobs.Redis/RedisScripts.cs +++ b/src/Immediate.Jobs.Redis/RedisScripts.cs @@ -550,6 +550,46 @@ elseif jobCapacity and jobCapacity > 0 then return 1 """; + internal const string MergeDefinitionCatalog = + """ + if (redis.call('GET', KEYS[2]) or '0') ~= ARGV[1] then return 0 end + local changes = cjson.decode(ARGV[3]) + for _, name in ipairs(changes.RemovedDefinitions) do redis.call('HDEL', KEYS[1], name) end + for _, definition in ipairs(changes.Definitions) do + redis.call('HSET', KEYS[1], definition.Name, definition.Metadata) + end + for _, name in ipairs(changes.RemovedSchedules) do + local key = ARGV[2] .. 'recurring:' .. name + if redis.call('HGET', key, 'code') == '1' then + local dueMember = redis.call('HGET', key, 'dueMember') + if dueMember then redis.call('ZREM', KEYS[4], dueMember) end + redis.call('DEL', key) + redis.call('SREM', KEYS[3], name) + end + end + for _, schedule in ipairs(changes.Schedules) do + local key = ARGV[2] .. 'recurring:' .. schedule.Name + local paused, last, next = schedule.Paused, schedule.Last, schedule.Next + local dueScore, dueMember = schedule.DueScore, schedule.DueMember + local current = redis.call('HMGET', key, 'paused', 'last', 'next', 'dueScore', 'dueMember', 'cron', 'timeZone') + paused = current[1] or paused + last = current[2] or last + if current[5] then redis.call('ZREM', KEYS[4], current[5]) end + if current[6] == schedule.Cron and current[7] == schedule.TimeZone then + next = current[3] or next + dueScore = current[4] or dueScore + dueMember = current[5] or dueMember + end + redis.call('HSET', key, 'record', schedule.Record, 'code', '1', 'paused', paused, + 'next', next, 'last', last, 'dueScore', dueScore, 'dueMember', dueMember, + 'cron', schedule.Cron, 'timeZone', schedule.TimeZone) + redis.call('SADD', KEYS[3], schedule.Name) + if paused ~= '1' then redis.call('ZADD', KEYS[4], dueScore, dueMember) end + end + redis.call('INCR', KEYS[2]) + return 1 + """; + internal const string UpsertRecurring = """ local exists = redis.call('EXISTS', KEYS[1]) == 1 diff --git a/src/Immediate.Jobs.Shared/Apis/JobDefinitionRecord.cs b/src/Immediate.Jobs.Shared/Apis/JobDefinitionRecord.cs new file mode 100644 index 00000000..b2173d7d --- /dev/null +++ b/src/Immediate.Jobs.Shared/Apis/JobDefinitionRecord.cs @@ -0,0 +1,72 @@ +namespace Immediate.Jobs.Shared.Apis; + +/// +/// Persisted, storage-neutral configuration for a job definition. +/// +public sealed record JobDefinitionRecord +{ + /// + /// The stable job name. + /// + public required string Name { get; init; } + + /// + /// The queue used for new invocations. + /// + public string QueueName { get; init; } = "default"; + + /// + /// The dispatch priority of the queue. + /// + public int QueuePriority { get; init; } + + /// + /// The per-server queue concurrency limit. Zero is unbounded. + /// + public int QueueConcurrency { get; init; } + + /// + /// The code-defined recurring expression, or null. + /// + public string? Cron { get; init; } + + /// + /// The recurring time-zone identifier. + /// + public string TimeZone { get; init; } = "UTC"; + + /// + /// The total permitted execution attempts. + /// + public int MaxAttempts { get; init; } = 3; + + /// + /// The execution timeout, or null. + /// + public TimeSpan? Timeout { get; init; } + + /// + /// The per-server definition concurrency limit. Zero is unbounded. + /// + public int MaxConcurrency { get; init; } + + /// + /// The recurring overlap policy. + /// + public OverlapPolicy OverlapPolicy { get; init; } = OverlapPolicy.Skip; + + /// + /// The recurring misfire policy. + /// + public MisfireHandlingMode MisfireHandlingMode { get; init; } = MisfireHandlingMode.EnqueueOne; + + /// + /// The retry delay algorithm. + /// + public BackoffStrategy Backoff { get; init; } = BackoffStrategy.ExponentialJitter; + + /// + /// The retry base delay. + /// + public TimeSpan BackoffBase { get; init; } = TimeSpan.FromSeconds(5); +} diff --git a/src/Immediate.Jobs.Shared/Internals/JobSchedulingService.cs b/src/Immediate.Jobs.Shared/Internals/JobSchedulingService.cs index cac55bc9..a5450158 100644 --- a/src/Immediate.Jobs.Shared/Internals/JobSchedulingService.cs +++ b/src/Immediate.Jobs.Shared/Internals/JobSchedulingService.cs @@ -44,7 +44,7 @@ private readonly List< > _queuesByPriority; private readonly ConcurrentDictionary _queueReservations = new(StringComparer.Ordinal); - private readonly ConcurrentDictionary _jobReservations = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _jobReservations = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary _openLeases = []; private readonly CancellationTokenSource _workerCancellation = new(); private readonly string _workerId = string.Create(CultureInfo.InvariantCulture, $"{Environment.MachineName}:{Environment.ProcessId}:{DateTimeOffset.UtcNow.Ticks}"); @@ -112,9 +112,9 @@ ILogger logger #pragma warning disable CA1851 // `definitions` is backed by a list _definitions = definitions - .ToDictionary(x => x.Name, StringComparer.Ordinal); + .ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase); - _queuesByPriority = definitions + _queuesByPriority = _definitions.Values .GroupBy(d => d.Queue) .GroupBy( g => g.Key.Priority, @@ -257,8 +257,27 @@ private async Task InitializeAsync(CancellationToken cancellationToken) }) .ToList(); - await _storage.MergeRecurringSchedulesListAsync( - schedules, + await _storage.MergeJobDefinitionsListAsync( + new() + { + Definitions = _definitions.Values.Select(static definition => new JobDefinitionRecord + { + Name = definition.Name, + QueueName = definition.Queue.Name, + QueuePriority = definition.Queue.Priority, + QueueConcurrency = definition.Queue.Concurrency, + Cron = definition.Cron, + TimeZone = definition.TimeZone, + MaxAttempts = definition.MaxAttempts, + Timeout = definition.Timeout, + MaxConcurrency = definition.MaxConcurrency, + OverlapPolicy = definition.OverlapPolicy, + MisfireHandlingMode = definition.MisfireHandlingMode, + Backoff = definition.Backoff, + BackoffBase = definition.BackoffBase, + }).ToList(), + RecurringSchedules = schedules, + }, cancellationToken ); } @@ -663,7 +682,7 @@ await _storage.FailAsync( Capacity = GetJobAcquisitionCapacity(definition, capacity), }) .Where(item => item.Capacity > 0) - .ToDictionary(item => item.Name, item => item.Capacity, StringComparer.Ordinal); + .ToDictionary(item => item.Name, item => item.Capacity, StringComparer.OrdinalIgnoreCase); if (jobCapacities.Count == 0) continue; diff --git a/src/Immediate.Jobs.Shared/JobAttribute.cs b/src/Immediate.Jobs.Shared/JobAttribute.cs index 0af14d74..3de601a5 100644 --- a/src/Immediate.Jobs.Shared/JobAttribute.cs +++ b/src/Immediate.Jobs.Shared/JobAttribute.cs @@ -11,6 +11,7 @@ public sealed class JobAttribute : Attribute /// /// The explicit persisted job name, or to derive it. /// + /// Names must not have leading or trailing whitespace. /// /// The explicit persisted job name, or to derive it. /// diff --git a/src/Immediate.Jobs.Shared/LibraryEventIds.cs b/src/Immediate.Jobs.Shared/LibraryEventIds.cs index f8a51a72..33084028 100644 --- a/src/Immediate.Jobs.Shared/LibraryEventIds.cs +++ b/src/Immediate.Jobs.Shared/LibraryEventIds.cs @@ -95,4 +95,8 @@ internal static class LibraryEventIds public const int SingleServerUpdatePayloadAsyncCalled = 11091; public const int SingleServerTryTriggerAsyncCalled = 11092; public const int SingleServerTryTriggerBatchAsyncCalled = 11093; + public const int InMemoryMergeJobDefinitionsListAsyncCalled = 11094; + public const int InMemoryGetJobDefinitionsAsyncCalled = 11095; + public const int SingleServerMergeJobDefinitionsListAsyncCalled = 11096; + public const int SingleServerGetJobDefinitionsAsyncCalled = 11097; } diff --git a/src/Immediate.Jobs.Shared/Storage/IJobStorage.cs b/src/Immediate.Jobs.Shared/Storage/IJobStorage.cs index 2e2ad657..c7e158a5 100644 --- a/src/Immediate.Jobs.Shared/Storage/IJobStorage.cs +++ b/src/Immediate.Jobs.Shared/Storage/IJobStorage.cs @@ -20,6 +20,36 @@ public interface IJobStorage : IAsyncDisposable /// ValueTask InitializeAsync(CancellationToken cancellationToken = default); + /// + /// Atomically reconciles definitions and code-defined schedules against the complete application catalogue. + /// + /// + /// The complete local definition catalogue, and code-defined schedules. + /// + /// + /// A token that can cancel the operation. + /// + /// + /// A value task representing the complete reconciliation. + /// + /// + /// Supplied definitions are inserted or updated; stored definitions omitted from the supplied list + /// are removed. Dynamic schedules, and invocation history are preserved. + /// Each server's list must be authoritative for the catalogue. Repeated calls are idempotent. + /// + ValueTask MergeJobDefinitionsListAsync(JobDefinitionRegistration registration, CancellationToken cancellationToken = default); + + /// + /// Returns every persisted definition, ordered by ordinal, case-insensitive name, without local registration filtering. + /// + /// + /// A token that can cancel the operation. + /// + /// + /// The complete stored definition catalogue. + /// + ValueTask> GetJobDefinitionsAsync(CancellationToken cancellationToken = default); + /// /// Inserts a pending or scheduled invocation. /// diff --git a/src/Immediate.Jobs.Shared/Storage/InMemoryJobStorage.Acquire.cs b/src/Immediate.Jobs.Shared/Storage/InMemoryJobStorage.Acquire.cs index 1e9d8fa0..f10b23e8 100644 --- a/src/Immediate.Jobs.Shared/Storage/InMemoryJobStorage.Acquire.cs +++ b/src/Immediate.Jobs.Shared/Storage/InMemoryJobStorage.Acquire.cs @@ -59,7 +59,7 @@ public async ValueTask> AcquireDueJobsAsync( if (queueCapacity <= 0) continue; - var jobCapacities = queue.JobCapacities.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal); + var jobCapacities = queue.JobCapacities.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.OrdinalIgnoreCase); if (!HasEligibleGroupedJob(queue.QueueName, jobCapacities, now)) { AcquireQueueInExistingOrder( @@ -96,7 +96,7 @@ private List AcquireInExistingOrder(JobAcquisitionRequest request, Da if (queueCapacity <= 0) continue; - var jobCapacities = queue.JobCapacities.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal); + var jobCapacities = queue.JobCapacities.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.OrdinalIgnoreCase); AcquireQueueInExistingOrder( request, queue.QueueName, diff --git a/src/Immediate.Jobs.Shared/Storage/InMemoryJobStorage.DefinitionCatalog.cs b/src/Immediate.Jobs.Shared/Storage/InMemoryJobStorage.DefinitionCatalog.cs new file mode 100644 index 00000000..5333a156 --- /dev/null +++ b/src/Immediate.Jobs.Shared/Storage/InMemoryJobStorage.DefinitionCatalog.cs @@ -0,0 +1,55 @@ +using Immediate.Jobs.Shared.Apis; +using Microsoft.Extensions.Logging; + +namespace Immediate.Jobs.Shared.Storage; + +public sealed partial class InMemoryJobStorage +{ + private readonly Dictionary _jobDefinitions = [with(StringComparer.OrdinalIgnoreCase)]; + + /// + public async ValueTask MergeJobDefinitionsListAsync(JobDefinitionRegistration registration, CancellationToken cancellationToken = default) + { + InMemoryMergeJobDefinitionsListAsyncCalled(); + cancellationToken.ThrowIfCancellationRequested(); + await TaskScheduler.Yield(); + lock (_gate) + { + var changes = JobDefinitionReconciliation.Create(registration, [.. _jobDefinitions.Values], [.. _recurring.Values]); + foreach (var name in changes.RemovedDefinitionNames) + _jobDefinitions.Remove(name); + foreach (var definition in changes.Definitions) + _jobDefinitions[definition.Name] = definition; + foreach (var name in changes.RemovedScheduleNames) + _recurring.Remove(name); + foreach (var schedule in changes.Schedules) + _recurring[schedule.Name] = schedule; + } + } + + /// + public async ValueTask> GetJobDefinitionsAsync(CancellationToken cancellationToken = default) + { + InMemoryGetJobDefinitionsAsyncCalled(); + cancellationToken.ThrowIfCancellationRequested(); + await TaskScheduler.Yield(); + lock (_gate) + return [.. _jobDefinitions.Values.OrderBy(static definition => definition.Name, StringComparer.OrdinalIgnoreCase)]; + } + + [LoggerMessage( + EventId = LibraryEventIds.InMemoryMergeJobDefinitionsListAsyncCalled, + EventName = "Immediate.Jobs.Shared.InMemoryMergeJobDefinitionsListAsyncCalled", + Level = LogLevel.Debug, + Message = "InMemoryMergeJobDefinitionsListAsyncCalled" + )] + private partial void InMemoryMergeJobDefinitionsListAsyncCalled(); + + [LoggerMessage( + EventId = LibraryEventIds.InMemoryGetJobDefinitionsAsyncCalled, + EventName = "Immediate.Jobs.Shared.InMemoryGetJobDefinitionsAsyncCalled", + Level = LogLevel.Debug, + Message = "InMemoryGetJobDefinitionsAsyncCalled" + )] + private partial void InMemoryGetJobDefinitionsAsyncCalled(); +} diff --git a/src/Immediate.Jobs.Shared/Storage/InMemoryJobStorage.Query.cs b/src/Immediate.Jobs.Shared/Storage/InMemoryJobStorage.Query.cs index 95d05938..fcf55047 100644 --- a/src/Immediate.Jobs.Shared/Storage/InMemoryJobStorage.Query.cs +++ b/src/Immediate.Jobs.Shared/Storage/InMemoryJobStorage.Query.cs @@ -45,7 +45,7 @@ public async ValueTask> QueryJobsAsync(JobQuery query, if (!string.IsNullOrWhiteSpace(query.QueueName)) jobs = jobs.Where(x => string.Equals(x.QueueName, query.QueueName, StringComparison.Ordinal)); if (!string.IsNullOrWhiteSpace(query.JobName)) - jobs = jobs.Where(x => string.Equals(x.JobName, query.JobName, StringComparison.Ordinal)); + jobs = jobs.Where(x => string.Equals(x.JobName, query.JobName, StringComparison.OrdinalIgnoreCase)); if (query.CreatedBefore is { } createdBefore) jobs = jobs.Where(x => x.CreatedAt < createdBefore); @@ -72,7 +72,7 @@ public async ValueTask> QueryNonCompletedJobsAsync(stri lock (_gate) { return _jobs.Values - .Where(j => string.Equals(j.JobName, jobName, StringComparison.Ordinal)) + .Where(j => string.Equals(j.JobName, jobName, StringComparison.OrdinalIgnoreCase)) .Where( j => j.State is JobState.AwaitingContinuation diff --git a/src/Immediate.Jobs.Shared/Storage/InMemoryJobStorage.Trigger.cs b/src/Immediate.Jobs.Shared/Storage/InMemoryJobStorage.Trigger.cs index 3d3ef200..3f3ba714 100644 --- a/src/Immediate.Jobs.Shared/Storage/InMemoryJobStorage.Trigger.cs +++ b/src/Immediate.Jobs.Shared/Storage/InMemoryJobStorage.Trigger.cs @@ -84,7 +84,7 @@ private JobRecord GetTriggerable(JobHandle jobHandle, string expectedJobName) { if (!_jobs.TryGetValue(jobHandle, out var job)) throw new KeyNotFoundException($"Job '{jobHandle}' was not found."); - if (!string.Equals(job.JobName, expectedJobName, StringComparison.Ordinal)) + if (!string.Equals(job.JobName, expectedJobName, StringComparison.OrdinalIgnoreCase)) throw new ImmediateJobException($"Job '{jobHandle}' is not a '{expectedJobName}' job."); return job; diff --git a/src/Immediate.Jobs.Shared/Storage/JobDefinitionReconciliation.cs b/src/Immediate.Jobs.Shared/Storage/JobDefinitionReconciliation.cs new file mode 100644 index 00000000..9e5c284e --- /dev/null +++ b/src/Immediate.Jobs.Shared/Storage/JobDefinitionReconciliation.cs @@ -0,0 +1,122 @@ +using System.ComponentModel; +using Immediate.Jobs.Shared.Apis; + +namespace Immediate.Jobs.Shared.Storage; + +/// +/// Storage-neutral changes computed inside a provider's atomic startup operation. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public sealed record JobDefinitionReconciliation +{ + /// + /// The definitions to insert or replace. + /// + public required IReadOnlyList Definitions { get; init; } + + /// + /// The names of obsolete definitions in the catalogue. + /// + public required IReadOnlyList RemovedDefinitionNames { get; init; } + + /// + /// The merged code-defined schedules, preserving pause and unchanged progress. + /// + public required IReadOnlyList Schedules { get; init; } + + /// + /// The names of obsolete code-defined schedules in the catalogue. + /// + public required IReadOnlyList RemovedScheduleNames { get; init; } + + /// + /// Validates a complete catalogue and computes changes against the provider's locked snapshot. + /// + /// + /// The application's complete catalogue and code-defined schedules. + /// + /// + /// The stored definitions before reconciliation. + /// + /// + /// The stored recurring schedules before this reconciliation. + /// + /// + /// The changes to commit atomically. + /// + public static JobDefinitionReconciliation Create( + JobDefinitionRegistration registration, + IReadOnlyList existingDefinitions, + IReadOnlyList existingSchedules + ) + { + ArgumentNullException.ThrowIfNull(registration); + ArgumentNullException.ThrowIfNull(existingDefinitions); + ArgumentNullException.ThrowIfNull(existingSchedules); + ArgumentNullException.ThrowIfNull(registration.Definitions, nameof(registration)); + ArgumentNullException.ThrowIfNull(registration.RecurringSchedules, nameof(registration)); + var supplied = registration.Definitions.ToDictionary(static definition => definition.Name, StringComparer.OrdinalIgnoreCase); + foreach (var definition in supplied.Values) + { + ArgumentException.ThrowIfNullOrWhiteSpace(definition.Name, nameof(registration)); + if (char.IsWhiteSpace(definition.Name[0]) || char.IsWhiteSpace(definition.Name[^1])) + throw new ArgumentException("Job definition names must not have leading or trailing whitespace.", nameof(registration)); + } + + var canonicalNames = existingDefinitions.ToDictionary(static definition => definition.Name, static definition => definition.Name, StringComparer.OrdinalIgnoreCase); + var desired = supplied.Values + .Select(definition => canonicalNames.TryGetValue(definition.Name, out var name) ? definition with { Name = name } : definition) + .ToDictionary(static definition => definition.Name, StringComparer.OrdinalIgnoreCase); + var managedNames = existingDefinitions + .Select(static definition => definition.Name) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var removedDefinitions = managedNames.Where(name => !desired.ContainsKey(name)).ToList(); + managedNames.UnionWith(existingSchedules.Where(static schedule => schedule.IsCodeDefined).Select(static schedule => schedule.JobName)); + managedNames.UnionWith(desired.Keys); + + var existing = existingSchedules.ToDictionary(static schedule => schedule.Name, StringComparer.Ordinal); + var schedules = new List(); + var scheduleNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var suppliedSchedule in registration.RecurringSchedules) + { + var schedule = suppliedSchedule; + if (!supplied.TryGetValue(schedule.JobName, out var definition) + || !schedule.IsCodeDefined + || !string.Equals(schedule.Name, definition.Name, StringComparison.OrdinalIgnoreCase) + || !string.Equals(schedule.Cron, definition.Cron, StringComparison.Ordinal) + || !string.Equals(schedule.TimeZone, definition.TimeZone, StringComparison.Ordinal) + || !string.Equals(schedule.QueueName, definition.QueueName, StringComparison.Ordinal) + || !scheduleNames.Add(schedule.Name)) + throw new ArgumentException("Code-defined schedules must match their supplied job definitions.", nameof(registration)); + if (!desired.TryGetValue(schedule.JobName, out var managedDefinition)) + continue; + schedule = schedule with { Name = managedDefinition.Name, JobName = managedDefinition.Name }; + if (existing.TryGetValue(schedule.Name, out var current)) + { + schedules.Add(schedule with + { + IsPaused = current.IsPaused, + LastRunAt = current.LastRunAt, + NextRunAt = string.Equals(current.Cron, schedule.Cron, StringComparison.Ordinal) + && string.Equals(current.TimeZone, schedule.TimeZone, StringComparison.Ordinal) + ? current.NextRunAt : schedule.NextRunAt, + }); + } + else + schedules.Add(schedule); + } + + if (desired.Values.Any(definition => definition.Cron is not null && !scheduleNames.Contains(definition.Name))) + throw new ArgumentException("Every managed cron definition must supply its code-defined schedule.", nameof(registration)); + var desiredScheduleNames = schedules.Select(static schedule => schedule.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); + return new() + { + Definitions = [.. desired.Values], + RemovedDefinitionNames = removedDefinitions, + Schedules = schedules, + RemovedScheduleNames = existingSchedules + .Where(schedule => schedule.IsCodeDefined && managedNames.Contains(schedule.JobName) && !desiredScheduleNames.Contains(schedule.Name)) + .Select(static schedule => schedule.Name).ToList(), + }; + } +} diff --git a/src/Immediate.Jobs.Shared/Storage/JobDefinitionRegistration.cs b/src/Immediate.Jobs.Shared/Storage/JobDefinitionRegistration.cs new file mode 100644 index 00000000..cf05c75d --- /dev/null +++ b/src/Immediate.Jobs.Shared/Storage/JobDefinitionRegistration.cs @@ -0,0 +1,19 @@ +using Immediate.Jobs.Shared.Apis; + +namespace Immediate.Jobs.Shared.Storage; + +/// +/// The complete application startup catalogue. +/// +public sealed record JobDefinitionRegistration +{ + /// + /// All locally registered definitions, provided at startup. + /// + public required IReadOnlyList Definitions { get; init; } + + /// + /// The complete list of code-defined recurring schedules for these definitions. + /// + public IReadOnlyList RecurringSchedules { get; init; } = []; +} diff --git a/src/Immediate.Jobs.Shared/Storage/SingleServerJobStorage.DefinitionCatalog.cs b/src/Immediate.Jobs.Shared/Storage/SingleServerJobStorage.DefinitionCatalog.cs new file mode 100644 index 00000000..256e17ed --- /dev/null +++ b/src/Immediate.Jobs.Shared/Storage/SingleServerJobStorage.DefinitionCatalog.cs @@ -0,0 +1,55 @@ +using Immediate.Jobs.Shared.Apis; +using Microsoft.Extensions.Logging; + +namespace Immediate.Jobs.Shared.Storage; + +internal sealed partial class SingleServerJobStorage +{ + /// + public async ValueTask MergeJobDefinitionsListAsync(JobDefinitionRegistration registration, CancellationToken cancellationToken = default) + { + SingleServerMergeJobDefinitionsListAsyncCalled(); + cancellationToken.ThrowIfCancellationRequested(); + await TaskScheduler.Yield(); + await EnsureInitializedAsync(cancellationToken); + await _recurringMaterialization.WaitAsync(cancellationToken); + try + { + await DurableStorage.MergeJobDefinitionsListAsync(registration, cancellationToken); + // Refresh the entire durable catalogue of schedules, from durable storage. + var snapshot = await DurableStorage.GetMonitoringSnapshotAsync(cancellationToken); + await PrimaryStorage.MergeRecurringSchedulesListAsync( + snapshot.Recurring.Where(static schedule => schedule.IsCodeDefined).ToList(), cancellationToken); + } + finally + { + _recurringMaterialization.Release(); + } + } + + /// + public async ValueTask> GetJobDefinitionsAsync(CancellationToken cancellationToken = default) + { + SingleServerGetJobDefinitionsAsyncCalled(); + cancellationToken.ThrowIfCancellationRequested(); + await TaskScheduler.Yield(); + await EnsureInitializedAsync(cancellationToken); + return await DurableStorage.GetJobDefinitionsAsync(cancellationToken); + } + + [LoggerMessage( + EventId = LibraryEventIds.SingleServerMergeJobDefinitionsListAsyncCalled, + EventName = "Immediate.Jobs.Shared.SingleServerMergeJobDefinitionsListAsyncCalled", + Level = LogLevel.Debug, + Message = "SingleServerMergeJobDefinitionsListAsyncCalled" + )] + private partial void SingleServerMergeJobDefinitionsListAsyncCalled(); + + [LoggerMessage( + EventId = LibraryEventIds.SingleServerGetJobDefinitionsAsyncCalled, + EventName = "Immediate.Jobs.Shared.SingleServerGetJobDefinitionsAsyncCalled", + Level = LogLevel.Debug, + Message = "SingleServerGetJobDefinitionsAsyncCalled" + )] + private partial void SingleServerGetJobDefinitionsAsyncCalled(); +} diff --git a/src/Immediate.Jobs.Testing/CapturingJobStorage.cs b/src/Immediate.Jobs.Testing/CapturingJobStorage.cs index 5b2d558d..cabac868 100644 --- a/src/Immediate.Jobs.Testing/CapturingJobStorage.cs +++ b/src/Immediate.Jobs.Testing/CapturingJobStorage.cs @@ -117,6 +117,24 @@ IReadOnlyList recurringSchedules /// public virtual async ValueTask InitializeAsync(CancellationToken cancellationToken = default) => await _inner.InitializeAsync(cancellationToken); + /// + public virtual async ValueTask MergeJobDefinitionsListAsync(JobDefinitionRegistration registration, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(registration); + await _inner.MergeJobDefinitionsListAsync(registration, cancellationToken); + lock (_gate) + { + foreach (var schedule in registration.RecurringSchedules) + { + _recurringSchedules[schedule.Name] = schedule; + } + } + } + + /// + public virtual ValueTask> GetJobDefinitionsAsync(CancellationToken cancellationToken = default) => + _inner.GetJobDefinitionsAsync(cancellationToken); + /// public virtual async ValueTask EnqueueAsync(JobRecord job, CancellationToken cancellationToken = default) { diff --git a/src/Immediate.Jobs.Testing/Storage/DefinitionCatalogStorageConformance.cs b/src/Immediate.Jobs.Testing/Storage/DefinitionCatalogStorageConformance.cs new file mode 100644 index 00000000..891449df --- /dev/null +++ b/src/Immediate.Jobs.Testing/Storage/DefinitionCatalogStorageConformance.cs @@ -0,0 +1,125 @@ +using Immediate.Jobs.Shared.Apis; +using Immediate.Jobs.Shared.Storage; +using Microsoft.Extensions.Time.Testing; + +namespace Immediate.Jobs.Testing.Storage; + +internal static class DefinitionCatalogStorageConformance +{ + private const string RecurringCase = "Definitions.Recurring.PreservesProgressAndInvocations"; + private const string ConcurrentCase = "Definitions.Catalogue.SerializesConcurrentCompleteLists"; + private const string InvalidCase = "Definitions.Catalogue.RejectsInvalidSnapshotsWithoutPartialChanges"; + private const string CaseNameCase = "Definitions.Catalogue.ReconcilesCaseInsensitiveNames"; + + internal static IReadOnlyList Cases { get; } = + [ + new(RecurringCase, StorageCapabilities.Queue, PreservesRecurringAsync), + new(ConcurrentCase, StorageCapabilities.Queue, ReconcilesConcurrentlyAsync), + new(InvalidCase, StorageCapabilities.Queue, RejectsInvalidAsync), + new(CaseNameCase, StorageCapabilities.Queue, ReconcilesCaseInsensitiveNamesAsync), + ]; + + private static JobDefinitionRecord Definition(string name) => new() { Name = name }; + + private static JobDefinitionRegistration Registration(IReadOnlyList definitions) => + new() { Definitions = definitions }; + + private static async ValueTask ReconcilesCaseInsensitiveNamesAsync(IJobStorage storage, FakeTimeProvider timeProvider, CancellationToken token) + { + var original = new[] { Definition("Invoice") with { QueueName = "priority", QueuePriority = 9, QueueConcurrency = 2, Timeout = TimeSpan.FromMinutes(3), MaxConcurrency = 4, BackoffBase = TimeSpan.FromSeconds(13) }, Definition("Résumé"), Definition(new string('a', 256)) }; + await storage.MergeJobDefinitionsListAsync(Registration(original), token); + var aliases = original.Select(definition => definition with { Name = definition.Name.ToUpperInvariant(), MaxAttempts = 9 }).ToList(); + await storage.MergeJobDefinitionsListAsync(Registration(aliases), token); + var definitions = await storage.GetJobDefinitionsAsync(token); + var invoice = definitions.Single(static definition => string.Equals(definition.Name, "Invoice", StringComparison.Ordinal)); + ConformanceAssert.Equal(("priority", 9, 2, TimeSpan.FromMinutes(3), 4, TimeSpan.FromSeconds(13)), + (invoice.QueueName, invoice.QueuePriority, invoice.QueueConcurrency, invoice.Timeout, invoice.MaxConcurrency, invoice.BackoffBase), CaseNameCase, "non-runtime metadata must round trip"); + ConformanceAssert.Equal(3, definitions.Count, CaseNameCase, "case variants must update the same definitions, including Unicode and maximum-length names"); + ConformanceAssert.True(definitions.All(static definition => definition.MaxAttempts == 9), CaseNameCase, "updates must reach case-insensitive identities"); + ConformanceAssert.SequenceEqual(original.Select(static definition => definition.Name).Order(StringComparer.OrdinalIgnoreCase), + definitions.Select(static definition => definition.Name), CaseNameCase, "registration aliases must preserve the original stored spelling"); + _ = await ConformanceAssert.ThrowsAsync(() => storage.MergeJobDefinitionsListAsync(Registration([Definition("Invoice"), Definition("invoice")]), token), + CaseNameCase, "one catalogue cannot contain duplicate names that differ only by case"); + await storage.MergeJobDefinitionsListAsync(Registration([]), token); + ConformanceAssert.Equal(0, (await storage.GetJobDefinitionsAsync(token)).Count, CaseNameCase, "an empty catalogue must remove aliases as one identity"); + } + + private static RecurringJobSchedule Schedule(JobDefinitionRecord definition, DateTimeOffset next) => new() + { + Name = definition.Name, + JobName = definition.Name, + QueueName = definition.QueueName, + Cron = definition.Cron!, + TimeZone = definition.TimeZone, + IsCodeDefined = true, + NextRunAt = next, + }; + + private static async ValueTask PreservesRecurringAsync(IJobStorage storage, FakeTimeProvider timeProvider, CancellationToken token) + { + var now = timeProvider.GetUtcNow(); + var ordinary = Definition("ordinary-cron") with { Cron = "* * * * *" }; + var schedule = Schedule(ordinary, now); + await storage.MergeJobDefinitionsListAsync(new() { Definitions = [ordinary], RecurringSchedules = [schedule] }, token); + var legacy = Schedule(Definition("legacy") with { Cron = "* * * * *" }, now); + await storage.UpsertRecurringAsync(legacy, token); + var dynamic = Schedule(ordinary, now) with { Name = "dynamic", IsCodeDefined = false }; + await storage.UpsertRecurringAsync(dynamic, token); + var invocation = new JobRecord + { + JobHandle = JobHandle.FromString("retained-cron-invocation"), + JobName = ordinary.Name, + QueueName = ordinary.QueueName, + State = JobState.Pending, + Payload = "{}", + CreatedAt = now, + DueAt = now, + RecurringKey = "ordinary-cron-occurrence", + }; + ConformanceAssert.True(await storage.MaterializeRecurringAsync(schedule, invocation, now.AddMinutes(1), cancellationToken: token), RecurringCase, "the initial occurrence must materialize"); + await storage.PauseRecurringAsync(ordinary.Name, token); + await storage.MergeJobDefinitionsListAsync(new() { Definitions = [ordinary], RecurringSchedules = [Schedule(ordinary, now.AddHours(1))] }, token); + var persisted = (await storage.GetMonitoringSnapshotAsync(token)).Recurring.Single(item => string.Equals(item.Name, ordinary.Name, StringComparison.Ordinal)); + ConformanceAssert.False((await storage.GetMonitoringSnapshotAsync(token)).Recurring.Any(item => string.Equals(item.Name, legacy.Name, StringComparison.Ordinal)), + RecurringCase, "startup must clean up obsolete legacy code schedules without metadata"); + ConformanceAssert.True(persisted.IsPaused, RecurringCase, "startup must preserve schedule pause state"); + ConformanceAssert.Equal(now.AddMinutes(1), persisted.NextRunAt, RecurringCase, "unchanged cron must retain progress"); + ConformanceAssert.Equal(now, persisted.LastRunAt, RecurringCase, "startup must retain the last occurrence"); + var changed = ordinary with { Cron = "15 * * * *" }; + await storage.MergeJobDefinitionsListAsync(new() { Definitions = [changed], RecurringSchedules = [Schedule(changed, now.AddHours(2))] }, token); + persisted = (await storage.GetMonitoringSnapshotAsync(token)).Recurring.Single(item => string.Equals(item.Name, ordinary.Name, StringComparison.Ordinal)); + ConformanceAssert.Equal(now.AddHours(2), persisted.NextRunAt, RecurringCase, "changed cron must reset the next occurrence"); + ConformanceAssert.True(persisted.IsPaused, RecurringCase, "cron changes must retain pause state"); + await storage.MergeJobDefinitionsListAsync(Registration([changed with { Cron = null }]), token); + var snapshot = await storage.GetMonitoringSnapshotAsync(token); + ConformanceAssert.SequenceEqual(["dynamic"], snapshot.Recurring.Select(static item => item.Name).Order(StringComparer.Ordinal), RecurringCase, "removing cron must preserve dynamic schedules"); + await storage.MergeJobDefinitionsListAsync(Registration([]), token); + ConformanceAssert.NotNull(await storage.GetJobStatusAsync(invocation.JobHandle, token), RecurringCase, "removing metadata must never delete invocations"); + } + + private static async ValueTask ReconcilesConcurrentlyAsync(IJobStorage storage, FakeTimeProvider timeProvider, CancellationToken token) + { + await Task.WhenAll( + storage.MergeJobDefinitionsListAsync(Registration([Definition("a"), Definition("b")]), token).AsTask(), + storage.MergeJobDefinitionsListAsync(Registration([Definition("c"), Definition("d")]), token).AsTask()); + var names = (await storage.GetJobDefinitionsAsync(token)).Select(static item => item.Name).ToList(); + ConformanceAssert.True(names.SequenceEqual(["a", "b"], StringComparer.Ordinal) || names.SequenceEqual(["c", "d"], StringComparer.Ordinal), ConcurrentCase, "concurrent startup snapshots must commit whole lists without mixed results"); + } + + private static async ValueTask RejectsInvalidAsync(IJobStorage storage, FakeTimeProvider timeProvider, CancellationToken token) + { + await storage.MergeJobDefinitionsListAsync(Registration([Definition("original")]), token); + var invalid = Registration([Definition("missing-schedule") with { Cron = "* * * * *" }]); + _ = await ConformanceAssert.ThrowsAsync(() => storage.MergeJobDefinitionsListAsync(invalid, token), InvalidCase, "invalid catalogues must be rejected before mutation"); + foreach (var name in new[] { " original", "original ", "\toriginal", "original\n", "\u00a0original", "original\u00a0" }) + { + _ = await ConformanceAssert.ThrowsAsync(() => storage.MergeJobDefinitionsListAsync(Registration([Definition(name)]), token), + InvalidCase, "names with leading or trailing whitespace must be rejected before mutation"); + } + + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + _ = await ConformanceAssert.ThrowsAsync(() => storage.MergeJobDefinitionsListAsync(Registration([]), cancellation.Token), InvalidCase, "cancelled startup must not delete metadata"); + ConformanceAssert.SequenceEqual(["original"], (await storage.GetJobDefinitionsAsync(token)).Select(static item => item.Name), InvalidCase, "rejected calls must preserve the complete previous catalogue"); + } +} diff --git a/src/Immediate.Jobs.Testing/Storage/JobStorageConformanceSuite.cs b/src/Immediate.Jobs.Testing/Storage/JobStorageConformanceSuite.cs index d11eba66..f17a471b 100644 --- a/src/Immediate.Jobs.Testing/Storage/JobStorageConformanceSuite.cs +++ b/src/Immediate.Jobs.Testing/Storage/JobStorageConformanceSuite.cs @@ -39,6 +39,7 @@ public static IReadOnlyList GetCases( return QueueStorageConformance.Cases .Concat(AddOptionalCases(capabilities, StorageCapabilities.Queue, RecurringStorageConformance.Cases)) .Concat(AddOptionalCases(capabilities, StorageCapabilities.Graph, GraphStorageConformance.Cases)) + .Concat(DefinitionCatalogStorageConformance.Cases) .Concat(FairQueueStorageConformance.Cases) .Concat(AddOptionalCases(capabilities, StorageCapabilities.Queue, TriggerStorageConformance.Cases)) .Concat(includeSingleServerReplicaCases ? ReplicaStorageConformance.Cases : []) diff --git a/tests/Immediate.Jobs.FunctionalTests/JobDefinitionTests.cs b/tests/Immediate.Jobs.FunctionalTests/JobDefinitionTests.cs new file mode 100644 index 00000000..3bdc684c --- /dev/null +++ b/tests/Immediate.Jobs.FunctionalTests/JobDefinitionTests.cs @@ -0,0 +1,87 @@ +using Immediate.Jobs.Shared.Apis; +using Immediate.Jobs.Shared.Interfaces; +using Immediate.Jobs.Shared.Internals; +using Immediate.Jobs.Shared.Storage; +using Immediate.Jobs.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Time.Testing; + +namespace Immediate.Jobs.FunctionalTests; + +public sealed class JobDefinitionTests +{ + [Fact] + public async Task DisabledWorkersStillRegisterMetadata() + { + var token = TestContext.Current.CancellationToken; + var clock = new FakeTimeProvider(DateTimeOffset.UnixEpoch); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(clock); + services.AddSingleton(Definition("metadata-only", new RecordingInvoker())); + services.AddImmediateJobsCore().DisableWorkers().ConfigureStorage(builder => builder.UseStorage().UseDistributed()); + await using var provider = services.BuildServiceProvider(); + var scheduler = provider.GetRequiredService(); + await scheduler.StartAsync(token); + var storage = provider.GetRequiredService(); + await storage.Registered.Task.WaitAsync(TimeSpan.FromSeconds(5), token); + Assert.Equal("metadata-only", Assert.Single(await storage.GetJobDefinitionsAsync(token)).Name); + await scheduler.StopAsync(token); + } + + [Fact] + public async Task CaseVariantInvocationsUseTheRegisteredHandlerAndStoredDefinition() + { + var token = TestContext.Current.CancellationToken; + var invoker = new RecordingInvoker(); + await using var harness = new JobTestHarness(services => services.AddSingleton(Definition("Invoice", invoker))); + await harness.DrainAsync(token); + var now = harness.TimeProvider.GetUtcNow(); + var handle = JobHandle.FromString("case-variant"); + await harness.Storage.EnqueueAsync(new() + { + JobHandle = handle, + JobName = "invoice", + Payload = "{}", + State = JobState.Pending, + CreatedAt = now, + DueAt = now, + }, token); + await harness.DrainAsync(token); + Assert.Equal(JobState.Succeeded, (await harness.GetJobAsync(handle, token)).State); + Assert.Equal(["invoice"], invoker.Names); + } + + private static JobDefinition Definition(string name, IJobInvoker invoker) => new() + { + Name = name, + Invoker = invoker, + JobType = typeof(RecordingInvoker), + }; + + private sealed class RecordingInvoker : IJobInvoker + { + public List Names { get; } = []; + public ValueTask InvokeAsync(IServiceProvider scopedServices, JobExecution execution) + { + Names.Add(execution.Record.JobName); + return ValueTask.CompletedTask; + } + } + + private sealed class StartupStorage(TimeProvider clock) : CapturingJobStorage(clock) + { + public TaskCompletionSource Registered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Heartbeat { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public override async ValueTask MergeJobDefinitionsListAsync(JobDefinitionRegistration registration, CancellationToken cancellationToken = default) + { + await base.MergeJobDefinitionsListAsync(registration, cancellationToken); + Registered.TrySetResult(); + } + public override async ValueTask HeartbeatAsync(JobServerSnapshot server, CancellationToken cancellationToken = default) + { + await base.HeartbeatAsync(server, cancellationToken); + Heartbeat.TrySetResult(server); + } + } +} diff --git a/tests/Immediate.Jobs.FunctionalTests/StorageCapabilityTests.cs b/tests/Immediate.Jobs.FunctionalTests/StorageCapabilityTests.cs index f353872a..17016195 100644 --- a/tests/Immediate.Jobs.FunctionalTests/StorageCapabilityTests.cs +++ b/tests/Immediate.Jobs.FunctionalTests/StorageCapabilityTests.cs @@ -218,6 +218,12 @@ public ValueTask PauseRecurringAsync(string name, CancellationToken cancellation public ValueTask ResumeRecurringAsync(string name, CancellationToken cancellationToken = default) => _inner.ResumeRecurringAsync(name, cancellationToken); + public ValueTask MergeJobDefinitionsListAsync(JobDefinitionRegistration registration, CancellationToken cancellationToken = default) => + _inner.MergeJobDefinitionsListAsync(registration, cancellationToken); + + public ValueTask> GetJobDefinitionsAsync(CancellationToken cancellationToken = default) => + _inner.GetJobDefinitionsAsync(cancellationToken); + public ValueTask> GetDueRecurringAsync( DateTimeOffset now, int batchSize, diff --git a/tests/Immediate.Jobs.StorageTests/EntityFrameworkCoreConformanceTests.cs b/tests/Immediate.Jobs.StorageTests/EntityFrameworkCoreConformanceTests.cs index 29a891ad..f1cd02ed 100644 --- a/tests/Immediate.Jobs.StorageTests/EntityFrameworkCoreConformanceTests.cs +++ b/tests/Immediate.Jobs.StorageTests/EntityFrameworkCoreConformanceTests.cs @@ -39,6 +39,21 @@ public static TheoryData Cre [Collection(EntityFrameworkCorePgSQLFixtureGroup.Name)] public sealed class EntityFrameworkCorePgSQLConformanceTests(EntityFrameworkCorePgSQLContainer container) { + [Theory] + [InlineData(ConformanceTopology.Distributed)] + [InlineData(ConformanceTopology.SingleServer)] + public async Task SearchesUnicodeJobNames(ConformanceTopology topology) + { + await using var fixture = await RelationalConformanceFixture.CreateAsync( + ConformanceDatabase.PostgreSql, + useDistributedTopology: topology == ConformanceTopology.Distributed, + container: container.PostgreSql, + new() { Jobs = [], Batches = [], Edges = [], RecurringSchedules = [] } + ); + + await PostgreSqlJobSearchAssertions.AssertUnicodeSearchAsync(fixture.Services, TestContext.Current.CancellationToken); + } + [Theory] [MemberData(nameof(EntityFrameworkCoreConformanceTestCases.CreateCases), MemberType = typeof(EntityFrameworkCoreConformanceTestCases))] public async Task EntityFrameworkCoreConforms( @@ -116,6 +131,8 @@ file sealed class RelationalConformanceFixture( "immediate_job_batches", "immediate_recurring_jobs", "immediate_job_servers", + "immediate_job_definition_metadata", + "immediate_job_definition_catalog", ]; internal IServiceProvider Services => services; @@ -240,6 +257,7 @@ public async ValueTask DisposeAsync() if (database == ConformanceDatabase.PostgreSql) { await connection.ExecuteAsync($"DROP SCHEMA IF EXISTS \"{schema}\" CASCADE"); + await connection.ExecuteAsync($"DROP COLLATION IF EXISTS \"{schema}_jobs_ci\""); return; } diff --git a/tests/Immediate.Jobs.StorageTests/LinqToDBConformanceTests.cs b/tests/Immediate.Jobs.StorageTests/LinqToDBConformanceTests.cs index f1aad140..3c7d8a82 100644 --- a/tests/Immediate.Jobs.StorageTests/LinqToDBConformanceTests.cs +++ b/tests/Immediate.Jobs.StorageTests/LinqToDBConformanceTests.cs @@ -38,6 +38,21 @@ public static TheoryData Cre [Collection(LinqToDBPgSQLFixtureGroup.Name)] public sealed class LinqToDBPgSQLConformanceTests(LinqToDBPgSQLContainer container) { + [Theory] + [InlineData(ConformanceTopology.Distributed)] + [InlineData(ConformanceTopology.SingleServer)] + public async Task SearchesUnicodeJobNames(ConformanceTopology topology) + { + await using var fixture = await RelationalConformanceFixture.CreateAsync( + ConformanceDatabase.PostgreSql, + useDistributedTopology: topology == ConformanceTopology.Distributed, + container: container.PostgreSql, + new() { Jobs = [], Batches = [], Edges = [], RecurringSchedules = [] } + ); + + await PostgreSqlJobSearchAssertions.AssertUnicodeSearchAsync(fixture.Services, TestContext.Current.CancellationToken); + } + [Theory] [MemberData(nameof(LinqToDBConformanceTestCases.CreateCases), MemberType = typeof(LinqToDBConformanceTestCases))] public async Task LinqToDBConforms( @@ -115,6 +130,8 @@ file sealed class RelationalConformanceFixture( "immediate_job_batches", "immediate_recurring_jobs", "immediate_job_servers", + "immediate_job_definition_metadata", + "immediate_job_definition_catalog", ]; internal IServiceProvider Services => services; diff --git a/tests/Immediate.Jobs.StorageTests/PostgreSqlJobSearchAssertions.cs b/tests/Immediate.Jobs.StorageTests/PostgreSqlJobSearchAssertions.cs new file mode 100644 index 00000000..f522b38f --- /dev/null +++ b/tests/Immediate.Jobs.StorageTests/PostgreSqlJobSearchAssertions.cs @@ -0,0 +1,35 @@ +using Immediate.Jobs.Shared.Storage; +using Microsoft.Extensions.DependencyInjection; + +namespace Immediate.Jobs.StorageTests; + +internal static class PostgreSqlJobSearchAssertions +{ + internal static async Task AssertUnicodeSearchAsync(IServiceProvider services, CancellationToken cancellationToken) + { + await using var scope = services.CreateAsyncScope(); + var storage = scope.ServiceProvider.GetRequiredService(); + await storage.InitializeAsync(cancellationToken); + var now = scope.ServiceProvider.GetRequiredService().GetUtcNow(); + var expected = JobHandle.FromString("unicode-search"); + await storage.EnqueueAsync(new() + { + JobHandle = expected, + JobName = "RésuméJob", + QueueName = "default", + Payload = "{}", + State = JobState.Pending, + DueAt = now, + CreatedAt = now, + }, cancellationToken); + + foreach (var search in new[] { "résumé", "RÉSUMÉ", "SuMé" }) + { + var matches = await storage.QueryJobsAsync(new() { Search = search }, cancellationToken); + Assert.Equal(expected, Assert.Single(matches).JobHandle); + } + + var unaccented = await storage.QueryJobsAsync(new() { Search = "resume" }, cancellationToken); + Assert.Empty(unaccented); + } +} diff --git a/tests/Immediate.Jobs.StorageTests/StorageConformanceInfrastructureTests.cs b/tests/Immediate.Jobs.StorageTests/StorageConformanceInfrastructureTests.cs index 2269e30f..c6716bc2 100644 --- a/tests/Immediate.Jobs.StorageTests/StorageConformanceInfrastructureTests.cs +++ b/tests/Immediate.Jobs.StorageTests/StorageConformanceInfrastructureTests.cs @@ -19,7 +19,7 @@ public void GetCasesAlwaysIncludesQueueTierAndRoutesOnlyAdvertisedOptionalSuites var graphCases = JobStorageConformanceSuite.GetCases(AllCapabilities); var replicaCases = JobStorageConformanceSuite.GetCases(AllCapabilities, includeSingleServerReplicaCases: true); - Assert.Equal(38, queueCases.Count); + Assert.Equal(42, queueCases.Count); Assert.All(queueCases, testCase => Assert.Equal(StorageCapabilities.Queue, testCase.RequiredCapabilities)); Assert.Contains(queueCases, testCase => testCase.Name.StartsWith("Recurring.", StringComparison.Ordinal)); Assert.Contains(queueCases, testCase => testCase.Name.StartsWith("FairQueues.", StringComparison.Ordinal)); diff --git a/tests/Immediate.Jobs.Tests/AnalyzerTests/DuplicateElementsAnalyzerTests.cs b/tests/Immediate.Jobs.Tests/AnalyzerTests/DuplicateElementsAnalyzerTests.cs index 542de189..1bc052ff 100644 --- a/tests/Immediate.Jobs.Tests/AnalyzerTests/DuplicateElementsAnalyzerTests.cs +++ b/tests/Immediate.Jobs.Tests/AnalyzerTests/DuplicateElementsAnalyzerTests.cs @@ -4,10 +4,12 @@ namespace Immediate.Jobs.Tests.AnalyzerTests; public sealed class DuplicateElementsAnalyzerTests { - [Fact] - public async Task DuplicateJobNamesShouldTriggerAtAllLocations() => + [Theory] + [InlineData("my-name")] + [InlineData("MY-NAME")] + public async Task DuplicateJobNamesShouldTriggerAtAllLocations(string secondName) => await AnalyzerTestHelpers.CreateAnalyzerTest( - """ + $$""" using System.Threading; using System.Threading.Tasks; using Immediate.Handlers.Shared; @@ -21,7 +23,7 @@ public sealed partial class {|IJOB0002:JobOne|} private async ValueTask Handle(EmptyJobRequest _, CancellationToken token) { } } - [Handler, Job(Name = "my-name")] + [Handler, Job(Name = "{{secondName}}")] public sealed partial class {|IJOB0002:JobTwo|} { private async ValueTask Handle(EmptyJobRequest _, CancellationToken token) { } diff --git a/tests/Immediate.Jobs.Tests/AnalyzerTests/JobClassAnalyzerTests.cs b/tests/Immediate.Jobs.Tests/AnalyzerTests/JobClassAnalyzerTests.cs index 8d5f51df..3fcd75e7 100644 --- a/tests/Immediate.Jobs.Tests/AnalyzerTests/JobClassAnalyzerTests.cs +++ b/tests/Immediate.Jobs.Tests/AnalyzerTests/JobClassAnalyzerTests.cs @@ -212,6 +212,51 @@ private async ValueTask Handle(EmptyJobRequest _, CancellationToken token) { } """ ).RunAsync(TestContext.Current.CancellationToken); + [Theory] + [InlineData(" Invoice")] + [InlineData("Invoice ")] + [InlineData("\tInvoice")] + [InlineData("Invoice\t")] + [InlineData("\nInvoice")] + [InlineData("Invoice\r\n")] + [InlineData("\u00a0Invoice")] + [InlineData("Invoice\u00a0")] + public async Task ExplicitNameWithLeadingOrTrailingWhitespaceShouldTrigger(string name) => + await AnalyzerTestHelpers.CreateAnalyzerTest( + $$""" + using System.Threading; + using System.Threading.Tasks; + using Immediate.Handlers.Shared; + using Immediate.Jobs.Shared; + + [Handler, Job(Name = {{Microsoft.CodeAnalysis.CSharp.SymbolDisplay.FormatLiteral(name, quote: true)}})] + public sealed partial class {|IJOB0008:NamedJob|} + { + private ValueTask HandleAsync(EmptyJobRequest request, CancellationToken token) => ValueTask.CompletedTask; + } + """ + ).RunAsync(TestContext.Current.CancellationToken); + + [Theory] + [InlineData("Invoice")] + [InlineData("invoice")] + [InlineData("Invoice Upload")] + public async Task ExplicitNameWithoutLeadingOrTrailingWhitespaceShouldNotTrigger(string name) => + await AnalyzerTestHelpers.CreateAnalyzerTest( + $$""" + using System.Threading; + using System.Threading.Tasks; + using Immediate.Handlers.Shared; + using Immediate.Jobs.Shared; + + [Handler, Job(Name = {{Microsoft.CodeAnalysis.CSharp.SymbolDisplay.FormatLiteral(name, quote: true)}})] + public sealed partial class NamedJob + { + private ValueTask HandleAsync(EmptyJobRequest request, CancellationToken token) => ValueTask.CompletedTask; + } + """ + ).RunAsync(TestContext.Current.CancellationToken); + [Fact] public async Task ExplicitNameShouldRescueAnUnderivableClassName() => await AnalyzerTestHelpers.CreateAnalyzerTest( diff --git a/tests/Immediate.Jobs.Tests/GeneratorTests/UnnameableJobsTests.cs b/tests/Immediate.Jobs.Tests/GeneratorTests/UnnameableJobsTests.cs index 748e0ca8..bdc66ec1 100644 --- a/tests/Immediate.Jobs.Tests/GeneratorTests/UnnameableJobsTests.cs +++ b/tests/Immediate.Jobs.Tests/GeneratorTests/UnnameableJobsTests.cs @@ -36,6 +36,36 @@ private ValueTask HandleAsync(EmptyJobRequest request, CancellationToken cancell _ = await Utility.VerifyIgnoreImmediateHandlers(result); } + [Theory] + [InlineData(" Invoice")] + [InlineData("Invoice ")] + [InlineData("\tInvoice")] + [InlineData("Invoice\n")] + [InlineData("\u00a0Invoice")] + [InlineData("Invoice\u00a0")] + public void ExplicitNameWithLeadingOrTrailingWhitespaceShouldNotGenerate(string name) + { + var result = GeneratorTestHelper.RunGenerator( + $$""" + using System.Threading; + using System.Threading.Tasks; + using Immediate.Handlers.Shared; + using Immediate.Jobs.Shared; + + namespace Dummy; + + [Handler, Job(Name = {{Microsoft.CodeAnalysis.CSharp.SymbolDisplay.FormatLiteral(name, quote: true)}})] + public sealed partial class NamedJob + { + private ValueTask HandleAsync(EmptyJobRequest request, CancellationToken token) => ValueTask.CompletedTask; + } + """, + skippedSteps: ["Jobs"] + ); + + Assert.DoesNotContain(result.GeneratedTrees, tree => tree.FilePath.EndsWith("IJ.Dummy.NamedJob.g.cs", StringComparison.Ordinal)); + } + [Fact] public async Task ExplicitNameShouldRescueAnUnderivableClassName() { From 6e2b29a67aad117fd9fb7e69cd028f5e41f7f4c5 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 9 Oct 2026 23:40:00 +0000 Subject: [PATCH 2/7] Simplify definition reconciliation and remove unused recurring merge API --- docs/recurring-jobs.md | 3 +- docs/storage.md | 16 ++- ...ameworkCoreJobStorage.DefinitionCatalog.cs | 14 +- ...EntityFrameworkCoreJobStorage.Recurring.cs | 63 --------- .../ImmediateJobsModelBuilderExtensions.cs | 46 ++++--- .../LibraryEventIds.cs | 1 - .../LibraryEventIds.cs | 1 - .../LinqToDBEntities.cs | 9 -- .../LinqToDBJobStorage.Concurrency.cs | 25 +++- .../LinqToDBJobStorage.Database.cs | 3 - .../LinqToDBJobStorage.DefinitionCatalog.cs | 105 +++++++++------ .../LinqToDBJobStorage.Recurring.cs | 70 ---------- .../LinqToDBSchemaExtensions.cs | 30 +++-- src/Immediate.Jobs.LinqToDB/readme.md | 4 + src/Immediate.Jobs.Redis/LibraryEventIds.cs | 1 - .../RedisJobStorage.DefinitionCatalog.cs | 2 +- .../RedisJobStorage.Recurring.cs | 97 +++----------- src/Immediate.Jobs.Redis/RedisScripts.cs | 36 +----- src/Immediate.Jobs.Shared/JobAttribute.cs | 4 +- src/Immediate.Jobs.Shared/LibraryEventIds.cs | 2 - .../Storage/IJobStorage.cs | 25 +--- .../Storage/InMemoryJobStorage.Recurring.cs | 49 ------- ...ingleServerJobStorage.DefinitionCatalog.cs | 10 +- .../SingleServerJobStorage.Recurring.cs | 21 --- .../CapturingJobStorage.cs | 121 +++++++++++------- .../DefinitionCatalogStorageConformance.cs | 38 ++++-- .../Storage/JobStorageConformanceSuite.cs | 4 +- .../Storage/RecurringStorageConformance.cs | 24 +++- .../StorageCapabilityTests.cs | 5 - .../LinqToDBConformanceTests.cs | 1 - 30 files changed, 326 insertions(+), 504 deletions(-) diff --git a/docs/recurring-jobs.md b/docs/recurring-jobs.md index 0096fba4..2ce7c223 100644 --- a/docs/recurring-jobs.md +++ b/docs/recurring-jobs.md @@ -20,8 +20,7 @@ A `RecurringJobSchedule` has a unique `Name`, the `JobName` it materializes, a ` At startup `JobSchedulingService.InitializeAsync` submits all definitions and their code-defined schedules through `MergeJobDefinitionsListAsync`. Storage atomically reconciles the catalogue, removing code-defined schedules when a definition disappears or loses its cron expression. It preserves dynamic schedules. Unchanged cron/time zone retains the next occurrence; updates -preserve the pause state and last occurrence. The older `MergeRecurringSchedulesListAsync` operation -still performs an explicit global replacement and is not used by application startup. +preserve the pause state and last occurrence. ## Materialization diff --git a/docs/storage.md b/docs/storage.md index f53babaf..58a46a2b 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -45,8 +45,11 @@ provider conventions can pass `definitionNameCollation` to `AddImmediateJobs`. Definition names must not have leading or trailing whitespace: the job analyzer reports `IJOB0008`, and storage rejects invalid manually supplied names before modifying the catalogue. -Concurrent startup snapshots commit atomically. In-memory uses its gate; relational providers take -an early write lock on a singleton catalogue row; Redis compares a version and applies metadata and +Concurrent startup snapshots commit atomically. In-memory uses its gate; EF Core takes an early write +lock on a singleton catalogue row. LinqToDB uses a serializable transaction and one SQL `MERGE` per +table on SQL Server and PostgreSQL 15 or later. SQLite uses transactional upserts and deletes because +it does not support SQL `MERGE`. Serialization conflicts retry the complete LinqToDB snapshot. +Redis compares a version and applies metadata and schedule changes in one Lua script, recomputing after a competing startup. Single-server delegates metadata reads and reconciliation to durable storage, then refreshes the primary's code-defined schedules. Pause and unchanged schedule progress survive startup. @@ -54,8 +57,9 @@ schedules. Pause and unchanged schedule progress survive startup. Each application's list is authoritative for the catalogue. Concurrent applications with inconsistent lists can remove or replace each other's definitions; the last successful reconciliation wins. -Custom providers must implement the catalogue operations. Relational users must add the two catalogue tables through their normal -schema update process; the bootstrap helpers do not upgrade existing production databases. +Custom providers must implement the catalogue operations. Relational users must add the definition metadata table +through their normal schema update process; EF Core also requires the singleton catalogue table. The +bootstrap helpers do not upgrade existing production databases. ## Providers @@ -78,8 +82,8 @@ back a job transition. Tables: `immediate_jobs`, `immediate_job_executions`, `immediate_job_continuations`, `immediate_job_batches`, `immediate_recurring_jobs`, `immediate_job_servers`, and -`immediate_fair_queue_groups`, `immediate_job_definition_metadata`, and -`immediate_job_definition_catalog`. +`immediate_fair_queue_groups`, and `immediate_job_definition_metadata`. EF Core also uses +`immediate_job_definition_catalog` to serialize catalogue updates. - **EF Core**: applications call `modelBuilder.AddImmediateJobs()` and own their migrations. A schema change is a breaking change for every EF Core user, and needs a migration note in the pull request. diff --git a/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.DefinitionCatalog.cs b/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.DefinitionCatalog.cs index 82c450b9..8f24d7e8 100644 --- a/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.DefinitionCatalog.cs +++ b/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.DefinitionCatalog.cs @@ -16,7 +16,7 @@ public async ValueTask MergeJobDefinitionsListAsync(JobDefinitionRegistration re cancellationToken.ThrowIfCancellationRequested(); await TaskScheduler.Yield(); // Validate before taking a write lock or creating the catalogue sentinel. - _ = JobDefinitionReconciliation.Create(registration, [], []); + JobDefinitionReconciliation.Create(registration, [], []); await RetryConcurrencyAsync(async token => { await EnsureDefinitionCatalogAsync(token); @@ -24,7 +24,7 @@ await RetryConcurrencyAsync(async token => await using var transaction = await context.Database.BeginTransactionAsync(token); // A singleton row serializes the complete read/merge/write across application instances. var stamp = Guid.NewGuid(); - _ = await context.Set().Where(item => item.Id == 1) + await context.Set().Where(item => item.Id == 1) .ExecuteUpdateAsync(update => update.SetProperty(item => item.ConcurrencyStamp, stamp), token); var definitions = await context.Set() .ToDictionaryAsync(static item => item.Name, StringComparer.OrdinalIgnoreCase, token); @@ -41,7 +41,7 @@ await RetryConcurrencyAsync(async token => if (definitions.TryGetValue(definition.Name, out var current)) current.Metadata = metadata; else - _ = context.Add(new ImmediateJobDefinitionMetadataEntity { Name = definition.Name, Metadata = metadata }); + context.Add(new ImmediateJobDefinitionMetadataEntity { Name = definition.Name, Metadata = metadata }); } foreach (var name in changes.RemovedScheduleNames) @@ -51,10 +51,10 @@ await RetryConcurrencyAsync(async token => if (schedules.TryGetValue(schedule.Name, out var current)) context.Entry(current).CurrentValues.SetValues(ToEntity(schedule)); else - _ = context.Add(ToEntity(schedule)); + context.Add(ToEntity(schedule)); } - _ = await context.SaveChangesAsync(token); + await context.SaveChangesAsync(token); await transaction.CommitAsync(token); }, cancellationToken); } @@ -80,10 +80,10 @@ private async Task EnsureDefinitionCatalogAsync(CancellationToken cancellationTo await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); if (await context.Set().AnyAsync(item => item.Id == 1, cancellationToken)) return; - _ = context.Add(new ImmediateJobDefinitionCatalogEntity { Id = 1, ConcurrencyStamp = Guid.NewGuid() }); + context.Add(new ImmediateJobDefinitionCatalogEntity { Id = 1, ConcurrencyStamp = Guid.NewGuid() }); try { - _ = await context.SaveChangesAsync(cancellationToken); + await context.SaveChangesAsync(cancellationToken); } catch (DbUpdateException) { diff --git a/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.Recurring.cs b/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.Recurring.cs index 6add4f88..b5af6a64 100644 --- a/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.Recurring.cs +++ b/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.Recurring.cs @@ -7,61 +7,6 @@ namespace Immediate.Jobs.EntityFrameworkCore; internal sealed partial class EntityFrameworkCoreJobStorage where TContext : DbContext { - /// - public async ValueTask MergeRecurringSchedulesListAsync( - IReadOnlyList schedules, - CancellationToken cancellationToken = default - ) - { - MergeRecurringSchedulesListAsyncCalled(); - cancellationToken.ThrowIfCancellationRequested(); - await TaskScheduler.Yield(); - await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken); - var strategy = strategyContext.Database.CreateExecutionStrategy(); - await strategy.ExecuteAsync(async operationCancellationToken => - { - await using var context = await contextFactory.CreateDbContextAsync(operationCancellationToken); - await using var transaction = await context.Database.BeginTransactionAsync(operationCancellationToken); - var existing = await context.Set() - .ToDictionaryAsync(schedule => schedule.Name, StringComparer.Ordinal, operationCancellationToken); - - foreach (var schedule in schedules) - { - if (!existing.Remove(schedule.Name, out var entity)) - { - _ = context.Add(ToEntity(schedule)); - continue; - } - - entity.NextRunAt = - string.Equals(entity.Cron, schedule.Cron, StringComparison.Ordinal) - && string.Equals(entity.TimeZone, schedule.TimeZone, StringComparison.Ordinal) - ? entity.NextRunAt - : schedule.NextRunAt; - - entity.JobName = schedule.JobName; - entity.QueueName = schedule.QueueName; - entity.Cron = schedule.Cron; - entity.TimeZone = schedule.TimeZone; - entity.IsCodeDefined = true; - entity.ConcurrencyStamp = Guid.NewGuid(); - } - - if (existing.Count != 0) - { - var toRemove = existing - .Where(kvp => kvp.Value.IsCodeDefined) - .Select(kvp => kvp.Value) - .ToList(); - - context.RemoveRange(toRemove); - } - - await context.SaveChangesAsync(operationCancellationToken); - await transaction.CommitAsync(operationCancellationToken); - }, cancellationToken); - } - /// public async ValueTask UpsertRecurringAsync(RecurringJobSchedule schedule, CancellationToken cancellationToken = default) { @@ -315,14 +260,6 @@ CancellationToken cancellationToken } } - [LoggerMessage( - EventId = LibraryEventIds.MergeRecurringSchedulesListAsyncCalled, - EventName = "Immediate.Jobs.EntityFrameworkCore.MergeRecurringSchedulesListAsyncCalled", - Level = LogLevel.Debug, - Message = "MergeRecurringSchedulesListAsync called" - )] - private partial void MergeRecurringSchedulesListAsyncCalled(); - [LoggerMessage( EventId = LibraryEventIds.UpsertRecurringAsyncCalled, EventName = "Immediate.Jobs.EntityFrameworkCore.UpsertRecurringAsyncCalled", diff --git a/src/Immediate.Jobs.EntityFrameworkCore/ImmediateJobsModelBuilderExtensions.cs b/src/Immediate.Jobs.EntityFrameworkCore/ImmediateJobsModelBuilderExtensions.cs index 0e031a6a..084580b2 100644 --- a/src/Immediate.Jobs.EntityFrameworkCore/ImmediateJobsModelBuilderExtensions.cs +++ b/src/Immediate.Jobs.EntityFrameworkCore/ImmediateJobsModelBuilderExtensions.cs @@ -6,14 +6,26 @@ namespace Immediate.Jobs.EntityFrameworkCore; -/// Adds the Immediate.Jobs persistence model to an application DbContext. +/// +/// Adds the Immediate.Jobs persistence model to an application DbContext. +/// public static class ImmediateJobsModelBuilderExtensions { - /// Configures the entities required by the Immediate.Jobs EF Core storage provider. - /// The model builder to configure. - /// The database schema for the Immediate.Jobs tables, or for the provider default. - /// An optional case-insensitive collation for definition names; defaults to the supported provider's case-insensitive collation. - /// The configured model builder. + /// + /// Configures the entities required by the Immediate.Jobs EF Core storage provider. + /// + /// + /// The model builder to configure. + /// + /// + /// The database schema for the Immediate.Jobs tables, or for the provider default. + /// + /// + /// An optional case-insensitive collation for definition names; defaults to the supported provider's case-insensitive collation. + /// + /// + /// The configured model builder. + /// public static ModelBuilder AddImmediateJobs(this ModelBuilder modelBuilder, string? schema = null, string? definitionNameCollation = null) { ArgumentNullException.ThrowIfNull(modelBuilder); @@ -25,8 +37,8 @@ public static ModelBuilder AddImmediateJobs(this ModelBuilder modelBuilder, stri ConfigureRecurring(modelBuilder.Entity(), schema); ConfigureServers(modelBuilder.Entity(), schema); var definitions = modelBuilder.Entity(); - _ = definitions.ToTable("immediate_job_definition_metadata", schema); - _ = definitions.HasKey(definition => definition.Name); + definitions.ToTable("immediate_job_definition_metadata", schema); + definitions.HasKey(definition => definition.Name); var isPostgreSql = modelBuilder.Model.FindAnnotation("Npgsql:ValueGenerationStrategy") is not null; var postgresCollation = schema is null ? "immediate_jobs_case_insensitive" : schema + "_jobs_ci"; definitionNameCollation ??= isPostgreSql ? postgresCollation @@ -34,21 +46,21 @@ public static ModelBuilder AddImmediateJobs(this ModelBuilder modelBuilder, stri if (isPostgreSql && string.Equals(definitionNameCollation, postgresCollation, StringComparison.Ordinal)) { // Npgsql's public collation metadata is stored as this annotation (EF Core 8, 10, and 11). - _ = modelBuilder.HasAnnotation("Npgsql:CollationDefinition:" + postgresCollation, "und-u-ks-level2,und-u-ks-level2,icu,False"); + modelBuilder.HasAnnotation("Npgsql:CollationDefinition:" + postgresCollation, "und-u-ks-level2,und-u-ks-level2,icu,False"); } - _ = modelBuilder.Entity().Property(job => job.JobName).UseCollation(definitionNameCollation); - _ = modelBuilder.Entity().Property(schedule => schedule.JobName).UseCollation(definitionNameCollation); + modelBuilder.Entity().Property(job => job.JobName).UseCollation(definitionNameCollation); + modelBuilder.Entity().Property(schedule => schedule.JobName).UseCollation(definitionNameCollation); var name = definitions.Property(definition => definition.Name).HasMaxLength(256).UseCollation(definitionNameCollation); name.Metadata.SetValueComparer(new ValueComparer( (left, right) => string.Equals(left, right, StringComparison.OrdinalIgnoreCase), value => StringComparer.OrdinalIgnoreCase.GetHashCode(value), value => value)); - _ = definitions.Property(definition => definition.Metadata).IsRequired(); + definitions.Property(definition => definition.Metadata).IsRequired(); var catalogue = modelBuilder.Entity(); - _ = catalogue.ToTable("immediate_job_definition_catalog", schema); - _ = catalogue.HasKey(item => item.Id); - _ = catalogue.Property(item => item.Id).ValueGeneratedNever(); + catalogue.ToTable("immediate_job_definition_catalog", schema); + catalogue.HasKey(item => item.Id); + catalogue.Property(item => item.Id).ValueGeneratedNever(); return modelBuilder; } @@ -344,6 +356,10 @@ internal sealed class ImmediateJobDefinitionMetadataEntity public string Metadata { get; set; } = null!; } +/// +/// A singleton row updated at the start of catalogue reconciliation to acquire a database write lock. +/// The lock serializes complete definition and recurring-schedule snapshots across servers until commit. +/// internal sealed class ImmediateJobDefinitionCatalogEntity { public int Id { get; set; } diff --git a/src/Immediate.Jobs.EntityFrameworkCore/LibraryEventIds.cs b/src/Immediate.Jobs.EntityFrameworkCore/LibraryEventIds.cs index 3a5a6fde..08852cfc 100644 --- a/src/Immediate.Jobs.EntityFrameworkCore/LibraryEventIds.cs +++ b/src/Immediate.Jobs.EntityFrameworkCore/LibraryEventIds.cs @@ -16,7 +16,6 @@ internal static class LibraryEventIds public const int CompleteWithContinuationsAsyncCalled = 11511; public const int AddBatchJobAsyncCalled = 11512; public const int FailAsyncCalled = 11513; - public const int MergeRecurringSchedulesListAsyncCalled = 11514; public const int UpsertRecurringAsyncCalled = 11515; public const int RemoveRecurringAsyncCalled = 11516; public const int PauseRecurringAsyncCalled = 11517; diff --git a/src/Immediate.Jobs.LinqToDB/LibraryEventIds.cs b/src/Immediate.Jobs.LinqToDB/LibraryEventIds.cs index b498a481..82708f72 100644 --- a/src/Immediate.Jobs.LinqToDB/LibraryEventIds.cs +++ b/src/Immediate.Jobs.LinqToDB/LibraryEventIds.cs @@ -17,7 +17,6 @@ internal static class LibraryEventIds public const int CompleteWithContinuationsAsyncCalled = 11612; public const int AddBatchJobAsyncCalled = 11613; public const int FailAsyncCalled = 11614; - public const int MergeRecurringSchedulesListAsyncCalled = 11615; public const int UpsertRecurringAsyncCalled = 11616; public const int RemoveRecurringAsyncCalled = 11617; public const int PauseRecurringAsyncCalled = 11618; diff --git a/src/Immediate.Jobs.LinqToDB/LinqToDBEntities.cs b/src/Immediate.Jobs.LinqToDB/LinqToDBEntities.cs index 8168ec68..71b9e9ea 100644 --- a/src/Immediate.Jobs.LinqToDB/LinqToDBEntities.cs +++ b/src/Immediate.Jobs.LinqToDB/LinqToDBEntities.cs @@ -212,12 +212,3 @@ internal sealed class ImmediateJobDefinitionMetadataEntity [Column(DataType = DataType.Text, CanBeNull = false)] public string Metadata { get; set; } = null!; } - -[Table(Name = "immediate_job_definition_catalog")] -internal sealed class ImmediateJobDefinitionCatalogEntity -{ - [PrimaryKey, Column] - public int Id { get; set; } - [Column] - public Guid ConcurrencyStamp { get; set; } -} diff --git a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.Concurrency.cs b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.Concurrency.cs index a248b0f7..c7ad6787 100644 --- a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.Concurrency.cs +++ b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.Concurrency.cs @@ -1,9 +1,11 @@ +using System.Data; using System.Data.Common; using Immediate.Jobs.Shared.Apis; using Immediate.Jobs.Shared.Storage; using LinqToDB; using LinqToDB.Async; using LinqToDB.Data; +using LinqToDB.DataProvider.SqlServer; namespace Immediate.Jobs.LinqToDB; @@ -107,7 +109,8 @@ await PropagateTerminalAsync( private async ValueTask RetryConcurrencyAsync( Func operation, CancellationToken cancellationToken, - int maxAttempts = MaxConcurrencyAttempts + int maxAttempts = MaxConcurrencyAttempts, + IsolationLevel isolationLevel = IsolationLevel.Unspecified ) { var concurrencyAttempt = 0; @@ -115,7 +118,10 @@ private async ValueTask RetryConcurrencyAsync( { await using var scope = contextScope.GetScope(out var connection); - _ = await connection.BeginTransactionAsync(cancellationToken); + if (isolationLevel == IsolationLevel.Unspecified) + await connection.BeginTransactionAsync(cancellationToken); + else + await connection.BeginTransactionAsync(isolationLevel, cancellationToken); try { await operation(connection); @@ -137,6 +143,18 @@ private async ValueTask RetryConcurrencyAsync( await connection.RollbackTransactionAsync(cancellationToken); await DelayConcurrencyRetryAsync(cancellationToken); } + catch (DbException exception) when ( + isolationLevel == IsolationLevel.Serializable + && (exception.SqlState is "40001" or "40P01" || SqlServerTransientExceptionDetector.ShouldRetryOn(exception)) + && ++concurrencyAttempt < maxAttempts + ) + { + // A failed serializable commit may already have completed the underlying transaction. + if (connection.Transaction?.Connection is not null) + await connection.RollbackTransactionAsync(cancellationToken); + // Serializable catalogues can deadlock or fail validation; retry the entire snapshot. + await DelayConcurrencyRetryAsync(cancellationToken); + } catch (SyntheticExecutionInsertFailedException exception) { await connection.RollbackTransactionAsync(cancellationToken); @@ -160,7 +178,8 @@ private async ValueTask RetryConcurrencyAsync( } catch { - await connection.RollbackTransactionAsync(cancellationToken); + if (connection.Transaction?.Connection is not null) + await connection.RollbackTransactionAsync(cancellationToken); throw; } } diff --git a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.Database.cs b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.Database.cs index 7c25265d..c009bacc 100644 --- a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.Database.cs +++ b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.Database.cs @@ -30,9 +30,6 @@ private ITable Servers(DataConnection connection) => private ITable Definitions(DataConnection connection) => WithSchema(connection.GetTable()); - private ITable DefinitionCatalog(DataConnection connection) => - WithSchema(connection.GetTable()); - private ITable WithSchema(ITable table) where TTable : notnull => _schema is null ? table : table.SchemaName(_schema); diff --git a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.DefinitionCatalog.cs b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.DefinitionCatalog.cs index e8e134a9..8df05625 100644 --- a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.DefinitionCatalog.cs +++ b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.DefinitionCatalog.cs @@ -1,4 +1,4 @@ -using System.Data.Common; +using System.Data; using System.Text.Json; using Immediate.Jobs.Shared.Apis; using Immediate.Jobs.Shared.Storage; @@ -18,13 +18,9 @@ public async ValueTask MergeJobDefinitionsListAsync(JobDefinitionRegistration re MergeJobDefinitionsListAsyncCalled(); cancellationToken.ThrowIfCancellationRequested(); await TaskScheduler.Yield(); - _ = JobDefinitionReconciliation.Create(registration, [], []); - await EnsureDefinitionCatalogAsync(cancellationToken); + JobDefinitionReconciliation.Create(registration, [], []); await RetryConcurrencyAsync(async connection => { - var stamp = Guid.NewGuid(); - _ = await DefinitionCatalog(connection).Where(item => item.Id == 1) - .Set(item => item.ConcurrencyStamp, stamp).UpdateAsync(cancellationToken); var definitions = await Definitions(connection) .ToDictionaryAsync(static item => item.Name, StringComparer.OrdinalIgnoreCase, cancellationToken); var schedules = await Recurring(connection) @@ -32,31 +28,9 @@ await RetryConcurrencyAsync(async connection => var changes = JobDefinitionReconciliation.Create(registration, definitions.Values.Select(static item => JsonSerializer.Deserialize(item.Metadata, LinqToDBJsonSerializerContext.Default.JobDefinitionRecord)!).ToList(), schedules.Values.Select(ToRecord).ToList()); - foreach (var name in changes.RemovedDefinitionNames) - _ = await Definitions(connection).Where(item => item.Name == name).DeleteAsync(cancellationToken); - foreach (var definition in changes.Definitions) - { - var metadata = JsonSerializer.Serialize(definition, LinqToDBJsonSerializerContext.Default.JobDefinitionRecord); - _ = definitions.ContainsKey(definition.Name) - ? await Definitions(connection).Where(item => item.Name == definition.Name) - .Set(item => item.Metadata, metadata).UpdateAsync(cancellationToken) - : await InsertAsync(connection, new ImmediateJobDefinitionMetadataEntity { Name = definition.Name, Metadata = metadata }, cancellationToken); - } - - foreach (var name in changes.RemovedScheduleNames) - _ = await Recurring(connection).Where(item => item.Name == name).DeleteAsync(cancellationToken); - foreach (var schedule in changes.Schedules) - { - var entity = ToEntity(schedule); - if (schedules.TryGetValue(schedule.Name, out var current)) - { - if (!await UpdateRecurringAsync(connection, entity, current.ConcurrencyStamp, cancellationToken)) - throw new LostRaceException(); - } - else - _ = await InsertAsync(connection, entity, cancellationToken); - } - }, cancellationToken); + await MergeDefinitionMetadataAsync(connection, changes, cancellationToken); + await MergeRecurringDefinitionsAsync(connection, changes, cancellationToken); + }, cancellationToken, isolationLevel: IsolationLevel.Serializable); } /// @@ -71,21 +45,72 @@ public async ValueTask> GetJobDefinitionsAsyn .OrderBy(static definition => definition.Name, StringComparer.OrdinalIgnoreCase)]; } - private async Task EnsureDefinitionCatalogAsync(CancellationToken cancellationToken) + private async Task MergeDefinitionMetadataAsync( + DataConnection connection, + JobDefinitionReconciliation changes, + CancellationToken cancellationToken + ) { - await using var scope = contextScope.GetScope(out var connection); - if (await DefinitionCatalog(connection).AnyAsync(item => item.Id == 1, cancellationToken)) + var source = changes.Definitions.Select(static definition => new + { + definition.Name, + Metadata = JsonSerializer.Serialize(definition, LinqToDBJsonSerializerContext.Default.JobDefinitionRecord), + Remove = false, + }).Concat(changes.RemovedDefinitionNames.Select(static name => new { Name = name, Metadata = "", Remove = true })).ToList(); + if (source.Count == 0) return; - try + + // SQLite has no SQL MERGE. Keep its upserts and deletes in the same serializable transaction. + if (connection.DataProvider.Name.Contains("SQLite", StringComparison.OrdinalIgnoreCase)) { - _ = await InsertAsync(connection, new ImmediateJobDefinitionCatalogEntity { Id = 1, ConcurrencyStamp = Guid.NewGuid() }, cancellationToken); + await Definitions(connection).Where(item => item.Name.In(changes.RemovedDefinitionNames)).DeleteAsync(cancellationToken); + foreach (var item in source.Where(static item => !item.Remove)) + await connection.InsertOrReplaceAsync( + new ImmediateJobDefinitionMetadataEntity { Name = item.Name, Metadata = item.Metadata }, + schemaName: _schema, + token: cancellationToken + ); + return; } - catch (DbException) + + await Definitions(connection).Merge() + .Using(source) + .On(static (target, item) => target.Name == item.Name) + .DeleteWhenMatchedAnd(static (target, item) => item.Remove) + .UpdateWhenMatched(static (target, item) => new ImmediateJobDefinitionMetadataEntity { Metadata = item.Metadata }) + .InsertWhenNotMatchedAnd(static item => !item.Remove, + static item => new ImmediateJobDefinitionMetadataEntity { Name = item.Name, Metadata = item.Metadata }) + .MergeAsync(cancellationToken); + } + + private async Task MergeRecurringDefinitionsAsync( + DataConnection connection, + JobDefinitionReconciliation changes, + CancellationToken cancellationToken + ) + { + // Only code-defined schedules are upserted. Non-code-defined source rows mark obsolete names for deletion. + var source = changes.Schedules.Select(ToEntity) + .Concat(changes.RemovedScheduleNames.Select(static name => new ImmediateRecurringJobEntity { Name = name })) + .ToList(); + if (source.Count == 0) + return; + + if (connection.DataProvider.Name.Contains("SQLite", StringComparison.OrdinalIgnoreCase)) { - await using var verificationScope = contextScope.GetScope(out var verification); - if (!await DefinitionCatalog(verification).AnyAsync(item => item.Id == 1, cancellationToken)) - throw; + await Recurring(connection).Where(item => item.Name.In(changes.RemovedScheduleNames)).DeleteAsync(cancellationToken); + foreach (var item in source.Where(static item => item.IsCodeDefined)) + await connection.InsertOrReplaceAsync(item, schemaName: _schema, token: cancellationToken); + return; } + + await Recurring(connection).Merge() + .Using(source) + .OnTargetKey() + .DeleteWhenMatchedAnd(static (target, item) => !item.IsCodeDefined) + .UpdateWhenMatched() + .InsertWhenNotMatchedAnd(static item => item.IsCodeDefined) + .MergeAsync(cancellationToken); } [LoggerMessage( diff --git a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.Recurring.cs b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.Recurring.cs index 55f7dbfb..00e9ff2e 100644 --- a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.Recurring.cs +++ b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.Recurring.cs @@ -10,68 +10,6 @@ namespace Immediate.Jobs.LinqToDB; internal sealed partial class LinqToDBJobStorage where T : DataConnection { - /// - public async ValueTask MergeRecurringSchedulesListAsync( - IReadOnlyList schedules, - CancellationToken cancellationToken = default - ) - { - MergeRecurringSchedulesListAsyncCalled(); - cancellationToken.ThrowIfCancellationRequested(); - await TaskScheduler.Yield(); - - await using var scope = contextScope.GetScope(out var connection); - await using var transaction = await connection.BeginTransactionAsync(cancellationToken); - - var existing = await Recurring(connection) - .ToDictionaryAsync(r => r.Name, StringComparer.Ordinal, cancellationToken); - - foreach (var schedule in schedules) - { - var entity = ToEntity(schedule); - - if (!existing.TryGetValue(schedule.Name, out var current)) - { - await connection.InsertAsync(entity, schemaName: _schema, token: cancellationToken); - continue; - } - - existing.Remove(schedule.Name); - - var oldStamp = current.ConcurrencyStamp; - - current.NextRunAt = - string.Equals(current.Cron, schedule.Cron, StringComparison.Ordinal) - && string.Equals(current.TimeZone, schedule.TimeZone, StringComparison.Ordinal) - ? current.NextRunAt - : schedule.NextRunAt; - - current.JobName = schedule.JobName; - current.QueueName = schedule.QueueName; - current.Cron = schedule.Cron; - current.TimeZone = schedule.TimeZone; - current.IsCodeDefined = true; - current.ConcurrencyStamp = Guid.NewGuid(); - - if (!await UpdateRecurringAsync(connection, current, oldStamp, cancellationToken)) - throw new ImmediateJobException("Failure saving updated schedule."); - } - - if (existing.Count != 0) - { - var toRemove = existing - .Where(kvp => kvp.Value.IsCodeDefined) - .Select(kvp => kvp.Key) - .ToList(); - - await Recurring(connection) - .Where(r => r.Name.In(toRemove)) - .DeleteAsync(cancellationToken); - } - - await transaction.CommitAsync(cancellationToken); - } - /// public async ValueTask UpsertRecurringAsync( RecurringJobSchedule schedule, @@ -314,14 +252,6 @@ CancellationToken cancellationToken return updated != 0; } - [LoggerMessage( - EventId = LibraryEventIds.MergeRecurringSchedulesListAsyncCalled, - EventName = "Immediate.Jobs.LinqToDB.MergeRecurringSchedulesListAsyncCalled", - Level = LogLevel.Debug, - Message = "MergeRecurringSchedulesListAsync called" - )] - private partial void MergeRecurringSchedulesListAsyncCalled(); - [LoggerMessage( EventId = LibraryEventIds.UpsertRecurringAsyncCalled, EventName = "Immediate.Jobs.LinqToDB.UpsertRecurringAsyncCalled", diff --git a/src/Immediate.Jobs.LinqToDB/LinqToDBSchemaExtensions.cs b/src/Immediate.Jobs.LinqToDB/LinqToDBSchemaExtensions.cs index d0bfbc14..60fbc50c 100644 --- a/src/Immediate.Jobs.LinqToDB/LinqToDBSchemaExtensions.cs +++ b/src/Immediate.Jobs.LinqToDB/LinqToDBSchemaExtensions.cs @@ -3,15 +3,29 @@ namespace Immediate.Jobs.LinqToDB; -/// Explicit bootstrap helpers for a fresh Immediate.Jobs schema. +/// +/// Explicit bootstrap helpers for a fresh Immediate.Jobs schema. +/// 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. - /// 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. + /// + /// 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. + /// + /// + /// 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 TContext context, string? schema = null, @@ -74,8 +88,6 @@ await context.CreateTableAsync( token: cancellationToken ); - await context.CreateTableAsync(schemaName: schema, tableOptions: TableOptions.CreateIfNotExists, token: cancellationToken); - await CreateConstraintsAndDefaultsAsync(context, provider, schema, cancellationToken); await CreateIndexesAsync(context, provider, schema, cancellationToken); diff --git a/src/Immediate.Jobs.LinqToDB/readme.md b/src/Immediate.Jobs.LinqToDB/readme.md index 21f97a89..11d8cb51 100644 --- a/src/Immediate.Jobs.LinqToDB/readme.md +++ b/src/Immediate.Jobs.LinqToDB/readme.md @@ -8,6 +8,10 @@ LinqToDB storage for [Immediate.Jobs](https://www.nuget.org/packages/Immediate.J SQLite, and SQL Server. The adapter supports durable jobs, recurring schedules, batches, continuations, execution history, multiple worker processes, and fair queues between tenant groups. +PostgreSQL storage requires PostgreSQL 15 or later. Definition reconciliation uses SQL `MERGE`; +configure LinqToDB with a PostgreSQL 15+ dialect. The adapter assumes this requirement is met and +does not validate the PostgreSQL version at runtime. + ## Installation Install the core scheduler, this adapter, and the ADO.NET driver for your database: diff --git a/src/Immediate.Jobs.Redis/LibraryEventIds.cs b/src/Immediate.Jobs.Redis/LibraryEventIds.cs index 9867ba92..363f4865 100644 --- a/src/Immediate.Jobs.Redis/LibraryEventIds.cs +++ b/src/Immediate.Jobs.Redis/LibraryEventIds.cs @@ -20,7 +20,6 @@ internal static class LibraryEventIds public const int PurgeJobsAsyncCalled = 11715; public const int HeartbeatAsyncCalled = 11716; public const int IsHealthyAsyncCalled = 11717; - public const int MergeRecurringSchedulesListAsyncCalled = 11718; public const int UpsertRecurringAsyncCalled = 11719; public const int RemoveRecurringAsyncCalled = 11720; public const int PauseRecurringAsyncCalled = 11721; diff --git a/src/Immediate.Jobs.Redis/RedisJobStorage.DefinitionCatalog.cs b/src/Immediate.Jobs.Redis/RedisJobStorage.DefinitionCatalog.cs index eeabb98d..d940f349 100644 --- a/src/Immediate.Jobs.Redis/RedisJobStorage.DefinitionCatalog.cs +++ b/src/Immediate.Jobs.Redis/RedisJobStorage.DefinitionCatalog.cs @@ -13,7 +13,7 @@ public async ValueTask MergeJobDefinitionsListAsync(JobDefinitionRegistration re MergeJobDefinitionsListAsyncCalled(); cancellationToken.ThrowIfCancellationRequested(); await TaskScheduler.Yield(); - _ = JobDefinitionReconciliation.Create(registration, [], []); + JobDefinitionReconciliation.Create(registration, [], []); while (true) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/Immediate.Jobs.Redis/RedisJobStorage.Recurring.cs b/src/Immediate.Jobs.Redis/RedisJobStorage.Recurring.cs index 4b9e8719..bf2cde4f 100644 --- a/src/Immediate.Jobs.Redis/RedisJobStorage.Recurring.cs +++ b/src/Immediate.Jobs.Redis/RedisJobStorage.Recurring.cs @@ -10,32 +10,34 @@ internal sealed partial class RedisJobStorage private static readonly RedisValue[] RecurringMutableFields = ["record", "paused", "next", "last"]; /// - public async ValueTask MergeRecurringSchedulesListAsync( - IReadOnlyList schedules, + public async ValueTask UpsertRecurringAsync( + RecurringJobSchedule schedule, CancellationToken cancellationToken = default ) { - MergeRecurringSchedulesListAsyncCalled(); + UpsertRecurringAsyncCalled(schedule.Name); cancellationToken.ThrowIfCancellationRequested(); await TaskScheduler.Yield(); - foreach (var schedule in schedules) - await UpsertRecurringAsync(schedule, preserveUnchangedNextRun: true, cancellationToken); - - await RemoveObsoleteCodeDefinedRecurringAsync( - schedules.Select(static schedule => schedule.Name).ToList(), + var result = await EvaluateInt64Async( + RedisScripts.UpsertRecurring, + [RecurringKey(schedule.Name), RecurringNamesKey, RecurringDueKey], + [ + JsonSerializer.Serialize(schedule, RedisJsonSerializerContext.Default.RecurringJobSchedule), + schedule.IsCodeDefined ? 1 : 0, + schedule.IsPaused ? 1 : 0, + Ticks(schedule.NextRunAt), + NullableTicks(schedule.LastRunAt), + schedule.Name, + Score(schedule.NextRunAt), + RecurringDueMember(schedule.NextRunAt, schedule.Name), + schedule.Cron, + schedule.TimeZone, + ], cancellationToken ); - } - - /// - public async ValueTask UpsertRecurringAsync( - RecurringJobSchedule schedule, - CancellationToken cancellationToken = default - ) - { - UpsertRecurringAsyncCalled(schedule.Name); - await UpsertRecurringAsync(schedule, preserveUnchangedNextRun: false, cancellationToken); + if (result < 0) + throw new ImmediateJobException("Code-defined recurring schedules cannot be replaced by dynamic schedules."); } /// @@ -168,57 +170,6 @@ public async ValueTask MaterializeRecurringAsync( return result == 1; } - private async ValueTask UpsertRecurringAsync( - RecurringJobSchedule schedule, - bool preserveUnchangedNextRun, - CancellationToken cancellationToken - ) - { - cancellationToken.ThrowIfCancellationRequested(); - await TaskScheduler.Yield(); - - var result = await EvaluateInt64Async( - RedisScripts.UpsertRecurring, - [RecurringKey(schedule.Name), RecurringNamesKey, RecurringDueKey], - [ - JsonSerializer.Serialize(schedule, RedisJsonSerializerContext.Default.RecurringJobSchedule), - schedule.IsCodeDefined ? 1 : 0, - schedule.IsPaused ? 1 : 0, - Ticks(schedule.NextRunAt), - NullableTicks(schedule.LastRunAt), - schedule.Name, - Score(schedule.NextRunAt), - RecurringDueMember(schedule.NextRunAt, schedule.Name), - schedule.Cron, - schedule.TimeZone, - preserveUnchangedNextRun ? 1 : 0, - ], - cancellationToken - ); - if (result < 0) - throw new ImmediateJobException("Code-defined recurring schedules cannot be replaced by dynamic schedules."); - } - - private async ValueTask RemoveObsoleteCodeDefinedRecurringAsync( - List activeScheduleNames, - CancellationToken cancellationToken = default - ) - { - var values = new RedisValue[activeScheduleNames.Count + 1]; - values[0] = _root; - - var index = 1; - foreach (var name in activeScheduleNames) - values[index++] = name; - - _ = await EvaluateInt64Async( - RedisScripts.RemoveObsoleteRecurring, - [RecurringNamesKey, RecurringDueKey], - values, - cancellationToken - ); - } - private async ValueTask SetRecurringPausedAsync( string name, bool isPaused, @@ -328,14 +279,6 @@ private static void ValidateMaterializedJob(JobRecord job) private static string RecurringDueMember(DateTimeOffset nextRunAt, string name) => $"{Ticks(nextRunAt)}|{name}"; - [LoggerMessage( - EventId = LibraryEventIds.MergeRecurringSchedulesListAsyncCalled, - EventName = "Immediate.Jobs.Redis.MergeRecurringSchedulesListAsyncCalled", - Level = LogLevel.Debug, - Message = "MergeRecurringSchedulesListAsync called" - )] - private partial void MergeRecurringSchedulesListAsyncCalled(); - [LoggerMessage( EventId = LibraryEventIds.UpsertRecurringAsyncCalled, EventName = "Immediate.Jobs.Redis.UpsertRecurringAsyncCalled", diff --git a/src/Immediate.Jobs.Redis/RedisScripts.cs b/src/Immediate.Jobs.Redis/RedisScripts.cs index 5614dd21..49080c24 100644 --- a/src/Immediate.Jobs.Redis/RedisScripts.cs +++ b/src/Immediate.Jobs.Redis/RedisScripts.cs @@ -599,25 +599,12 @@ elseif jobCapacity and jobCapacity > 0 then local dueScore = ARGV[7] local dueMember = ARGV[8] if exists then - local current = redis.call('HMGET', KEYS[1], 'code', 'paused', 'last', 'dueMember', 'cron', 'timeZone', 'record', 'next', 'dueScore') + local current = redis.call('HMGET', KEYS[1], 'code', 'paused', 'last', 'dueMember') if current[1] == '1' and ARGV[2] == '0' then return -1 end paused = current[2] or paused last = current[3] or last if current[4] then redis.call('ZREM', KEYS[3], current[4]) end - if ARGV[11] == '1' then - local currentCron = current[5] - local currentTimeZone = current[6] - if (not currentCron or not currentTimeZone) and current[7] then - local record = cjson.decode(current[7]) - currentCron = record.Cron - currentTimeZone = record.TimeZone - end - if currentCron == ARGV[9] and currentTimeZone == ARGV[10] then - next = current[8] or next - dueScore = current[9] or dueScore - dueMember = current[4] or dueMember - end - end + end redis.call('HSET', KEYS[1], 'record', ARGV[1], 'code', ARGV[2], 'paused', paused, @@ -632,25 +619,6 @@ elseif jobCapacity and jobCapacity > 0 then return 1 """; - internal const string RemoveObsoleteRecurring = - """ - local active = {} - for index = 2, #ARGV do active[ARGV[index]] = true end - local names = redis.call('SMEMBERS', KEYS[1]) - local removed = 0 - for _, name in ipairs(names) do - local key = ARGV[1] .. 'recurring:' .. name - if redis.call('HGET', key, 'code') == '1' and not active[name] then - local dueMember = redis.call('HGET', key, 'dueMember') - redis.call('DEL', key) - redis.call('SREM', KEYS[1], name) - if dueMember then redis.call('ZREM', KEYS[2], dueMember) end - removed = removed + 1 - end - end - return removed - """; - internal const string RemoveRecurring = """ if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end diff --git a/src/Immediate.Jobs.Shared/JobAttribute.cs b/src/Immediate.Jobs.Shared/JobAttribute.cs index 3de601a5..cbbf1998 100644 --- a/src/Immediate.Jobs.Shared/JobAttribute.cs +++ b/src/Immediate.Jobs.Shared/JobAttribute.cs @@ -11,7 +11,9 @@ public sealed class JobAttribute : Attribute /// /// The explicit persisted job name, or to derive it. /// - /// Names must not have leading or trailing whitespace. + /// + /// Names must not have leading or trailing whitespace. + /// /// /// The explicit persisted job name, or to derive it. /// diff --git a/src/Immediate.Jobs.Shared/LibraryEventIds.cs b/src/Immediate.Jobs.Shared/LibraryEventIds.cs index 33084028..e7317738 100644 --- a/src/Immediate.Jobs.Shared/LibraryEventIds.cs +++ b/src/Immediate.Jobs.Shared/LibraryEventIds.cs @@ -27,7 +27,6 @@ internal static class LibraryEventIds public const int InMemoryCompleteWithContinuationsAsyncCalled = 11023; public const int InMemoryAddBatchJobAsyncCalled = 11024; public const int InMemoryFailAsyncCalled = 11025; - public const int InMemoryMergeRecurringSchedulesListAsyncCalled = 11026; public const int InMemoryUpsertRecurringAsyncCalled = 11027; public const int InMemoryRemoveRecurringAsyncCalled = 11028; public const int InMemoryPauseRecurringAsyncCalled = 11029; @@ -63,7 +62,6 @@ internal static class LibraryEventIds public const int SingleServerCompleteWithContinuationsAsyncCalled = 11059; public const int SingleServerAddBatchJobAsyncCalled = 11060; public const int SingleServerFailAsyncCalled = 11061; - public const int SingleServerMergeRecurringSchedulesListAsyncCalled = 11062; public const int SingleServerUpsertRecurringAsyncCalled = 11063; public const int SingleServerRemoveRecurringAsyncCalled = 11064; public const int SingleServerPauseRecurringAsyncCalled = 11065; diff --git a/src/Immediate.Jobs.Shared/Storage/IJobStorage.cs b/src/Immediate.Jobs.Shared/Storage/IJobStorage.cs index c7e158a5..ea8675a4 100644 --- a/src/Immediate.Jobs.Shared/Storage/IJobStorage.cs +++ b/src/Immediate.Jobs.Shared/Storage/IJobStorage.cs @@ -444,31 +444,10 @@ ValueTask PurgeJobsAsync( /// /// A token that can cancel the storage operation. /// - /// when the provider is reachable; otherwise, . - /// - ValueTask IsHealthyAsync(CancellationToken cancellationToken = default); - - /// - /// Resets the list of code-defined recurring job schedules to the provided list. - /// - /// - /// The complete list of code-defined recurring job schedules. - /// - /// - /// A token that can cancel the storage operation. - /// /// - /// A value task that represents the asynchronous merge. + /// when the provider is reachable; otherwise, . /// - /// - /// This method should be called exactly once per app start, after to reset the list of code-defined cron jobs to the - /// currently compiled list. - /// - ValueTask MergeRecurringSchedulesListAsync( - IReadOnlyList schedules, - CancellationToken cancellationToken = default - ); + ValueTask IsHealthyAsync(CancellationToken cancellationToken = default); /// /// Creates or updates a recurring schedule. diff --git a/src/Immediate.Jobs.Shared/Storage/InMemoryJobStorage.Recurring.cs b/src/Immediate.Jobs.Shared/Storage/InMemoryJobStorage.Recurring.cs index 54e9e407..ef1cb491 100644 --- a/src/Immediate.Jobs.Shared/Storage/InMemoryJobStorage.Recurring.cs +++ b/src/Immediate.Jobs.Shared/Storage/InMemoryJobStorage.Recurring.cs @@ -6,47 +6,6 @@ namespace Immediate.Jobs.Shared.Storage; public sealed partial class InMemoryJobStorage { - /// - public async ValueTask MergeRecurringSchedulesListAsync( - IReadOnlyList schedules, - CancellationToken cancellationToken = default - ) - { - MergeRecurringSchedulesListAsyncCalled(); - cancellationToken.ThrowIfCancellationRequested(); - await TaskScheduler.Yield(); - - lock (_gate) - { - var existingStaticDefinitions = _recurring - .Where(kvp => kvp.Value.IsCodeDefined) - .ToDictionary(StringComparer.Ordinal); - - foreach (var schedule in schedules) - { - ref var current = ref CollectionsMarshal.GetValueRefOrAddDefault(_recurring, schedule.Name, out _); - - current = current switch - { - { } existing when - string.Equals(existing.Cron, schedule.Cron, StringComparison.Ordinal) - && string.Equals(existing.TimeZone, schedule.TimeZone, StringComparison.Ordinal) => - existing with { JobName = schedule.JobName, QueueName = schedule.QueueName, IsCodeDefined = true }, - - { } existing => - schedule with { IsPaused = existing.IsPaused, LastRunAt = existing.LastRunAt }, - - _ => schedule, - }; - - existingStaticDefinitions.Remove(schedule.Name); - } - - foreach (var s in existingStaticDefinitions) - _recurring.Remove(s.Key); - } - } - /// public async ValueTask UpsertRecurringAsync(RecurringJobSchedule schedule, CancellationToken cancellationToken = default) { @@ -179,14 +138,6 @@ private ValueTask SetRecurringPausedAsync(string name, bool isPaused, Cancellati return ValueTask.CompletedTask; } - [LoggerMessage( - EventId = LibraryEventIds.InMemoryMergeRecurringSchedulesListAsyncCalled, - EventName = "Immediate.Jobs.Shared.MergeRecurringSchedulesListAsyncCalled", - Level = LogLevel.Debug, - Message = "MergeRecurringSchedulesListAsync called" - )] - private partial void MergeRecurringSchedulesListAsyncCalled(); - [LoggerMessage( EventId = LibraryEventIds.InMemoryUpsertRecurringAsyncCalled, EventName = "Immediate.Jobs.Shared.UpsertRecurringAsyncCalled", diff --git a/src/Immediate.Jobs.Shared/Storage/SingleServerJobStorage.DefinitionCatalog.cs b/src/Immediate.Jobs.Shared/Storage/SingleServerJobStorage.DefinitionCatalog.cs index 256e17ed..44810a03 100644 --- a/src/Immediate.Jobs.Shared/Storage/SingleServerJobStorage.DefinitionCatalog.cs +++ b/src/Immediate.Jobs.Shared/Storage/SingleServerJobStorage.DefinitionCatalog.cs @@ -18,8 +18,14 @@ public async ValueTask MergeJobDefinitionsListAsync(JobDefinitionRegistration re await DurableStorage.MergeJobDefinitionsListAsync(registration, cancellationToken); // Refresh the entire durable catalogue of schedules, from durable storage. var snapshot = await DurableStorage.GetMonitoringSnapshotAsync(cancellationToken); - await PrimaryStorage.MergeRecurringSchedulesListAsync( - snapshot.Recurring.Where(static schedule => schedule.IsCodeDefined).ToList(), cancellationToken); + await PrimaryStorage.MergeJobDefinitionsListAsync( + new() + { + Definitions = await DurableStorage.GetJobDefinitionsAsync(cancellationToken), + RecurringSchedules = snapshot.Recurring.Where(static schedule => schedule.IsCodeDefined).ToList(), + }, + cancellationToken + ); } finally { diff --git a/src/Immediate.Jobs.Shared/Storage/SingleServerJobStorage.Recurring.cs b/src/Immediate.Jobs.Shared/Storage/SingleServerJobStorage.Recurring.cs index fdf8827f..636649c9 100644 --- a/src/Immediate.Jobs.Shared/Storage/SingleServerJobStorage.Recurring.cs +++ b/src/Immediate.Jobs.Shared/Storage/SingleServerJobStorage.Recurring.cs @@ -7,19 +7,6 @@ internal sealed partial class SingleServerJobStorage { private readonly SemaphoreSlim _recurringMaterialization = new(1, 1); - /// - public async ValueTask MergeRecurringSchedulesListAsync( - IReadOnlyList schedules, - CancellationToken cancellationToken = default - ) - { - SingleServerMergeRecurringSchedulesListAsyncCalled(schedules.Count); - await TaskScheduler.Yield(); - await EnsureInitializedAsync(cancellationToken); - await DurableStorage.MergeRecurringSchedulesListAsync(schedules, cancellationToken); - await PrimaryStorage.MergeRecurringSchedulesListAsync(schedules, cancellationToken); - } - /// public async ValueTask UpsertRecurringAsync(RecurringJobSchedule schedule, CancellationToken cancellationToken = default) { @@ -111,14 +98,6 @@ public async ValueTask MaterializeRecurringAsync( } } - [LoggerMessage( - EventId = LibraryEventIds.SingleServerMergeRecurringSchedulesListAsyncCalled, - EventName = "Immediate.Jobs.Shared.SingleServerMergeRecurringSchedulesListAsyncCalled", - Level = LogLevel.Debug, - Message = "Single-server storage MergeRecurringSchedulesListAsync called (Schedules={Schedules})" - )] - private partial void SingleServerMergeRecurringSchedulesListAsyncCalled(int schedules); - [LoggerMessage( EventId = LibraryEventIds.SingleServerUpsertRecurringAsyncCalled, EventName = "Immediate.Jobs.Shared.SingleServerUpsertRecurringAsyncCalled", diff --git a/src/Immediate.Jobs.Testing/CapturingJobStorage.cs b/src/Immediate.Jobs.Testing/CapturingJobStorage.cs index cabac868..de85c054 100644 --- a/src/Immediate.Jobs.Testing/CapturingJobStorage.cs +++ b/src/Immediate.Jobs.Testing/CapturingJobStorage.cs @@ -28,45 +28,67 @@ public class CapturingJobStorage(TimeProvider timeProvider) : private readonly List _recurringOperations = []; private readonly List _recurringMaterializations = []; - /// Gets snapshots of all job records submitted by schedulers. + /// + /// Gets snapshots of all job records submitted by schedulers. + /// public IReadOnlyList Jobs { get { lock (_gate) return [.. _jobs]; } } - /// Gets snapshots of continuation enqueue operations. + /// + /// Gets snapshots of continuation enqueue operations. + /// public IReadOnlyList Continuations { get { lock (_gate) return [.. _continuations]; } } - /// Gets snapshots of committed batch enqueue operations. + /// + /// Gets snapshots of committed batch enqueue operations. + /// public IReadOnlyList Batches { get { lock (_gate) return [.. _batches]; } } - /// Gets snapshots of jobs dynamically added to running batches. + /// + /// Gets snapshots of jobs dynamically added to running batches. + /// public IReadOnlyList BatchJobs { get { lock (_gate) return [.. _batchJobs]; } } - /// Gets snapshots of continuations buffered by running jobs. + /// + /// Gets snapshots of continuations buffered by running jobs. + /// public IReadOnlyList DynamicContinuations { get { lock (_gate) return [.. _dynamicContinuations]; } } - /// Gets recurring schedules submitted for creation or update. + /// + /// Gets recurring schedules submitted for creation or update. + /// public IReadOnlyDictionary RecurringSchedules { get { lock (_gate) return _recurringSchedules.ToDictionary(StringComparer.Ordinal); } } - /// Gets recurring schedule mutation calls in call order. + /// + /// Gets recurring schedule mutation calls in call order. + /// public IReadOnlyList RecurringOperations { get { lock (_gate) return [.. _recurringOperations]; } } - /// Gets recurring occurrence materialization attempts. + /// + /// Gets recurring occurrence materialization attempts. + /// public IReadOnlyList RecurringMaterializations { get { lock (_gate) return [.. _recurringMaterializations]; } } - /// Returns the captured job with the supplied identifier, or . + /// + /// Returns the captured job with the supplied identifier, or . + /// public JobRecord? FindJob(JobHandle jobHandle) { lock (_gate) return _jobs.LastOrDefault(job => job.JobHandle == jobHandle); } - /// Returns the captured batch with the supplied identifier, or . + /// + /// Returns the captured batch with the supplied identifier, or . + /// public BatchCapture? FindBatch(BatchHandle batchHandle) { lock (_gate) return _batches.LastOrDefault(batch => batch.Batch.BatchHandle == batchHandle); } - /// Clears captured inputs without changing persisted in-memory state. + /// + /// Clears captured inputs without changing persisted in-memory state. + /// public void Clear() { lock (_gate) @@ -194,23 +216,6 @@ public virtual async ValueTask CompleteWithContinuationsAsync(JobHandle jobHandl await _inner.CompleteWithContinuationsAsync(jobHandle, executionNumber, workerId, additions, cancellationToken); } - /// - public virtual async ValueTask MergeRecurringSchedulesListAsync( - IReadOnlyList schedules, - CancellationToken cancellationToken = default - ) - { - ArgumentNullException.ThrowIfNull(schedules); - - await _inner.MergeRecurringSchedulesListAsync(schedules, cancellationToken); - - lock (_gate) - { - foreach (var rs in schedules) - _recurringSchedules[rs.Name] = rs; - } - } - /// public virtual async ValueTask UpsertRecurringAsync(RecurringJobSchedule schedule, CancellationToken cancellationToken = default) { @@ -315,12 +320,20 @@ public virtual async ValueTask ResumeRecurringAsync(string name, CancellationTok /// public virtual async ValueTask PurgeBatchesAsync(TimeSpan batchSucceededRetention, TimeSpan batchFailedRetention, CancellationToken cancellationToken = default) => await _inner.PurgeBatchesAsync(batchSucceededRetention, batchFailedRetention, cancellationToken); /// - /// Not supported: capturing storage cannot be used as a single-server durable store. - /// Always thrown. + /// + /// Not supported: capturing storage cannot be used as a single-server durable store. + /// + /// + /// Always thrown. + /// public virtual async ValueTask> AcquireJobsAsync(IReadOnlyCollection jobs, string workerId, TimeSpan lease, CancellationToken cancellationToken = default) => await _inner.AcquireJobsAsync(jobs, workerId, lease, cancellationToken); /// - /// Not supported: capturing storage cannot be used as a single-server durable store. - /// Always thrown. + /// + /// Not supported: capturing storage cannot be used as a single-server durable store. + /// + /// + /// Always thrown. + /// public virtual async ValueTask> GetIncomingEdgesAsync(IReadOnlyCollection childJobs, CancellationToken cancellationToken = default) => await _inner.GetIncomingEdgesAsync(childJobs, cancellationToken); /// public virtual async ValueTask HeartbeatAsync(JobServerSnapshot server, CancellationToken cancellationToken = default) => await _inner.HeartbeatAsync(server, cancellationToken); @@ -333,35 +346,57 @@ private void Capture(JobRecord job) } } -/// A captured continuation enqueue operation. +/// +/// A captured continuation enqueue operation. +/// public sealed record ContinuationCapture(JobRecord Job, IReadOnlyList Edges); -/// A captured atomic batch enqueue operation. +/// +/// A captured atomic batch enqueue operation. +/// public sealed record BatchCapture(BatchRecord Batch, IReadOnlyList Jobs, IReadOnlyList Edges); -/// A captured dynamic batch-member operation. +/// +/// A captured dynamic batch-member operation. +/// public sealed record BatchJobCapture(JobHandle CurrentJobHandle, int ExecutionNumber, JobRecord Job, ContinuationOptions Options); -/// A captured set of continuations flushed when a running job completed. +/// +/// A captured set of continuations flushed when a running job completed. +/// public sealed record DynamicContinuationCapture(JobHandle JobHandle, int ExecutionNumber, string WorkerId, IReadOnlyList Additions); -/// The kind of recurring schedule mutation that was captured. +/// +/// The kind of recurring schedule mutation that was captured. +/// public enum RecurringOperation { - /// A schedule was created or updated. + /// + /// A schedule was created or updated. + /// Upsert, - /// A schedule was removed. + /// + /// A schedule was removed. + /// Remove, - /// A schedule was paused. + /// + /// A schedule was paused. + /// Pause, - /// A schedule was resumed. + /// + /// A schedule was resumed. + /// Resume, } -/// A captured recurring schedule mutation. +/// +/// A captured recurring schedule mutation. +/// public sealed record RecurringOperationCapture(RecurringOperation Operation, string Name); -/// A captured recurring occurrence materialization attempt. +/// +/// A captured recurring occurrence materialization attempt. +/// public sealed record RecurringMaterializationCapture( RecurringJobSchedule Schedule, JobRecord Job, diff --git a/src/Immediate.Jobs.Testing/Storage/DefinitionCatalogStorageConformance.cs b/src/Immediate.Jobs.Testing/Storage/DefinitionCatalogStorageConformance.cs index 891449df..ddf8c845 100644 --- a/src/Immediate.Jobs.Testing/Storage/DefinitionCatalogStorageConformance.cs +++ b/src/Immediate.Jobs.Testing/Storage/DefinitionCatalogStorageConformance.cs @@ -38,7 +38,7 @@ private static async ValueTask ReconcilesCaseInsensitiveNamesAsync(IJobStorage s ConformanceAssert.True(definitions.All(static definition => definition.MaxAttempts == 9), CaseNameCase, "updates must reach case-insensitive identities"); ConformanceAssert.SequenceEqual(original.Select(static definition => definition.Name).Order(StringComparer.OrdinalIgnoreCase), definitions.Select(static definition => definition.Name), CaseNameCase, "registration aliases must preserve the original stored spelling"); - _ = await ConformanceAssert.ThrowsAsync(() => storage.MergeJobDefinitionsListAsync(Registration([Definition("Invoice"), Definition("invoice")]), token), + await ConformanceAssert.ThrowsAsync(() => storage.MergeJobDefinitionsListAsync(Registration([Definition("Invoice"), Definition("invoice")]), token), CaseNameCase, "one catalogue cannot contain duplicate names that differ only by case"); await storage.MergeJobDefinitionsListAsync(Registration([]), token); ConformanceAssert.Equal(0, (await storage.GetJobDefinitionsAsync(token)).Count, CaseNameCase, "an empty catalogue must remove aliases as one identity"); @@ -99,27 +99,47 @@ private static async ValueTask PreservesRecurringAsync(IJobStorage storage, Fake private static async ValueTask ReconcilesConcurrentlyAsync(IJobStorage storage, FakeTimeProvider timeProvider, CancellationToken token) { - await Task.WhenAll( - storage.MergeJobDefinitionsListAsync(Registration([Definition("a"), Definition("b")]), token).AsTask(), - storage.MergeJobDefinitionsListAsync(Registration([Definition("c"), Definition("d")]), token).AsTask()); - var names = (await storage.GetJobDefinitionsAsync(token)).Select(static item => item.Name).ToList(); - ConformanceAssert.True(names.SequenceEqual(["a", "b"], StringComparer.Ordinal) || names.SequenceEqual(["c", "d"], StringComparer.Ordinal), ConcurrentCase, "concurrent startup snapshots must commit whole lists without mixed results"); + var firstCron = Definition("a") with { Cron = "* * * * *" }; + var secondCron = Definition("c") with { Cron = "15 * * * *" }; + var first = new JobDefinitionRegistration + { + Definitions = [firstCron, Definition("b"), Definition("shared")], + RecurringSchedules = [Schedule(firstCron, timeProvider.GetUtcNow())], + }; + var second = new JobDefinitionRegistration + { + Definitions = [secondCron, Definition("d"), Definition("shared")], + RecurringSchedules = [Schedule(secondCron, timeProvider.GetUtcNow())], + }; + for (var round = 0; round < 5; round++) + { + await Task.WhenAll( + storage.MergeJobDefinitionsListAsync(first, token).AsTask(), + storage.MergeJobDefinitionsListAsync(second, token).AsTask(), + storage.MergeJobDefinitionsListAsync(Registration([]), token).AsTask()); + var names = (await storage.GetJobDefinitionsAsync(token)).Select(static item => item.Name).ToList(); + ConformanceAssert.True(names.Count == 0 || names.SequenceEqual(["a", "b", "shared"], StringComparer.Ordinal) || names.SequenceEqual(["c", "d", "shared"], StringComparer.Ordinal), + ConcurrentCase, "concurrent startup snapshots, including empty lists, must commit whole catalogues without mixed results"); + var schedules = (await storage.GetMonitoringSnapshotAsync(token)).Recurring; + ConformanceAssert.SequenceEqual(names.Where(static name => name is "a" or "c"), schedules.Select(static schedule => schedule.JobName), + ConcurrentCase, "definitions and their code-defined schedules must commit together"); + } } private static async ValueTask RejectsInvalidAsync(IJobStorage storage, FakeTimeProvider timeProvider, CancellationToken token) { await storage.MergeJobDefinitionsListAsync(Registration([Definition("original")]), token); var invalid = Registration([Definition("missing-schedule") with { Cron = "* * * * *" }]); - _ = await ConformanceAssert.ThrowsAsync(() => storage.MergeJobDefinitionsListAsync(invalid, token), InvalidCase, "invalid catalogues must be rejected before mutation"); + await ConformanceAssert.ThrowsAsync(() => storage.MergeJobDefinitionsListAsync(invalid, token), InvalidCase, "invalid catalogues must be rejected before mutation"); foreach (var name in new[] { " original", "original ", "\toriginal", "original\n", "\u00a0original", "original\u00a0" }) { - _ = await ConformanceAssert.ThrowsAsync(() => storage.MergeJobDefinitionsListAsync(Registration([Definition(name)]), token), + await ConformanceAssert.ThrowsAsync(() => storage.MergeJobDefinitionsListAsync(Registration([Definition(name)]), token), InvalidCase, "names with leading or trailing whitespace must be rejected before mutation"); } using var cancellation = new CancellationTokenSource(); await cancellation.CancelAsync(); - _ = await ConformanceAssert.ThrowsAsync(() => storage.MergeJobDefinitionsListAsync(Registration([]), cancellation.Token), InvalidCase, "cancelled startup must not delete metadata"); + await ConformanceAssert.ThrowsAsync(() => storage.MergeJobDefinitionsListAsync(Registration([]), cancellation.Token), InvalidCase, "cancelled startup must not delete metadata"); ConformanceAssert.SequenceEqual(["original"], (await storage.GetJobDefinitionsAsync(token)).Select(static item => item.Name), InvalidCase, "rejected calls must preserve the complete previous catalogue"); } } diff --git a/src/Immediate.Jobs.Testing/Storage/JobStorageConformanceSuite.cs b/src/Immediate.Jobs.Testing/Storage/JobStorageConformanceSuite.cs index f17a471b..98f1e6bd 100644 --- a/src/Immediate.Jobs.Testing/Storage/JobStorageConformanceSuite.cs +++ b/src/Immediate.Jobs.Testing/Storage/JobStorageConformanceSuite.cs @@ -21,7 +21,9 @@ public static class JobStorageConformanceSuite /// Whether to include the cases for , which only providers that /// can act as a single-server durable store implement. Requires . /// - /// Individually discoverable conformance test cases. + /// + /// Individually discoverable conformance test cases. + /// public static IReadOnlyList GetCases( StorageCapabilities capabilities, bool includeSingleServerReplicaCases = false diff --git a/src/Immediate.Jobs.Testing/Storage/RecurringStorageConformance.cs b/src/Immediate.Jobs.Testing/Storage/RecurringStorageConformance.cs index f85e9030..4a6d2e0e 100644 --- a/src/Immediate.Jobs.Testing/Storage/RecurringStorageConformance.cs +++ b/src/Immediate.Jobs.Testing/Storage/RecurringStorageConformance.cs @@ -114,16 +114,30 @@ CancellationToken cancellationToken var persistedAt = new DateTimeOffset(2026, 8, 8, 10, 0, 0, TimeSpan.Zero); var updatedDefinition = Schedule("merge-update", now.AddHours(4), isCodeDefined: true) with { - JobName = "merge-updated-job", + JobName = "merge-update", QueueName = "merge-updated-queue", Cron = "30 * * * *", TimeZone = "Europe/Vienna", }; - var insertedDefinition = Schedule("merge-insert", now.AddHours(5), isCodeDefined: true); - var upgradeToStatic = Schedule("merge-dynamic-to-static", now.AddHours(3), isCodeDefined: true); - var preserve = Schedule("preserve-next-run", persistedAt.AddHours(12), isCodeDefined: true); + var insertedDefinition = Schedule("merge-insert", now.AddHours(5), isCodeDefined: true) with { JobName = "merge-insert" }; + var upgradeToStatic = Schedule("merge-dynamic-to-static", now.AddHours(3), isCodeDefined: true) with { JobName = "merge-dynamic-to-static" }; + var preserve = Schedule("preserve-next-run", persistedAt.AddHours(12), isCodeDefined: true) with { JobName = "preserve-next-run" }; - await storage.MergeRecurringSchedulesListAsync([updatedDefinition, insertedDefinition, upgradeToStatic, preserve], cancellationToken); + var supplied = new[] { updatedDefinition, insertedDefinition, upgradeToStatic, preserve }; + await storage.MergeJobDefinitionsListAsync( + new() + { + Definitions = supplied.Select(static schedule => new JobDefinitionRecord + { + Name = schedule.JobName, + QueueName = schedule.QueueName, + Cron = schedule.Cron, + TimeZone = schedule.TimeZone, + }).ToList(), + RecurringSchedules = supplied, + }, + cancellationToken + ); var schedules = (await storage.GetMonitoringSnapshotAsync(cancellationToken)).Recurring; var updated = schedules.Single(schedule => string.Equals(schedule.Name, "merge-update", StringComparison.Ordinal)); diff --git a/tests/Immediate.Jobs.FunctionalTests/StorageCapabilityTests.cs b/tests/Immediate.Jobs.FunctionalTests/StorageCapabilityTests.cs index 17016195..7364402e 100644 --- a/tests/Immediate.Jobs.FunctionalTests/StorageCapabilityTests.cs +++ b/tests/Immediate.Jobs.FunctionalTests/StorageCapabilityTests.cs @@ -201,11 +201,6 @@ public ValueTask TryTriggerAsync( CancellationToken cancellationToken = default ) => _inner.TryTriggerAsync(jobHandle, expectedJobName, dueAt, cancellationToken); - public ValueTask MergeRecurringSchedulesListAsync( - IReadOnlyList schedules, - CancellationToken cancellationToken = default - ) => _inner.MergeRecurringSchedulesListAsync(schedules, cancellationToken); - public ValueTask UpsertRecurringAsync(RecurringJobSchedule schedule, CancellationToken cancellationToken = default) => _inner.UpsertRecurringAsync(schedule, cancellationToken); diff --git a/tests/Immediate.Jobs.StorageTests/LinqToDBConformanceTests.cs b/tests/Immediate.Jobs.StorageTests/LinqToDBConformanceTests.cs index 3c7d8a82..8a54d9be 100644 --- a/tests/Immediate.Jobs.StorageTests/LinqToDBConformanceTests.cs +++ b/tests/Immediate.Jobs.StorageTests/LinqToDBConformanceTests.cs @@ -131,7 +131,6 @@ file sealed class RelationalConformanceFixture( "immediate_recurring_jobs", "immediate_job_servers", "immediate_job_definition_metadata", - "immediate_job_definition_catalog", ]; internal IServiceProvider Services => services; From 4d89c7e5ad9bac7afe889fe2108341824ea72866 Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 10 Oct 2026 03:58:05 +0000 Subject: [PATCH 3/7] Keep storage behavior tests in the conformance suite --- docs/testing.md | 5 +- .../EntityFrameworkCoreConformanceTests.cs | 15 ------ .../JobExecutionStorageTests.cs | 51 ------------------- .../LinqToDBConformanceTests.cs | 15 ------ .../PostgreSqlJobSearchAssertions.cs | 35 ------------- 5 files changed, 2 insertions(+), 119 deletions(-) delete mode 100644 tests/Immediate.Jobs.StorageTests/JobExecutionStorageTests.cs delete mode 100644 tests/Immediate.Jobs.StorageTests/PostgreSqlJobSearchAssertions.cs diff --git a/docs/testing.md b/docs/testing.md index da804fde..e6f11002 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -11,9 +11,8 @@ **Guidance for adding tests:** -- **Storage behaviour belongs in the conformance suite**, not in provider-specific tests, so every - provider is held to it. Write a provider-specific test only for behaviour that exists in that - provider alone. +- **Every storage behaviour test belongs in the conformance suite**, so every provider is held to + the same contract. Do not write standalone or provider-specific storage tests. - **Functional tests cover only what the layer above storage adds**, for example a scheduler turning `TryTriggerAsync`'s `false` into an exception, or argument validation. Don't re-assert storage semantics there. diff --git a/tests/Immediate.Jobs.StorageTests/EntityFrameworkCoreConformanceTests.cs b/tests/Immediate.Jobs.StorageTests/EntityFrameworkCoreConformanceTests.cs index f1cd02ed..b89d0976 100644 --- a/tests/Immediate.Jobs.StorageTests/EntityFrameworkCoreConformanceTests.cs +++ b/tests/Immediate.Jobs.StorageTests/EntityFrameworkCoreConformanceTests.cs @@ -39,21 +39,6 @@ public static TheoryData Cre [Collection(EntityFrameworkCorePgSQLFixtureGroup.Name)] public sealed class EntityFrameworkCorePgSQLConformanceTests(EntityFrameworkCorePgSQLContainer container) { - [Theory] - [InlineData(ConformanceTopology.Distributed)] - [InlineData(ConformanceTopology.SingleServer)] - public async Task SearchesUnicodeJobNames(ConformanceTopology topology) - { - await using var fixture = await RelationalConformanceFixture.CreateAsync( - ConformanceDatabase.PostgreSql, - useDistributedTopology: topology == ConformanceTopology.Distributed, - container: container.PostgreSql, - new() { Jobs = [], Batches = [], Edges = [], RecurringSchedules = [] } - ); - - await PostgreSqlJobSearchAssertions.AssertUnicodeSearchAsync(fixture.Services, TestContext.Current.CancellationToken); - } - [Theory] [MemberData(nameof(EntityFrameworkCoreConformanceTestCases.CreateCases), MemberType = typeof(EntityFrameworkCoreConformanceTestCases))] public async Task EntityFrameworkCoreConforms( diff --git a/tests/Immediate.Jobs.StorageTests/JobExecutionStorageTests.cs b/tests/Immediate.Jobs.StorageTests/JobExecutionStorageTests.cs deleted file mode 100644 index 767052ec..00000000 --- a/tests/Immediate.Jobs.StorageTests/JobExecutionStorageTests.cs +++ /dev/null @@ -1,51 +0,0 @@ -using Immediate.Jobs.Shared.Apis; -using Immediate.Jobs.Shared.Storage; -using Microsoft.Extensions.Time.Testing; - -namespace Immediate.Jobs.StorageTests; - -public sealed class JobExecutionStorageTests -{ - [Fact] - public async Task LegacyLatestExecutionIsMarkedSyntheticAndMaterializedBeforeRetryClearsIt() - { - var cancellationToken = TestContext.Current.CancellationToken; - var clock = new FakeTimeProvider(DateTimeOffset.UnixEpoch); - await using var storage = new InMemoryJobStorage(clock); - var now = clock.GetUtcNow(); - var completedAt = now.AddMinutes(-1); - JobHandle jobHandle = new() { Value = "legacy" }; - await storage.EnqueueAsync(CreateJob(jobHandle, now.AddMinutes(-2)) with - { - State = JobState.Failed, - Attempt = 4, - CompletedAt = completedAt, - LastError = "legacy failure", - ExecutionStartedAt = completedAt.AddSeconds(-2), - ExecutionTraceId = "44444444444444444444444444444444", - ExecutionSpanId = "4444444444444444", - }, cancellationToken); - - var synthetic = Assert.Single(await storage.QueryJobExecutionsAsync(jobHandle, new() { }, cancellationToken)); - Assert.True(synthetic.IsSynthetic); - Assert.Equal(4, synthetic.Attempt); - Assert.Equal(JobExecutionState.Failed, synthetic.State); - Assert.Equal("legacy failure", synthetic.Error); - Assert.Equal(completedAt, synthetic.CompletedAt); - - await storage.RetryAsync(jobHandle, cancellationToken); - var retained = Assert.Single(await storage.QueryJobExecutionsAsync(jobHandle, new() { }, cancellationToken)); - Assert.Equal(synthetic, retained); - } - - private static JobRecord CreateJob(JobHandle id, DateTimeOffset now) => new() - { - JobHandle = id, - JobName = "execution-test", - Payload = "{}", - State = JobState.Pending, - DueAt = now, - CreatedAt = now, - }; - -} diff --git a/tests/Immediate.Jobs.StorageTests/LinqToDBConformanceTests.cs b/tests/Immediate.Jobs.StorageTests/LinqToDBConformanceTests.cs index 8a54d9be..dca4158d 100644 --- a/tests/Immediate.Jobs.StorageTests/LinqToDBConformanceTests.cs +++ b/tests/Immediate.Jobs.StorageTests/LinqToDBConformanceTests.cs @@ -38,21 +38,6 @@ public static TheoryData Cre [Collection(LinqToDBPgSQLFixtureGroup.Name)] public sealed class LinqToDBPgSQLConformanceTests(LinqToDBPgSQLContainer container) { - [Theory] - [InlineData(ConformanceTopology.Distributed)] - [InlineData(ConformanceTopology.SingleServer)] - public async Task SearchesUnicodeJobNames(ConformanceTopology topology) - { - await using var fixture = await RelationalConformanceFixture.CreateAsync( - ConformanceDatabase.PostgreSql, - useDistributedTopology: topology == ConformanceTopology.Distributed, - container: container.PostgreSql, - new() { Jobs = [], Batches = [], Edges = [], RecurringSchedules = [] } - ); - - await PostgreSqlJobSearchAssertions.AssertUnicodeSearchAsync(fixture.Services, TestContext.Current.CancellationToken); - } - [Theory] [MemberData(nameof(LinqToDBConformanceTestCases.CreateCases), MemberType = typeof(LinqToDBConformanceTestCases))] public async Task LinqToDBConforms( diff --git a/tests/Immediate.Jobs.StorageTests/PostgreSqlJobSearchAssertions.cs b/tests/Immediate.Jobs.StorageTests/PostgreSqlJobSearchAssertions.cs deleted file mode 100644 index f522b38f..00000000 --- a/tests/Immediate.Jobs.StorageTests/PostgreSqlJobSearchAssertions.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Immediate.Jobs.Shared.Storage; -using Microsoft.Extensions.DependencyInjection; - -namespace Immediate.Jobs.StorageTests; - -internal static class PostgreSqlJobSearchAssertions -{ - internal static async Task AssertUnicodeSearchAsync(IServiceProvider services, CancellationToken cancellationToken) - { - await using var scope = services.CreateAsyncScope(); - var storage = scope.ServiceProvider.GetRequiredService(); - await storage.InitializeAsync(cancellationToken); - var now = scope.ServiceProvider.GetRequiredService().GetUtcNow(); - var expected = JobHandle.FromString("unicode-search"); - await storage.EnqueueAsync(new() - { - JobHandle = expected, - JobName = "RésuméJob", - QueueName = "default", - Payload = "{}", - State = JobState.Pending, - DueAt = now, - CreatedAt = now, - }, cancellationToken); - - foreach (var search in new[] { "résumé", "RÉSUMÉ", "SuMé" }) - { - var matches = await storage.QueryJobsAsync(new() { Search = search }, cancellationToken); - Assert.Equal(expected, Assert.Single(matches).JobHandle); - } - - var unaccented = await storage.QueryJobsAsync(new() { Search = "resume" }, cancellationToken); - Assert.Empty(unaccented); - } -} From 46f12665787cd2182c1ba344736952c59984b7d7 Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 10 Oct 2026 04:44:47 +0000 Subject: [PATCH 4/7] Reconcile LinqToDB definition snapshots inside database merges --- docs/storage.md | 4 +- .../LinqToDBJobStorage.DefinitionCatalog.cs | 174 +++++++++++++----- .../DefinitionCatalogStorageConformance.cs | 11 +- 3 files changed, 136 insertions(+), 53 deletions(-) diff --git a/docs/storage.md b/docs/storage.md index 58a46a2b..03f647d3 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -47,7 +47,9 @@ and storage rejects invalid manually supplied names before modifying the catalog Concurrent startup snapshots commit atomically. In-memory uses its gate; EF Core takes an early write lock on a singleton catalogue row. LinqToDB uses a serializable transaction and one SQL `MERGE` per -table on SQL Server and PostgreSQL 15 or later. SQLite uses transactional upserts and deletes because +table on SQL Server and PostgreSQL 15 or later. The merge source queries reconcile obsolete records, +canonical names, and recurring progress in the database. Only SQLite reads a snapshot for client-side +reconciliation; it uses transactional upserts and deletes because it does not support SQL `MERGE`. Serialization conflicts retry the complete LinqToDB snapshot. Redis compares a version and applies metadata and schedule changes in one Lua script, recomputing after a competing startup. Single-server delegates diff --git a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.DefinitionCatalog.cs b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.DefinitionCatalog.cs index 8df05625..85240284 100644 --- a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.DefinitionCatalog.cs +++ b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.DefinitionCatalog.cs @@ -18,18 +18,17 @@ public async ValueTask MergeJobDefinitionsListAsync(JobDefinitionRegistration re MergeJobDefinitionsListAsyncCalled(); cancellationToken.ThrowIfCancellationRequested(); await TaskScheduler.Yield(); - JobDefinitionReconciliation.Create(registration, [], []); + ValidateRegistration(registration); await RetryConcurrencyAsync(async connection => { - var definitions = await Definitions(connection) - .ToDictionaryAsync(static item => item.Name, StringComparer.OrdinalIgnoreCase, cancellationToken); - var schedules = await Recurring(connection) - .ToDictionaryAsync(static item => item.Name, StringComparer.Ordinal, cancellationToken); - var changes = JobDefinitionReconciliation.Create(registration, - definitions.Values.Select(static item => JsonSerializer.Deserialize(item.Metadata, LinqToDBJsonSerializerContext.Default.JobDefinitionRecord)!).ToList(), - schedules.Values.Select(ToRecord).ToList()); - await MergeDefinitionMetadataAsync(connection, changes, cancellationToken); - await MergeRecurringDefinitionsAsync(connection, changes, cancellationToken); + if (connection.DataProvider.Name.Contains("SQLite", StringComparison.OrdinalIgnoreCase)) + { + await MergeSQLiteDefinitionsAsync(connection, registration, cancellationToken); + return; + } + + await MergeDefinitionMetadataAsync(connection, registration.Definitions, cancellationToken); + await MergeRecurringDefinitionsAsync(connection, registration.RecurringSchedules, cancellationToken); }, cancellationToken, isolationLevel: IsolationLevel.Serializable); } @@ -40,39 +39,88 @@ public async ValueTask> GetJobDefinitionsAsyn cancellationToken.ThrowIfCancellationRequested(); await TaskScheduler.Yield(); await using var scope = contextScope.GetScope(out var connection); - var metadata = await Definitions(connection).Select(static item => item.Metadata).ToListAsync(cancellationToken); - return [.. metadata.Select(static value => JsonSerializer.Deserialize(value, LinqToDBJsonSerializerContext.Default.JobDefinitionRecord)!) + var definitions = await Definitions(connection).ToListAsync(cancellationToken); + // The persisted name column is authoritative; serialized metadata may contain a registration alias. + return [.. definitions.Select(static item => JsonSerializer.Deserialize(item.Metadata, LinqToDBJsonSerializerContext.Default.JobDefinitionRecord)! with { Name = item.Name }) .OrderBy(static definition => definition.Name, StringComparer.OrdinalIgnoreCase)]; } + private static void ValidateRegistration(JobDefinitionRegistration registration) + { + ArgumentNullException.ThrowIfNull(registration); + ArgumentNullException.ThrowIfNull(registration.Definitions, nameof(registration)); + ArgumentNullException.ThrowIfNull(registration.RecurringSchedules, nameof(registration)); + var definitions = registration.Definitions.ToDictionary(static definition => definition.Name, StringComparer.OrdinalIgnoreCase); + foreach (var definition in definitions.Values) + { + ArgumentException.ThrowIfNullOrWhiteSpace(definition.Name, nameof(registration)); + if (char.IsWhiteSpace(definition.Name[0]) || char.IsWhiteSpace(definition.Name[^1])) + throw new ArgumentException("Job definition names must not have leading or trailing whitespace.", nameof(registration)); + } + + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var schedule in registration.RecurringSchedules) + { + if (!definitions.TryGetValue(schedule.JobName, out var definition) + || !schedule.IsCodeDefined + || !string.Equals(schedule.Name, definition.Name, StringComparison.OrdinalIgnoreCase) + || !string.Equals(schedule.Cron, definition.Cron, StringComparison.Ordinal) + || !string.Equals(schedule.TimeZone, definition.TimeZone, StringComparison.Ordinal) + || !string.Equals(schedule.QueueName, definition.QueueName, StringComparison.Ordinal) + || !names.Add(schedule.Name)) + throw new ArgumentException("Code-defined schedules must match their supplied job definitions.", nameof(registration)); + } + + if (definitions.Values.Any(definition => definition.Cron is not null && !names.Contains(definition.Name))) + throw new ArgumentException("Every managed cron definition must supply its code-defined schedule.", nameof(registration)); + } + + private async Task MergeSQLiteDefinitionsAsync( + DataConnection connection, + JobDefinitionRegistration registration, + CancellationToken cancellationToken + ) + { + // SQLite has no SQL MERGE, so reconcile its stored snapshot in the client. + var definitions = await Definitions(connection).ToListAsync(cancellationToken); + var schedules = await Recurring(connection).ToListAsync(cancellationToken); + var changes = JobDefinitionReconciliation.Create(registration, + definitions.Select(static item => JsonSerializer.Deserialize(item.Metadata, LinqToDBJsonSerializerContext.Default.JobDefinitionRecord)!).ToList(), + schedules.Select(ToRecord).ToList()); + await Definitions(connection).Where(item => item.Name.In(changes.RemovedDefinitionNames)).DeleteAsync(cancellationToken); + foreach (var definition in changes.Definitions) + await connection.InsertOrReplaceAsync(new ImmediateJobDefinitionMetadataEntity + { + Name = definition.Name, + Metadata = JsonSerializer.Serialize(definition, LinqToDBJsonSerializerContext.Default.JobDefinitionRecord), + }, schemaName: _schema, token: cancellationToken); + + await Recurring(connection).Where(item => item.Name.In(changes.RemovedScheduleNames)).DeleteAsync(cancellationToken); + foreach (var schedule in changes.Schedules) + await connection.InsertOrReplaceAsync(ToEntity(schedule), schemaName: _schema, token: cancellationToken); + } + private async Task MergeDefinitionMetadataAsync( DataConnection connection, - JobDefinitionReconciliation changes, + IReadOnlyList definitions, CancellationToken cancellationToken ) { - var source = changes.Definitions.Select(static definition => new + var supplied = definitions.Select(static definition => new ImmediateJobDefinitionMetadataEntity { - definition.Name, + Name = definition.Name, Metadata = JsonSerializer.Serialize(definition, LinqToDBJsonSerializerContext.Default.JobDefinitionRecord), - Remove = false, - }).Concat(changes.RemovedDefinitionNames.Select(static name => new { Name = name, Metadata = "", Remove = true })).ToList(); - if (source.Count == 0) - return; - - // SQLite has no SQL MERGE. Keep its upserts and deletes in the same serializable transaction. - if (connection.DataProvider.Name.Contains("SQLite", StringComparison.OrdinalIgnoreCase)) - { - await Definitions(connection).Where(item => item.Name.In(changes.RemovedDefinitionNames)).DeleteAsync(cancellationToken); - foreach (var item in source.Where(static item => !item.Remove)) - await connection.InsertOrReplaceAsync( - new ImmediateJobDefinitionMetadataEntity { Name = item.Name, Metadata = item.Metadata }, - schemaName: _schema, - token: cancellationToken - ); - return; - } - + }).ToList().AsQueryable(connection); + // A full join supplies obsolete rows as deletion markers, including an empty startup list. + // This works with PostgreSQL 15, which has no WHEN NOT MATCHED BY SOURCE clause. + var source = Definitions(connection).FullJoin(supplied, + static (stored, item) => stored.Name == item.Name, + static (stored, item) => new + { + Name = stored.Name ?? item.Name, + item.Metadata, + Remove = item.Name == null, + }); await Definitions(connection).Merge() .Using(source) .On(static (target, item) => target.Name == item.Name) @@ -85,25 +133,49 @@ await Definitions(connection).Merge() private async Task MergeRecurringDefinitionsAsync( DataConnection connection, - JobDefinitionReconciliation changes, + IReadOnlyList schedules, CancellationToken cancellationToken ) { - // Only code-defined schedules are upserted. Non-code-defined source rows mark obsolete names for deletion. - var source = changes.Schedules.Select(ToEntity) - .Concat(changes.RemovedScheduleNames.Select(static name => new ImmediateRecurringJobEntity { Name = name })) - .ToList(); - if (source.Count == 0) - return; - - if (connection.DataProvider.Name.Contains("SQLite", StringComparison.OrdinalIgnoreCase)) - { - await Recurring(connection).Where(item => item.Name.In(changes.RemovedScheduleNames)).DeleteAsync(cancellationToken); - foreach (var item in source.Where(static item => item.IsCodeDefined)) - await connection.InsertOrReplaceAsync(item, schemaName: _schema, token: cancellationToken); - return; - } - + var supplied = schedules.Select(ToEntity).ToList().AsQueryable(connection); + var canonical = supplied.Join(Definitions(connection), + static schedule => schedule.JobName, + static definition => definition.Name, + static (schedule, definition) => new ImmediateRecurringJobEntity + { + Name = definition.Name, + JobName = definition.Name, + QueueName = schedule.QueueName, + Cron = schedule.Cron, + TimeZone = schedule.TimeZone, + IsCodeDefined = true, + IsPaused = schedule.IsPaused, + NextRunAt = schedule.NextRunAt, + LastRunAt = schedule.LastRunAt, + ConcurrencyStamp = schedule.ConcurrencyStamp, + }); + var source = Recurring(connection).FullJoin(canonical, + static (stored, item) => stored.Name == item.Name, + static (stored, item) => new + { + Schedule = new ImmediateRecurringJobEntity + { + Name = stored.Name ?? item.Name, + JobName = item.JobName, + QueueName = item.QueueName, + Cron = item.Cron, + TimeZone = item.TimeZone, + IsCodeDefined = item.Name != null, + IsPaused = stored.Name != null ? stored.IsPaused : item.IsPaused, + LastRunAt = stored.Name != null ? stored.LastRunAt : item.LastRunAt, + NextRunAt = stored.Name != null && EqualsOrdinal(stored.Cron, item.Cron) && EqualsOrdinal(stored.TimeZone, item.TimeZone) + ? stored.NextRunAt : item.NextRunAt, + ConcurrencyStamp = item.ConcurrencyStamp, + }, + WasCodeDefined = stored.IsCodeDefined, + }) + .Where(static item => item.Schedule.IsCodeDefined || item.WasCodeDefined) + .Select(static item => item.Schedule); await Recurring(connection).Merge() .Using(source) .OnTargetKey() @@ -113,6 +185,10 @@ await Recurring(connection).Merge() .MergeAsync(cancellationToken); } + [Sql.Expression(ProviderName.SqlServer, "({0} COLLATE Latin1_General_100_BIN2 = {1} COLLATE Latin1_General_100_BIN2 AND DATALENGTH({0}) = DATALENGTH({1}))", ServerSideOnly = true, IsPredicate = true)] + [Sql.Expression(ProviderName.PostgreSQL, "{0} = {1}", ServerSideOnly = true, IsPredicate = true)] + private static bool EqualsOrdinal(string left, string right) => throw new InvalidOperationException("This function must be translated to SQL."); + [LoggerMessage( EventId = LibraryEventIds.MergeJobDefinitionsListAsyncCalled, EventName = "Immediate.Jobs.LinqToDB.MergeJobDefinitionsListAsyncCalled", diff --git a/src/Immediate.Jobs.Testing/Storage/DefinitionCatalogStorageConformance.cs b/src/Immediate.Jobs.Testing/Storage/DefinitionCatalogStorageConformance.cs index ddf8c845..7c5f6812 100644 --- a/src/Immediate.Jobs.Testing/Storage/DefinitionCatalogStorageConformance.cs +++ b/src/Immediate.Jobs.Testing/Storage/DefinitionCatalogStorageConformance.cs @@ -26,7 +26,7 @@ private static JobDefinitionRegistration Registration(IReadOnlyList definition with { Name = definition.Name.ToUpperInvariant(), MaxAttempts = 9 }).ToList(); await storage.MergeJobDefinitionsListAsync(Registration(aliases), token); @@ -34,7 +34,7 @@ private static async ValueTask ReconcilesCaseInsensitiveNamesAsync(IJobStorage s var invoice = definitions.Single(static definition => string.Equals(definition.Name, "Invoice", StringComparison.Ordinal)); ConformanceAssert.Equal(("priority", 9, 2, TimeSpan.FromMinutes(3), 4, TimeSpan.FromSeconds(13)), (invoice.QueueName, invoice.QueuePriority, invoice.QueueConcurrency, invoice.Timeout, invoice.MaxConcurrency, invoice.BackoffBase), CaseNameCase, "non-runtime metadata must round trip"); - ConformanceAssert.Equal(3, definitions.Count, CaseNameCase, "case variants must update the same definitions, including Unicode and maximum-length names"); + ConformanceAssert.Equal(5, definitions.Count, CaseNameCase, "case variants must update the same definitions, including Unicode and maximum-length names"); ConformanceAssert.True(definitions.All(static definition => definition.MaxAttempts == 9), CaseNameCase, "updates must reach case-insensitive identities"); ConformanceAssert.SequenceEqual(original.Select(static definition => definition.Name).Order(StringComparer.OrdinalIgnoreCase), definitions.Select(static definition => definition.Name), CaseNameCase, "registration aliases must preserve the original stored spelling"); @@ -78,7 +78,8 @@ private static async ValueTask PreservesRecurringAsync(IJobStorage storage, Fake }; ConformanceAssert.True(await storage.MaterializeRecurringAsync(schedule, invocation, now.AddMinutes(1), cancellationToken: token), RecurringCase, "the initial occurrence must materialize"); await storage.PauseRecurringAsync(ordinary.Name, token); - await storage.MergeJobDefinitionsListAsync(new() { Definitions = [ordinary], RecurringSchedules = [Schedule(ordinary, now.AddHours(1))] }, token); + var alias = ordinary with { Name = "ORDINARY-CRON" }; + await storage.MergeJobDefinitionsListAsync(new() { Definitions = [alias], RecurringSchedules = [Schedule(alias, now.AddHours(1))] }, token); var persisted = (await storage.GetMonitoringSnapshotAsync(token)).Recurring.Single(item => string.Equals(item.Name, ordinary.Name, StringComparison.Ordinal)); ConformanceAssert.False((await storage.GetMonitoringSnapshotAsync(token)).Recurring.Any(item => string.Equals(item.Name, legacy.Name, StringComparison.Ordinal)), RecurringCase, "startup must clean up obsolete legacy code schedules without metadata"); @@ -90,6 +91,10 @@ private static async ValueTask PreservesRecurringAsync(IJobStorage storage, Fake persisted = (await storage.GetMonitoringSnapshotAsync(token)).Recurring.Single(item => string.Equals(item.Name, ordinary.Name, StringComparison.Ordinal)); ConformanceAssert.Equal(now.AddHours(2), persisted.NextRunAt, RecurringCase, "changed cron must reset the next occurrence"); ConformanceAssert.True(persisted.IsPaused, RecurringCase, "cron changes must retain pause state"); + var reformatted = changed with { Cron = "15 * * * * " }; + await storage.MergeJobDefinitionsListAsync(new() { Definitions = [reformatted], RecurringSchedules = [Schedule(reformatted, now.AddHours(3))] }, token); + persisted = (await storage.GetMonitoringSnapshotAsync(token)).Recurring.Single(item => string.Equals(item.Name, ordinary.Name, StringComparison.Ordinal)); + ConformanceAssert.Equal(now.AddHours(3), persisted.NextRunAt, RecurringCase, "any change to the stored cron text must reset the next occurrence"); await storage.MergeJobDefinitionsListAsync(Registration([changed with { Cron = null }]), token); var snapshot = await storage.GetMonitoringSnapshotAsync(token); ConformanceAssert.SequenceEqual(["dynamic"], snapshot.Recurring.Select(static item => item.Name).Order(StringComparer.Ordinal), RecurringCase, "removing cron must preserve dynamic schedules"); From 4bf44f8943d32dc7588a42821d49e19c855120ac Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 11 Oct 2026 15:36:55 +0000 Subject: [PATCH 5/7] Merge supplied definition records directly into storage --- docs/storage.md | 9 +- .../LinqToDBJobStorage.DefinitionCatalog.cs | 82 ++++++++----------- 2 files changed, 40 insertions(+), 51 deletions(-) diff --git a/docs/storage.md b/docs/storage.md index 03f647d3..8011d7bc 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -46,10 +46,11 @@ Definition names must not have leading or trailing whitespace: the job analyzer and storage rejects invalid manually supplied names before modifying the catalogue. Concurrent startup snapshots commit atomically. In-memory uses its gate; EF Core takes an early write -lock on a singleton catalogue row. LinqToDB uses a serializable transaction and one SQL `MERGE` per -table on SQL Server and PostgreSQL 15 or later. The merge source queries reconcile obsolete records, -canonical names, and recurring progress in the database. Only SQLite reads a snapshot for client-side -reconciliation; it uses transactional upserts and deletes because +lock on a singleton catalogue row. LinqToDB uses a serializable transaction and merges supplied +records directly into each table on SQL Server and PostgreSQL 15 or later. Matched updates preserve +canonical names and recurring progress using target values. SQL Server removes obsolete rows in the +merge; PostgreSQL uses a separate set-based delete in the same transaction because PostgreSQL 15 has +no WHEN NOT MATCHED BY SOURCE clause. Only SQLite reads a snapshot for client-side reconciliation; it uses transactional upserts and deletes because it does not support SQL `MERGE`. Serialization conflicts retry the complete LinqToDB snapshot. Redis compares a version and applies metadata and schedule changes in one Lua script, recomputing after a competing startup. Single-server delegates diff --git a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.DefinitionCatalog.cs b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.DefinitionCatalog.cs index 85240284..5b548e70 100644 --- a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.DefinitionCatalog.cs +++ b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.DefinitionCatalog.cs @@ -111,24 +111,19 @@ CancellationToken cancellationToken Name = definition.Name, Metadata = JsonSerializer.Serialize(definition, LinqToDBJsonSerializerContext.Default.JobDefinitionRecord), }).ToList().AsQueryable(connection); - // A full join supplies obsolete rows as deletion markers, including an empty startup list. - // This works with PostgreSQL 15, which has no WHEN NOT MATCHED BY SOURCE clause. - var source = Definitions(connection).FullJoin(supplied, - static (stored, item) => stored.Name == item.Name, - static (stored, item) => new - { - Name = stored.Name ?? item.Name, - item.Metadata, - Remove = item.Name == null, - }); - await Definitions(connection).Merge() - .Using(source) - .On(static (target, item) => target.Name == item.Name) - .DeleteWhenMatchedAnd(static (target, item) => item.Remove) - .UpdateWhenMatched(static (target, item) => new ImmediateJobDefinitionMetadataEntity { Metadata = item.Metadata }) - .InsertWhenNotMatchedAnd(static item => !item.Remove, - static item => new ImmediateJobDefinitionMetadataEntity { Name = item.Name, Metadata = item.Metadata }) - .MergeAsync(cancellationToken); + var merge = Definitions(connection).Merge() + .Using(supplied) + .On(static (dst, src) => dst.Name == src.Name) + .UpdateWhenMatched(static (dst, src) => new ImmediateJobDefinitionMetadataEntity { Metadata = src.Metadata }) + .InsertWhenNotMatched(); + if (connection.DataProvider.Name.Contains("SqlServer", StringComparison.OrdinalIgnoreCase)) + await merge.DeleteWhenNotMatchedBySource().MergeAsync(cancellationToken); + else + { + await merge.MergeAsync(cancellationToken); + // PostgreSQL 15 has no WHEN NOT MATCHED BY SOURCE; remove obsolete rows in the same transaction. + await Definitions(connection).Where(dst => !supplied.Any(src => dst.Name == src.Name)).DeleteAsync(cancellationToken); + } } private async Task MergeRecurringDefinitionsAsync( @@ -137,8 +132,8 @@ private async Task MergeRecurringDefinitionsAsync( CancellationToken cancellationToken ) { - var supplied = schedules.Select(ToEntity).ToList().AsQueryable(connection); - var canonical = supplied.Join(Definitions(connection), + var incoming = schedules.Select(ToEntity).ToList().AsQueryable(connection); + var supplied = incoming.Join(Definitions(connection), static schedule => schedule.JobName, static definition => definition.Name, static (schedule, definition) => new ImmediateRecurringJobEntity @@ -154,35 +149,28 @@ CancellationToken cancellationToken LastRunAt = schedule.LastRunAt, ConcurrencyStamp = schedule.ConcurrencyStamp, }); - var source = Recurring(connection).FullJoin(canonical, - static (stored, item) => stored.Name == item.Name, - static (stored, item) => new + var merge = Recurring(connection).Merge() + .Using(supplied) + .On(static (dst, src) => dst.Name == src.Name) + .UpdateWhenMatched(static (dst, src) => new ImmediateRecurringJobEntity { - Schedule = new ImmediateRecurringJobEntity - { - Name = stored.Name ?? item.Name, - JobName = item.JobName, - QueueName = item.QueueName, - Cron = item.Cron, - TimeZone = item.TimeZone, - IsCodeDefined = item.Name != null, - IsPaused = stored.Name != null ? stored.IsPaused : item.IsPaused, - LastRunAt = stored.Name != null ? stored.LastRunAt : item.LastRunAt, - NextRunAt = stored.Name != null && EqualsOrdinal(stored.Cron, item.Cron) && EqualsOrdinal(stored.TimeZone, item.TimeZone) - ? stored.NextRunAt : item.NextRunAt, - ConcurrencyStamp = item.ConcurrencyStamp, - }, - WasCodeDefined = stored.IsCodeDefined, + JobName = src.JobName, + QueueName = src.QueueName, + Cron = src.Cron, + TimeZone = src.TimeZone, + IsCodeDefined = true, + NextRunAt = EqualsOrdinal(dst.Cron, src.Cron) && EqualsOrdinal(dst.TimeZone, src.TimeZone) + ? dst.NextRunAt : src.NextRunAt, + ConcurrencyStamp = src.ConcurrencyStamp, }) - .Where(static item => item.Schedule.IsCodeDefined || item.WasCodeDefined) - .Select(static item => item.Schedule); - await Recurring(connection).Merge() - .Using(source) - .OnTargetKey() - .DeleteWhenMatchedAnd(static (target, item) => !item.IsCodeDefined) - .UpdateWhenMatched() - .InsertWhenNotMatchedAnd(static item => item.IsCodeDefined) - .MergeAsync(cancellationToken); + .InsertWhenNotMatched(); + if (connection.DataProvider.Name.Contains("SqlServer", StringComparison.OrdinalIgnoreCase)) + await merge.DeleteWhenNotMatchedBySourceAnd(static dst => dst.IsCodeDefined).MergeAsync(cancellationToken); + else + { + await merge.MergeAsync(cancellationToken); + await Recurring(connection).Where(dst => dst.IsCodeDefined && !supplied.Any(src => dst.Name == src.Name)).DeleteAsync(cancellationToken); + } } [Sql.Expression(ProviderName.SqlServer, "({0} COLLATE Latin1_General_100_BIN2 = {1} COLLATE Latin1_General_100_BIN2 AND DATALENGTH({0}) = DATALENGTH({1}))", ServerSideOnly = true, IsPredicate = true)] From 0dc1a4559582f13a051c4097c0cbd3357875f251 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 11 Oct 2026 19:30:47 +0000 Subject: [PATCH 6/7] Persist job definition configuration in relational columns --- docs/storage.md | 5 ++ ...261009144316_DefinitionCatalog.Designer.cs | 41 ++++++++++- .../20261009144316_DefinitionCatalog.cs | 13 +++- .../Migrations/JobsDbContextModelSnapshot.cs | 41 ++++++++++- ...ameworkCoreJobStorage.DefinitionCatalog.cs | 49 ++++++++++--- ...ntityFrameworkCoreJsonSerializerContext.cs | 1 - .../ImmediateJobsModelBuilderExtensions.cs | 20 +++++- .../LinqToDBEntities.cs | 26 ++++++- .../LinqToDBJobStorage.DefinitionCatalog.cs | 68 +++++++++++++++---- .../LinqToDBJsonSerializerContext.cs | 1 - .../LinqToDBSchemaExtensions.cs | 39 ++++++++++- .../DefinitionCatalogStorageConformance.cs | 31 +++++++++ .../StorageConformanceInfrastructureTests.cs | 2 +- 13 files changed, 298 insertions(+), 39 deletions(-) diff --git a/docs/storage.md b/docs/storage.md index 8011d7bc..0e36a61a 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -45,6 +45,11 @@ provider conventions can pass `definitionNameCollation` to `AddImmediateJobs`. Definition names must not have leading or trailing whitespace: the job analyzer reports `IJOB0008`, and storage rejects invalid manually supplied names before modifying the catalogue. +Relational providers store definition configuration in individual columns in +`immediate_job_definition_metadata`; the name key is the only persisted definition name. +`TimeoutTicks` and `BackoffBaseTicks` store exact .NET ticks (100 nanoseconds), and policy enums +use small integers. Redis continues storing serialized definition records. + Concurrent startup snapshots commit atomically. In-memory uses its gate; EF Core takes an early write lock on a singleton catalogue row. LinqToDB uses a serializable transaction and merges supplied records directly into each table on SQL Server and PostgreSQL 15 or later. Matched updates preserve diff --git a/samples/DistributedAspire/DistributedAspire.Shared/Migrations/20261009144316_DefinitionCatalog.Designer.cs b/samples/DistributedAspire/DistributedAspire.Shared/Migrations/20261009144316_DefinitionCatalog.Designer.cs index f8f62c9d..81663e7a 100644 --- a/samples/DistributedAspire/DistributedAspire.Shared/Migrations/20261009144316_DefinitionCatalog.Designer.cs +++ b/samples/DistributedAspire/DistributedAspire.Shared/Migrations/20261009144316_DefinitionCatalog.Designer.cs @@ -178,9 +178,46 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("character varying(256)") .UseCollation("immediate_jobs_case_insensitive"); - b.Property("Metadata") + b.Property("Backoff") + .HasColumnType("smallint"); + + b.Property("BackoffBaseTicks") + .HasColumnType("bigint"); + + b.Property("Cron") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("MaxAttempts") + .HasColumnType("integer"); + + b.Property("MaxConcurrency") + .HasColumnType("integer"); + + b.Property("MisfireHandlingMode") + .HasColumnType("smallint"); + + b.Property("OverlapPolicy") + .HasColumnType("smallint"); + + b.Property("QueueConcurrency") + .HasColumnType("integer"); + + b.Property("QueueName") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("QueuePriority") + .HasColumnType("integer"); + + b.Property("TimeZone") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TimeoutTicks") + .HasColumnType("bigint"); b.HasKey("Name"); diff --git a/samples/DistributedAspire/DistributedAspire.Shared/Migrations/20261009144316_DefinitionCatalog.cs b/samples/DistributedAspire/DistributedAspire.Shared/Migrations/20261009144316_DefinitionCatalog.cs index e108427d..349022ba 100644 --- a/samples/DistributedAspire/DistributedAspire.Shared/Migrations/20261009144316_DefinitionCatalog.cs +++ b/samples/DistributedAspire/DistributedAspire.Shared/Migrations/20261009144316_DefinitionCatalog.cs @@ -39,7 +39,18 @@ protected override void Up(MigrationBuilder migrationBuilder) columns: table => new { Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false, collation: "immediate_jobs_case_insensitive"), - Metadata = table.Column(type: "text", nullable: false), + QueueName = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + QueuePriority = table.Column(type: "integer", nullable: false), + QueueConcurrency = table.Column(type: "integer", nullable: false), + Cron = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + TimeZone = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + MaxAttempts = table.Column(type: "integer", nullable: false), + TimeoutTicks = table.Column(type: "bigint", nullable: true), + MaxConcurrency = table.Column(type: "integer", nullable: false), + OverlapPolicy = table.Column(type: "smallint", nullable: false), + MisfireHandlingMode = table.Column(type: "smallint", nullable: false), + Backoff = table.Column(type: "smallint", nullable: false), + BackoffBaseTicks = table.Column(type: "bigint", nullable: false), }, constraints: table => table.PrimaryKey("PK_immediate_job_definition_metadata", x => x.Name)); } diff --git a/samples/DistributedAspire/DistributedAspire.Shared/Migrations/JobsDbContextModelSnapshot.cs b/samples/DistributedAspire/DistributedAspire.Shared/Migrations/JobsDbContextModelSnapshot.cs index 3fa59d61..b61e76c0 100644 --- a/samples/DistributedAspire/DistributedAspire.Shared/Migrations/JobsDbContextModelSnapshot.cs +++ b/samples/DistributedAspire/DistributedAspire.Shared/Migrations/JobsDbContextModelSnapshot.cs @@ -175,9 +175,46 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(256)") .UseCollation("immediate_jobs_case_insensitive"); - b.Property("Metadata") + b.Property("Backoff") + .HasColumnType("smallint"); + + b.Property("BackoffBaseTicks") + .HasColumnType("bigint"); + + b.Property("Cron") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("MaxAttempts") + .HasColumnType("integer"); + + b.Property("MaxConcurrency") + .HasColumnType("integer"); + + b.Property("MisfireHandlingMode") + .HasColumnType("smallint"); + + b.Property("OverlapPolicy") + .HasColumnType("smallint"); + + b.Property("QueueConcurrency") + .HasColumnType("integer"); + + b.Property("QueueName") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("QueuePriority") + .HasColumnType("integer"); + + b.Property("TimeZone") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TimeoutTicks") + .HasColumnType("bigint"); b.HasKey("Name"); diff --git a/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.DefinitionCatalog.cs b/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.DefinitionCatalog.cs index 8f24d7e8..58139e7f 100644 --- a/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.DefinitionCatalog.cs +++ b/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.DefinitionCatalog.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using Immediate.Jobs.Shared.Apis; using Immediate.Jobs.Shared.Storage; using Microsoft.EntityFrameworkCore; @@ -31,17 +30,17 @@ await context.Set().Where(item => item.Id = var schedules = await context.Set() .ToDictionaryAsync(static item => item.Name, StringComparer.Ordinal, token); var changes = JobDefinitionReconciliation.Create(registration, - definitions.Values.Select(static item => JsonSerializer.Deserialize(item.Metadata, EntityFrameworkCoreJsonSerializerContext.Default.JobDefinitionRecord)!).ToList(), + definitions.Values.Select(ToRecord).ToList(), schedules.Values.Select(ToDefinitionSchedule).ToList()); foreach (var name in changes.RemovedDefinitionNames) context.Remove(definitions[name]); foreach (var definition in changes.Definitions) { - var metadata = JsonSerializer.Serialize(definition, EntityFrameworkCoreJsonSerializerContext.Default.JobDefinitionRecord); + var entity = ToEntity(definition); if (definitions.TryGetValue(definition.Name, out var current)) - current.Metadata = metadata; + context.Entry(current).CurrentValues.SetValues(entity); else - context.Add(new ImmediateJobDefinitionMetadataEntity { Name = definition.Name, Metadata = metadata }); + context.Add(entity); } foreach (var name in changes.RemovedScheduleNames) @@ -67,10 +66,8 @@ public async ValueTask> GetJobDefinitionsAsyn await TaskScheduler.Yield(); return await ReadWithStrategyAsync(async (context, token) => { - var metadata = await context.Set().AsNoTracking() - .Select(static item => item.Metadata).ToListAsync(token); - return (IReadOnlyList)[.. metadata - .Select(static value => JsonSerializer.Deserialize(value, EntityFrameworkCoreJsonSerializerContext.Default.JobDefinitionRecord)!) + var definitions = await context.Set().AsNoTracking().ToListAsync(token); + return (IReadOnlyList)[.. definitions.Select(ToRecord) .OrderBy(static definition => definition.Name, StringComparer.OrdinalIgnoreCase)]; }, cancellationToken); } @@ -106,6 +103,40 @@ private async Task EnsureDefinitionCatalogAsync(CancellationToken cancellationTo LastRunAt = entity.LastRunAt, }; + private static ImmediateJobDefinitionMetadataEntity ToEntity(JobDefinitionRecord definition) => new() + { + Name = definition.Name, + QueueName = definition.QueueName, + QueuePriority = definition.QueuePriority, + QueueConcurrency = definition.QueueConcurrency, + Cron = definition.Cron, + TimeZone = definition.TimeZone, + MaxAttempts = definition.MaxAttempts, + TimeoutTicks = definition.Timeout?.Ticks, + MaxConcurrency = definition.MaxConcurrency, + OverlapPolicy = definition.OverlapPolicy, + MisfireHandlingMode = definition.MisfireHandlingMode, + Backoff = definition.Backoff, + BackoffBaseTicks = definition.BackoffBase.Ticks, + }; + + private static JobDefinitionRecord ToRecord(ImmediateJobDefinitionMetadataEntity entity) => new() + { + Name = entity.Name, + QueueName = entity.QueueName, + QueuePriority = entity.QueuePriority, + QueueConcurrency = entity.QueueConcurrency, + Cron = entity.Cron, + TimeZone = entity.TimeZone, + MaxAttempts = entity.MaxAttempts, + Timeout = entity.TimeoutTicks is { } ticks ? TimeSpan.FromTicks(ticks) : null, + MaxConcurrency = entity.MaxConcurrency, + OverlapPolicy = entity.OverlapPolicy, + MisfireHandlingMode = entity.MisfireHandlingMode, + Backoff = entity.Backoff, + BackoffBase = TimeSpan.FromTicks(entity.BackoffBaseTicks), + }; + [LoggerMessage( EventId = LibraryEventIds.MergeJobDefinitionsListAsyncCalled, EventName = "Immediate.Jobs.EntityFrameworkCore.MergeJobDefinitionsListAsyncCalled", diff --git a/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJsonSerializerContext.cs b/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJsonSerializerContext.cs index 369e55e5..47d75c2e 100644 --- a/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJsonSerializerContext.cs +++ b/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJsonSerializerContext.cs @@ -4,5 +4,4 @@ namespace Immediate.Jobs.EntityFrameworkCore; [JsonSerializable(typeof(JobServerSnapshot))] -[JsonSerializable(typeof(JobDefinitionRecord))] internal sealed partial class EntityFrameworkCoreJsonSerializerContext : JsonSerializerContext; diff --git a/src/Immediate.Jobs.EntityFrameworkCore/ImmediateJobsModelBuilderExtensions.cs b/src/Immediate.Jobs.EntityFrameworkCore/ImmediateJobsModelBuilderExtensions.cs index 084580b2..fc26ddf2 100644 --- a/src/Immediate.Jobs.EntityFrameworkCore/ImmediateJobsModelBuilderExtensions.cs +++ b/src/Immediate.Jobs.EntityFrameworkCore/ImmediateJobsModelBuilderExtensions.cs @@ -56,7 +56,12 @@ public static ModelBuilder AddImmediateJobs(this ModelBuilder modelBuilder, stri (left, right) => string.Equals(left, right, StringComparison.OrdinalIgnoreCase), value => StringComparer.OrdinalIgnoreCase.GetHashCode(value), value => value)); - definitions.Property(definition => definition.Metadata).IsRequired(); + definitions.Property(definition => definition.QueueName).HasMaxLength(256).IsRequired(); + definitions.Property(definition => definition.Cron).HasMaxLength(128); + definitions.Property(definition => definition.TimeZone).HasMaxLength(128).IsRequired(); + definitions.Property(definition => definition.OverlapPolicy).HasConversion(); + definitions.Property(definition => definition.MisfireHandlingMode).HasConversion(); + definitions.Property(definition => definition.Backoff).HasConversion(); var catalogue = modelBuilder.Entity(); catalogue.ToTable("immediate_job_definition_catalog", schema); catalogue.HasKey(item => item.Id); @@ -353,7 +358,18 @@ internal sealed class ImmediateJobServerEntity internal sealed class ImmediateJobDefinitionMetadataEntity { public string Name { get; set; } = null!; - public string Metadata { get; set; } = null!; + public string QueueName { get; set; } = null!; + public int QueuePriority { get; set; } + public int QueueConcurrency { get; set; } + public string? Cron { get; set; } + public string TimeZone { get; set; } = null!; + public int MaxAttempts { get; set; } + public long? TimeoutTicks { get; set; } + public int MaxConcurrency { get; set; } + public OverlapPolicy OverlapPolicy { get; set; } + public MisfireHandlingMode MisfireHandlingMode { get; set; } + public BackoffStrategy Backoff { get; set; } + public long BackoffBaseTicks { get; set; } } /// diff --git a/src/Immediate.Jobs.LinqToDB/LinqToDBEntities.cs b/src/Immediate.Jobs.LinqToDB/LinqToDBEntities.cs index 71b9e9ea..05886b18 100644 --- a/src/Immediate.Jobs.LinqToDB/LinqToDBEntities.cs +++ b/src/Immediate.Jobs.LinqToDB/LinqToDBEntities.cs @@ -209,6 +209,28 @@ internal sealed class ImmediateJobDefinitionMetadataEntity { [PrimaryKey, Column(Length = 256, CanBeNull = false)] public string Name { get; set; } = null!; - [Column(DataType = DataType.Text, CanBeNull = false)] - public string Metadata { get; set; } = null!; + [Column(Length = 256, CanBeNull = false)] + public string QueueName { get; set; } = null!; + [Column(DataType = DataType.Int32)] + public int QueuePriority { get; set; } + [Column(DataType = DataType.Int32)] + public int QueueConcurrency { get; set; } + [Column(Length = 128, CanBeNull = true)] + public string? Cron { get; set; } + [Column(Length = 128, CanBeNull = false)] + public string TimeZone { get; set; } = null!; + [Column(DataType = DataType.Int32)] + public int MaxAttempts { get; set; } + [Column(DataType = DataType.Int64, CanBeNull = true)] + public long? TimeoutTicks { get; set; } + [Column(DataType = DataType.Int32)] + public int MaxConcurrency { get; set; } + [Column(DataType = DataType.Int16)] + public OverlapPolicy OverlapPolicy { get; set; } + [Column(DataType = DataType.Int16)] + public MisfireHandlingMode MisfireHandlingMode { get; set; } + [Column(DataType = DataType.Int16)] + public BackoffStrategy Backoff { get; set; } + [Column(DataType = DataType.Int64)] + public long BackoffBaseTicks { get; set; } } diff --git a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.DefinitionCatalog.cs b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.DefinitionCatalog.cs index 5b548e70..d87509d3 100644 --- a/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.DefinitionCatalog.cs +++ b/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.DefinitionCatalog.cs @@ -1,5 +1,4 @@ using System.Data; -using System.Text.Json; using Immediate.Jobs.Shared.Apis; using Immediate.Jobs.Shared.Storage; using LinqToDB; @@ -40,8 +39,7 @@ public async ValueTask> GetJobDefinitionsAsyn await TaskScheduler.Yield(); await using var scope = contextScope.GetScope(out var connection); var definitions = await Definitions(connection).ToListAsync(cancellationToken); - // The persisted name column is authoritative; serialized metadata may contain a registration alias. - return [.. definitions.Select(static item => JsonSerializer.Deserialize(item.Metadata, LinqToDBJsonSerializerContext.Default.JobDefinitionRecord)! with { Name = item.Name }) + return [.. definitions.Select(static item => ToRecord(item)) .OrderBy(static definition => definition.Name, StringComparer.OrdinalIgnoreCase)]; } @@ -85,15 +83,11 @@ CancellationToken cancellationToken var definitions = await Definitions(connection).ToListAsync(cancellationToken); var schedules = await Recurring(connection).ToListAsync(cancellationToken); var changes = JobDefinitionReconciliation.Create(registration, - definitions.Select(static item => JsonSerializer.Deserialize(item.Metadata, LinqToDBJsonSerializerContext.Default.JobDefinitionRecord)!).ToList(), + definitions.Select(ToRecord).ToList(), schedules.Select(ToRecord).ToList()); await Definitions(connection).Where(item => item.Name.In(changes.RemovedDefinitionNames)).DeleteAsync(cancellationToken); foreach (var definition in changes.Definitions) - await connection.InsertOrReplaceAsync(new ImmediateJobDefinitionMetadataEntity - { - Name = definition.Name, - Metadata = JsonSerializer.Serialize(definition, LinqToDBJsonSerializerContext.Default.JobDefinitionRecord), - }, schemaName: _schema, token: cancellationToken); + await connection.InsertOrReplaceAsync(ToEntity(definition), schemaName: _schema, token: cancellationToken); await Recurring(connection).Where(item => item.Name.In(changes.RemovedScheduleNames)).DeleteAsync(cancellationToken); foreach (var schedule in changes.Schedules) @@ -106,15 +100,25 @@ private async Task MergeDefinitionMetadataAsync( CancellationToken cancellationToken ) { - var supplied = definitions.Select(static definition => new ImmediateJobDefinitionMetadataEntity - { - Name = definition.Name, - Metadata = JsonSerializer.Serialize(definition, LinqToDBJsonSerializerContext.Default.JobDefinitionRecord), - }).ToList().AsQueryable(connection); + var supplied = definitions.Select(ToEntity).ToList().AsQueryable(connection); var merge = Definitions(connection).Merge() .Using(supplied) .On(static (dst, src) => dst.Name == src.Name) - .UpdateWhenMatched(static (dst, src) => new ImmediateJobDefinitionMetadataEntity { Metadata = src.Metadata }) + .UpdateWhenMatched(static (dst, src) => new ImmediateJobDefinitionMetadataEntity + { + QueueName = src.QueueName, + QueuePriority = src.QueuePriority, + QueueConcurrency = src.QueueConcurrency, + Cron = src.Cron, + TimeZone = src.TimeZone, + MaxAttempts = src.MaxAttempts, + TimeoutTicks = src.TimeoutTicks, + MaxConcurrency = src.MaxConcurrency, + OverlapPolicy = src.OverlapPolicy, + MisfireHandlingMode = src.MisfireHandlingMode, + Backoff = src.Backoff, + BackoffBaseTicks = src.BackoffBaseTicks, + }) .InsertWhenNotMatched(); if (connection.DataProvider.Name.Contains("SqlServer", StringComparison.OrdinalIgnoreCase)) await merge.DeleteWhenNotMatchedBySource().MergeAsync(cancellationToken); @@ -177,6 +181,40 @@ CancellationToken cancellationToken [Sql.Expression(ProviderName.PostgreSQL, "{0} = {1}", ServerSideOnly = true, IsPredicate = true)] private static bool EqualsOrdinal(string left, string right) => throw new InvalidOperationException("This function must be translated to SQL."); + private static ImmediateJobDefinitionMetadataEntity ToEntity(JobDefinitionRecord definition) => new() + { + Name = definition.Name, + QueueName = definition.QueueName, + QueuePriority = definition.QueuePriority, + QueueConcurrency = definition.QueueConcurrency, + Cron = definition.Cron, + TimeZone = definition.TimeZone, + MaxAttempts = definition.MaxAttempts, + TimeoutTicks = definition.Timeout?.Ticks, + MaxConcurrency = definition.MaxConcurrency, + OverlapPolicy = definition.OverlapPolicy, + MisfireHandlingMode = definition.MisfireHandlingMode, + Backoff = definition.Backoff, + BackoffBaseTicks = definition.BackoffBase.Ticks, + }; + + private static JobDefinitionRecord ToRecord(ImmediateJobDefinitionMetadataEntity entity) => new() + { + Name = entity.Name, + QueueName = entity.QueueName, + QueuePriority = entity.QueuePriority, + QueueConcurrency = entity.QueueConcurrency, + Cron = entity.Cron, + TimeZone = entity.TimeZone, + MaxAttempts = entity.MaxAttempts, + Timeout = entity.TimeoutTicks is { } ticks ? TimeSpan.FromTicks(ticks) : null, + MaxConcurrency = entity.MaxConcurrency, + OverlapPolicy = entity.OverlapPolicy, + MisfireHandlingMode = entity.MisfireHandlingMode, + Backoff = entity.Backoff, + BackoffBase = TimeSpan.FromTicks(entity.BackoffBaseTicks), + }; + [LoggerMessage( EventId = LibraryEventIds.MergeJobDefinitionsListAsyncCalled, EventName = "Immediate.Jobs.LinqToDB.MergeJobDefinitionsListAsyncCalled", diff --git a/src/Immediate.Jobs.LinqToDB/LinqToDBJsonSerializerContext.cs b/src/Immediate.Jobs.LinqToDB/LinqToDBJsonSerializerContext.cs index 3f393798..4d1087f1 100644 --- a/src/Immediate.Jobs.LinqToDB/LinqToDBJsonSerializerContext.cs +++ b/src/Immediate.Jobs.LinqToDB/LinqToDBJsonSerializerContext.cs @@ -4,5 +4,4 @@ namespace Immediate.Jobs.LinqToDB; [JsonSerializable(typeof(JobServerSnapshot))] -[JsonSerializable(typeof(JobDefinitionRecord))] internal sealed partial class LinqToDBJsonSerializerContext : JsonSerializerContext; diff --git a/src/Immediate.Jobs.LinqToDB/LinqToDBSchemaExtensions.cs b/src/Immediate.Jobs.LinqToDB/LinqToDBSchemaExtensions.cs index 60fbc50c..7367cc2d 100644 --- a/src/Immediate.Jobs.LinqToDB/LinqToDBSchemaExtensions.cs +++ b/src/Immediate.Jobs.LinqToDB/LinqToDBSchemaExtensions.cs @@ -118,7 +118,18 @@ CancellationToken cancellationToken IF OBJECT_ID(N'{{qualified}}', N'U') IS NULL CREATE TABLE {{qualified}} ( [Name] nvarchar(256) NOT NULL PRIMARY KEY, - [Metadata] nvarchar(max) NOT NULL + [QueueName] nvarchar(256) NOT NULL, + [QueuePriority] int NOT NULL, + [QueueConcurrency] int NOT NULL, + [Cron] nvarchar(128) NULL, + [TimeZone] nvarchar(128) NOT NULL, + [MaxAttempts] int NOT NULL, + [TimeoutTicks] bigint NULL, + [MaxConcurrency] int NOT NULL, + [OverlapPolicy] smallint NOT NULL, + [MisfireHandlingMode] smallint NOT NULL, + [Backoff] smallint NOT NULL, + [BackoffBaseTicks] bigint NOT NULL ); """, cancellationToken); } @@ -136,7 +147,18 @@ IF NOT EXISTS (SELECT 1 FROM pg_collation WHERE collname = 'immediate_jobs_case_ END $collation$; CREATE TABLE IF NOT EXISTS {{qualified}} ( "Name" character varying(256) COLLATE "immediate_jobs_case_insensitive" NOT NULL PRIMARY KEY, - "Metadata" text NOT NULL + "QueueName" character varying(256) NOT NULL, + "QueuePriority" integer NOT NULL, + "QueueConcurrency" integer NOT NULL, + "Cron" character varying(128) NULL, + "TimeZone" character varying(128) NOT NULL, + "MaxAttempts" integer NOT NULL, + "TimeoutTicks" bigint NULL, + "MaxConcurrency" integer NOT NULL, + "OverlapPolicy" smallint NOT NULL, + "MisfireHandlingMode" smallint NOT NULL, + "Backoff" smallint NOT NULL, + "BackoffBaseTicks" bigint NOT NULL ); """, cancellationToken); } @@ -146,7 +168,18 @@ IF NOT EXISTS (SELECT 1 FROM pg_collation WHERE collname = 'immediate_jobs_case_ return connection.ExecuteAsync(""" CREATE TABLE IF NOT EXISTS "immediate_job_definition_metadata" ( "Name" nvarchar(256) COLLATE NOCASE NOT NULL PRIMARY KEY, - "Metadata" text NOT NULL + "QueueName" nvarchar(256) NOT NULL, + "QueuePriority" integer NOT NULL, + "QueueConcurrency" integer NOT NULL, + "Cron" nvarchar(128) NULL, + "TimeZone" nvarchar(128) NOT NULL, + "MaxAttempts" integer NOT NULL, + "TimeoutTicks" bigint NULL, + "MaxConcurrency" integer NOT NULL, + "OverlapPolicy" smallint NOT NULL, + "MisfireHandlingMode" smallint NOT NULL, + "Backoff" smallint NOT NULL, + "BackoffBaseTicks" bigint NOT NULL ); """, cancellationToken); } diff --git a/src/Immediate.Jobs.Testing/Storage/DefinitionCatalogStorageConformance.cs b/src/Immediate.Jobs.Testing/Storage/DefinitionCatalogStorageConformance.cs index 7c5f6812..8d55f566 100644 --- a/src/Immediate.Jobs.Testing/Storage/DefinitionCatalogStorageConformance.cs +++ b/src/Immediate.Jobs.Testing/Storage/DefinitionCatalogStorageConformance.cs @@ -10,6 +10,7 @@ internal static class DefinitionCatalogStorageConformance private const string ConcurrentCase = "Definitions.Catalogue.SerializesConcurrentCompleteLists"; private const string InvalidCase = "Definitions.Catalogue.RejectsInvalidSnapshotsWithoutPartialChanges"; private const string CaseNameCase = "Definitions.Catalogue.ReconcilesCaseInsensitiveNames"; + private const string ConfigurationCase = "Definitions.Catalogue.RoundTripsAndUpdatesConfiguration"; internal static IReadOnlyList Cases { get; } = [ @@ -17,6 +18,7 @@ internal static class DefinitionCatalogStorageConformance new(ConcurrentCase, StorageCapabilities.Queue, ReconcilesConcurrentlyAsync), new(InvalidCase, StorageCapabilities.Queue, RejectsInvalidAsync), new(CaseNameCase, StorageCapabilities.Queue, ReconcilesCaseInsensitiveNamesAsync), + new(ConfigurationCase, StorageCapabilities.Queue, RoundTripsConfigurationAsync), ]; private static JobDefinitionRecord Definition(string name) => new() { Name = name }; @@ -24,6 +26,35 @@ internal static class DefinitionCatalogStorageConformance private static JobDefinitionRegistration Registration(IReadOnlyList definitions) => new() { Definitions = definitions }; + private static async ValueTask RoundTripsConfigurationAsync(IJobStorage storage, FakeTimeProvider timeProvider, CancellationToken token) + { + var definition = Definition("Configured") with + { + QueueName = "priority", + QueuePriority = 9, + QueueConcurrency = 2, + Cron = "15 * * * *", + TimeZone = "America/Chicago", + MaxAttempts = 7, + Timeout = TimeSpan.FromSeconds(3) + TimeSpan.FromTicks(1), + MaxConcurrency = 4, + OverlapPolicy = OverlapPolicy.Concurrent, + MisfireHandlingMode = MisfireHandlingMode.EnqueueNone, + Backoff = BackoffStrategy.Exponential, + BackoffBase = TimeSpan.FromSeconds(13) + TimeSpan.FromTicks(3), + }; + await storage.MergeJobDefinitionsListAsync(new() + { + Definitions = [definition], + RecurringSchedules = [Schedule(definition, timeProvider.GetUtcNow())], + }, token); + ConformanceAssert.Equal(definition, (await storage.GetJobDefinitionsAsync(token)).Single(), ConfigurationCase, + "all configuration fields, nullable values, enums, and duration ticks must round trip"); + await storage.MergeJobDefinitionsListAsync(Registration([Definition("CONFIGURED")]), token); + ConformanceAssert.Equal(Definition("Configured"), (await storage.GetJobDefinitionsAsync(token)).Single(), ConfigurationCase, + "an alias update must replace every field, clear nullable values, and preserve the stored name"); + } + private static async ValueTask ReconcilesCaseInsensitiveNamesAsync(IJobStorage storage, FakeTimeProvider timeProvider, CancellationToken token) { var original = new[] { Definition("Invoice") with { QueueName = "priority", QueuePriority = 9, QueueConcurrency = 2, Timeout = TimeSpan.FromMinutes(3), MaxConcurrency = 4, BackoffBase = TimeSpan.FromSeconds(13) }, Definition("Résumé"), Definition("Δοκιμή"), Definition("測試"), Definition(new string('a', 256)) }; diff --git a/tests/Immediate.Jobs.StorageTests/StorageConformanceInfrastructureTests.cs b/tests/Immediate.Jobs.StorageTests/StorageConformanceInfrastructureTests.cs index c6716bc2..f3e24d36 100644 --- a/tests/Immediate.Jobs.StorageTests/StorageConformanceInfrastructureTests.cs +++ b/tests/Immediate.Jobs.StorageTests/StorageConformanceInfrastructureTests.cs @@ -19,7 +19,7 @@ public void GetCasesAlwaysIncludesQueueTierAndRoutesOnlyAdvertisedOptionalSuites var graphCases = JobStorageConformanceSuite.GetCases(AllCapabilities); var replicaCases = JobStorageConformanceSuite.GetCases(AllCapabilities, includeSingleServerReplicaCases: true); - Assert.Equal(42, queueCases.Count); + Assert.Equal(43, queueCases.Count); Assert.All(queueCases, testCase => Assert.Equal(StorageCapabilities.Queue, testCase.RequiredCapabilities)); Assert.Contains(queueCases, testCase => testCase.Name.StartsWith("Recurring.", StringComparison.Ordinal)); Assert.Contains(queueCases, testCase => testCase.Name.StartsWith("FairQueues.", StringComparison.Ordinal)); From 1a2ad953e81996aba9c114de94d1b083af0c2bac Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 11 Oct 2026 20:31:24 +0000 Subject: [PATCH 7/7] Create the LinqToDB definition table from its mapped columns Declare Name collations consistently with invocation and recurring job names. Use CreateTableAsync for the definition table and retain custom SQL only for PostgreSQL collation creation. --- .../LinqToDBEntities.cs | 2 + .../LinqToDBSchemaExtensions.cs | 74 +++---------------- 2 files changed, 13 insertions(+), 63 deletions(-) diff --git a/src/Immediate.Jobs.LinqToDB/LinqToDBEntities.cs b/src/Immediate.Jobs.LinqToDB/LinqToDBEntities.cs index 05886b18..45b0ac04 100644 --- a/src/Immediate.Jobs.LinqToDB/LinqToDBEntities.cs +++ b/src/Immediate.Jobs.LinqToDB/LinqToDBEntities.cs @@ -208,6 +208,8 @@ internal sealed class ImmediateJobServerEntity internal sealed class ImmediateJobDefinitionMetadataEntity { [PrimaryKey, Column(Length = 256, CanBeNull = false)] + [Column(Configuration = ProviderName.PostgreSQL, DbType = "varchar(256) COLLATE \"immediate_jobs_case_insensitive\"", CanBeNull = false)] + [Column(Configuration = ProviderName.SQLite, DbType = "TEXT COLLATE NOCASE", CanBeNull = false)] public string Name { get; set; } = null!; [Column(Length = 256, CanBeNull = false)] public string QueueName { get; set; } = null!; diff --git a/src/Immediate.Jobs.LinqToDB/LinqToDBSchemaExtensions.cs b/src/Immediate.Jobs.LinqToDB/LinqToDBSchemaExtensions.cs index 7367cc2d..c2b445de 100644 --- a/src/Immediate.Jobs.LinqToDB/LinqToDBSchemaExtensions.cs +++ b/src/Immediate.Jobs.LinqToDB/LinqToDBSchemaExtensions.cs @@ -44,7 +44,12 @@ public static async Task CreateImmediateJobsSchemaAsync( if (schema is not null) await CreateSchemaAsync(context, provider, schema, cancellationToken); - await CreateDefinitionMetadataTableAsync(context, provider, schema, cancellationToken); + await CreateDefinitionNameCollationAsync(context, provider, cancellationToken); + await context.CreateTableAsync( + schemaName: schema, + tableOptions: TableOptions.CreateIfNotExists, + token: cancellationToken + ); await context.CreateTableAsync( schemaName: schema, @@ -104,40 +109,15 @@ internal static void ValidateSchema(string? schema) } } - private static Task CreateDefinitionMetadataTableAsync( + private static Task CreateDefinitionNameCollationAsync( DataConnection connection, string provider, - string? schema, CancellationToken cancellationToken ) { - if (provider.Contains("SqlServer", StringComparison.OrdinalIgnoreCase)) - { - var qualified = schema is null ? "[dbo].[immediate_job_definition_metadata]" : $"[{schema}].[immediate_job_definition_metadata]"; - return connection.ExecuteAsync($$""" - IF OBJECT_ID(N'{{qualified}}', N'U') IS NULL - CREATE TABLE {{qualified}} ( - [Name] nvarchar(256) NOT NULL PRIMARY KEY, - [QueueName] nvarchar(256) NOT NULL, - [QueuePriority] int NOT NULL, - [QueueConcurrency] int NOT NULL, - [Cron] nvarchar(128) NULL, - [TimeZone] nvarchar(128) NOT NULL, - [MaxAttempts] int NOT NULL, - [TimeoutTicks] bigint NULL, - [MaxConcurrency] int NOT NULL, - [OverlapPolicy] smallint NOT NULL, - [MisfireHandlingMode] smallint NOT NULL, - [Backoff] smallint NOT NULL, - [BackoffBaseTicks] bigint NOT NULL - ); - """, cancellationToken); - } - if (provider.Contains("PostgreSQL", StringComparison.OrdinalIgnoreCase)) { - var qualified = schema is null ? "\"immediate_job_definition_metadata\"" : $"\"{schema}\".\"immediate_job_definition_metadata\""; - return connection.ExecuteAsync($$""" + return connection.ExecuteAsync(""" DO $collation$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_collation WHERE collname = 'immediate_jobs_case_insensitive') THEN @@ -145,44 +125,12 @@ IF NOT EXISTS (SELECT 1 FROM pg_collation WHERE collname = 'immediate_jobs_case_ END IF; EXCEPTION WHEN duplicate_object THEN NULL; END $collation$; - CREATE TABLE IF NOT EXISTS {{qualified}} ( - "Name" character varying(256) COLLATE "immediate_jobs_case_insensitive" NOT NULL PRIMARY KEY, - "QueueName" character varying(256) NOT NULL, - "QueuePriority" integer NOT NULL, - "QueueConcurrency" integer NOT NULL, - "Cron" character varying(128) NULL, - "TimeZone" character varying(128) NOT NULL, - "MaxAttempts" integer NOT NULL, - "TimeoutTicks" bigint NULL, - "MaxConcurrency" integer NOT NULL, - "OverlapPolicy" smallint NOT NULL, - "MisfireHandlingMode" smallint NOT NULL, - "Backoff" smallint NOT NULL, - "BackoffBaseTicks" bigint NOT NULL - ); """, cancellationToken); } - if (provider.Contains("SQLite", StringComparison.OrdinalIgnoreCase)) - { - return connection.ExecuteAsync(""" - CREATE TABLE IF NOT EXISTS "immediate_job_definition_metadata" ( - "Name" nvarchar(256) COLLATE NOCASE NOT NULL PRIMARY KEY, - "QueueName" nvarchar(256) NOT NULL, - "QueuePriority" integer NOT NULL, - "QueueConcurrency" integer NOT NULL, - "Cron" nvarchar(128) NULL, - "TimeZone" nvarchar(128) NOT NULL, - "MaxAttempts" integer NOT NULL, - "TimeoutTicks" bigint NULL, - "MaxConcurrency" integer NOT NULL, - "OverlapPolicy" smallint NOT NULL, - "MisfireHandlingMode" smallint NOT NULL, - "Backoff" smallint NOT NULL, - "BackoffBaseTicks" bigint NOT NULL - ); - """, cancellationToken); - } + if (provider.Contains("SqlServer", StringComparison.OrdinalIgnoreCase) + || provider.Contains("SQLite", StringComparison.OrdinalIgnoreCase)) + return Task.CompletedTask; throw new NotSupportedException($"Immediate.Jobs schema bootstrap does not support provider '{provider}'."); }