Skip to content

Commit fe5cd9a

Browse files
committed
feat: Add Notification System when job is done
1 parent 9282a25 commit fe5cd9a

File tree

13 files changed

+349
-88
lines changed

13 files changed

+349
-88
lines changed

.editorconfig

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -442,11 +442,7 @@ dotnet_diagnostic.CA2201.severity = suggestion # CA2201: Do not raise reserved e
442442

443443
# SonarAnalyzer.CSharp
444444
# https://rules.sonarsource.com/csharp
445-
dotnet_diagnostic.S112.severity = none # S112: General exceptions should never be thrown
446-
dotnet_diagnostic.S1075.severity = suggestion # S1075: URIs should not be hardcoded
447-
dotnet_diagnostic.S1186.severity = suggestion # S1186: Methods should not be empty
448-
dotnet_diagnostic.S2292.severity = suggestion # S2292: Trivial properties should be auto-implemented
449-
dotnet_diagnostic.S4158.severity = none # BUGGY with C#9 code - doesnt understand local methods
445+
dotnet_diagnostic.S2326.severity = none # S2326: Unused type parameters should be removed
450446

451447
##########################################
452448
# Custom Test Code Analyzers Rules
@@ -456,16 +452,6 @@ dotnet_diagnostic.S4158.severity = none # BUGGY with C#9 code - doesnt understan
456452
# Visual Studio
457453
csharp_style_pattern_local_over_anonymous_function = false # IDE0039: Use local function
458454

459-
# AsyncFixer
460-
# http://www.asyncfixer.com
461-
dotnet_diagnostic.AsyncFixer01.severity = none # AsyncFixer01: Unnecessary async/await usage
462-
463-
# Meziantou
464-
# https://www.meziantou.net/enforcing-asynchronous-code-good-practices-using-a-roslyn-analyzer.htm
465-
dotnet_diagnostic.MA0006.severity = suggestion # MA0006: use String.Equals
466-
dotnet_diagnostic.MA0007.severity = none # MA0007: Add a comma after the last value
467-
dotnet_diagnostic.MA0048.severity = silent # MA0048: File name must match type name
468-
469455
# Microsoft - Code Analysis
470456
# https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/
471457
dotnet_diagnostic.CA1054.severity = none # CA1054: URI-like parameters should not be strings

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ All notable changes to **NCronJob** will be documented in this file. The project
1111
- Ability to set cron expressions with second-level precision
1212
- Support for `net9.0`
1313
- Support for Isolation Level to run jobs independent of the current scheduler
14+
- Notification system that allows to run a task when a job is finished
1415

1516
## [0.10.1] - 2024-03-19
1617

README.md

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
# NCronJob
1515
A Job Scheduler sitting on top of `IHostedService` in dotnet.
1616

17-
Often times one finds themself between the simplicisty of the `BackgroundService`/`IHostedService` and the complexity of a full blown `Hangfire` or `Quartz` scheduler.
17+
Often times one finds themself between the simplicity of the `BackgroundService`/`IHostedService` and the complexity of a full blown `Hangfire` or `Quartz` scheduler.
1818
This library aims to fill that gap by providing a simple and easy to use job scheduler that can be used in any dotnet application and feels "native".
1919

2020
So no need for setting up a database, just schedule your stuff right away! The library gives you two ways of scheduling jobs:
@@ -26,7 +26,7 @@ So no need for setting up a database, just schedule your stuff right away! The l
2626
- [x] The ability to instantly run a job
2727
- [x] Parameterized jobs - instant as well as cron jobs!
2828
- [x] Integrated in ASP.NET - Access your DI container like you would in any other service
29-
- [ ] Get notified when a job is done (either successfully or with an error) - currently in development
29+
- [x] Get notified when a job is done (either successfully or with an error).
3030

3131
## Not features
3232

