Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, …).
Expand Down
7 changes: 6 additions & 1 deletion docs/job-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/recurring-jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TPayload>.AddOrUpdateRecurringCoreAsync` / `RemoveRecurringCoreAsync`.
Expand Down
36 changes: 35 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 25 additions & 0 deletions src/Common/AttributeDataExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Immutable;
using System.Globalization;
using Microsoft.CodeAnalysis;

namespace Immediate.Jobs;
Expand Down Expand Up @@ -29,5 +30,29 @@ public string GetQueueName(string className) =>
attribute.NamedArguments.GetStringValue("Name") ?? className.AsQueueName();

public Location? Location => attribute.ApplicationSyntaxReference?.GetSyntax().GetLocation();

public IEnumerable<string> 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.";
}
}

}
}
8 changes: 8 additions & 0 deletions src/Common/ITypeSymbolExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
1 change: 1 addition & 0 deletions src/Immediate.Jobs.Analyzers/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ IJOB0013 | ImmediateJobs | Error | PayloadAnalyzer
IJOB0014 | ImmediateJobs | Error | PayloadAnalyzer
IJOB0015 | ImmediateJobs | Warning | DetachedJobCannotBeAddedToBatch
IJOB0016 | ImmediateJobs | Warning | DuplicateElementsAnalyzer
IJOB0017 | ImmediateJobs | Error | JobDefaultsAnalyzer
1 change: 1 addition & 0 deletions src/Immediate.Jobs.Analyzers/DiagnosticIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
11 changes: 6 additions & 5 deletions src/Immediate.Jobs.Analyzers/JobClassAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
)
);
}
Expand Down
57 changes: 57 additions & 0 deletions src/Immediate.Jobs.Analyzers/JobDefaultsAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -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<DiagnosticDescriptor> 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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ private static void RenderJob(SourceProductionContext context, JobModel job, Tem
{
var cancellationToken = context.CancellationToken;
cancellationToken.ThrowIfCancellationRequested();

var model = new
{
job.Namespace,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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("<global namespace>");

var parameterType = handleMethod.Parameters[0].Type;
Expand All @@ -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";
Expand Down Expand Up @@ -211,4 +215,25 @@ file static class Extensions
}
}
}

extension(AttributeData attribute)
{
public ImmutableArray<KeyValuePair<string, TypedConstant>> 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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

return arguments.ToImmutableArray();
}
}
}
15 changes: 12 additions & 3 deletions src/Immediate.Jobs.Shared/JobAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ namespace Immediate.Jobs.Shared;
public sealed class JobAttribute : Attribute
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// An explicit null or empty list uses the default tag, overriding any assembly tags.
/// </remarks>
[System.Diagnostics.CodeAnalysis.NotNull]
public string[]? Tags { get; init => field = [.. JobTags.Normalize(value)]; } = [JobTags.Default];

Expand All @@ -34,16 +37,19 @@ public sealed class JobAttribute : Attribute
public string? Cron { get; init; }

/// <summary>
/// An IANA time-zone identifier. Defaults to UTC.
/// A time-zone identifier. When omitted, inherits the assembly default, or uses UTC.
/// </summary>
/// <value>
/// The IANA time-zone identifier, or <see langword="null"/> to use UTC.
/// The time-zone identifier, or <see langword="null"/> to use UTC.
/// </value>
public string? TimeZone { get; init; }

/// <summary>
/// Total execution attempts, including the first attempt.
/// </summary>
/// <remarks>
/// When omitted, inherits the assembly default, or allows three total attempts.
/// </remarks>
/// <value>
/// The total number of permitted execution attempts.
/// </value>
Expand All @@ -52,6 +58,9 @@ public sealed class JobAttribute : Attribute
/// <summary>
/// A <see cref="TimeSpan"/> formatted execution timeout.
/// </summary>
/// <remarks>
/// When omitted, inherits the assembly default. An explicit null disables the inherited timeout.
/// </remarks>
/// <value>
/// The formatted execution timeout, or <see langword="null"/> for no timeout.
/// </value>
Expand Down
37 changes: 37 additions & 0 deletions src/Immediate.Jobs.Shared/JobDefaultsAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System.Diagnostics.CodeAnalysis;

namespace Immediate.Jobs.Shared;

/// <summary>
/// Supplies defaults for generated jobs declared in this assembly. Explicit job settings override these values.
/// </summary>
/// <remarks>
/// Only explicitly supplied settings become assembly defaults. Unspecified settings use the built-in job defaults.
/// </remarks>
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)]
public sealed class JobDefaultsAttribute : Attribute
{
/// <summary>
/// The routing tags inherited by jobs that omit a tag list.
/// </summary>
/// <remarks>
/// Null or empty lists use the default tag. Explicit job tag lists, including null or empty lists, override these tags.
/// </remarks>
[NotNull]
public string[]? Tags { get; init => field = [.. JobTags.Normalize(value)]; }

/// <summary>
/// The default time-zone identifier. A null value uses UTC.
/// </summary>
public string? TimeZone { get; init; }

/// <summary>
/// The default total execution attempts, including the first attempt.
/// </summary>
public int MaxAttempts { get; init; }

/// <summary>
/// The default execution timeout as a TimeSpan string, or null for no timeout.
/// </summary>
public string? Timeout { get; init; }
}
Loading
Loading