From 400c76b541e1a9ab9cea2b2f665c179fee4e3ecc Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 12 Oct 2026 19:29:49 +0000 Subject: [PATCH 1/5] Support assembly-wide job defaults with explicit per-job overrides --- readme.md | 15 +++++++ src/Common/AttributeDataExtensions.cs | 19 +++++++++ src/Common/ITypeSymbolExtensions.cs | 8 ++++ src/Common/TypedConstantExtensions.cs | 33 +++++++++++++++ .../AnalyzerReleases.Unshipped.md | 1 + src/Immediate.Jobs.Analyzers/DiagnosticIds.cs | 1 + .../JobClassAnalyzer.cs | 4 +- .../JobDefaultsAnalyzer.cs | 38 +++++++++++++++++ .../ImmediateJobsGenerator.Transform.cs | 6 ++- src/Immediate.Jobs.Shared/JobAttribute.cs | 2 +- .../JobDefaultsAttribute.cs | 31 ++++++++++++++ .../AnalyzerTests/JobDefaultsAnalyzerTests.cs | 35 ++++++++++++++++ .../GeneratorTests/InvalidJobsTests.cs | 23 ++++++++++ .../GeneratorTests/JobDefaultsTests.cs | 42 +++++++++++++++++++ 14 files changed, 254 insertions(+), 4 deletions(-) create mode 100644 src/Immediate.Jobs.Analyzers/JobDefaultsAnalyzer.cs create mode 100644 src/Immediate.Jobs.Shared/JobDefaultsAttribute.cs create mode 100644 tests/Immediate.Jobs.Tests/AnalyzerTests/JobDefaultsAnalyzerTests.cs create mode 100644 tests/Immediate.Jobs.Tests/GeneratorTests/JobDefaultsTests.cs diff --git a/readme.md b/readme.md index 627de261..4516feb5 100644 --- a/readme.md +++ b/readme.md @@ -54,6 +54,21 @@ are `AddMyAppHandlers()` and `AddMyAppJobs()`. ## Routing jobs with tags +Set defaults for jobs in an assembly with: + +```csharp +[assembly: JobDefaults( + Tags = new[] { "email", "default" }, + TimeZone = "UTC", + MaxAttempts = 5, + Timeout = "00:02:00")] +``` + +Explicit `[Job]` settings override assembly defaults. Omitted, null, or empty job tag lists inherit +assembly tags; without assembly tags, jobs use `default`. An explicit nonempty list replaces the +assembly list. Setting `Timeout = null` on a job disables its inherited timeout, and +`TimeZone = null` uses UTC. Defaults apply only to jobs declared in that assembly. + 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. diff --git a/src/Common/AttributeDataExtensions.cs b/src/Common/AttributeDataExtensions.cs index 8f084364..3858b4e4 100644 --- a/src/Common/AttributeDataExtensions.cs +++ b/src/Common/AttributeDataExtensions.cs @@ -22,6 +22,25 @@ internal static class AttributeDataExtensions extension(AttributeData attribute) { + public ImmutableArray> GetJobArguments(INamedTypeSymbol job) + { + var defaults = job.ContainingAssembly.GetAttributes().FirstOrDefault(attribute => attribute.AttributeClass.IsJobDefaultsAttribute); + if (defaults is null) + return attribute.NamedArguments; + + var arguments = defaults.NamedArguments.ToDictionary(static argument => argument.Key, static argument => argument.Value, StringComparer.Ordinal); + foreach (var argument in attribute.NamedArguments) + { + if (string.Equals(argument.Key, "Tags", StringComparison.Ordinal) + && (argument.Value.IsNull || argument.Value is { Kind: TypedConstantKind.Array, Values.Length: 0 })) + continue; + + arguments[argument.Key] = argument.Value; + } + + return [.. arguments]; + } + public string GetJobName(string className) => attribute.NamedArguments.GetStringValue("Name") ?? className.AsJobName(); 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/Common/TypedConstantExtensions.cs b/src/Common/TypedConstantExtensions.cs index 2f51b31e..58bb1db3 100644 --- a/src/Common/TypedConstantExtensions.cs +++ b/src/Common/TypedConstantExtensions.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.Globalization; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -8,6 +9,38 @@ internal static class TypedConstantExtensions { extension(ImmutableArray> arguments) { + public IEnumerable GetJobDefaultsErrors() + { + 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) + { + try + { + TimeZoneInfo.FindSystemTimeZoneById(timeZone); + } + catch (TimeZoneNotFoundException) + { + timeZone = string.Empty; + } + catch (InvalidTimeZoneException) + { + timeZone = string.Empty; + } + + if (timeZone.Length == 0) + yield return "TimeZone must identify a valid time zone."; + } + } + public TypedConstant? GetArgumentValue(string name) { foreach (var argument in arguments) 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..ea2c0d28 100644 --- a/src/Immediate.Jobs.Analyzers/JobClassAnalyzer.cs +++ b/src/Immediate.Jobs.Analyzers/JobClassAnalyzer.cs @@ -154,7 +154,7 @@ private static void AnalyzeJobConfiguration(SymbolAnalysisContext context, Attri { context.CancellationToken.ThrowIfCancellationRequested(); - var arguments = jobAttribute.NamedArguments; + var arguments = jobAttribute.GetJobArguments((INamedTypeSymbol)context.Symbol); if (arguments.GetArgumentValue("Tags").GetTags() is null) { @@ -275,7 +275,7 @@ private static void AnalyzeCronConfiguration(SymbolAnalysisContext context, Attr context.CancellationToken.ThrowIfCancellationRequested(); var hasPayload = !parameterType.IsEmptyJobRequest; - var arguments = jobAttribute.NamedArguments; + var arguments = jobAttribute.GetJobArguments((INamedTypeSymbol)context.Symbol); var cron = arguments.GetStringValue("Cron"); var timeZone = arguments.GetStringValue("TimeZone") ?? "UTC"; diff --git a/src/Immediate.Jobs.Analyzers/JobDefaultsAnalyzer.cs b/src/Immediate.Jobs.Analyzers/JobDefaultsAnalyzer.cs new file mode 100644 index 00000000..11f0358e --- /dev/null +++ b/src/Immediate.Jobs.Analyzers/JobDefaultsAnalyzer.cs @@ -0,0 +1,38 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +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, WellKnownDiagnosticTags.CompilationEnd]); + + public override ImmutableArray SupportedDiagnostics => [InvalidJobDefaults]; + + public override void Initialize(AnalysisContext context) + { + ArgumentNullException.ThrowIfNull(context); + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationAction(AnalyzeDefaults); + } + + private static void AnalyzeDefaults(CompilationAnalysisContext context) + { + foreach (var attribute in context.Compilation.Assembly.GetAttributes().Where(attribute => attribute.AttributeClass.IsJobDefaultsAttribute)) + { + foreach (var error in attribute.NamedArguments.GetJobDefaultsErrors()) + context.ReportDiagnostic(Diagnostic.Create(InvalidJobDefaults, attribute.Location, error)); + } + } +} diff --git a/src/Immediate.Jobs.Generators/ImmediateJobsGenerator.Transform.cs b/src/Immediate.Jobs.Generators/ImmediateJobsGenerator.Transform.cs index b4688bb8..a5f047c5 100644 --- a/src/Immediate.Jobs.Generators/ImmediateJobsGenerator.Transform.cs +++ b/src/Immediate.Jobs.Generators/ImmediateJobsGenerator.Transform.cs @@ -34,7 +34,11 @@ CancellationToken cancellationToken if (!jobName.HasNameContent() || jobName.HasLeadingOrTrailingWhitespace()) return null; - var arguments = attribute.NamedArguments; + var defaults = symbol.ContainingAssembly.GetAttributes().FirstOrDefault(attribute => attribute.AttributeClass.IsJobDefaultsAttribute); + if (defaults is not null && defaults.NamedArguments.GetJobDefaultsErrors().Any()) + return null; + + var arguments = attribute.GetJobArguments(symbol); var cron = arguments.GetStringValue("Cron"); var timeZone = arguments.GetStringValue("TimeZone") ?? "UTC"; diff --git a/src/Immediate.Jobs.Shared/JobAttribute.cs b/src/Immediate.Jobs.Shared/JobAttribute.cs index d9bad74a..7d4303e3 100644 --- a/src/Immediate.Jobs.Shared/JobAttribute.cs +++ b/src/Immediate.Jobs.Shared/JobAttribute.cs @@ -9,7 +9,7 @@ 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. Omitted, null, or empty lists inherit assembly defaults, or use the default tag. /// [System.Diagnostics.CodeAnalysis.NotNull] public string[]? Tags { get; init => field = [.. JobTags.Normalize(value)]; } = [JobTags.Default]; diff --git a/src/Immediate.Jobs.Shared/JobDefaultsAttribute.cs b/src/Immediate.Jobs.Shared/JobDefaultsAttribute.cs new file mode 100644 index 00000000..0a4a5029 --- /dev/null +++ b/src/Immediate.Jobs.Shared/JobDefaultsAttribute.cs @@ -0,0 +1,31 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Immediate.Jobs.Shared; + +/// +/// Supplies defaults for generated jobs declared in this assembly. Explicit job settings override these values. +/// +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)] +public sealed class JobDefaultsAttribute : Attribute +{ + /// + /// The tags inherited by jobs that omit a tag list or supply a null or empty list. + /// + [NotNull] + public string[]? Tags { get; init => field = [.. JobTags.Normalize(value)]; } = [JobTags.Default]; + + /// + /// 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; } = 3; + + /// + /// 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..dc53e419 --- /dev/null +++ b/tests/Immediate.Jobs.Tests/AnalyzerTests/JobDefaultsAnalyzerTests.cs @@ -0,0 +1,35 @@ +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 = \"missing-zone\"")] + [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 = new[] { \"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/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/JobDefaultsTests.cs b/tests/Immediate.Jobs.Tests/GeneratorTests/JobDefaultsTests.cs new file mode 100644 index 00000000..df1abc94 --- /dev/null +++ b/tests/Immediate.Jobs.Tests/GeneratorTests/JobDefaultsTests.cs @@ -0,0 +1,42 @@ +using System.Globalization; + +namespace Immediate.Jobs.Tests.GeneratorTests; + +public sealed class JobDefaultsTests +{ + [Theory] + [InlineData("", "\"assembly\"", "America/New_York", 5, "00:02:00")] + [InlineData("Tags = new string[0]", "\"assembly\"", "America/New_York", 5, "00:02:00")] + [InlineData("Tags = null", "\"assembly\"", "America/New_York", 5, "00:02:00")] + [InlineData("Tags = new[] { \"job\" }, TimeZone = \"UTC\", MaxAttempts = 2, Timeout = \"00:00:30\"", "\"job\"", "UTC", 2, "00:00:30")] + [InlineData("TimeZone = null, Timeout = null", "\"assembly\"", "UTC", 5, null)] + public void JobSettingsOverrideAssemblyDefaults(string settings, string tags, string timeZone, int attempts, string? timeout) + { + 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({{settings}})] + public sealed partial class WorkJob + { + private static ValueTask HandleAsync(EmptyJobRequest request, CancellationToken cancellationToken) => ValueTask.CompletedTask; + } + """ + ); + + var generated = result.GeneratedTrees.Single(tree => tree.FilePath.EndsWith("IJ..WorkJob.g.cs", StringComparison.Ordinal)).ToString(); + Assert.Contains($"Tags = [{tags}]", generated, StringComparison.Ordinal); + Assert.Contains($"TimeZone = \"{timeZone}\"", generated, StringComparison.Ordinal); + Assert.Contains($"MaxAttempts = {attempts.ToString(CultureInfo.InvariantCulture)}", generated, StringComparison.Ordinal); + + if (timeout is null) + Assert.DoesNotContain("Timeout =", generated, StringComparison.Ordinal); + else + Assert.Contains($"TimeSpan.Parse(\"{timeout}\"", generated, StringComparison.Ordinal); + } +} From 2b9f2318a7e0748af24fa206677b2bebf9e26c65 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 12 Oct 2026 20:25:28 +0000 Subject: [PATCH 2/5] Support assembly defaults on the older Roslyn targets --- src/Common/AttributeDataExtensions.cs | 2 +- src/Immediate.Jobs.Analyzers/JobDefaultsAnalyzer.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Common/AttributeDataExtensions.cs b/src/Common/AttributeDataExtensions.cs index 3858b4e4..efa9cdaa 100644 --- a/src/Common/AttributeDataExtensions.cs +++ b/src/Common/AttributeDataExtensions.cs @@ -38,7 +38,7 @@ public ImmutableArray> GetJobArguments(IName arguments[argument.Key] = argument.Value; } - return [.. arguments]; + return arguments.ToImmutableArray(); } public string GetJobName(string className) => diff --git a/src/Immediate.Jobs.Analyzers/JobDefaultsAnalyzer.cs b/src/Immediate.Jobs.Analyzers/JobDefaultsAnalyzer.cs index 11f0358e..0e132bf9 100644 --- a/src/Immediate.Jobs.Analyzers/JobDefaultsAnalyzer.cs +++ b/src/Immediate.Jobs.Analyzers/JobDefaultsAnalyzer.cs @@ -16,7 +16,7 @@ public sealed class JobDefaultsAnalyzer : DiagnosticAnalyzer isEnabledByDefault: true, customTags: [WellKnownDiagnosticTags.NotConfigurable, WellKnownDiagnosticTags.CompilationEnd]); - public override ImmutableArray SupportedDiagnostics => [InvalidJobDefaults]; + public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create([InvalidJobDefaults]); public override void Initialize(AnalysisContext context) { From fd64783c6518c20ef48c8a859b7672479b6386f0 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 12 Oct 2026 21:19:24 +0000 Subject: [PATCH 3/5] Reuse tag parsing and analyze job defaults on attributes --- src/Common/AttributeDataExtensions.cs | 2 +- src/Common/TypedConstantExtensions.cs | 6 ++-- .../JobDefaultsAnalyzer.cs | 28 +++++++++++++------ .../GeneratorTests/JobDefaultsTests.cs | 1 + 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/Common/AttributeDataExtensions.cs b/src/Common/AttributeDataExtensions.cs index efa9cdaa..8cfe20b5 100644 --- a/src/Common/AttributeDataExtensions.cs +++ b/src/Common/AttributeDataExtensions.cs @@ -32,7 +32,7 @@ public ImmutableArray> GetJobArguments(IName foreach (var argument in attribute.NamedArguments) { if (string.Equals(argument.Key, "Tags", StringComparison.Ordinal) - && (argument.Value.IsNull || argument.Value is { Kind: TypedConstantKind.Array, Values.Length: 0 })) + && ((TypedConstant?)argument.Value).GetTags(defaultTags: string.Empty) is "") continue; arguments[argument.Key] = argument.Value; diff --git a/src/Common/TypedConstantExtensions.cs b/src/Common/TypedConstantExtensions.cs index 58bb1db3..e4024de3 100644 --- a/src/Common/TypedConstantExtensions.cs +++ b/src/Common/TypedConstantExtensions.cs @@ -135,13 +135,13 @@ public int GetIntValue(string name, int defaultValue) => extension(TypedConstant? constant) { - public string? GetTags() + public string? GetTags(string defaultTags = "\"default\"") { if (constant is not { IsNull: false, Kind: TypedConstantKind.Array, Values: var values } av) - return "\"default\""; + return defaultTags; if (values is []) - return "\"default\""; + return defaultTags; if (values.Any(tag => string.IsNullOrWhiteSpace(tag.Value as string))) return null; diff --git a/src/Immediate.Jobs.Analyzers/JobDefaultsAnalyzer.cs b/src/Immediate.Jobs.Analyzers/JobDefaultsAnalyzer.cs index 0e132bf9..931eab19 100644 --- a/src/Immediate.Jobs.Analyzers/JobDefaultsAnalyzer.cs +++ b/src/Immediate.Jobs.Analyzers/JobDefaultsAnalyzer.cs @@ -1,6 +1,7 @@ using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; namespace Immediate.Jobs.Analyzers; @@ -14,7 +15,7 @@ public sealed class JobDefaultsAnalyzer : DiagnosticAnalyzer category: "ImmediateJobs", defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true, - customTags: [WellKnownDiagnosticTags.NotConfigurable, WellKnownDiagnosticTags.CompilationEnd]); + customTags: [WellKnownDiagnosticTags.NotConfigurable]); public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create([InvalidJobDefaults]); @@ -24,15 +25,26 @@ public override void Initialize(AnalysisContext context) context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); context.EnableConcurrentExecution(); - context.RegisterCompilationAction(AnalyzeDefaults); + + context.RegisterOperationAction(AnalyzeDefaults, OperationKind.Attribute); } - private static void AnalyzeDefaults(CompilationAnalysisContext context) + private static void AnalyzeDefaults(OperationAnalysisContext context) { - foreach (var attribute in context.Compilation.Assembly.GetAttributes().Where(attribute => attribute.AttributeClass.IsJobDefaultsAttribute)) - { - foreach (var error in attribute.NamedArguments.GetJobDefaultsErrors()) - context.ReportDiagnostic(Diagnostic.Create(InvalidJobDefaults, attribute.Location, error)); - } + 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.NamedArguments.GetJobDefaultsErrors()) + context.ReportDiagnostic(Diagnostic.Create(InvalidJobDefaults, syntax.GetLocation(), error)); } } diff --git a/tests/Immediate.Jobs.Tests/GeneratorTests/JobDefaultsTests.cs b/tests/Immediate.Jobs.Tests/GeneratorTests/JobDefaultsTests.cs index df1abc94..f52414d8 100644 --- a/tests/Immediate.Jobs.Tests/GeneratorTests/JobDefaultsTests.cs +++ b/tests/Immediate.Jobs.Tests/GeneratorTests/JobDefaultsTests.cs @@ -8,6 +8,7 @@ public sealed class JobDefaultsTests [InlineData("", "\"assembly\"", "America/New_York", 5, "00:02:00")] [InlineData("Tags = new string[0]", "\"assembly\"", "America/New_York", 5, "00:02:00")] [InlineData("Tags = null", "\"assembly\"", "America/New_York", 5, "00:02:00")] + [InlineData("Tags = new[] { \"default\" }", "\"default\"", "America/New_York", 5, "00:02:00")] [InlineData("Tags = new[] { \"job\" }, TimeZone = \"UTC\", MaxAttempts = 2, Timeout = \"00:00:30\"", "\"job\"", "UTC", 2, "00:00:30")] [InlineData("TimeZone = null, Timeout = null", "\"assembly\"", "UTC", 5, null)] public void JobSettingsOverrideAssemblyDefaults(string settings, string tags, string timeZone, int attempts, string? timeout) From d0275e1727f69d6c2317b012be42e349c1b6e7a0 Mon Sep 17 00:00:00 2001 From: Stuart Turner Date: Mon, 12 Oct 2026 18:12:19 -0500 Subject: [PATCH 4/5] Better organization --- src/Common/AttributeDataExtensions.cs | 44 ++--- src/Common/TypedConstantExtensions.cs | 39 +---- .../JobClassAnalyzer.cs | 15 +- .../JobDefaultsAnalyzer.cs | 33 ++-- .../ImmediateJobsGenerator.Render.cs | 1 + .../ImmediateJobsGenerator.Transform.cs | 31 +++- .../JobDefaultsAttribute.cs | 4 +- .../AnalyzerTests/JobDefaultsAnalyzerTests.cs | 3 +- .../ImmediateJobsGeneratorTests.cs | 66 ++++++++ .../GeneratorTests/JobDefaultsTests.cs | 43 ----- ...AssemblyDefaults#IJ..WorkJob.g.verified.cs | 150 ++++++++++++++++++ ...AssemblyDefaults#IJ..WorkJob.g.verified.cs | 150 ++++++++++++++++++ 12 files changed, 452 insertions(+), 127 deletions(-) delete mode 100644 tests/Immediate.Jobs.Tests/GeneratorTests/JobDefaultsTests.cs create mode 100644 tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/ImmediateJobsGeneratorTests.JobSettingsOverrideAssemblyDefaults#IJ..WorkJob.g.verified.cs create mode 100644 tests/Immediate.Jobs.Tests/GeneratorTests/Snapshots/ImmediateJobsGeneratorTests.JobSettingsUseAssemblyDefaults#IJ..WorkJob.g.verified.cs diff --git a/src/Common/AttributeDataExtensions.cs b/src/Common/AttributeDataExtensions.cs index 8cfe20b5..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; @@ -22,25 +23,6 @@ internal static class AttributeDataExtensions extension(AttributeData attribute) { - public ImmutableArray> GetJobArguments(INamedTypeSymbol job) - { - var defaults = job.ContainingAssembly.GetAttributes().FirstOrDefault(attribute => attribute.AttributeClass.IsJobDefaultsAttribute); - if (defaults is null) - return attribute.NamedArguments; - - var arguments = defaults.NamedArguments.ToDictionary(static argument => argument.Key, static argument => argument.Value, StringComparer.Ordinal); - foreach (var argument in attribute.NamedArguments) - { - if (string.Equals(argument.Key, "Tags", StringComparison.Ordinal) - && ((TypedConstant?)argument.Value).GetTags(defaultTags: string.Empty) is "") - continue; - - arguments[argument.Key] = argument.Value; - } - - return arguments.ToImmutableArray(); - } - public string GetJobName(string className) => attribute.NamedArguments.GetStringValue("Name") ?? className.AsJobName(); @@ -48,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/TypedConstantExtensions.cs b/src/Common/TypedConstantExtensions.cs index e4024de3..2f51b31e 100644 --- a/src/Common/TypedConstantExtensions.cs +++ b/src/Common/TypedConstantExtensions.cs @@ -1,5 +1,4 @@ using System.Collections.Immutable; -using System.Globalization; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -9,38 +8,6 @@ internal static class TypedConstantExtensions { extension(ImmutableArray> arguments) { - public IEnumerable GetJobDefaultsErrors() - { - 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) - { - try - { - TimeZoneInfo.FindSystemTimeZoneById(timeZone); - } - catch (TimeZoneNotFoundException) - { - timeZone = string.Empty; - } - catch (InvalidTimeZoneException) - { - timeZone = string.Empty; - } - - if (timeZone.Length == 0) - yield return "TimeZone must identify a valid time zone."; - } - } - public TypedConstant? GetArgumentValue(string name) { foreach (var argument in arguments) @@ -135,13 +102,13 @@ public int GetIntValue(string name, int defaultValue) => extension(TypedConstant? constant) { - public string? GetTags(string defaultTags = "\"default\"") + public string? GetTags() { if (constant is not { IsNull: false, Kind: TypedConstantKind.Array, Values: var values } av) - return defaultTags; + return "\"default\""; if (values is []) - return defaultTags; + return "\"default\""; if (values.Any(tag => string.IsNullOrWhiteSpace(tag.Value as string))) return null; diff --git a/src/Immediate.Jobs.Analyzers/JobClassAnalyzer.cs b/src/Immediate.Jobs.Analyzers/JobClassAnalyzer.cs index ea2c0d28..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", + } ) ); } @@ -154,7 +155,7 @@ private static void AnalyzeJobConfiguration(SymbolAnalysisContext context, Attri { context.CancellationToken.ThrowIfCancellationRequested(); - var arguments = jobAttribute.GetJobArguments((INamedTypeSymbol)context.Symbol); + var arguments = jobAttribute.NamedArguments; if (arguments.GetArgumentValue("Tags").GetTags() is null) { @@ -275,7 +276,7 @@ private static void AnalyzeCronConfiguration(SymbolAnalysisContext context, Attr context.CancellationToken.ThrowIfCancellationRequested(); var hasPayload = !parameterType.IsEmptyJobRequest; - var arguments = jobAttribute.GetJobArguments((INamedTypeSymbol)context.Symbol); + var arguments = jobAttribute.NamedArguments; var cron = arguments.GetStringValue("Cron"); var timeZone = arguments.GetStringValue("TimeZone") ?? "UTC"; diff --git a/src/Immediate.Jobs.Analyzers/JobDefaultsAnalyzer.cs b/src/Immediate.Jobs.Analyzers/JobDefaultsAnalyzer.cs index 931eab19..95e9cebf 100644 --- a/src/Immediate.Jobs.Analyzers/JobDefaultsAnalyzer.cs +++ b/src/Immediate.Jobs.Analyzers/JobDefaultsAnalyzer.cs @@ -8,14 +8,16 @@ 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 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]); @@ -37,14 +39,19 @@ private static void AnalyzeDefaults(OperationAnalysisContext context) 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); + + 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.NamedArguments.GetJobDefaultsErrors()) + 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 a5f047c5..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,11 +38,7 @@ CancellationToken cancellationToken if (!jobName.HasNameContent() || jobName.HasLeadingOrTrailingWhitespace()) return null; - var defaults = symbol.ContainingAssembly.GetAttributes().FirstOrDefault(attribute => attribute.AttributeClass.IsJobDefaultsAttribute); - if (defaults is not null && defaults.NamedArguments.GetJobDefaultsErrors().Any()) - return null; - - var arguments = attribute.GetJobArguments(symbol); + var arguments = attribute.GetJobArguments(defaults); var cron = arguments.GetStringValue("Cron"); var timeZone = arguments.GetStringValue("TimeZone") ?? "UTC"; @@ -215,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/JobDefaultsAttribute.cs b/src/Immediate.Jobs.Shared/JobDefaultsAttribute.cs index 0a4a5029..a72e72f3 100644 --- a/src/Immediate.Jobs.Shared/JobDefaultsAttribute.cs +++ b/src/Immediate.Jobs.Shared/JobDefaultsAttribute.cs @@ -12,7 +12,7 @@ public sealed class JobDefaultsAttribute : Attribute /// The tags inherited by jobs that omit a tag list or supply a null or empty list. /// [NotNull] - public string[]? Tags { get; init => field = [.. JobTags.Normalize(value)]; } = [JobTags.Default]; + public string[]? Tags { get; init => field = [.. JobTags.Normalize(value)]; } /// /// The default time-zone identifier. A null value uses UTC. @@ -22,7 +22,7 @@ public sealed class JobDefaultsAttribute : Attribute /// /// The default total execution attempts, including the first attempt. /// - public int MaxAttempts { get; init; } = 3; + public int MaxAttempts { get; init; } /// /// The default execution timeout as a TimeSpan string, or null for no timeout. diff --git a/tests/Immediate.Jobs.Tests/AnalyzerTests/JobDefaultsAnalyzerTests.cs b/tests/Immediate.Jobs.Tests/AnalyzerTests/JobDefaultsAnalyzerTests.cs index dc53e419..5b4c81de 100644 --- a/tests/Immediate.Jobs.Tests/AnalyzerTests/JobDefaultsAnalyzerTests.cs +++ b/tests/Immediate.Jobs.Tests/AnalyzerTests/JobDefaultsAnalyzerTests.cs @@ -10,7 +10,6 @@ public sealed class JobDefaultsAnalyzerTests [InlineData("MaxAttempts = 0")] [InlineData("Timeout = \"invalid\"")] [InlineData("Timeout = \"00:00:00\"")] - [InlineData("TimeZone = \"missing-zone\"")] [InlineData("TimeZone = \"\"")] public async Task InvalidDefaultsAreRejectedWithoutAnyJobs(string settings) => await AnalyzerTestHelpers.CreateAnalyzerTest( @@ -24,7 +23,7 @@ await AnalyzerTestHelpers.CreateAnalyzerTest( [InlineData("")] [InlineData("Tags = null, Timeout = null, TimeZone = null")] [InlineData("Tags = new string[0]")] - [InlineData("Tags = new[] { \"email\", \"EMAIL\" }, TimeZone = \"UTC\", MaxAttempts = 1, Timeout = \"00:00:10\"")] + [InlineData("Tags = [\"email\", \"EMAIL\"], TimeZone = \"UTC\", MaxAttempts = 1, Timeout = \"00:00:10\"")] public async Task ValidDefaultsAreAccepted(string settings) => await AnalyzerTestHelpers.CreateAnalyzerTest( $$""" 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/JobDefaultsTests.cs b/tests/Immediate.Jobs.Tests/GeneratorTests/JobDefaultsTests.cs deleted file mode 100644 index f52414d8..00000000 --- a/tests/Immediate.Jobs.Tests/GeneratorTests/JobDefaultsTests.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Globalization; - -namespace Immediate.Jobs.Tests.GeneratorTests; - -public sealed class JobDefaultsTests -{ - [Theory] - [InlineData("", "\"assembly\"", "America/New_York", 5, "00:02:00")] - [InlineData("Tags = new string[0]", "\"assembly\"", "America/New_York", 5, "00:02:00")] - [InlineData("Tags = null", "\"assembly\"", "America/New_York", 5, "00:02:00")] - [InlineData("Tags = new[] { \"default\" }", "\"default\"", "America/New_York", 5, "00:02:00")] - [InlineData("Tags = new[] { \"job\" }, TimeZone = \"UTC\", MaxAttempts = 2, Timeout = \"00:00:30\"", "\"job\"", "UTC", 2, "00:00:30")] - [InlineData("TimeZone = null, Timeout = null", "\"assembly\"", "UTC", 5, null)] - public void JobSettingsOverrideAssemblyDefaults(string settings, string tags, string timeZone, int attempts, string? timeout) - { - 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({{settings}})] - public sealed partial class WorkJob - { - private static ValueTask HandleAsync(EmptyJobRequest request, CancellationToken cancellationToken) => ValueTask.CompletedTask; - } - """ - ); - - var generated = result.GeneratedTrees.Single(tree => tree.FilePath.EndsWith("IJ..WorkJob.g.cs", StringComparison.Ordinal)).ToString(); - Assert.Contains($"Tags = [{tags}]", generated, StringComparison.Ordinal); - Assert.Contains($"TimeZone = \"{timeZone}\"", generated, StringComparison.Ordinal); - Assert.Contains($"MaxAttempts = {attempts.ToString(CultureInfo.InvariantCulture)}", generated, StringComparison.Ordinal); - - if (timeout is null) - Assert.DoesNotContain("Timeout =", generated, StringComparison.Ordinal); - else - Assert.Contains($"TimeSpan.Parse(\"{timeout}\"", generated, StringComparison.Ordinal); - } -} 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; + } +} From 939c7d744717a9cba21d840824da8c20a528569b Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 12 Oct 2026 23:22:47 +0000 Subject: [PATCH 5/5] Document explicit job overrides and assembly default precedence --- docs/architecture.md | 11 +++++++ docs/job-lifecycle.md | 7 ++++- docs/recurring-jobs.md | 3 +- readme.md | 31 +++++++++++++++---- src/Immediate.Jobs.Shared/JobAttribute.cs | 15 +++++++-- .../JobDefaultsAttribute.cs | 8 ++++- 6 files changed, 63 insertions(+), 12 deletions(-) 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 4516feb5..7b515c64 100644 --- a/readme.md +++ b/readme.md @@ -52,7 +52,7 @@ 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()`. -## Routing jobs with tags +## Assembly defaults Set defaults for jobs in an assembly with: @@ -64,16 +64,35 @@ Set defaults for jobs in an assembly with: Timeout = "00:02:00")] ``` -Explicit `[Job]` settings override assembly defaults. Omitted, null, or empty job tag lists inherit -assembly tags; without assembly tags, jobs use `default`. An explicit nonempty list replaces the -assembly list. Setting `Timeout = null` on a job disables its inherited timeout, and -`TimeZone = null` uses UTC. Defaults apply only to jobs declared in that assembly. +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/Immediate.Jobs.Shared/JobAttribute.cs b/src/Immediate.Jobs.Shared/JobAttribute.cs index 7d4303e3..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 inherit assembly defaults, or 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 index a72e72f3..d582ba35 100644 --- a/src/Immediate.Jobs.Shared/JobDefaultsAttribute.cs +++ b/src/Immediate.Jobs.Shared/JobDefaultsAttribute.cs @@ -5,12 +5,18 @@ 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 tags inherited by jobs that omit a tag list or supply a null or empty list. + /// 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)]; }