diff --git a/docs/architecture.md b/docs/architecture.md index aafc16c3..c58a817d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -36,6 +36,17 @@ For each `[Job]` class (see `Templates/Job.sbntxt`), nested in the user's partia - **`PayloadJsonContext`**: hand-emitted System.Text.Json metadata for the payload and context types, so serialization never uses reflection. +`[assembly: JobDefaults(...)]` supplies defaults for `Tags`, `TimeZone`, `MaxAttempts`, and `Timeout` +for jobs declared in that assembly. The generator merges explicitly supplied assembly arguments +with each job's explicit arguments, then applies built-in defaults to unspecified settings. Job +arguments always take precedence: null or empty job tags use `["default"]`, a null timeout disables +the inherited timeout, and a null time zone uses UTC. The generated `JobDefinition` contains the +effective settings that startup persists. See [assembly defaults](../readme.md#assembly-defaults). + +`JobDefaultsAnalyzer` validates each assembly attribute through `OperationKind.Attribute`, reporting +`IJOB0017` even when the assembly has no jobs. Invalid defaults prevent the generator from emitting +jobs for that assembly, including jobs that override the invalid setting. + `ServiceCollectionExtensions.sbntxt` emits one `Add{Assembly}Jobs()` method per assembly that calls `AddImmediateJobsCore()` and registers every job. It returns `IImmediateJobsBuilder`, the fluent configuration surface (`ConfigureStorage`, `ConfigureWorkers`, `UseFairQueues`, `AddHealthCheck`, …). diff --git a/docs/job-lifecycle.md b/docs/job-lifecycle.md index 875ef290..39741e71 100644 --- a/docs/job-lifecycle.md +++ b/docs/job-lifecycle.md @@ -34,9 +34,14 @@ whose lease has expired (they are reclaimed first). ## Attempts, retries, and timeouts +`MaxAttempts` and `Timeout` come from explicit `[Job]` settings, then `[assembly: JobDefaults(...)]`. +If neither supplies them, jobs allow three total attempts and have no timeout. An explicit +`Timeout = null` on a job disables its inherited timeout. + - **Claiming** sets `Active`, increments `Attempt`, assigns the worker and a lease of `ImmediateJobsOptions.LeaseDuration`, and opens an execution row. -- **Timeout** comes from `[Job(Timeout = "hh:mm:ss")]`; the handler's token is cancelled when it +- **Timeout** is a positive, invariant-culture `TimeSpan` string in either attribute, for example + `Timeout = "00:02:00"`; the handler's token is cancelled when it elapses. That fails the attempt only if the handler throws; a handler that ignores the token and returns completes the job. - **Failure** calls `FailAsync` with a `nextRetryAt` while `Attempt < MaxAttempts`, which moves the job diff --git a/docs/recurring-jobs.md b/docs/recurring-jobs.md index bb751c1d..ae4b9b7c 100644 --- a/docs/recurring-jobs.md +++ b/docs/recurring-jobs.md @@ -7,7 +7,8 @@ A `RecurringJobSchedule` has a unique `Name`, the `JobName` it materializes, a ` - **Code-defined.** `[Job(Cron = "...", TimeZone = "...")]` on a payloadless job (`IJOB0006` rejects parameters). The schedule name is the job name. Its scheduler implements `IRecurringJobTrigger` - (`TriggerNowAsync`). + (`TriggerNowAsync`). An omitted `TimeZone` inherits the assembly default, or uses UTC if none is + supplied. Explicit `TimeZone = null` uses UTC even when the assembly supplies a default. - **Dynamic.** Payloadless jobs without a `Cron` get `IRecurringJobScheduler`: `AddOrUpdateRecurringAsync(name, cron, timeZone)` and `RemoveRecurringAsync(name)`, implemented by `JobScheduler.AddOrUpdateRecurringCoreAsync` / `RemoveRecurringCoreAsync`. diff --git a/readme.md b/readme.md index 627de261..7b515c64 100644 --- a/readme.md +++ b/readme.md @@ -52,13 +52,47 @@ 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()`. +## Assembly defaults + +Set defaults for jobs in an assembly with: + +```csharp +[assembly: JobDefaults( + Tags = new[] { "email", "default" }, + TimeZone = "UTC", + MaxAttempts = 5, + Timeout = "00:02:00")] +``` + +Only explicitly supplied assembly settings become defaults. If neither `[JobDefaults]` nor `[Job]` +specifies a setting, the generated job uses: + +| Setting | Built-in default | +| --- | --- | +| `Tags` | `["default"]` | +| `TimeZone` | `"UTC"` | +| `MaxAttempts` | `3`, including the first attempt | +| `Timeout` | No timeout | + +Explicit `[Job]` settings override assembly defaults, including null values and empty lists. Omit +`Tags` to inherit assembly tags. Setting `Tags = null` or `Tags = new string[0]` on a job uses +`["default"]`; an explicit nonempty list replaces the assembly list. Setting `Timeout = null` +disables its inherited timeout, and `TimeZone = null` uses UTC. Defaults apply only to jobs declared +in that assembly; they do not configure server tags or `[Handler]` registration tags. + +`MaxAttempts` must be at least one, and a non-null `Timeout` must be a positive, invariant-culture +`TimeSpan` string. Use a nonblank time-zone identifier supported by the server's +`TimeZoneInfo.FindSystemTimeZoneById`. Invalid assembly defaults produce `IJOB0017` at the attribute, +even without any jobs, and prevent job generation even when a job overrides the invalid setting. + ## Routing jobs with tags Configure job tags with `[Job(Tags = new[] { "email", "default" })]` and server tags through `ConfigureWorkers(options => options.Tags = ["email", "default"])`. A server manages definitions and acquires invocations only when the effective job and server lists intersect. -Omitted, null, and empty lists become `["default"]`. Explicit nonempty lists replace that fallback: +Omitted job tag lists inherit assembly defaults; null or empty job lists, and omitted, null, or empty +server lists, become `["default"]`. Explicit nonempty lists replace that fallback: a server with `["email"]` does not process default-only jobs, while `["email", "default"]` processes both. Tags match with `OrdinalIgnoreCase`; case variants are deduplicated while retaining the first spelling. Null/blank entries are rejected. `default` is an ordinary tag, not a wildcard. `[Handler]` registration tags are separate. diff --git a/src/Common/AttributeDataExtensions.cs b/src/Common/AttributeDataExtensions.cs index 8f084364..20211c9a 100644 --- a/src/Common/AttributeDataExtensions.cs +++ b/src/Common/AttributeDataExtensions.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.Globalization; using Microsoft.CodeAnalysis; namespace Immediate.Jobs; @@ -29,5 +30,29 @@ public string GetQueueName(string className) => attribute.NamedArguments.GetStringValue("Name") ?? className.AsQueueName(); public Location? Location => attribute.ApplicationSyntaxReference?.GetSyntax().GetLocation(); + + public IEnumerable GetJobDefaultsErrors() + { + var arguments = attribute.NamedArguments; + + if (arguments.GetArgumentValue("Tags").GetTags() is null) + yield return "Tags must not contain null, empty, or whitespace entries."; + + if (arguments.GetIntValue("MaxAttempts", 3) < 1) + yield return "MaxAttempts must be at least one."; + + if (arguments.GetStringValue("Timeout") is { } timeout + && (!TimeSpan.TryParse(timeout, CultureInfo.InvariantCulture, out var duration) || duration <= TimeSpan.Zero)) + { + yield return "Timeout must be a positive TimeSpan."; + } + + if (arguments.GetStringValue("TimeZone") is { } timeZone + && timeZone.IsWhiteSpace()) + { + yield return "Time Zone is invalid."; + } + } + } } diff --git a/src/Common/ITypeSymbolExtensions.cs b/src/Common/ITypeSymbolExtensions.cs index 232a2497..7409c025 100644 --- a/src/Common/ITypeSymbolExtensions.cs +++ b/src/Common/ITypeSymbolExtensions.cs @@ -15,6 +15,14 @@ typeSymbol is INamedTypeSymbol ContainingNamespace.IsImmediateJobsShared: true, }; + public bool IsJobDefaultsAttribute => + typeSymbol is INamedTypeSymbol + { + Arity: 0, + Name: "JobDefaultsAttribute", + ContainingNamespace.IsImmediateJobsShared: true, + }; + public bool IsJobDetails => typeSymbol is INamedTypeSymbol { diff --git a/src/Immediate.Jobs.Analyzers/AnalyzerReleases.Unshipped.md b/src/Immediate.Jobs.Analyzers/AnalyzerReleases.Unshipped.md index 0488fea6..afec45f5 100644 --- a/src/Immediate.Jobs.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/Immediate.Jobs.Analyzers/AnalyzerReleases.Unshipped.md @@ -18,3 +18,4 @@ IJOB0013 | ImmediateJobs | Error | PayloadAnalyzer IJOB0014 | ImmediateJobs | Error | PayloadAnalyzer IJOB0015 | ImmediateJobs | Warning | DetachedJobCannotBeAddedToBatch IJOB0016 | ImmediateJobs | Warning | DuplicateElementsAnalyzer +IJOB0017 | ImmediateJobs | Error | JobDefaultsAnalyzer diff --git a/src/Immediate.Jobs.Analyzers/DiagnosticIds.cs b/src/Immediate.Jobs.Analyzers/DiagnosticIds.cs index a9bb08a2..fe7f91d9 100644 --- a/src/Immediate.Jobs.Analyzers/DiagnosticIds.cs +++ b/src/Immediate.Jobs.Analyzers/DiagnosticIds.cs @@ -18,4 +18,5 @@ internal static class DiagnosticIds public const string IJOB0014JobContextIsNotSerializable = "IJOB0014"; public const string IJOB0015DetachedJobCannotBeAddedToBatch = "IJOB0015"; public const string IJOB0016UnusedQueueDefinition = "IJOB0016"; + public const string IJOB0017JobDefaultsInvalid = "IJOB0017"; } diff --git a/src/Immediate.Jobs.Analyzers/JobClassAnalyzer.cs b/src/Immediate.Jobs.Analyzers/JobClassAnalyzer.cs index 5bb23613..a285cb64 100644 --- a/src/Immediate.Jobs.Analyzers/JobClassAnalyzer.cs +++ b/src/Immediate.Jobs.Analyzers/JobClassAnalyzer.cs @@ -141,11 +141,12 @@ private static void AnalyzeJobName(SymbolAnalysisContext context, AttributeData JobNameInvalid, context.Symbol.Locations.FirstOrDefault(), 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.HasNameContent() - ? "`Name` must contain at least one letter or digit" - : "`Name` must not have leading or trailing whitespace" + (explicitName is null, !name.HasNameContent()) switch + { + (true, _) => $"a name cannot be derived from the class name `{context.Symbol.Name}`; rename the class or set `Name`", + (false, true) => "`Name` must contain at least one letter or digit", + _ => "`Name` must not have leading or trailing whitespace", + } ) ); } diff --git a/src/Immediate.Jobs.Analyzers/JobDefaultsAnalyzer.cs b/src/Immediate.Jobs.Analyzers/JobDefaultsAnalyzer.cs new file mode 100644 index 00000000..95e9cebf --- /dev/null +++ b/src/Immediate.Jobs.Analyzers/JobDefaultsAnalyzer.cs @@ -0,0 +1,57 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Immediate.Jobs.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class JobDefaultsAnalyzer : DiagnosticAnalyzer +{ + public static readonly DiagnosticDescriptor InvalidJobDefaults = + new( + id: DiagnosticIds.IJOB0017JobDefaultsInvalid, + title: "Job Defaults Invalid", + messageFormat: "Invalid job defaults: {0}", + category: "ImmediateJobs", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + customTags: [WellKnownDiagnosticTags.NotConfigurable] + ); + + public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create([InvalidJobDefaults]); + + public override void Initialize(AnalysisContext context) + { + ArgumentNullException.ThrowIfNull(context); + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterOperationAction(AnalyzeDefaults, OperationKind.Attribute); + } + + private static void AnalyzeDefaults(OperationAnalysisContext context) + { + context.CancellationToken.ThrowIfCancellationRequested(); + + if (context.Operation is not IAttributeOperation { Operation: IObjectCreationOperation { Type.IsJobDefaultsAttribute: true } }) + return; + + var syntax = context.Operation.Syntax; + + var attribute = context.Compilation.Assembly.GetAttributes() + .FirstOrDefault( + attribute => + attribute.ApplicationSyntaxReference is { } reference + && reference.SyntaxTree == syntax.SyntaxTree + && reference.Span == syntax.Span + ); + + if (attribute is null) + return; + + foreach (var error in attribute.GetJobDefaultsErrors()) + context.ReportDiagnostic(Diagnostic.Create(InvalidJobDefaults, syntax.GetLocation(), error)); + } +} diff --git a/src/Immediate.Jobs.Generators/ImmediateJobsGenerator.Render.cs b/src/Immediate.Jobs.Generators/ImmediateJobsGenerator.Render.cs index 0f4503da..55084daf 100644 --- a/src/Immediate.Jobs.Generators/ImmediateJobsGenerator.Render.cs +++ b/src/Immediate.Jobs.Generators/ImmediateJobsGenerator.Render.cs @@ -13,6 +13,7 @@ private static void RenderJob(SourceProductionContext context, JobModel job, Tem { var cancellationToken = context.CancellationToken; cancellationToken.ThrowIfCancellationRequested(); + var model = new { job.Namespace, diff --git a/src/Immediate.Jobs.Generators/ImmediateJobsGenerator.Transform.cs b/src/Immediate.Jobs.Generators/ImmediateJobsGenerator.Transform.cs index b4688bb8..906ec7a4 100644 --- a/src/Immediate.Jobs.Generators/ImmediateJobsGenerator.Transform.cs +++ b/src/Immediate.Jobs.Generators/ImmediateJobsGenerator.Transform.cs @@ -23,6 +23,10 @@ CancellationToken cancellationToken if (attributes.HandlerAttribute is not { } handlerAttribute) return null; + var defaults = symbol.ContainingAssembly.GetAttributes().FirstOrDefault(attribute => attribute.AttributeClass.IsJobDefaultsAttribute); + if (defaults is { } && defaults.GetJobDefaultsErrors().Any()) + return null; + var @namespace = symbol.ContainingNamespace.ToDisplayString().NullIf(""); var parameterType = handleMethod.Parameters[0].Type; @@ -34,7 +38,7 @@ CancellationToken cancellationToken if (!jobName.HasNameContent() || jobName.HasLeadingOrTrailingWhitespace()) return null; - var arguments = attribute.NamedArguments; + var arguments = attribute.GetJobArguments(defaults); var cron = arguments.GetStringValue("Cron"); var timeZone = arguments.GetStringValue("TimeZone") ?? "UTC"; @@ -211,4 +215,25 @@ file static class Extensions } } } + + extension(AttributeData attribute) + { + public ImmutableArray> GetJobArguments(AttributeData? defaults) + { + if (defaults is null) + return attribute.NamedArguments; + + var arguments = defaults.NamedArguments + .ToDictionary( + argument => argument.Key, + argument => argument.Value, + StringComparer.Ordinal + ); + + foreach (var argument in attribute.NamedArguments) + arguments[argument.Key] = argument.Value; + + return arguments.ToImmutableArray(); + } + } } diff --git a/src/Immediate.Jobs.Shared/JobAttribute.cs b/src/Immediate.Jobs.Shared/JobAttribute.cs index d9bad74a..ad58e4f7 100644 --- a/src/Immediate.Jobs.Shared/JobAttribute.cs +++ b/src/Immediate.Jobs.Shared/JobAttribute.cs @@ -9,8 +9,11 @@ namespace Immediate.Jobs.Shared; public sealed class JobAttribute : Attribute { /// - /// The job routing tags. Omitted, null, or empty lists use the default tag. + /// The job routing tags. An omitted list inherits assembly defaults, or uses the default tag. /// + /// + /// An explicit null or empty list uses the default tag, overriding any assembly tags. + /// [System.Diagnostics.CodeAnalysis.NotNull] public string[]? Tags { get; init => field = [.. JobTags.Normalize(value)]; } = [JobTags.Default]; @@ -34,16 +37,19 @@ public sealed class JobAttribute : Attribute public string? Cron { get; init; } /// - /// An IANA time-zone identifier. Defaults to UTC. + /// A time-zone identifier. When omitted, inherits the assembly default, or uses UTC. /// /// - /// The IANA time-zone identifier, or to use UTC. + /// The time-zone identifier, or to use UTC. /// public string? TimeZone { get; init; } /// /// Total execution attempts, including the first attempt. /// + /// + /// When omitted, inherits the assembly default, or allows three total attempts. + /// /// /// The total number of permitted execution attempts. /// @@ -52,6 +58,9 @@ public sealed class JobAttribute : Attribute /// /// A formatted execution timeout. /// + /// + /// When omitted, inherits the assembly default. An explicit null disables the inherited timeout. + /// /// /// The formatted execution timeout, or for no timeout. /// diff --git a/src/Immediate.Jobs.Shared/JobDefaultsAttribute.cs b/src/Immediate.Jobs.Shared/JobDefaultsAttribute.cs new file mode 100644 index 00000000..d582ba35 --- /dev/null +++ b/src/Immediate.Jobs.Shared/JobDefaultsAttribute.cs @@ -0,0 +1,37 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Immediate.Jobs.Shared; + +/// +/// Supplies defaults for generated jobs declared in this assembly. Explicit job settings override these values. +/// +/// +/// Only explicitly supplied settings become assembly defaults. Unspecified settings use the built-in job defaults. +/// +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)] +public sealed class JobDefaultsAttribute : Attribute +{ + /// + /// The routing tags inherited by jobs that omit a tag list. + /// + /// + /// Null or empty lists use the default tag. Explicit job tag lists, including null or empty lists, override these tags. + /// + [NotNull] + public string[]? Tags { get; init => field = [.. JobTags.Normalize(value)]; } + + /// + /// The default time-zone identifier. A null value uses UTC. + /// + public string? TimeZone { get; init; } + + /// + /// The default total execution attempts, including the first attempt. + /// + public int MaxAttempts { get; init; } + + /// + /// The default execution timeout as a TimeSpan string, or null for no timeout. + /// + public string? Timeout { get; init; } +} diff --git a/tests/Immediate.Jobs.Tests/AnalyzerTests/JobDefaultsAnalyzerTests.cs b/tests/Immediate.Jobs.Tests/AnalyzerTests/JobDefaultsAnalyzerTests.cs new file mode 100644 index 00000000..5b4c81de --- /dev/null +++ b/tests/Immediate.Jobs.Tests/AnalyzerTests/JobDefaultsAnalyzerTests.cs @@ -0,0 +1,34 @@ +using Immediate.Jobs.Analyzers; + +namespace Immediate.Jobs.Tests.AnalyzerTests; + +public sealed class JobDefaultsAnalyzerTests +{ + [Theory] + [InlineData("Tags = new[] { \" \" }")] + [InlineData("Tags = new[] { \"tag\", null }")] + [InlineData("MaxAttempts = 0")] + [InlineData("Timeout = \"invalid\"")] + [InlineData("Timeout = \"00:00:00\"")] + [InlineData("TimeZone = \"\"")] + public async Task InvalidDefaultsAreRejectedWithoutAnyJobs(string settings) => + await AnalyzerTestHelpers.CreateAnalyzerTest( + $$""" + using Immediate.Jobs.Shared; + [assembly: {|IJOB0017:JobDefaults({{settings}})|}] + """ + ).RunAsync(TestContext.Current.CancellationToken); + + [Theory] + [InlineData("")] + [InlineData("Tags = null, Timeout = null, TimeZone = null")] + [InlineData("Tags = new string[0]")] + [InlineData("Tags = [\"email\", \"EMAIL\"], TimeZone = \"UTC\", MaxAttempts = 1, Timeout = \"00:00:10\"")] + public async Task ValidDefaultsAreAccepted(string settings) => + await AnalyzerTestHelpers.CreateAnalyzerTest( + $$""" + using Immediate.Jobs.Shared; + [assembly: JobDefaults({{settings}})] + """ + ).RunAsync(TestContext.Current.CancellationToken); +} diff --git a/tests/Immediate.Jobs.Tests/GeneratorTests/ImmediateJobsGeneratorTests.cs b/tests/Immediate.Jobs.Tests/GeneratorTests/ImmediateJobsGeneratorTests.cs index 7e7cc869..80ae4cae 100644 --- a/tests/Immediate.Jobs.Tests/GeneratorTests/ImmediateJobsGeneratorTests.cs +++ b/tests/Immediate.Jobs.Tests/GeneratorTests/ImmediateJobsGeneratorTests.cs @@ -445,4 +445,70 @@ public sealed partial class ClockJob _ = await Utility.VerifyIgnoreImmediateHandlers(result); } + + [Fact] + public async Task JobSettingsUseAssemblyDefaults() + { + var result = GeneratorTestHelper.RunGenerator( + """ + using Immediate.Jobs.Shared; + using Immediate.Handlers.Shared; + using System.Threading; + using System.Threading.Tasks; + + [assembly: JobDefaults(Tags = new[] { "assembly" }, TimeZone = "America/New_York", MaxAttempts = 5, Timeout = "00:02:00")] + + [Handler, Job] + public sealed partial class WorkJob + { + private static ValueTask HandleAsync(EmptyJobRequest request, CancellationToken cancellationToken) => ValueTask.CompletedTask; + } + """ + ); + + Assert.Equal( + [ + "Immediate.Handlers.Generators/Immediate.Handlers.Generators.ImmediateHandlersGenerator/IH..WorkJob.g.cs", + "Immediate.Handlers.Generators/Immediate.Handlers.Generators.ImmediateHandlersGenerator/IH.ServiceCollectionExtensions.g.cs", + "Immediate.Jobs.Generators/Immediate.Jobs.Generators.ImmediateJobsGenerator/IJ..WorkJob.g.cs", + "Immediate.Jobs.Generators/Immediate.Jobs.Generators.ImmediateJobsGenerator/IJ.ServiceCollectionExtensions.g.cs", + ], + result.GeneratedTrees.Select(tree => tree.FilePath.Replace('\\', '/')) + ); + + await Utility.VerifyIgnoreImmediateHandlers(result); + } + + [Fact] + public async Task JobSettingsOverrideAssemblyDefaults() + { + var result = GeneratorTestHelper.RunGenerator( + """ + using Immediate.Jobs.Shared; + using Immediate.Handlers.Shared; + using System.Threading; + using System.Threading.Tasks; + + [assembly: JobDefaults(Tags = new[] { "assembly" }, TimeZone = "America/New_York", MaxAttempts = 5, Timeout = "00:02:00")] + + [Handler, Job(Tags = ["my-assembly"], TimeZone = "UTC", MaxAttempts = 4, Timeout = "00:05:00")] + public sealed partial class WorkJob + { + private static ValueTask HandleAsync(EmptyJobRequest request, CancellationToken cancellationToken) => ValueTask.CompletedTask; + } + """ + ); + + Assert.Equal( + [ + "Immediate.Handlers.Generators/Immediate.Handlers.Generators.ImmediateHandlersGenerator/IH..WorkJob.g.cs", + "Immediate.Handlers.Generators/Immediate.Handlers.Generators.ImmediateHandlersGenerator/IH.ServiceCollectionExtensions.g.cs", + "Immediate.Jobs.Generators/Immediate.Jobs.Generators.ImmediateJobsGenerator/IJ..WorkJob.g.cs", + "Immediate.Jobs.Generators/Immediate.Jobs.Generators.ImmediateJobsGenerator/IJ.ServiceCollectionExtensions.g.cs", + ], + result.GeneratedTrees.Select(tree => tree.FilePath.Replace('\\', '/')) + ); + + await Utility.VerifyIgnoreImmediateHandlers(result); + } } diff --git a/tests/Immediate.Jobs.Tests/GeneratorTests/InvalidJobsTests.cs b/tests/Immediate.Jobs.Tests/GeneratorTests/InvalidJobsTests.cs index 2556d3a4..148307ea 100644 --- a/tests/Immediate.Jobs.Tests/GeneratorTests/InvalidJobsTests.cs +++ b/tests/Immediate.Jobs.Tests/GeneratorTests/InvalidJobsTests.cs @@ -2,6 +2,29 @@ namespace Immediate.Jobs.Tests.GeneratorTests; public sealed class InvalidJobsTests { + [Fact] + public void InvalidAssemblyDefaultsPreventGenerationEvenWhenJobOverridesThem() + { + var result = GeneratorTestHelper.RunGenerator( + """ + using System.Threading; + using System.Threading.Tasks; + using Immediate.Handlers.Shared; + using Immediate.Jobs.Shared; + [assembly: JobDefaults(MaxAttempts = 0)] + + [Handler, Job(MaxAttempts = 2)] + public sealed partial class WorkJob + { + private static ValueTask HandleAsync(EmptyJobRequest request, CancellationToken cancellationToken) => ValueTask.CompletedTask; + } + """, + skippedSteps: ["Jobs"] + ); + + Assert.DoesNotContain(result.GeneratedTrees, tree => tree.FilePath.EndsWith("IJ..WorkJob.g.cs", StringComparison.Ordinal)); + } + [Fact] public async Task MissingHandlerAttributeShouldError() { diff --git a/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/ImmediateJobsGeneratorTests.JobSettingsOverrideAssemblyDefaults#IJ..WorkJob.g.verified.cs b/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/ImmediateJobsGeneratorTests.JobSettingsOverrideAssemblyDefaults#IJ..WorkJob.g.verified.cs new file mode 100644 index 00000000..a02ac9fa --- /dev/null +++ b/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/ImmediateJobsGeneratorTests.JobSettingsOverrideAssemblyDefaults#IJ..WorkJob.g.verified.cs @@ -0,0 +1,150 @@ +//HintName: IJ..WorkJob.g.cs +// +#nullable enable + +#pragma warning disable CS1591 + + +partial class WorkJob +{ + public sealed class Scheduler( + global::Immediate.Jobs.Shared.Storage.IJobStorage storage, + global::Immediate.Jobs.Shared.Interfaces.IJobSerializer serializer, + global::System.TimeProvider timeProvider, + global::Immediate.Jobs.Shared.Interfaces.IIdGenerator idGenerator + ) : global::Immediate.Jobs.Shared.JobScheduler( + storage, + serializer, + timeProvider, + idGenerator, + "work", + "default", + static options => new PayloadJsonContext(options).Payload + ) + , global::Immediate.Jobs.Shared.Interfaces.IRecurringJobScheduler + { + + public global::System.Threading.Tasks.ValueTask TriggerNowAsync(global::System.Threading.CancellationToken cancellationToken = default) => + EnqueueAsync(default, cancellationToken); + + public global::System.Threading.Tasks.ValueTask AddOrUpdateRecurringAsync(string name, string cron, string timeZone = "UTC", global::System.Threading.CancellationToken cancellationToken = default) => + AddOrUpdateRecurringCoreAsync(name, cron, timeZone, cancellationToken); + + public global::System.Threading.Tasks.ValueTask RemoveRecurringAsync(string name, global::System.Threading.CancellationToken cancellationToken = default) => + RemoveRecurringCoreAsync(name, cancellationToken); + } + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + internal sealed class Invoker : global::Immediate.Jobs.Shared.Interfaces.IJobInvoker + { + public async global::System.Threading.Tasks.ValueTask InvokeAsync(global::System.IServiceProvider scopedServices, global::Immediate.Jobs.Shared.JobExecution execution) + { + if (execution.Record.Context is { } envelope) + { + var contextSlices = global::Immediate.Jobs.Shared.Internals.JobContextEnvelope.Read(envelope); + + global::Immediate.Jobs.Shared.Internals.JobContextEnvelope.LogOrphanedSlices(scopedServices, execution.Record, contextSlices.Keys); + } + + var payload = new global::Immediate.Jobs.Shared.EmptyJobRequest(); + + SetJobDetails( + ref payload, + new global::Immediate.Jobs.Shared.JobDetails(execution) + ); + + var handler = global::Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService(scopedServices); + _ = await handler.HandleAsync(payload, execution.CancellationToken).ConfigureAwait(false); + } + + /// Generic reified method to support `struct`s with explicitly implemented properties + private static void SetJobDetails( + ref TRequest request, + global::Immediate.Jobs.Shared.JobDetails details + ) + where TRequest : global::Immediate.Jobs.Shared.IJobRequest => request.JobDetails = details; + } + + internal sealed record JobDefinition : global::Immediate.Jobs.Shared.Internals.JobDefinition; + + internal static WorkJob.JobDefinition CreateJobDefinition(global::System.IServiceProvider services) => + new() + { + Name = "work", + Tags = ["my-assembly"], + Queue = new global::Immediate.Jobs.Shared.Internals.JobQueueDefinition + { + Name = "default", + Priority = 0, + Concurrency = 0, + }, + Invoker = global::Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService(services), + JobType = typeof(global::WorkJob), + TimeZone = "UTC", + MaxAttempts = 4, + Timeout = global::System.TimeSpan.Parse("00:05:00", global::System.Globalization.CultureInfo.InvariantCulture), + MaxConcurrency = 0, + OverlapPolicy = global::Immediate.Jobs.Shared.OverlapPolicy.Skip, + MisfireHandlingMode = global::Immediate.Jobs.Shared.MisfireHandlingMode.EnqueueOne, + Backoff = global::Immediate.Jobs.Shared.BackoffStrategy.ExponentialJitter, + BackoffBase = global::System.TimeSpan.Parse("00:00:05", global::System.Globalization.CultureInfo.InvariantCulture), + }; + + internal sealed class PayloadJsonContext : global::System.Text.Json.Serialization.JsonSerializerContext, global::System.Text.Json.Serialization.Metadata.IJsonTypeInfoResolver + { + private static readonly global::System.Text.Json.JsonSerializerOptions DefaultOptions = new(global::System.Text.Json.JsonSerializerDefaults.Web); + public static PayloadJsonContext Default { get; } = new(new global::System.Text.Json.JsonSerializerOptions(DefaultOptions)); + protected override global::System.Text.Json.JsonSerializerOptions? GeneratedSerializerOptions => DefaultOptions; + public PayloadJsonContext(global::System.Text.Json.JsonSerializerOptions options) : base(options) { } + public override global::System.Text.Json.Serialization.Metadata.JsonTypeInfo? GetTypeInfo(global::System.Type type) => + ((global::System.Text.Json.Serialization.Metadata.IJsonTypeInfoResolver)this).GetTypeInfo(type, Options); + + global::System.Text.Json.Serialization.Metadata.JsonTypeInfo? global::System.Text.Json.Serialization.Metadata.IJsonTypeInfoResolver.GetTypeInfo(global::System.Type type, global::System.Text.Json.JsonSerializerOptions options) + { + if (type == typeof(global::Immediate.Jobs.Shared.EmptyJobRequest)) + return CreateTypeInfo0(options); + + return null; + } + + public global::System.Text.Json.Serialization.Metadata.JsonTypeInfo Payload => + (global::System.Text.Json.Serialization.Metadata.JsonTypeInfo)GetTypeInfo( + typeof(global::Immediate.Jobs.Shared.EmptyJobRequest) + )!; + + private global::System.Text.Json.Serialization.Metadata.JsonTypeInfo CreateTypeInfo0(global::System.Text.Json.JsonSerializerOptions options) + { + var objectInfo = new global::System.Text.Json.Serialization.Metadata.JsonObjectInfoValues + { + ObjectCreator = static () => new global::Immediate.Jobs.Shared.EmptyJobRequest(), + PropertyMetadataInitializer = _ => new global::System.Text.Json.Serialization.Metadata.JsonPropertyInfo[] + { + }, + }; + var info = global::System.Text.Json.Serialization.Metadata.JsonMetadataServices.CreateObjectInfo(options, objectInfo); + info.OriginatingResolver = this; + return info; + } + } + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + internal static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddJob( + global::Microsoft.Extensions.DependencyInjection.IServiceCollection services + ) + { + global::Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions.TryAddEnumerable( + services, + global::Microsoft.Extensions.DependencyInjection.ServiceDescriptor.Singleton< + global::Immediate.Jobs.Shared.Internals.JobDefinition, + WorkJob.JobDefinition + >(WorkJob.CreateJobDefinition) + ); + + global::Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions.TryAddScoped(services); + + global::Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions.TryAddSingleton(services); + + + return services; + } +} diff --git a/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/ImmediateJobsGeneratorTests.JobSettingsUseAssemblyDefaults#IJ..WorkJob.g.verified.cs b/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/ImmediateJobsGeneratorTests.JobSettingsUseAssemblyDefaults#IJ..WorkJob.g.verified.cs new file mode 100644 index 00000000..b873ef85 --- /dev/null +++ b/tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/ImmediateJobsGeneratorTests.JobSettingsUseAssemblyDefaults#IJ..WorkJob.g.verified.cs @@ -0,0 +1,150 @@ +//HintName: IJ..WorkJob.g.cs +// +#nullable enable + +#pragma warning disable CS1591 + + +partial class WorkJob +{ + public sealed class Scheduler( + global::Immediate.Jobs.Shared.Storage.IJobStorage storage, + global::Immediate.Jobs.Shared.Interfaces.IJobSerializer serializer, + global::System.TimeProvider timeProvider, + global::Immediate.Jobs.Shared.Interfaces.IIdGenerator idGenerator + ) : global::Immediate.Jobs.Shared.JobScheduler( + storage, + serializer, + timeProvider, + idGenerator, + "work", + "default", + static options => new PayloadJsonContext(options).Payload + ) + , global::Immediate.Jobs.Shared.Interfaces.IRecurringJobScheduler + { + + public global::System.Threading.Tasks.ValueTask TriggerNowAsync(global::System.Threading.CancellationToken cancellationToken = default) => + EnqueueAsync(default, cancellationToken); + + public global::System.Threading.Tasks.ValueTask AddOrUpdateRecurringAsync(string name, string cron, string timeZone = "UTC", global::System.Threading.CancellationToken cancellationToken = default) => + AddOrUpdateRecurringCoreAsync(name, cron, timeZone, cancellationToken); + + public global::System.Threading.Tasks.ValueTask RemoveRecurringAsync(string name, global::System.Threading.CancellationToken cancellationToken = default) => + RemoveRecurringCoreAsync(name, cancellationToken); + } + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + internal sealed class Invoker : global::Immediate.Jobs.Shared.Interfaces.IJobInvoker + { + public async global::System.Threading.Tasks.ValueTask InvokeAsync(global::System.IServiceProvider scopedServices, global::Immediate.Jobs.Shared.JobExecution execution) + { + if (execution.Record.Context is { } envelope) + { + var contextSlices = global::Immediate.Jobs.Shared.Internals.JobContextEnvelope.Read(envelope); + + global::Immediate.Jobs.Shared.Internals.JobContextEnvelope.LogOrphanedSlices(scopedServices, execution.Record, contextSlices.Keys); + } + + var payload = new global::Immediate.Jobs.Shared.EmptyJobRequest(); + + SetJobDetails( + ref payload, + new global::Immediate.Jobs.Shared.JobDetails(execution) + ); + + var handler = global::Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService(scopedServices); + _ = await handler.HandleAsync(payload, execution.CancellationToken).ConfigureAwait(false); + } + + /// Generic reified method to support `struct`s with explicitly implemented properties + private static void SetJobDetails( + ref TRequest request, + global::Immediate.Jobs.Shared.JobDetails details + ) + where TRequest : global::Immediate.Jobs.Shared.IJobRequest => request.JobDetails = details; + } + + internal sealed record JobDefinition : global::Immediate.Jobs.Shared.Internals.JobDefinition; + + internal static WorkJob.JobDefinition CreateJobDefinition(global::System.IServiceProvider services) => + new() + { + Name = "work", + Tags = ["assembly"], + Queue = new global::Immediate.Jobs.Shared.Internals.JobQueueDefinition + { + Name = "default", + Priority = 0, + Concurrency = 0, + }, + Invoker = global::Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService(services), + JobType = typeof(global::WorkJob), + TimeZone = "America/New_York", + MaxAttempts = 5, + Timeout = global::System.TimeSpan.Parse("00:02:00", global::System.Globalization.CultureInfo.InvariantCulture), + MaxConcurrency = 0, + OverlapPolicy = global::Immediate.Jobs.Shared.OverlapPolicy.Skip, + MisfireHandlingMode = global::Immediate.Jobs.Shared.MisfireHandlingMode.EnqueueOne, + Backoff = global::Immediate.Jobs.Shared.BackoffStrategy.ExponentialJitter, + BackoffBase = global::System.TimeSpan.Parse("00:00:05", global::System.Globalization.CultureInfo.InvariantCulture), + }; + + internal sealed class PayloadJsonContext : global::System.Text.Json.Serialization.JsonSerializerContext, global::System.Text.Json.Serialization.Metadata.IJsonTypeInfoResolver + { + private static readonly global::System.Text.Json.JsonSerializerOptions DefaultOptions = new(global::System.Text.Json.JsonSerializerDefaults.Web); + public static PayloadJsonContext Default { get; } = new(new global::System.Text.Json.JsonSerializerOptions(DefaultOptions)); + protected override global::System.Text.Json.JsonSerializerOptions? GeneratedSerializerOptions => DefaultOptions; + public PayloadJsonContext(global::System.Text.Json.JsonSerializerOptions options) : base(options) { } + public override global::System.Text.Json.Serialization.Metadata.JsonTypeInfo? GetTypeInfo(global::System.Type type) => + ((global::System.Text.Json.Serialization.Metadata.IJsonTypeInfoResolver)this).GetTypeInfo(type, Options); + + global::System.Text.Json.Serialization.Metadata.JsonTypeInfo? global::System.Text.Json.Serialization.Metadata.IJsonTypeInfoResolver.GetTypeInfo(global::System.Type type, global::System.Text.Json.JsonSerializerOptions options) + { + if (type == typeof(global::Immediate.Jobs.Shared.EmptyJobRequest)) + return CreateTypeInfo0(options); + + return null; + } + + public global::System.Text.Json.Serialization.Metadata.JsonTypeInfo Payload => + (global::System.Text.Json.Serialization.Metadata.JsonTypeInfo)GetTypeInfo( + typeof(global::Immediate.Jobs.Shared.EmptyJobRequest) + )!; + + private global::System.Text.Json.Serialization.Metadata.JsonTypeInfo CreateTypeInfo0(global::System.Text.Json.JsonSerializerOptions options) + { + var objectInfo = new global::System.Text.Json.Serialization.Metadata.JsonObjectInfoValues + { + ObjectCreator = static () => new global::Immediate.Jobs.Shared.EmptyJobRequest(), + PropertyMetadataInitializer = _ => new global::System.Text.Json.Serialization.Metadata.JsonPropertyInfo[] + { + }, + }; + var info = global::System.Text.Json.Serialization.Metadata.JsonMetadataServices.CreateObjectInfo(options, objectInfo); + info.OriginatingResolver = this; + return info; + } + } + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + internal static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddJob( + global::Microsoft.Extensions.DependencyInjection.IServiceCollection services + ) + { + global::Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions.TryAddEnumerable( + services, + global::Microsoft.Extensions.DependencyInjection.ServiceDescriptor.Singleton< + global::Immediate.Jobs.Shared.Internals.JobDefinition, + WorkJob.JobDefinition + >(WorkJob.CreateJobDefinition) + ); + + global::Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions.TryAddScoped(services); + + global::Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions.TryAddSingleton(services); + + + return services; + } +}