diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index bde9deec..e9fa6fbd 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -2,9 +2,9 @@ name: .NET on: push: - branches: [ main ] + branches: [ main, v4 ] pull_request: - branches: [ main ] + branches: [ main, v4 ] env: VSTEST_CONNECTION_TIMEOUT: 180 diff --git a/CHANGELOG.md b/CHANGELOG.md index e126fd97..60f89eb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ All notable changes to **NCronJob** will be documented in this file. The project ## [Unreleased] +New `v4` release with some new features and improvements. Check the [`v4` migration guide](https://docs.ncronjob.dev/migration/v4/) for more information. + +### Added + +- Optionally make startup jobs run early + +### Changed + +- `IRuntimeRegistry.AddJob` is now called `TryRegister` to better reflect its behavior. +- Explicit handling of duplicates +- Make `UseNCronJobAsync` mandatory when startup jobs have been defined + ## [v3.3.8] - 2024-11-16 ### Changed diff --git a/README.md b/README.md index 9da554b2..c5716228 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,10 @@ builder.Services.AddNCronJob((ILoggerFactory factory, TimeProvider timeProvider) var logger = factory.CreateLogger("My Anonymous Job"); logger.LogInformation("Hello World - The current date and time is {Time}", timeProvider.GetLocalNow()); }, "*/5 * * * * *"); + +var app = builder.Build(); +await app.UseNCronJobAsync(); +app.Run(); ``` With this simple lambda, you can define a job that runs every 5 seconds. Pass in all dependencies, just like you would with a Minimal API. @@ -155,6 +159,11 @@ builder.Services.AddNCronJob(options => options.AddJob() .RunAtStartup(); }); + +var app = builder.Build(); +// Here the startup jobs will be executed +await app.UseNCronJobAsync(); +app.Run(); ``` In this example, the job of type 'MyJob' will be executed as soon as the application starts. This is diff --git a/docs/advanced/dynamic-job-control.md b/docs/advanced/dynamic-job-control.md index 57c80270..32f4ebbf 100644 --- a/docs/advanced/dynamic-job-control.md +++ b/docs/advanced/dynamic-job-control.md @@ -27,7 +27,13 @@ To add a job at runtime, leverage the `IRuntimeJobRegistry` interface: ```csharp app.MapPost("/add-job", (IRuntimeJobRegistry registry) => { - registry.AddJob(n => n.AddJob(p => p.WithCronExpression("* * * * *").WithName("MyName"))); + var hasSucceeded = registry.TryRegister(n => n.AddJob(p => p.WithCronExpression("* * * * *").WithName("MyName")), out Exception? exc); + + if (!hasSucceeded) + { + return TypedResults.Error(exc?.Message); + } + return TypedResults.Ok(); }); ``` diff --git a/docs/features/minimal-api.md b/docs/features/minimal-api.md index 0a4b808f..d7cb5803 100644 --- a/docs/features/minimal-api.md +++ b/docs/features/minimal-api.md @@ -77,6 +77,10 @@ In the same way, the concurrency level can be controlled (see [**Concurrency**]( ```csharp builder.Services.AddNCronJob([SupportsConcurrency(2)] () => { }, "0 * * * *"); + +var app = builder.Build(); +await app.UseNCronJobAsync(); +app.Run(); ``` Now, the job can only be executed by two instances at the same time. diff --git a/docs/features/startup-jobs.md b/docs/features/startup-jobs.md index 3ff54137..e11ed3c4 100644 --- a/docs/features/startup-jobs.md +++ b/docs/features/startup-jobs.md @@ -19,20 +19,28 @@ public class MyStartupJob : IJob As with CRON jobs, they must be registered in the `AddNCronJob` method. ```csharp -Services.AddNCronJob(options => +builder.Services.AddNCronJob(options => { options.AddJob() .RunAtStartup(); // Configure the job to run at startup }); + +var app = builder.Build(); +// Execute all startup jobs +await app.UseNCronJobAsync(); +app.Run(); ``` -The `RunAtStartup` method ensures that the job is executed as soon as the application starts. This method is useful for scenarios where certain tasks need to be performed immediately upon application launch. +The `RunAtStartup` in combination with `UseNCronJobAsync` method ensures that the job is executed as soon as the application starts. This method is useful for scenarios where certain tasks need to be performed immediately upon application launch. + +Failure to call `UseNCronJobAsync` when startup jobs are defined will lead to a fatal exception during the application start. ## Example Use Case Consider an application that needs to load initial data from a database or perform some cleanup tasks whenever it starts. You can define and configure a startup job to handle this: ### Job Definition + ```csharp public class InitialDataLoader : IJob { @@ -67,6 +75,6 @@ This setup ensures that the `InitialDataLoader` job will be executed as soon as ## Summary -Startup jobs are a powerful feature of **NCronJob** that enable you to execute critical tasks immediately upon application startup. By using the `RunAtStartup` method, you can ensure that your application performs necessary setup procedures, data loading, or cleanup tasks right at the beginning of its lifecycle. +Startup jobs are a powerful feature of **NCronJob** that enable you to execute critical tasks immediately before application startup. By using the `RunAtStartup` method, you can ensure that your application performs necessary setup procedures, data loading, or cleanup tasks right at the beginning of its lifecycle. This feature is particularly useful for applications that require certain operations to be completed before they are fully functional. By configuring startup jobs, you can streamline your application's initialization process and improve its overall reliability and performance. diff --git a/docs/getting-started.md b/docs/getting-started.md index f5750902..972d8efd 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -41,10 +41,10 @@ public class PrintHelloWorld : IJob ``` ## 3. Register the service and the job -The **NCronJob** library provides one easy entry point for all its magic, the `AddNCronJob` extension method on top of the `IServiceCollection` interface. +The **NCronJob** library provides one easy entry point for all its magic, the `AddNCronJob` extension method on top of the `IServiceCollection` interface. Additionally call the `UseNCronJobAsync` method. ```csharp -Services.AddNCronJob(options => +builder.Services.AddNCronJob(options => { options.AddJob(j => { @@ -53,6 +53,10 @@ Services.AddNCronJob(options => .WithParameter("Hello World"); })); }); + +var app = builder.Build(); +await app.UseNCronJobAsync(); +app.Run(); ``` Now your `PrintHelloWorld` job will run every minute and log "Hello World" to the console. And that is all! diff --git a/docs/migration/v4.md b/docs/migration/v4.md new file mode 100644 index 00000000..6bdcaddc --- /dev/null +++ b/docs/migration/v4.md @@ -0,0 +1,79 @@ +# v4 Migration Guide + +This document describes the changes made in `v4` of **NCronJob** and how to migrate from `v3`. + +Version 4 of **NCronJob** introduces some breaking changes to improve the API. + +## `UseNCronJob` and `UseNCronJobAsync` methods +Following the classical ASP.NET pattern, the `UseNCronJob` and `UseNCronJobAsync` methods have been introduced to the `IHost` interface. The minimal setup now looks like this: + +```csharp +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddNCronJob(...); + +var app = builder.Build(); +await app.UseNCronJobAsync(); +app.Run(); +``` + +Instead of the async method `UseNCronJobAsync`, you can also use the synchronous `UseNCronJob` method. The difference is that the async method returns a `Task` that you can await. + +## `RunAtStartup` method +The `RunAtStartup` method has a bit different semantics than before. All startup jobs are now executed when `UseNCronJob` or `UseNCronJobAsync` is called. This ensures that all startup jobs are completed before any other part of the application is executed. + +## `AddNCronJob(Delegate)` move to `NCronJobExtensions` +The `AddNCronJob(Delegate)` (Minimal API) was a different static class than the `AddNCronJob(Action)` version. This has been fixed by moving the `AddNCronJob(Delegate)` method to the `NCronJobExtensions` class. As most developers are not invoking those extensions via the static class, this should not affect most users. Otherwise the migration is simple: + +```diff +- IServiceCollection.AddNCronJob(() => {}, "* * * * *"); ++ NCronJobExtensions.AddNCronJob(services, () => {}, "* * * * *"); +``` + +## `IRuntimeRegistry` is more restrictive +In `v3` the `IRuntimeRegistry` offered the ability to use the whole `NCronJobOptionBuilder` which led to confusion, especially if done something like this: + +```csharp +runtimeRegistry.AddJob(n => n.AddJob(...).RunAtStartup().AddExceptionHandler()); +``` + +It didn't make sense to add a startup job during runtime. Also adding exception handlers during runtime was out of scope for this feature. Therefore the interface is more restrictive now and only allows to add jobs. + +## `IRuntimeRegistry`s `AddJob` is now called `TryRegister` +The `AddJob` method of the `IRuntimeRegistry` has been renamed to `Register` to better reflect its purpose and to avoid the convoluted naming. + +```diff +- runtimeRegistry.AddJob(r => r.AddJob); ++ runtimeRegistry.TryRegister(r => r.AddJob); +``` + +Not only that there is another overload: +```csharp ++ runtimeRegistry.TryRegister(r => r.AddJob, out Exception? exc); +``` + +The new `TryRegister` method returns a boolean indicating if the registration was successful and an exception if the registration failed. This can happen if the same configuration of a job is already configured (like same job type, with same cron expression and parameter). + +### Chaining was removed +Additionally, the chaining of the former `Add` (now `TryRegister`) method was removed. If the first job registration was successful, but the second failed, the first job was still registered. This seemed arbitrary and was removed. + +Each chain should be its own `TryRegister` call now. + +## Registering duplicated jobs will lead to an exception during startup +Given the following job registration: + +```csharp +builder.Services.AddNCronJob(r => r.AddJob(p => p + .WithCronExpression("* * * * *") + .And + .WithCronExpression("* * * * *"))); +``` + +In `v3` and earlier the second registration would have been ignored. In `v4` this will lead to an exception. This is to prevent accidental misconfigurations. Especially because jobs, by default, are not executed in parallel without further configuration. +If you want to register the same job multiple times, you can define a custom name for the job: + +```csharp +builder.Services.AddNCronJob(r => r.AddJob(p => p + .WithCronExpression("* * * * *") + .And + .WithCronExpression("* * * * *").WithName("MyJob"))); +``` \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 88cfc18e..64c52b8c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -42,6 +42,7 @@ nav: - Model dependencies: features/model-dependencies.md - Exception handler: features/exception-handler.md - Migration: + - v4 Migration Guide: migration/v4.md - v3 Migration Guide: migration/v3.md - v2 Migration Guide: migration/v2.md - Advanced: diff --git a/sample/MinimalSample/MinimalSample.csproj b/sample/MinimalSample/MinimalSample.csproj index 7b1af2ee..493a8d34 100644 --- a/sample/MinimalSample/MinimalSample.csproj +++ b/sample/MinimalSample/MinimalSample.csproj @@ -15,7 +15,7 @@ - + diff --git a/sample/RunOnceSample/Program.cs b/sample/RunOnceSample/Program.cs index 40a5741a..85df595e 100644 --- a/sample/RunOnceSample/Program.cs +++ b/sample/RunOnceSample/Program.cs @@ -11,4 +11,6 @@ var app = builder.Build(); +await app.UseNCronJobAsync(); + await app.RunAsync(); diff --git a/src/NCronJob/Configuration/Builder/IRuntimeJobBuilder.cs b/src/NCronJob/Configuration/Builder/IRuntimeJobBuilder.cs new file mode 100644 index 00000000..d490ab33 --- /dev/null +++ b/src/NCronJob/Configuration/Builder/IRuntimeJobBuilder.cs @@ -0,0 +1,36 @@ +namespace NCronJob; + +/// +/// Represents a builder for adding jobs at runtime. +/// +public interface IRuntimeJobBuilder +{ + /// + /// Adds a job to the service collection that gets executed based on the given cron expression. + /// If a job with the same configuration is already registered, it will throw an exception. + /// + /// Configures the , like the cron expression or parameters that get passed down. + void AddJob(Action? options = null) where TJob : class, IJob; + + /// + /// Adds a job to the service collection that gets executed based on the given cron expression. + /// If a job with the same configuration is already registered, it will throw an exception. + /// + /// The type of the job to be added. + /// Configures the , like the cron expression or parameters that get passed down. + void AddJob(Type jobType, Action? options = null); + + /// + /// Adds a job using an asynchronous anonymous delegate to the service collection that gets executed based on the given cron expression. + /// + /// The delegate that represents the job to be executed. + /// The cron expression that defines when the job should be executed. + /// The time zone information that the cron expression should be evaluated against. + /// If not set the default time zone is UTC. + /// + /// Sets the job name that can be used to identify and manipulate the job later on. + void AddJob(Delegate jobDelegate, + string cronExpression, + TimeZoneInfo? timeZoneInfo = null, + string? jobName = null); +} diff --git a/src/NCronJob/Configuration/Builder/NCronJobOptionBuilder.cs b/src/NCronJob/Configuration/Builder/NCronJobOptionBuilder.cs index 3f40819d..9c3259ab 100644 --- a/src/NCronJob/Configuration/Builder/NCronJobOptionBuilder.cs +++ b/src/NCronJob/Configuration/Builder/NCronJobOptionBuilder.cs @@ -8,10 +8,10 @@ namespace NCronJob; /// /// Represents the builder for the NCronJob options. /// -public class NCronJobOptionBuilder : IJobStage +public class NCronJobOptionBuilder : IJobStage, IRuntimeJobBuilder { - private protected readonly IServiceCollection Services; - private protected readonly ConcurrencySettings Settings; + private readonly IServiceCollection services; + private readonly ConcurrencySettings settings; private readonly JobRegistry jobRegistry; internal NCronJobOptionBuilder( @@ -19,8 +19,8 @@ internal NCronJobOptionBuilder( ConcurrencySettings settings, JobRegistry jobRegistry) { - Services = services; - Settings = settings; + this.services = services; + this.settings = settings; this.jobRegistry = jobRegistry; } @@ -41,7 +41,7 @@ public IStartupStage AddJob(Action? options = null) where T : class, IJob { var (builder, jobDefinitions) = AddJobInternal(typeof(T), options); - return new StartupStage(Services, jobDefinitions, Settings, jobRegistry, builder); + return new StartupStage(services, jobDefinitions, settings, jobRegistry, builder); } /// @@ -60,7 +60,7 @@ public IStartupStage AddJob(Action? options = null) public IStartupStage AddJob(Type jobType, Action? options = null) { var (builder, jobDefinitions) = AddJobInternal(jobType, options); - return new StartupStage(Services, jobDefinitions, Settings, jobRegistry, builder); + return new StartupStage(services, jobDefinitions, settings, jobRegistry, builder); } /// @@ -103,10 +103,17 @@ public NCronJobOptionBuilder AddJob( /// public NCronJobOptionBuilder AddExceptionHandler() where TExceptionHandler : class, IExceptionHandler { - Services.AddSingleton(); + services.AddSingleton(); return this; } + void IRuntimeJobBuilder.AddJob(Action? options) => AddJob(options); + + void IRuntimeJobBuilder.AddJob(Type jobType, Action? options) => AddJob(jobType, options); + + void IRuntimeJobBuilder.AddJob(Delegate jobDelegate, string cronExpression, TimeZoneInfo? timeZoneInfo, string? jobName) => + AddJob(jobDelegate, cronExpression, timeZoneInfo, jobName); + private void ValidateConcurrencySetting(object jobIdentifier) { var cachedJobAttributes = jobIdentifier switch @@ -117,11 +124,11 @@ private void ValidateConcurrencySetting(object jobIdentifier) }; var concurrencyAttribute = cachedJobAttributes.ConcurrencyPolicy; - if (concurrencyAttribute != null && concurrencyAttribute.MaxDegreeOfParallelism > Settings.MaxDegreeOfParallelism) + if (concurrencyAttribute != null && concurrencyAttribute.MaxDegreeOfParallelism > settings.MaxDegreeOfParallelism) { var name = jobIdentifier is Type type ? type.Name : ((MethodInfo)jobIdentifier).Name; throw new InvalidOperationException( - $"The MaxDegreeOfParallelism for {name} ({concurrencyAttribute.MaxDegreeOfParallelism}) cannot exceed the global limit ({Settings.MaxDegreeOfParallelism})."); + $"The MaxDegreeOfParallelism for {name} ({concurrencyAttribute.MaxDegreeOfParallelism}) cannot exceed the global limit ({settings.MaxDegreeOfParallelism})."); } } @@ -147,7 +154,7 @@ internal static CronExpression GetCronExpression(string expression) var builder = new JobOptionBuilder(); options?.Invoke(builder); - Services.TryAddScoped(jobType); + services.TryAddScoped(jobType); var jobOptions = builder.GetJobOptions(); @@ -313,7 +320,7 @@ public interface IJobStage /// /// IStartupStage AddJob(Action? options = null) where TJob : class, IJob; - + /// /// Adds a job to the service collection that gets executed based on the given cron expression. /// @@ -328,9 +335,18 @@ public interface IStartupStage : INotificationStage where TJob : class, IJob { /// - /// Configures the job to run once during the application startup before any other jobs. + /// Configures the job to run once before the application itself runs. /// /// Returns a that allows adding notifications of another job. + /// + /// If a job is marked to run at startup, it will be executed before any `IHostedService` is started. Use the method to trigger the job execution. + /// In the context of ASP.NET: + /// + /// await app.UseNCronJobAsync(); + /// await app.RunAsync(); + /// + /// All startup jobs will be executed (and awaited) before the web application is started. This is particular useful for migration and cache hydration. + /// INotificationStage RunAtStartup(); } diff --git a/src/NCronJob/Configuration/MissingMethodCalledHandler.cs b/src/NCronJob/Configuration/MissingMethodCalledHandler.cs new file mode 100644 index 00000000..ad9e8621 --- /dev/null +++ b/src/NCronJob/Configuration/MissingMethodCalledHandler.cs @@ -0,0 +1,6 @@ +namespace NCronJob; + +internal sealed class MissingMethodCalledHandler +{ + public bool UseWasCalled { get; set; } +} diff --git a/src/NCronJob/Configuration/ServiceCollectionExtensions.cs b/src/NCronJob/Configuration/ServiceCollectionExtensions.cs deleted file mode 100644 index 47190223..00000000 --- a/src/NCronJob/Configuration/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,70 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace NCronJob; - -/// -/// Extensions for the to add cron jobs. -/// -public static class ServiceCollectionExtensions -{ - /// - /// Adds a job using an anonymous delegate to the service collection that gets executed based on the given cron expression. - /// This method allows for the scheduling of either synchronous or asynchronous tasks which are defined using lambda expressions. - /// The delegate can depend on services registered in the dependency injection container, which are resolved at runtime. - /// - /// The service collection used to register the services. - /// The delegate that represents the job to be executed. This delegate must return either void or Task. - /// The cron expression that defines when the job should be executed. - /// - /// Example of cron expression: "*/5 * * * * *" - /// This expression schedules the job to run every 5 seconds. - /// - /// - /// The time zone information that the cron expression should be evaluated against. - /// If not set the default time zone is UTC. - /// - /// - /// Synchronous job example: - /// - /// builder.Services.AddNCronJob((ILogger<Program> logger, TimeProvider timeProvider) => - /// { - /// logger.LogInformation("Hello World - The current date and time is {Time}", timeProvider.GetLocalNow()); - /// }, "*/40 * * * * *"); - /// - /// Asynchronous job example: - /// - /// builder.Services.AddNCronJob(async (ILogger<Program> logger, TimeProvider timeProvider, CancellationToken ct) => - /// { - /// logger.LogInformation("Hello World - The current date and time is {Time}", timeProvider.GetLocalNow()); - /// await Task.Delay(1000, ct); - /// }, "*/40 * * * * *"); - /// - /// Synchronous job with retry policy example: - /// - /// builder.Services.AddNCronJob([RetryPolicy(retryCount: 4)] (JobExecutionContext context, ILogger<Program> logger) => - /// { - /// var attemptCount = context.Attempts; - /// if (attemptCount <= 4) - /// { - /// logger.LogWarning("TestRetryJob simulating failure."); - /// throw new InvalidOperationException("Simulated operation failure in TestRetryJob."); - /// } - /// logger.LogInformation($"Job ran after {attemptCount} attempts"); - /// }, "*/5 * * * * *"); - /// - /// Synchronous job example with TimeZone: - /// - /// builder.Services.AddNCronJob((ILogger<Program> logger, TimeProvider timeProvider) => - /// { - /// logger.LogInformation("Hello World - The current date and time is {Time}", timeProvider.GetLocalNow()); - /// }, "*/40 * * * * *", TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time")); - /// - /// - /// The modified service collection. - public static IServiceCollection AddNCronJob(this IServiceCollection services, Delegate jobDelegate, string cronExpression, TimeZoneInfo? timeZoneInfo = null) - => services.AddNCronJob(builder => builder.AddJob(jobDelegate, cronExpression, timeZoneInfo)); -} - - - - diff --git a/src/NCronJob/IJob.cs b/src/NCronJob/IJob.cs index d9ee4b35..e598e7ca 100644 --- a/src/NCronJob/IJob.cs +++ b/src/NCronJob/IJob.cs @@ -10,5 +10,5 @@ public interface IJob /// /// The context of the job execution. /// A cancellation token that can be used to cancel the job. - Task RunAsync(IJobExecutionContext context, CancellationToken token); + public Task RunAsync(IJobExecutionContext context, CancellationToken token); } diff --git a/src/NCronJob/IJobExecutionContext.cs b/src/NCronJob/IJobExecutionContext.cs index 9086f9e6..d91ae7bd 100644 --- a/src/NCronJob/IJobExecutionContext.cs +++ b/src/NCronJob/IJobExecutionContext.cs @@ -51,5 +51,5 @@ public interface IJobExecutionContext /// Calling has no effects when no dependent jobs are defined /// via . /// - void SkipChildren(); + public void SkipChildren(); } diff --git a/src/NCronJob/IJobNotificationHandler.cs b/src/NCronJob/IJobNotificationHandler.cs index 412dd6ed..a8cbc716 100644 --- a/src/NCronJob/IJobNotificationHandler.cs +++ b/src/NCronJob/IJobNotificationHandler.cs @@ -15,7 +15,7 @@ public interface IJobNotificationHandler /// /// The method will be invoked with the same scope as the job itself. /// - Task HandleAsync(IJobExecutionContext context, Exception? exception, CancellationToken cancellationToken); + public Task HandleAsync(IJobExecutionContext context, Exception? exception, CancellationToken cancellationToken); } /// diff --git a/src/NCronJob/IServiceCollectionExtensions.cs b/src/NCronJob/IServiceCollectionExtensions.cs deleted file mode 100644 index e3f82d01..00000000 --- a/src/NCronJob/IServiceCollectionExtensions.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; - -namespace NCronJob; - -// Inspired by https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.DependencyInjection.Abstractions/src/Extensions/ServiceCollectionDescriptorExtensions.cs -// License MIT -internal static class IServiceCollectionExtensions -{ - public static IServiceCollection TryAddSingleton( - this IServiceCollection services, - Func implementationFactory) - where TService : class - where TImplementation : class, TService - { - ArgumentNullException.ThrowIfNull(services); - ArgumentNullException.ThrowIfNull(implementationFactory); - - var descriptor = ServiceDescriptor.Singleton(implementationFactory); - - services.TryAdd(descriptor); - - return services; - } -} diff --git a/src/NCronJob/NCronJobExtensions.cs b/src/NCronJob/NCronJobExtensions.cs index d32666a7..0a30e16f 100644 --- a/src/NCronJob/NCronJobExtensions.cs +++ b/src/NCronJob/NCronJobExtensions.cs @@ -1,10 +1,11 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; namespace NCronJob; /// -/// Extensions for the to add cron jobs. +/// Extensions for various types to use NCronJob. /// public static class NCronJobExtensions { @@ -43,17 +44,119 @@ public static IServiceCollection AddNCronJob( services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton((sp) => - { - return new RuntimeJobRegistry( - services, - jobRegistry, - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService()); - }); + services.TryAddSingleton(sp => new RuntimeJobRegistry( + services, + jobRegistry, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); services.TryAddSingleton(TimeProvider.System); services.TryAddSingleton(); + services.TryAddSingleton(); + + return services; + } + + /// + /// Adds a job using an anonymous delegate to the service collection that gets executed based on the given cron expression. + /// This method allows for the scheduling of either synchronous or asynchronous tasks which are defined using lambda expressions. + /// The delegate can depend on services registered in the dependency injection container, which are resolved at runtime. + /// + /// The service collection used to register the services. + /// The delegate that represents the job to be executed. This delegate must return either void or Task. + /// The cron expression that defines when the job should be executed. + /// + /// Example of cron expression: "*/5 * * * * *" + /// This expression schedules the job to run every 5 seconds. + /// + /// + /// The time zone information that the cron expression should be evaluated against. + /// If not set the default time zone is UTC. + /// + /// + /// Synchronous job example: + /// + /// builder.Services.AddNCronJob((ILogger<Program> logger, TimeProvider timeProvider) => + /// { + /// logger.LogInformation("Hello World - The current date and time is {Time}", timeProvider.GetLocalNow()); + /// }, "*/40 * * * * *"); + /// + /// Asynchronous job example: + /// + /// builder.Services.AddNCronJob(async (ILogger<Program> logger, TimeProvider timeProvider, CancellationToken ct) => + /// { + /// logger.LogInformation("Hello World - The current date and time is {Time}", timeProvider.GetLocalNow()); + /// await Task.Delay(1000, ct); + /// }, "*/40 * * * * *"); + /// + /// Synchronous job with retry policy example: + /// + /// builder.Services.AddNCronJob([RetryPolicy(retryCount: 4)] (JobExecutionContext context, ILogger<Program> logger) => + /// { + /// var attemptCount = context.Attempts; + /// if (attemptCount <= 4) + /// { + /// logger.LogWarning("TestRetryJob simulating failure."); + /// throw new InvalidOperationException("Simulated operation failure in TestRetryJob."); + /// } + /// logger.LogInformation($"Job ran after {attemptCount} attempts"); + /// }, "*/5 * * * * *"); + /// + /// Synchronous job example with TimeZone: + /// + /// builder.Services.AddNCronJob((ILogger<Program> logger, TimeProvider timeProvider) => + /// { + /// logger.LogInformation("Hello World - The current date and time is {Time}", timeProvider.GetLocalNow()); + /// }, "*/40 * * * * *", TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time")); + /// + /// + /// The modified service collection. + public static IServiceCollection AddNCronJob(this IServiceCollection services, Delegate jobDelegate, string cronExpression, TimeZoneInfo? timeZoneInfo = null) + => services.AddNCronJob(builder => builder.AddJob(jobDelegate, cronExpression, timeZoneInfo)); + + /// + /// Configures the host to use NCronJob. This will also start any given startup jobs and their dependencies. + /// + /// + /// Failure to call this method (or ) when startup jobs are defined will lead to a fatal exception during the application start. + /// + /// The host. + public static IHost UseNCronJob(this IHost host) => UseNCronJobAsync(host).ConfigureAwait(false).GetAwaiter().GetResult(); + + /// + /// Configures the host to use NCronJob. This will also start any given startup jobs and their dependencies. + /// + /// + /// Failure to call this method (or ) when startup jobs are defined will lead to a fatal exception during the application start. + /// + /// The host. + public static async Task UseNCronJobAsync(this IHost host) + { + ArgumentNullException.ThrowIfNull(host); + + var jobManager = host.Services.GetRequiredService(); + var stopToken = host.Services.GetRequiredService().ApplicationStopping; + await jobManager.ProcessStartupJobs(stopToken); + + host.Services.GetRequiredService().UseWasCalled = true; + + return host; + } + + // Inspired by https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.DependencyInjection.Abstractions/src/Extensions/ServiceCollectionDescriptorExtensions.cs + // License MIT + private static IServiceCollection TryAddSingleton( + this IServiceCollection services, + Func implementationFactory) + where TService : class + where TImplementation : class, TService + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(implementationFactory); + + var descriptor = ServiceDescriptor.Singleton(implementationFactory); + + services.TryAdd(descriptor); return services; } diff --git a/src/NCronJob/Registry/IInstantJobRegistry.cs b/src/NCronJob/Registry/IInstantJobRegistry.cs index fe7703ac..e9803a66 100644 --- a/src/NCronJob/Registry/IInstantJobRegistry.cs +++ b/src/NCronJob/Registry/IInstantJobRegistry.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using System.Diagnostics; @@ -30,7 +31,7 @@ void RunInstantJob(object? parameter = null, CancellationToken token = def /// Runs an instant job, which gets directly executed. /// /// - /// The delegate supports, like , that services can be retrieved dynamically. + /// The delegate supports, like , that services can be retrieved dynamically. /// Also, the can be retrieved in this way. /// /// The delegate to execute. @@ -62,7 +63,7 @@ void RunScheduledJob(DateTimeOffset startDate, object? parameter = null, C /// The delay until the job will be executed. /// An optional token to cancel the job. /// - /// The delegate supports, like , that services can be retrieved dynamically. + /// The delegate supports, like , that services can be retrieved dynamically. /// Also, the can be retrieved in this way. /// void RunScheduledJob(Delegate jobDelegate, TimeSpan delay, CancellationToken token = default); @@ -74,7 +75,7 @@ void RunScheduledJob(DateTimeOffset startDate, object? parameter = null, C /// The starting point when the job will be executed. /// An optional token to cancel the job. /// - /// The delegate supports, like , that services can be retrieved dynamically. + /// The delegate supports, like , that services can be retrieved dynamically. /// Also, the can be retrieved in this way. /// void RunScheduledJob(Delegate jobDelegate, DateTimeOffset startDate, CancellationToken token = default); @@ -86,7 +87,7 @@ void RunScheduledJob(DateTimeOffset startDate, object? parameter = null, C /// The delay until the job will be executed. /// An optional token to cancel the job. /// - /// The delegate supports, like , that services can be retrieved dynamically. + /// The delegate supports, like , that services can be retrieved dynamically. /// Also, the can be retrieved in this way. /// void ForceRunScheduledJob(Delegate jobDelegate, TimeSpan delay, CancellationToken token = default); @@ -98,7 +99,7 @@ void RunScheduledJob(DateTimeOffset startDate, object? parameter = null, C /// The starting point when the job will be executed. /// An optional token to cancel the job. /// - /// The delegate supports, like , that services can be retrieved dynamically. + /// The delegate supports, like , that services can be retrieved dynamically. /// Also, the can be retrieved in this way. /// void ForceRunScheduledJob(Delegate jobDelegate, DateTimeOffset startDate, CancellationToken token = default); @@ -135,7 +136,7 @@ void ForceRunInstantJob(object? parameter = null, CancellationToken token /// Runs an instant job, which gets directly executed. The job will not be queued into the JobQueue, but executed directly. /// /// - /// The delegate supports, like , that services can be retrieved dynamically. + /// The delegate supports, like , that services can be retrieved dynamically. /// Also, the can be retrieved in this way. /// /// The delegate to execute. diff --git a/src/NCronJob/Registry/JobRegistry.cs b/src/NCronJob/Registry/JobRegistry.cs index dc1fdc6e..b81098ce 100644 --- a/src/NCronJob/Registry/JobRegistry.cs +++ b/src/NCronJob/Registry/JobRegistry.cs @@ -26,13 +26,14 @@ public void Add(JobDefinition jobDefinition) { AssertNoDuplicateJobNames(jobDefinition.CustomName); - var isTypeUpdate = allJobs.Any(j => j.JobFullName == jobDefinition.JobFullName); - if (isTypeUpdate) + if (!allJobs.Add(jobDefinition)) { - Remove(jobDefinition); + throw new InvalidOperationException( + $""" + Job registration conflict for type: {jobDefinition.Type.Name} detected. Another job with the same type, parameters, or cron expression already exists. + Please either remove the duplicate job, change its parameters, or assign a unique name to it if duplication is intended. + """); } - - allJobs.Add(jobDefinition); } public int GetJobTypeConcurrencyLimit(string jobTypeName) @@ -142,7 +143,11 @@ private void AssertNoDuplicateJobNames(string? additionalJobName = null) if (duplicateJobName is not null) { - throw new InvalidOperationException($"Duplicate job names found: {string.Join(", ", duplicateJobName)}"); + throw new InvalidOperationException( + $""" + Job registration conflict detected. Duplicate job names found: {duplicateJobName}. + Please use a different name for each job. + """); } } diff --git a/src/NCronJob/Registry/RuntimeJobRegistry.cs b/src/NCronJob/Registry/RuntimeJobRegistry.cs index 8e20e0b7..901e96f9 100644 --- a/src/NCronJob/Registry/RuntimeJobRegistry.cs +++ b/src/NCronJob/Registry/RuntimeJobRegistry.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Cronos; using Microsoft.Extensions.DependencyInjection; @@ -9,9 +10,19 @@ namespace NCronJob; public interface IRuntimeJobRegistry { /// - /// Gives the ability to add a job. - /// - void AddJob(Action jobBuilder); + /// Tries to register a job with the given configuration. + /// /param> + /// The job builder that configures the job. + /// Returns true if the registration was successful, otherwise false. + bool TryRegister(Action jobBuilder); + + /// + /// Tries to register a job with the given configuration. + /// /param> + /// The job builder that configures the job. + /// The exception that occurred during the registration process. Or null if the registration was successful. + /// Returns true if the registration was successful, otherwise false. + bool TryRegister(Action jobBuilder, [NotNullWhen(false)]out Exception? exception); /// /// Removes the job with the given name. @@ -121,22 +132,36 @@ public RuntimeJobRegistry( } /// - public void AddJob(Action jobBuilder) - { - var oldJobs = jobRegistry.GetAllJobs(); - var builder = new NCronJobOptionBuilder(services, concurrencySettings, jobRegistry); - jobBuilder(builder); + public bool TryRegister(Action jobBuilder) + => TryRegister(jobBuilder, out _); - var newJobs = jobRegistry.GetAllJobs().Except(oldJobs); - foreach (var jobDefinition in newJobs) + /// + public bool TryRegister(Action jobBuilder, [NotNullWhen(false)]out Exception? exception) + { + try { - jobWorker.ScheduleJob(jobDefinition); - jobQueueManager.SignalJobQueue(jobDefinition.JobFullName); + var oldJobs = jobRegistry.GetAllJobs(); + var builder = new NCronJobOptionBuilder(services, concurrencySettings, jobRegistry); + jobBuilder(builder); + + var newJobs = jobRegistry.GetAllJobs().Except(oldJobs); + foreach (var jobDefinition in newJobs) + { + jobWorker.ScheduleJob(jobDefinition); + jobQueueManager.SignalJobQueue(jobDefinition.JobFullName); + } + + foreach (var entry in jobRegistry.DynamicJobRegistrations) + { + jobQueueManager.SignalJobQueue(entry.JobDefinition.JobFullName); + } + exception = null; + return true; } - - foreach (var entry in jobRegistry.DynamicJobRegistrations) + catch (Exception ex) { - jobQueueManager.SignalJobQueue(entry.JobDefinition.JobFullName); + exception = ex; + return false; } } diff --git a/src/NCronJob/Scheduler/QueueWorker.cs b/src/NCronJob/Scheduler/QueueWorker.cs index 96f064f3..0ffd0920 100644 --- a/src/NCronJob/Scheduler/QueueWorker.cs +++ b/src/NCronJob/Scheduler/QueueWorker.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging; using System.Collections.Concurrent; using System.Collections.Specialized; +using System.Diagnostics.CodeAnalysis; namespace NCronJob; @@ -10,8 +11,8 @@ internal sealed partial class QueueWorker : BackgroundService private readonly JobQueueManager jobQueueManager; private readonly JobWorker jobWorker; private readonly JobRegistry jobRegistry; - private readonly StartupJobManager startupJobManager; private readonly ILogger logger; + private readonly MissingMethodCalledHandler missingMethodCalledHandler; private CancellationTokenSource? shutdown; private readonly ConcurrentDictionary workerTasks = new(); private readonly ConcurrentDictionary addingWorkerTasks = new(); @@ -21,15 +22,15 @@ public QueueWorker( JobQueueManager jobQueueManager, JobWorker jobWorker, JobRegistry jobRegistry, - StartupJobManager startupJobManager, ILogger logger, + MissingMethodCalledHandler missingMethodCalledHandler, IHostApplicationLifetime lifetime) { this.jobQueueManager = jobQueueManager; this.jobWorker = jobWorker; this.jobRegistry = jobRegistry; - this.startupJobManager = startupJobManager; this.logger = logger; + this.missingMethodCalledHandler = missingMethodCalledHandler; lifetime.ApplicationStopping.Register(() => shutdown?.Cancel()); @@ -92,6 +93,8 @@ public override void Dispose() protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + AssertUseNCronJobWasCalled(); + shutdown?.Dispose(); shutdown = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); var stopToken = shutdown.Token; @@ -99,9 +102,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) try { - await startupJobManager.ProcessStartupJobs(stopToken).ConfigureAwait(false); ScheduleInitialJobs(); - await startupJobManager.WaitForStartupJobsCompletion().ConfigureAwait(false); CreateWorkerQueues(stopToken); jobQueueManager.QueueAdded += OnQueueAdded; // this needs to come after we create the initial Worker Queues @@ -119,6 +120,24 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } } + private void AssertUseNCronJobWasCalled() + { + if (missingMethodCalledHandler.UseWasCalled) + { + return; + } + + if (jobRegistry.GetAllOneTimeJobs().Count == 0) + { + return; + } + + throw new InvalidOperationException( + $""" + Startup jobs have been registered. However, neither IHost.UseNCronJobAsync(), nor IHost.UseNCronJob() have been been called. + """); + } + private void CreateWorkerQueues(CancellationToken stopToken) { foreach (var jobQueueName in jobQueueManager.GetAllJobQueueNames()) diff --git a/tests/NCronJob.Tests/NCronJob.Tests.csproj b/tests/NCronJob.Tests/NCronJob.Tests.csproj index e9430b8d..7dbb4ed4 100644 --- a/tests/NCronJob.Tests/NCronJob.Tests.csproj +++ b/tests/NCronJob.Tests/NCronJob.Tests.csproj @@ -15,7 +15,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + @@ -25,13 +25,9 @@ - - - - - - - + + + diff --git a/tests/NCronJob.Tests/NCronJobIntegrationTests.cs b/tests/NCronJob.Tests/NCronJobIntegrationTests.cs index c86e9b64..e90a9eb2 100644 --- a/tests/NCronJob.Tests/NCronJobIntegrationTests.cs +++ b/tests/NCronJob.Tests/NCronJobIntegrationTests.cs @@ -394,24 +394,10 @@ public void AddJobsDynamicallyWhenNameIsDuplicatedLeadsToException() var provider = CreateServiceProvider(); var runtimeRegistry = provider.GetRequiredService(); - var act = () => runtimeRegistry.AddJob(n => n.AddJob(() => { }, "* * * * *", jobName: "Job1")); + var successful = runtimeRegistry.TryRegister(n => n.AddJob(() => { }, "* * * * *", jobName: "Job1"), out var exception); - act.ShouldThrow(); - } - - [Fact] - public async Task TwoJobsWithSameDefinitionLeadToOneExecution() - { - ServiceCollection.AddNCronJob(n => n.AddJob(p => p.WithCronExpression("* * * * *").And.WithCronExpression("* * * * *"))); - var provider = CreateServiceProvider(); - await provider.GetRequiredService().StartAsync(CancellationToken); - - var countJobs = provider.GetRequiredService().GetAllCronJobs().Count; - countJobs.ShouldBe(1); - - FakeTimer.Advance(TimeSpan.FromMinutes(1)); - var jobFinished = await WaitForJobsOrTimeout(2, TimeSpan.FromMilliseconds(250)); - jobFinished.ShouldBeFalse(); + successful.ShouldBeFalse(); + exception.ShouldNotBeNull(); } [Fact] @@ -453,8 +439,8 @@ public async Task AddingJobsAndDuringStartupAndRuntimeNotInOnlyOneCallOnlyOneExe await provider.GetRequiredService().StartAsync(CancellationToken); var registry = provider.GetRequiredService(); - registry.AddJob(n => n.AddJob(p => p.WithCronExpression("* * * * *"))); - registry.AddJob(n => n.AddJob(p => p.WithCronExpression("0 0 10 * *"))); + registry.TryRegister(n => n.AddJob(p => p.WithCronExpression("* * * * *"))); + registry.TryRegister(n => n.AddJob(p => p.WithCronExpression("0 0 10 * *"))); FakeTimer.Advance(TimeSpan.FromMinutes(1)); var jobFinished = await WaitForJobsOrTimeout(1); @@ -477,6 +463,31 @@ public async Task CallingAddNCronJobMultipleTimesWillRegisterAllJobs() jobFinished.ShouldBeTrue(); } + [Fact] + public void RegisteringDuplicatedJobsLeadToAnExceptionWhenRegistration() + { + Action act = () => + ServiceCollection.AddNCronJob(n => n.AddJob(p => p + .WithCronExpression("* * * * *") + .And + .WithCronExpression("* * * * *"))); + + act.ShouldThrow(); + } + + [Fact] + public void RegisteringDuplicateDuringRuntimeLeadsToException() + { + ServiceCollection.AddNCronJob(n => n.AddJob(p => p.WithCronExpression("* * * * *"))); + var provider = CreateServiceProvider(); + var runtimeRegistry = provider.GetRequiredService(); + + var successful = runtimeRegistry.TryRegister(n => n.AddJob(p => p.WithCronExpression("* * * * *")), out var exception); + + successful.ShouldBeFalse(); + exception.ShouldNotBeNull(); + } + private static class JobMethods { public static async Task WriteTrueStaticAsync(ChannelWriter writer, CancellationToken ct) diff --git a/tests/NCronJob.Tests/RunAtStartupJobTests.cs b/tests/NCronJob.Tests/RunAtStartupJobTests.cs new file mode 100644 index 00000000..d56c9cb3 --- /dev/null +++ b/tests/NCronJob.Tests/RunAtStartupJobTests.cs @@ -0,0 +1,169 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; + +namespace NCronJob.Tests; + +public class RunAtStartupJobTests : JobIntegrationBase +{ + [Fact] + public async Task UseNCronJobIsMandatoryWhenStartupJobsAreDefined() + { + var builder = Host.CreateDefaultBuilder(); + var storage = new Storage(); + builder.ConfigureServices(services => + { + services.AddNCronJob(s => s.AddJob().RunAtStartup()); + services.AddSingleton(_ => storage); + }); + + using var app = builder.Build(); + +#pragma warning disable IDISP013 // Await in using + Func act = () => RunApp(app); +#pragma warning restore IDISP013 // Await in using + + await act.ShouldThrowAsync(); + } + + [Fact] + public async Task UseNCronJobShouldTriggerStartupJobs() + { + var builder = Host.CreateDefaultBuilder(); + var storage = new Storage(); + builder.ConfigureServices(services => + { + services.AddNCronJob(s => s.AddJob().RunAtStartup()); + services.AddSingleton(_ => storage); + }); + using var app = builder.Build(); + + await app.UseNCronJobAsync(); + + storage.Content.Count.ShouldBe(1); + storage.Content[0].ShouldBe("SimpleJob"); + } + + [Fact] + public async Task ShouldStartStartupJobsBeforeApplicationIsSpunUp() + { + var builder = Host.CreateDefaultBuilder(); + var storage = new Storage(); + builder.ConfigureServices(services => + { + services.AddNCronJob(s => s.AddJob().RunAtStartup()); + services.AddSingleton(_ => storage); + services.AddHostedService(); + }); + using var app = builder.Build(); + + await app.UseNCronJobAsync(); + await RunApp(app); + + storage.Content.Count.ShouldBe(2); + storage.Content[0].ShouldBe("SimpleJob"); + storage.Content[1].ShouldBe("StartingService"); + } + + [Fact] + public async Task StartupJobThatThrowsShouldNotPreventHostFromStarting() + { + var builder = Host.CreateDefaultBuilder(); + var storage = new Storage(); + builder.ConfigureServices(services => + { + services.AddNCronJob(s => + { + s.AddJob().RunAtStartup(); + s.AddExceptionHandler(); + }); + services.AddSingleton(_ => storage); + }); + using var app = builder.Build(); + + await app.UseNCronJobAsync(); + await RunApp(app); + + storage.Content.Count.ShouldBe(1); + storage.Content[0].ShouldBe("ExceptionHandler"); + } + + [SuppressMessage("Major Code Smell", "S108:Nested blocks of code should not be left empty", Justification = "On purpose")] + private static async Task RunApp(IHost app, TimeSpan? runtime = null) + { + using var cts = new CancellationTokenSource(runtime ?? TimeSpan.FromSeconds(1)); + try + { + await app.RunAsync(cts.Token); + } + catch (OperationCanceledException) + { + } + } + + private sealed class Storage + { +#if NET8_0 + private readonly object locker = new(); +#else + private readonly Lock locker = new(); +#endif + public List Content { get; private set; } = []; + + public void Add(string content) + { + lock (locker) + { + Content.Add(content); + } + } + } + + private sealed class StartingService : IHostedService + { + private readonly Storage storage; + + public StartingService(Storage storage) => this.storage = storage; + + public Task StartAsync(CancellationToken cancellationToken) + { + storage.Add("StartingService"); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + + private sealed class SimpleJob: IJob + { + private readonly Storage storage; + + public SimpleJob(Storage storage) => this.storage = storage; + + public Task RunAsync(IJobExecutionContext context, CancellationToken token) + { + storage.Add("SimpleJob"); + return Task.CompletedTask; + } + } + + private sealed class FailingJob: IJob + { + public Task RunAsync(IJobExecutionContext context, CancellationToken token) => throw new InvalidOperationException("Failed"); + } + + private sealed class ExceptionHandler : IExceptionHandler + { + private readonly Storage storage; + + public ExceptionHandler(Storage storage) => this.storage = storage; + + + public Task TryHandleAsync(IJobExecutionContext jobExecutionContext, Exception exception, CancellationToken cancellationToken) + { + storage.Add("ExceptionHandler"); + return Task.FromResult(true); + } + } +} diff --git a/tests/NCronJob.Tests/RunDependentJobTests.cs b/tests/NCronJob.Tests/RunDependentJobTests.cs index d337792c..7fa1fe9d 100644 --- a/tests/NCronJob.Tests/RunDependentJobTests.cs +++ b/tests/NCronJob.Tests/RunDependentJobTests.cs @@ -57,21 +57,21 @@ public async Task RemovingAJobShouldAlsoRemoveItsDependencies() instantJobRegistry.ForceRunInstantJob(); var result = await CommunicationChannel.Reader.ReadAsync(CancellationToken); - Assert.Equal(nameof(MainJob), result); + result.ShouldBe(nameof(MainJob)); result = await CommunicationChannel.Reader.ReadAsync(CancellationToken); - Assert.Equal(nameof(SubMainJob), result); + result.ShouldBe(nameof(SubMainJob)); var registry = provider.GetRequiredService(); - registry.RemoveJob(); - registry.AddJob(n => n.AddJob()); + registry.RemoveJob(); + registry.TryRegister(n => n.AddJob()); instantJobRegistry.ForceRunInstantJob(); result = await CommunicationChannel.Reader.ReadAsync(CancellationToken); - Assert.Equal(nameof(MainJob), result); + result.ShouldBe(nameof(MainJob)); - (await WaitForJobsOrTimeout(1)).ShouldBe(false); + (await WaitForJobsOrTimeout(1, TimeSpan.FromMilliseconds(500))).ShouldBe(false); } [Fact] diff --git a/tests/NCronJob.Tests/RuntimeJobRegistryTests.cs b/tests/NCronJob.Tests/RuntimeJobRegistryTests.cs index f751ddd0..b1638e9b 100644 --- a/tests/NCronJob.Tests/RuntimeJobRegistryTests.cs +++ b/tests/NCronJob.Tests/RuntimeJobRegistryTests.cs @@ -15,7 +15,7 @@ public async Task DynamicallyAddedJobIsExecuted() await provider.GetRequiredService().StartAsync(CancellationToken); var registry = provider.GetRequiredService(); - registry.AddJob(s => s.AddJob(async (ChannelWriter writer) => await writer.WriteAsync(true), "* * * * *")); + registry.TryRegister(s => s.AddJob(async (ChannelWriter writer) => await writer.WriteAsync(true), "* * * * *"), out _); FakeTimer.Advance(TimeSpan.FromMinutes(1)); var jobFinished = await WaitForJobsOrTimeout(1); @@ -30,9 +30,8 @@ public async Task MultipleDynamicallyAddedJobsAreExecuted() await provider.GetRequiredService().StartAsync(CancellationToken); var registry = provider.GetRequiredService(); - registry.AddJob(s => s - .AddJob(async (ChannelWriter writer) => await writer.WriteAsync(true), "* * * * *") - .AddJob(async (ChannelWriter writer) => await writer.WriteAsync(true), "* * * * *")); + registry.TryRegister(s => s.AddJob(async (ChannelWriter writer) => await writer.WriteAsync(true), "* * * * *")); + registry.TryRegister(s => s.AddJob(async (ChannelWriter writer) => await writer.WriteAsync(true), "* * * * *")); FakeTimer.Advance(TimeSpan.FromMinutes(1)); var jobFinished = await WaitForJobsOrTimeout(2); @@ -171,7 +170,7 @@ public void ShouldRetrieveAllSchedules() .WithName("JobName"))); var provider = CreateServiceProvider(); var registry = provider.GetRequiredService(); - registry.AddJob(s => s.AddJob(() => { }, "* * * * *", jobName: "JobName2")); + registry.TryRegister(s => s.AddJob(() => { }, "* * * * *", jobName: "JobName2"), out _); var allSchedules = registry.GetAllRecurringJobs(); @@ -190,7 +189,7 @@ public void AddingJobDuringRuntimeIsRetrieved() ServiceCollection.AddNCronJob(p => p.AddJob()); var provider = CreateServiceProvider(); var registry = provider.GetRequiredService(); - registry.AddJob(n => n.AddJob(p => p.WithCronExpression("* * * * *").WithName("JobName"))); + registry.TryRegister(n => n.AddJob(p => p.WithCronExpression("* * * * *").WithName("JobName"))); var allSchedules = registry.GetAllRecurringJobs(); @@ -257,11 +256,38 @@ public void ShouldThrowRuntimeExceptionWithDuplicateJob() ServiceCollection.AddNCronJob(s => s.AddJob(p => p.WithCronExpression("* * * * *").WithName("JobName"))); var runtimeJobRegistry = CreateServiceProvider().GetRequiredService(); - var act = () => runtimeJobRegistry.AddJob(s => s.AddJob(() => + var successful = runtimeJobRegistry.TryRegister(s => s.AddJob(() => { - }, "* * * * *", jobName: "JobName")); + }, "* * * * *", jobName: "JobName"), out var exception); - act.ShouldThrow(); + successful.ShouldBeFalse(); + exception.ShouldNotBeNull(); + exception.ShouldBeOfType(); + } + + [Fact] + public void TryRegisteringShouldIndicateFailureWithAGivenException() + { + ServiceCollection.AddNCronJob(); + var runtimeJobRegistry = CreateServiceProvider().GetRequiredService(); + runtimeJobRegistry.TryRegister(s => s.AddJob(p => p.WithCronExpression("* * * * *"))); + + var successful = runtimeJobRegistry.TryRegister(s => s.AddJob(p => p.WithCronExpression("* * * * *")), out var exception); + + successful.ShouldBeFalse(); + exception.ShouldNotBeNull(); + } + + [Fact] + public void TryRegisterShouldIndicateSuccess() + { + ServiceCollection.AddNCronJob(); + var runtimeJobRegistry = CreateServiceProvider().GetRequiredService(); + + var successful = runtimeJobRegistry.TryRegister(s => s.AddJob(p => p.WithCronExpression("* * * * *")), out var exception); + + successful.ShouldBeTrue(); + exception.ShouldBeNull(); } private sealed class SimpleJob(ChannelWriter writer) : IJob