@@ -91,6 +91,42 @@ public class MyService
9191
}
9292
```
9393

94+
## Getting notified when a job is done
95+
**NCronJob** provides a way to get notified when a job is done. For this, implement a `IJobNotificationHandler<TJob>` and register it in your DI container.
96+
```csharp
97+
Services.AddNotificationHandler<MyJobNotificationHandler, MyJob>();
98+
```
99+
100+
This allows to run logic after a job is done. The `JobExecutionContext` and the `Exception` (if there was one) are passed to the `Handle` method.
101+
102+
```csharp
103+
public class MyJobNotificationHandler : IJobNotificationHandler<MyJob>
104+
{
105+
private readonly ILogger<MyJobNotificationHandler> logger;
106+
107+
public MyJobNotificationHandler(ILogger<MyJobNotificationHandler> logger)
108+
{
109+
this.logger = logger;
110+
}
111+
112+
public Task HandleAsync(JobExecutionContext context, Exception? exception, CancellationToken token)
113+
{
114+
if (exception is not null)
115+
{
116+
logger.LogError(exception, "Job failed");
117+
}
118+
else
119+
{
120+
logger.LogInformation("Job was successful");
121+
logger.LogInformation("Output: {Output}", context.Output);
122+
}
123+
124+
return Task.CompletedTask;
125+
}
126+
}
127+
```
128+
129+
94130
## Advanced Cases
95131

96132
### Isolation Level
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using LinkDotNet.NCronJob;
2+
3+
namespace NCronJobSample;
4+
5+
public partial class HelloWorldJobHandler : IJobNotificationHandler<PrintHelloWorldJob>
6+
{
7+
private readonly ILogger<HelloWorldJobHandler> logger;
8+
9+
public HelloWorldJobHandler(ILogger<HelloWorldJobHandler> logger)
10+
{
11+
this.logger = logger;
12+
}
13+
14+
public Task HandleAsync(JobExecutionContext context, Exception? exception, CancellationToken cancellationToken)
15+
{
16+
ArgumentNullException.ThrowIfNull(context);
17+
18+
LogMessage(context.Output);
19+
return Task.CompletedTask;
20+
}
21+
22+
[LoggerMessage(LogLevel.Information, "PrintHelloWorldJob is done and outputs: {Output}")]
23+
private partial void LogMessage(object? output);
24+
}

sample/NCronJobSample/PrintHelloWorld.cs renamed to sample/NCronJobSample/PrintHelloWorldJob.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22

33
namespace NCronJobSample;
44

5-
public partial class PrintHelloWorld : IJob
5+
public partial class PrintHelloWorldJob : IJob
66
{
7-
private readonly ILogger<PrintHelloWorld> logger;
7+
private readonly ILogger<PrintHelloWorldJob> logger;
88

9-
public PrintHelloWorld(ILogger<PrintHelloWorld> logger)
9+
public PrintHelloWorldJob(ILogger<PrintHelloWorldJob> logger)
1010
{
1111
this.logger = logger;
1212
}
@@ -17,6 +17,8 @@ public Task Run(JobExecutionContext context, CancellationToken token = default)
1717

1818
LogMessage(context.Parameter);
1919

20+
context.Output = "Hey there!";
21+
2022
return Task.CompletedTask;
2123
}
2224

sample/NCronJobSample/Program.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,15 @@
1616
});
1717

1818
// Execute the job every minute
19-
builder.Services.AddCronJob<PrintHelloWorld>(p =>
19+
builder.Services.AddCronJob<PrintHelloWorldJob>(p =>
2020
{
2121
p.CronExpression = "* * * * *";
2222
p.Parameter = "Hello from NCronJob";
2323
});
2424

25+
// Register a handler that gets executed when the job is done
26+
builder.Services.AddNotificationHandler<HelloWorldJobHandler, PrintHelloWorldJob>();
27+
2528
var app = builder.Build();
2629

2730
// Configure the HTTP request pipeline.
@@ -35,7 +38,7 @@
3538

3639
app.MapPost("/trigger-instant", (IInstantJobRegistry instantJobRegistry) =>
3740
{
38-
instantJobRegistry.AddInstantJob<PrintHelloWorld>("Hello from instant job!");
41+
instantJobRegistry.AddInstantJob<PrintHelloWorldJob>("Hello from instant job!");
3942
})
4043
.WithName("TriggerInstantJob")
4144
.WithOpenApi();
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
namespace LinkDotNet.NCronJob;
2+
3+
/// <summary>
4+
/// Classes implementing this interface can handle the notification of a job execution.
5+
/// </summary>
6+
public interface IJobNotificationHandler
7+
{
8+
/// <summary>
9+
/// This method is invoked when a <see cref="IJob"/> is finished (either successfully or with an exception).
10+
/// </summary>
11+
/// <param name="context">The <see cref="JobExecutionContext"/> that was used for this run.</param>
12+
/// <param name="exception">The exception that was thrown during the execution of the job. If the job was successful, this will be <c>null</c>.</param>
13+
/// <param name="cancellationToken">The <see cref="CancellationToken"/> that can be used to cancel the notification.</param>
14+
/// <remarks>
15+
/// The method will be invoked with the same scope as the job itself.
16+
/// </remarks>
17+
Task HandleAsync(JobExecutionContext context, Exception? exception, CancellationToken cancellationToken);
18+
}
19+
20+
/// <inheritdoc />
21+
public interface IJobNotificationHandler<TJob> : IJobNotificationHandler
22+
where TJob : IJob;

src/LinkDotNet.NCronJob/JobExecutionContext.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,10 @@ namespace LinkDotNet.NCronJob;
44
/// Represents the context of a job execution.
55
/// </summary>
66
/// <param name="Parameter">The passed in parameters to a job</param>
7-
public sealed record JobExecutionContext(object? Parameter);
7+
public sealed record JobExecutionContext(object? Parameter)
8+
{
9+
/// <summary>
10+
/// The output of a job that can be read by the <see cref="IJobNotificationHandler{TJob}"/>.
11+
/// </summary>
12+
public object? Output { get; set; }
13+
}

src/LinkDotNet.NCronJob/NCronJobExtensions.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,24 @@ public static IServiceCollection AddCronJob<T>(this IServiceCollection services,
5959
return services;
6060
}
6161

62+
/// <summary>
63+
/// Adds a notification handler for a given <see cref="IJob"/>.
64+
/// </summary>
65+
/// <param name="services">The service collection used to register the handler.</param>
66+
/// <typeparam name="TJobNotificationHandler">The handler-type that is used to handle the job.</typeparam>
67+
/// <typeparam name="TJob">The job type.</typeparam>
68+
/// <remarks>
69+
/// The given <see cref="IJobNotificationHandler{TJob}"/> instance is registered as a scoped service sharing the same scope as the job.
70+
/// Also only one handler per job is allowed. If multiple handlers are registered, only the first one will be executed.
71+
/// </remarks>
72+
public static IServiceCollection AddNotificationHandler<TJobNotificationHandler, TJob>(this IServiceCollection services)
73+
where TJobNotificationHandler : class, IJobNotificationHandler<TJob>
74+
where TJob : class, IJob
75+
{
76+
services.TryAddScoped<IJobNotificationHandler<TJob>, TJobNotificationHandler>();
77+
return services;
78+
}
79+
6280
private static CrontabSchedule GetCronExpression(IServiceCollection services, JobOption option)
6381
{
6482
using var serviceProvider = services.BuildServiceProvider();

src/LinkDotNet.NCronJob/Scheduler/CronScheduler.cs

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
3333
var runs = new List<Run>();
3434
while (await tickTimer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
3535
{
36+
// We don't want to await jobs explicitly because that
37+
// could interfere with other job runs)
3638
RunActiveJobs(runs, stoppingToken);
3739
runs = GetNextJobRuns();
3840
}
@@ -46,13 +48,51 @@ private void RunActiveJobs(List<Run> runs, CancellationToken stoppingToken)
4648
var scope = serviceProvider.CreateScope();
4749
var job = (IJob)scope.ServiceProvider.GetRequiredService(run.Type);
4850

49-
// We don't want to await jobs explicitly because that
50-
// could interfere with other job runs)
51-
var jobTask = run.IsolationLevel == IsolationLevel.None
51+
RunJob(run, job, scope, stoppingToken);
52+
}
53+
}
54+
55+
private static void RunJob(Run run, IJob job, IServiceScope serviceScope, CancellationToken stoppingToken)
56+
{
57+
try
58+
{
59+
GetJobTask()
60+
.ContinueWith(
61+
task => AfterJobCompletionTask(task.Exception),
62+
TaskScheduler.Current)
63+
.ConfigureAwait(false);
64+
}
65+
catch (Exception exc) when (exc is not OperationCanceledException or AggregateException)
66+
{
67+
// This part is only reached if the synchronous part of the job throws an exception
68+
AfterJobCompletionTask(exc);
69+
}
70+
71+
Task GetJobTask()
72+
{
73+
return run.IsolationLevel == IsolationLevel.None
5274
? job.Run(run.Context, stoppingToken)
5375
: Task.Run(() => job.Run(run.Context, stoppingToken), stoppingToken);
76+
}
77+
78+
void AfterJobCompletionTask(Exception? exc)
79+
{
80+
var notificationServiceType = typeof(IJobNotificationHandler<>).MakeGenericType(run.Type);
81+
82+
if (serviceScope.ServiceProvider.GetService(notificationServiceType) is IJobNotificationHandler notificationService)
83+
{
84+
try
85+
{
86+
notificationService.HandleAsync(run.Context, exc, stoppingToken).ConfigureAwait(false);
87+
}
88+
catch (Exception innerExc) when (innerExc is not OperationCanceledException or AggregateException)
89+
{
90+
// We don't want to throw exceptions from the notification service
91+
}
92+
93+
}
5494

55-
jobTask.ContinueWith(_ => scope.Dispose(), TaskScheduler.Current).ConfigureAwait(false);
95+
serviceScope.Dispose();
5696
}
5797
}
5898

0 commit comments

Comments
 (0)