├── Job.Scheduler.AspNetCore.Tests ├── Usings.cs ├── Mock │ ├── OneTimeJob.cs │ ├── HasRunJob.cs │ └── OneTimeQueueJob.cs ├── Job.Scheduler.AspNetCore.Tests.csproj ├── JobSchedulerAspNetCoreTests.cs └── QueueJobSchedulerTests.cs ├── .github ├── FUNDING.yml ├── .kodiak.toml ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ └── dotnet.yml ├── .idea └── .idea.Job.Scheduler │ └── .idea │ ├── encodings.xml │ ├── indexLayout.xml │ ├── .gitignore │ └── vcs.xml ├── nuget.config ├── Job.Scheduler ├── Queue │ ├── QueueSettings.cs │ ├── QueueJobContainer.cs │ └── Queue.cs ├── Job │ ├── Data │ │ ├── JobId.cs │ │ └── Debouncer.cs │ ├── Exception │ │ ├── JobException.cs │ │ └── MaxRuntimeJobException.cs │ ├── Action │ │ ├── NoRetry.cs │ │ ├── AlwaysRetry.cs │ │ ├── IRetryAction.cs │ │ ├── RetryNTimes.cs │ │ ├── ExponentialBackoffRetry.cs │ │ ├── BackoffRetry.cs │ │ └── ExponentialDecorrelatedJittedBackoffRetry.cs │ ├── Runner │ │ ├── QueuedJobRunner.cs │ │ ├── OneTimeJobRunner.cs │ │ ├── DebounceJobRunner.cs │ │ ├── DelayedJobRunner.cs │ │ ├── RecurringJobRunner.cs │ │ ├── IJobRunner.cs │ │ └── JobRunner.cs │ ├── IJobContainerBuilder.cs │ └── IJob.cs ├── Builder │ ├── IJobRunnerBuilder.cs │ └── JobRunnerBuilder.cs ├── Utils │ ├── TaskUtils.cs │ └── DebounceDispatcher.cs ├── LICENSE.txt ├── Job.Scheduler.csproj └── Scheduler │ ├── IJobScheduler.cs │ └── JobScheduler.cs ├── Job.Scheduler.AspNetCore ├── Builder │ ├── IJobBuilder.cs │ └── JobBuilder.cs ├── Extensions │ └── ServiceCollectionExtensions.cs ├── Background │ └── JobSchedulerHostedService.cs ├── Job.Scheduler.AspNetCore.csproj └── Configuration │ └── JobSchedulerStartupConfig.cs ├── Job.Scheduler.Tests ├── Mocks │ ├── ThreadJob.cs │ ├── OneTimeJob.cs │ ├── MaxRuntimeJob.cs │ ├── FailingRetringJob.cs │ ├── LongRunningDebounceJob.cs │ ├── OneTimeQueueJob.cs │ ├── DebounceJob.cs │ └── MockTaskScheduler.cs ├── Job.Scheduler.Tests.csproj ├── QueueJobSchedulerTests.cs └── JobSchedulerTests.cs ├── LICENSE ├── package.json ├── Job.Scheduler.sln ├── README.md ├── .gitignore └── CHANGELOG.md /Job.Scheduler.AspNetCore.Tests/Usings.cs: -------------------------------------------------------------------------------- 1 | global using NUnit.Framework; -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: Belphemur 4 | custom: 'https://soundswitch.aaflalo.me/#donate' 5 | -------------------------------------------------------------------------------- /.idea/.idea.Job.Scheduler/.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/.idea.Job.Scheduler/.idea/indexLayout.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /nuget.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Job.Scheduler/Queue/QueueSettings.cs: -------------------------------------------------------------------------------- 1 | namespace Job.Scheduler.Queue; 2 | 3 | /// 4 | /// Settings of the queue 5 | /// 6 | /// UniqueId of the queue 7 | /// Max number of job that can be run concurrently on the queue 8 | public record QueueSettings(string QueueId, int MaxConcurrency); -------------------------------------------------------------------------------- /Job.Scheduler/Queue/QueueJobContainer.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | using Job.Scheduler.Job; 4 | 5 | namespace Job.Scheduler.Queue; 6 | 7 | internal record QueueJobContainer(IJobContainerBuilder JobContainer, TaskScheduler TaskScheduler, CancellationToken Token) 8 | { 9 | public string Key => JobContainer.Key; 10 | } -------------------------------------------------------------------------------- /Job.Scheduler/Job/Data/JobId.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Job.Scheduler.Job.Data 4 | { 5 | public struct JobId 6 | { 7 | /// 8 | /// Unique ID of the job 9 | /// 10 | public Guid UniqueId { get; } 11 | 12 | internal JobId(Guid uniqueId) 13 | { 14 | UniqueId = uniqueId; 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /Job.Scheduler/Job/Exception/JobException.cs: -------------------------------------------------------------------------------- 1 | using Job.Scheduler.Scheduler; 2 | 3 | namespace Job.Scheduler.Job.Exception 4 | { 5 | /// 6 | /// Wrapped exception of the 7 | /// 8 | public class JobException : System.Exception 9 | { 10 | public JobException(string message, System.Exception exception) : base(message, exception) {} 11 | } 12 | } -------------------------------------------------------------------------------- /Job.Scheduler/Job/Exception/MaxRuntimeJobException.cs: -------------------------------------------------------------------------------- 1 | namespace Job.Scheduler.Job.Exception 2 | { 3 | /// 4 | /// Thrown when a Job took longer than it's max runtime 5 | /// 6 | public class MaxRuntimeJobException : JobException 7 | { 8 | public MaxRuntimeJobException(string message, System.Exception exception) : base(message, exception) 9 | { 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /.idea/.idea.Job.Scheduler/.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Rider ignored files 5 | /modules.xml 6 | /projectSettingsUpdater.xml 7 | /contentModel.xml 8 | /.idea.Job.Scheduler.iml 9 | # Datasource local storage ignored files 10 | /../../../../../../../../../:\Users\Antoine\source\repos\Job.Scheduler\.idea\.idea.Job.Scheduler\.idea/dataSources/ 11 | /dataSources.local.xml 12 | # Editor-based HTTP Client requests 13 | /httpRequests/ 14 | -------------------------------------------------------------------------------- /.idea/.idea.Job.Scheduler/.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /Job.Scheduler/Job/Action/NoRetry.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Job.Scheduler.Job.Action 4 | { 5 | /// 6 | /// Don't retry the job 7 | /// 8 | public class NoRetry : IRetryAction 9 | { 10 | 11 | public bool ShouldRetry(int currentRetry) 12 | { 13 | return false; 14 | } 15 | 16 | public TimeSpan? GetDelayBetweenRetries(int currentRetry) 17 | { 18 | return null; 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /Job.Scheduler.AspNetCore/Builder/IJobBuilder.cs: -------------------------------------------------------------------------------- 1 | using Job.Scheduler.Job; 2 | 3 | namespace Job.Scheduler.AspNetCore.Builder; 4 | 5 | /// 6 | /// Helper to build job using the DI of Asp.NET Core 7 | /// 8 | public interface IJobBuilder 9 | { 10 | /// 11 | /// Create a job container of the given type 12 | /// 13 | /// 14 | /// 15 | JobBuilder.Container Create() where T : IJob; 16 | } -------------------------------------------------------------------------------- /Job.Scheduler/Builder/IJobRunnerBuilder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Job.Scheduler.Job; 4 | using Job.Scheduler.Job.Runner; 5 | 6 | namespace Job.Scheduler.Builder 7 | { 8 | public interface IJobRunnerBuilder 9 | { 10 | /// 11 | /// Build a Job runner for the given job 12 | /// 13 | IJobRunner Build(IJobContainerBuilder builder, Func jobDone, TaskScheduler taskScheduler) where TJob : IJob; 14 | } 15 | } -------------------------------------------------------------------------------- /.github/.kodiak.toml: -------------------------------------------------------------------------------- 1 | # Minimal config. version is the only required field. 2 | version = 1 3 | [merge] 4 | automerge_label = "ship it!" 5 | 6 | [merge.automerge_dependencies] 7 | # auto merge all PRs opened by "dependabot" that are "minor" or "patch" version upgrades. "major" version upgrades will be ignored. 8 | versions = ["minor", "patch"] 9 | usernames = ["dependabot"] 10 | 11 | # if using `update.always`, add dependabot to `update.ignore_usernames` to allow 12 | # dependabot to update and close stale dependency upgrades. 13 | [update] 14 | ignored_usernames = ["dependabot"] -------------------------------------------------------------------------------- /Job.Scheduler.AspNetCore.Tests/Mock/OneTimeJob.cs: -------------------------------------------------------------------------------- 1 | using Job.Scheduler.Job; 2 | using Job.Scheduler.Job.Action; 3 | using Job.Scheduler.Job.Exception; 4 | 5 | namespace Job.Scheduler.AspNetCore.Tests.Mock; 6 | 7 | public class OneTimeJob : IJob 8 | { 9 | public IRetryAction FailRule { get; } = new AlwaysRetry(); 10 | public TimeSpan? MaxRuntime { get; } 11 | public Task ExecuteAsync(CancellationToken cancellationToken) 12 | { 13 | return Task.CompletedTask; 14 | } 15 | 16 | public Task OnFailure(JobException exception) 17 | { 18 | return Task.CompletedTask; 19 | } 20 | 21 | } -------------------------------------------------------------------------------- /Job.Scheduler/Utils/TaskUtils.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | 5 | namespace Job.Scheduler.Utils 6 | { 7 | public static class TaskUtils 8 | { 9 | /// 10 | /// Wait for the given delay or a cancellation token to expire. 11 | /// 12 | /// Doesn't trigger an exception 13 | /// 14 | public static async Task WaitForDelayOrCancellation(TimeSpan delay, CancellationToken token) 15 | { 16 | try 17 | { 18 | await Task.Delay(delay, token); 19 | } 20 | catch (OperationCanceledException) 21 | { 22 | } 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /Job.Scheduler/Job/Action/AlwaysRetry.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Job.Scheduler.Job.Action 4 | { 5 | /// 6 | /// Should always retry the job 7 | /// 8 | public class AlwaysRetry : IRetryAction 9 | { 10 | private readonly TimeSpan? _delayBetweenRetries; 11 | 12 | public AlwaysRetry(TimeSpan? delayBetweenRetries = null) 13 | { 14 | _delayBetweenRetries = delayBetweenRetries; 15 | } 16 | 17 | public bool ShouldRetry(int currentRetry) 18 | { 19 | return true; 20 | } 21 | 22 | public TimeSpan? GetDelayBetweenRetries(int currentRetry) 23 | { 24 | return _delayBetweenRetries; 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /Job.Scheduler.Tests/Mocks/ThreadJob.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | 4 | namespace Job.Scheduler.Tests.Mocks 5 | { 6 | public class ThreadJob : OneTimeJob 7 | { 8 | public Thread InitThread { get; } 9 | public Thread RunThread { get; private set; } 10 | 11 | public int? TaskId { get; private set; } 12 | 13 | public ThreadJob(Thread initThread) 14 | { 15 | InitThread = initThread; 16 | } 17 | public override Task ExecuteAsync(CancellationToken cancellationToken) 18 | { 19 | RunThread = Thread.CurrentThread; 20 | TaskId = Task.CurrentId; 21 | return base.ExecuteAsync(cancellationToken); 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /Job.Scheduler.AspNetCore.Tests/Mock/HasRunJob.cs: -------------------------------------------------------------------------------- 1 | using Job.Scheduler.Job; 2 | using Job.Scheduler.Job.Action; 3 | using Job.Scheduler.Job.Exception; 4 | 5 | namespace Job.Scheduler.AspNetCore.Tests.Mock; 6 | 7 | public class HasRunJob : IJob 8 | { 9 | public class Runstate 10 | { 11 | public bool HasRun; 12 | } 13 | 14 | public Runstate Run = null!; 15 | 16 | public IRetryAction FailRule { get; } = new NoRetry(); 17 | public TimeSpan? MaxRuntime { get; } 18 | 19 | public Task ExecuteAsync(CancellationToken cancellationToken) 20 | { 21 | Run.HasRun = true; 22 | return Task.CompletedTask; 23 | } 24 | 25 | public Task OnFailure(JobException exception) 26 | { 27 | return Task.CompletedTask; 28 | } 29 | } -------------------------------------------------------------------------------- /Job.Scheduler/Job/Action/IRetryAction.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Job.Scheduler.Job.Action 4 | { 5 | public interface IRetryAction 6 | { 7 | /// 8 | /// Should the job be retried 9 | /// 10 | /// 11 | bool ShouldRetry(int currentRetry); 12 | 13 | /// 14 | /// Should there be a delay between the retries. 15 | /// 16 | /// Also you're able to define you're own backoff strategy using the . 17 | /// 18 | /// 19 | /// 20 | /// 21 | public TimeSpan? GetDelayBetweenRetries(int currentRetry); 22 | } 23 | } -------------------------------------------------------------------------------- /Job.Scheduler.Tests/Job.Scheduler.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /Job.Scheduler/Job/Runner/QueuedJobRunner.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using JetBrains.Annotations; 5 | 6 | namespace Job.Scheduler.Job.Runner; 7 | 8 | internal class QueuedJobRunner : JobRunner 9 | { 10 | public QueuedJobRunner(IJobContainerBuilder builderJobContainer, Func jobDone, [CanBeNull] TaskScheduler taskScheduler) : base(builderJobContainer, jobDone, taskScheduler) 11 | { 12 | } 13 | 14 | protected override async Task StartJobAsync(IJobContainerBuilder builderJobContainer, CancellationToken token) 15 | { 16 | using var jobContainer = builderJobContainer.BuildJob(); 17 | var job = jobContainer.Job; 18 | await InnerExecuteJob(job, token); 19 | } 20 | } -------------------------------------------------------------------------------- /Job.Scheduler.Tests/Mocks/OneTimeJob.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using Job.Scheduler.Job; 5 | using Job.Scheduler.Job.Action; 6 | using Job.Scheduler.Job.Exception; 7 | 8 | namespace Job.Scheduler.Tests.Mocks 9 | { 10 | public class OneTimeJob : IJob 11 | { 12 | public bool HasRun { get; set; } 13 | 14 | public IRetryAction FailRule { get; } = new NoRetry(); 15 | public TimeSpan? MaxRuntime { get; } 16 | 17 | public virtual Task ExecuteAsync(CancellationToken cancellationToken) 18 | { 19 | HasRun = true; 20 | return Task.CompletedTask; 21 | } 22 | 23 | public Task OnFailure(JobException exception) 24 | { 25 | return Task.FromResult(new AlwaysRetry()); 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /Job.Scheduler/Job/Action/RetryNTimes.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Job.Scheduler.Job.Action 4 | { 5 | /// 6 | /// Retry the job 7 | /// 8 | public class RetryNTimes : IRetryAction 9 | { 10 | private readonly int _maxRetries; 11 | private readonly TimeSpan? _delayBetweenRetries; 12 | 13 | 14 | public RetryNTimes(int maxRetries, TimeSpan? delayBetweenRetries = null) 15 | { 16 | _maxRetries = maxRetries; 17 | _delayBetweenRetries = delayBetweenRetries; 18 | } 19 | 20 | public bool ShouldRetry(int currentRetry) 21 | { 22 | return currentRetry < _maxRetries; 23 | } 24 | 25 | public TimeSpan? GetDelayBetweenRetries(int currentRetry) 26 | { 27 | return _delayBetweenRetries; 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /Job.Scheduler/Job/Runner/OneTimeJobRunner.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using JetBrains.Annotations; 5 | 6 | namespace Job.Scheduler.Job.Runner 7 | { 8 | internal class OneTimeJobRunner : JobRunner 9 | { 10 | public OneTimeJobRunner(IJobContainerBuilder builderJobContainer, Func jobDone, [CanBeNull] TaskScheduler taskScheduler) : base(builderJobContainer, jobDone, taskScheduler) 11 | { 12 | } 13 | 14 | protected override async Task StartJobAsync(IJobContainerBuilder builderJobContainer, CancellationToken token) 15 | { 16 | using var jobContainer = builderJobContainer.BuildJob(); 17 | var job = jobContainer.Job; 18 | await InnerExecuteJob(job, token); 19 | } 20 | 21 | } 22 | } -------------------------------------------------------------------------------- /Job.Scheduler/Job/Action/ExponentialBackoffRetry.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Job.Scheduler.Job.Action 4 | { 5 | /// 6 | /// Exponential backoff strategy where we wait more and more between retries 7 | /// 8 | public class ExponentialBackoffRetry : BackoffRetry 9 | { 10 | /// 11 | /// Exponential backoff 12 | /// 13 | /// Base delay that will be exponentially increased at each retries 14 | /// null = always retries. Any other value simple set the maximum of retries 15 | public ExponentialBackoffRetry(TimeSpan baseDelay, int? maxRetries) : base(currentRetry => TimeSpan.FromMilliseconds(baseDelay.TotalMilliseconds * Math.Pow(2, currentRetry)), maxRetries) 16 | { 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /Job.Scheduler.AspNetCore.Tests/Job.Scheduler.AspNetCore.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | enable 6 | enable 7 | 8 | false 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /Job.Scheduler/Job/Runner/DebounceJobRunner.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using JetBrains.Annotations; 5 | using Job.Scheduler.Utils; 6 | 7 | namespace Job.Scheduler.Job.Runner 8 | { 9 | internal class DebounceJobRunner : JobRunner 10 | { 11 | public DebounceJobRunner(IJobContainerBuilder builderJobContainer, Func jobDone, [CanBeNull] TaskScheduler taskScheduler) : base(builderJobContainer, jobDone, taskScheduler) 12 | { 13 | } 14 | 15 | public override string Key => BuilderJobContainer.Key; 16 | 17 | protected override async Task StartJobAsync(IJobContainerBuilder builderJobContainer, CancellationToken token) 18 | { 19 | using var jobContainer = builderJobContainer.BuildJob(); 20 | var job = jobContainer.Job; 21 | await InnerExecuteJob(job, token); 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /Job.Scheduler.Tests/Mocks/MaxRuntimeJob.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using Job.Scheduler.Job; 5 | using Job.Scheduler.Job.Action; 6 | using Job.Scheduler.Job.Exception; 7 | 8 | namespace Job.Scheduler.Tests.Mocks 9 | { 10 | public class MaxRuntimeJob : IJob 11 | { 12 | public IRetryAction FailRule { get; } 13 | public TimeSpan? MaxRuntime { get; } 14 | 15 | public MaxRuntimeJob(IRetryAction failRule, TimeSpan? maxRuntime) 16 | { 17 | FailRule = failRule; 18 | MaxRuntime = maxRuntime; 19 | } 20 | 21 | 22 | public async Task ExecuteAsync(CancellationToken cancellationToken) 23 | { 24 | while (true) 25 | { 26 | await Task.Delay(1, cancellationToken); 27 | } 28 | } 29 | 30 | public Task OnFailure(JobException exception) 31 | { 32 | return Task.CompletedTask; 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /Job.Scheduler.Tests/Mocks/FailingRetringJob.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using Job.Scheduler.Job; 5 | using Job.Scheduler.Job.Action; 6 | using Job.Scheduler.Job.Exception; 7 | 8 | namespace Job.Scheduler.Tests.Mocks 9 | { 10 | 11 | public class FailingRetringJob : IJob 12 | { 13 | 14 | public int Ran { get; private set; } 15 | 16 | public IRetryAction FailRule { get; } 17 | public TimeSpan? MaxRuntime { get; } 18 | 19 | public FailingRetringJob(IRetryAction failRule) 20 | { 21 | FailRule = failRule; 22 | } 23 | 24 | public Task ExecuteAsync(CancellationToken cancellationToken) 25 | { 26 | Ran++; 27 | throw new Exception("Test"); 28 | } 29 | 30 | public Task OnFailure(JobException exception) 31 | { 32 | return Task.CompletedTask; 33 | } 34 | 35 | public TimeSpan Delay { get; } = TimeSpan.FromMilliseconds(10); 36 | } 37 | } -------------------------------------------------------------------------------- /Job.Scheduler.Tests/Mocks/LongRunningDebounceJob.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using Job.Scheduler.Job; 6 | using Job.Scheduler.Job.Action; 7 | using Job.Scheduler.Job.Exception; 8 | using Job.Scheduler.Utils; 9 | 10 | namespace Job.Scheduler.Tests.Mocks 11 | { 12 | public class LongRunningDebounceJob : DebounceJob 13 | { 14 | public bool HasBeenInterrupted { get; private set; } 15 | public LongRunningDebounceJob(List list, string key, int id) : base(list, key, id) 16 | { 17 | } 18 | 19 | public override async Task ExecuteAsync(CancellationToken cancellationToken) 20 | { 21 | await TaskUtils.WaitForDelayOrCancellation(TimeSpan.FromSeconds(10), cancellationToken); 22 | if (cancellationToken.IsCancellationRequested) 23 | { 24 | HasBeenInterrupted = true; 25 | return; 26 | } 27 | await base.ExecuteAsync(cancellationToken); 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /Job.Scheduler/Job/Data/Debouncer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Job.Scheduler.Job.Runner; 3 | using Job.Scheduler.Utils; 4 | 5 | namespace Job.Scheduler.Job.Data; 6 | 7 | internal class Debouncer : IDisposable 8 | { 9 | public DebounceJobRunner JobRunner { get; private set; } 10 | private readonly DebounceDispatcher _debouncer; 11 | 12 | public Debouncer(IDebounceJob job, DebounceJobRunner jobRunner) 13 | { 14 | JobRunner = jobRunner; 15 | _debouncer = new DebounceDispatcher(job.DebounceTime, _ => 16 | { 17 | JobRunner.Start(); 18 | }, null); 19 | } 20 | 21 | 22 | public void Debounce(DebounceJobRunner jobRunner) 23 | { 24 | var stoppingTask = JobRunner.StopAsync(default); 25 | _debouncer.Debounce(); 26 | JobRunner = jobRunner; 27 | stoppingTask.GetAwaiter().GetResult(); 28 | } 29 | 30 | public void Start() 31 | { 32 | _debouncer.Debounce(); 33 | } 34 | 35 | public void Dispose() 36 | { 37 | _debouncer?.Dispose(); 38 | } 39 | } -------------------------------------------------------------------------------- /Job.Scheduler/Job/Runner/DelayedJobRunner.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using JetBrains.Annotations; 5 | using Job.Scheduler.Utils; 6 | 7 | namespace Job.Scheduler.Job.Runner 8 | { 9 | internal class DelayedJobRunner : JobRunner 10 | { 11 | public DelayedJobRunner(IJobContainerBuilder builderJobContainer, Func jobDone, [CanBeNull] TaskScheduler taskScheduler) : base(builderJobContainer, jobDone, taskScheduler) 12 | { 13 | } 14 | 15 | protected override async Task StartJobAsync(IJobContainerBuilder builderJobContainer, CancellationToken token) 16 | { 17 | using var jobContainer = builderJobContainer.BuildJob(); 18 | var job = jobContainer.Job; 19 | await TaskUtils.WaitForDelayOrCancellation(job.Delay, token); 20 | if (token.IsCancellationRequested) 21 | { 22 | return; 23 | } 24 | 25 | await InnerExecuteJob(job, token); 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /Job.Scheduler/Job/Runner/RecurringJobRunner.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using JetBrains.Annotations; 5 | using Job.Scheduler.Utils; 6 | 7 | namespace Job.Scheduler.Job.Runner 8 | { 9 | internal class RecurringJobRunner : JobRunner 10 | { 11 | public RecurringJobRunner(IJobContainerBuilder builderJobContainer, Func jobDone, [CanBeNull] TaskScheduler taskScheduler) : base(builderJobContainer, jobDone, taskScheduler) 12 | { 13 | } 14 | 15 | protected override async Task StartJobAsync(IJobContainerBuilder builderJobContainer, CancellationToken token) 16 | { 17 | while (!token.IsCancellationRequested) 18 | { 19 | using var jobContainer = builderJobContainer.BuildJob(); 20 | var job = jobContainer.Job; 21 | await InnerExecuteJob(job, token); 22 | 23 | await TaskUtils.WaitForDelayOrCancellation(job.Delay, token); 24 | } 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Antoine Aflalo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Job.Scheduler.AspNetCore.Tests/Mock/OneTimeQueueJob.cs: -------------------------------------------------------------------------------- 1 | using Job.Scheduler.Job; 2 | using Job.Scheduler.Job.Action; 3 | using Job.Scheduler.Job.Exception; 4 | using Job.Scheduler.Utils; 5 | 6 | namespace Job.Scheduler.AspNetCore.Tests.Mock 7 | { 8 | public class OneTimeQueueJob : IQueueJob 9 | { 10 | public class RunStateInfo 11 | { 12 | public bool HasRun { get; set; } 13 | } 14 | public TimeSpan WaitTime { get; set; } 15 | 16 | 17 | public RunStateInfo RunState { get; set; } 18 | 19 | public IRetryAction FailRule { get; } = new NoRetry(); 20 | public TimeSpan? MaxRuntime { get; } 21 | 22 | public virtual async Task ExecuteAsync(CancellationToken cancellationToken) 23 | { 24 | await TaskUtils.WaitForDelayOrCancellation(WaitTime, cancellationToken); 25 | RunState.HasRun = true; 26 | } 27 | 28 | public Task OnFailure(JobException exception) 29 | { 30 | return Task.CompletedTask; 31 | } 32 | 33 | public string Key { get; set; } = "test"; 34 | public string QueueId { get; set; } = "test"; 35 | } 36 | } -------------------------------------------------------------------------------- /Job.Scheduler.Tests/Mocks/OneTimeQueueJob.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using Job.Scheduler.Job; 5 | using Job.Scheduler.Job.Action; 6 | using Job.Scheduler.Job.Exception; 7 | using Job.Scheduler.Utils; 8 | 9 | namespace Job.Scheduler.Tests.Mocks 10 | { 11 | public class OneTimeQueueJob : IQueueJob 12 | { 13 | private readonly TimeSpan _waitTime; 14 | 15 | public OneTimeQueueJob(TimeSpan waitTime) 16 | { 17 | _waitTime = waitTime; 18 | } 19 | 20 | public bool HasRun { get; set; } 21 | 22 | public IRetryAction FailRule { get; } = new NoRetry(); 23 | public TimeSpan? MaxRuntime { get; } 24 | 25 | public virtual async Task ExecuteAsync(CancellationToken cancellationToken) 26 | { 27 | await TaskUtils.WaitForDelayOrCancellation(_waitTime, cancellationToken); 28 | HasRun = true; 29 | } 30 | 31 | public Task OnFailure(JobException exception) 32 | { 33 | return Task.CompletedTask; 34 | } 35 | 36 | public string Key { get; set; } = "test"; 37 | public string QueueId { get; set; } = "test"; 38 | } 39 | } -------------------------------------------------------------------------------- /Job.Scheduler.Tests/Mocks/DebounceJob.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using Job.Scheduler.Job; 6 | using Job.Scheduler.Job.Action; 7 | using Job.Scheduler.Job.Exception; 8 | 9 | namespace Job.Scheduler.Tests.Mocks 10 | { 11 | public class DebounceJob : IDebounceJob 12 | { 13 | public IRetryAction FailRule { get; } = new NoRetry(); 14 | public TimeSpan? MaxRuntime { get; } 15 | 16 | private readonly List _list; 17 | private readonly int _id; 18 | 19 | public DebounceJob(List list, string key, int id) 20 | { 21 | _list = list; 22 | _id = id; 23 | Key = key; 24 | } 25 | 26 | public virtual Task ExecuteAsync(CancellationToken cancellationToken) 27 | { 28 | _list.Add(Key + _id); 29 | return Task.CompletedTask; 30 | } 31 | 32 | public Task OnFailure(JobException exception) 33 | { 34 | return Task.CompletedTask; 35 | } 36 | 37 | public string Key { get; } 38 | public TimeSpan DebounceTime { get; } = TimeSpan.FromMilliseconds(100); 39 | } 40 | } -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | labels: 13 | - "dependencies" 14 | # Add Kodiak `merge.automerge_label` 15 | - "ship it!" 16 | - package-ecosystem: "nuget" # See documentation for possible values 17 | directory: "/" # Location of package manifests 18 | schedule: 19 | interval: "daily" 20 | labels: 21 | - "dependencies" 22 | # Add Kodiak `merge.automerge_label` 23 | - "ship it!" 24 | - package-ecosystem: "github-actions" 25 | directory: "/" 26 | schedule: 27 | # Check for updates to GitHub Actions every weekday 28 | interval: "daily" 29 | labels: 30 | - "dependencies" 31 | # Add Kodiak `merge.automerge_label` 32 | - "ship it!" 33 | -------------------------------------------------------------------------------- /Job.Scheduler/Job/Action/BackoffRetry.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Job.Scheduler.Job.Action 4 | { 5 | public class BackoffRetry : IRetryAction 6 | { 7 | private readonly int? _maxRetries; 8 | private readonly Func _retryStrategy; 9 | 10 | /// 11 | /// Implement your own backoff strategy like 12 | /// 13 | /// Implement your own strategy for delay between retries based on the current retry 14 | /// null to always retry 15 | public BackoffRetry(Func retryStrategy, int? maxRetries) 16 | { 17 | _maxRetries = maxRetries; 18 | _retryStrategy = retryStrategy; 19 | } 20 | 21 | public bool ShouldRetry(int currentRetry) 22 | { 23 | if (!_maxRetries.HasValue) 24 | { 25 | return true; 26 | } 27 | 28 | return currentRetry < _maxRetries; 29 | } 30 | 31 | public TimeSpan? GetDelayBetweenRetries(int currentRetry) 32 | { 33 | return _retryStrategy(currentRetry); 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /Job.Scheduler/Job/Action/ExponentialDecorrelatedJittedBackoffRetry.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Polly.Contrib.WaitAndRetry; 4 | 5 | namespace Job.Scheduler.Job.Action; 6 | 7 | /// 8 | /// Add some jitter to the exponential backoff to avoid having the job retrying at the same time 9 | /// 10 | public class ExponentialDecorrelatedJittedBackoffRetry : IRetryAction 11 | { 12 | private readonly Lazy> _retryTimes; 13 | 14 | /// 15 | /// Add some jitter to the exponential backoff to avoid having the job retrying at the same time 16 | /// 17 | /// 18 | /// 19 | public ExponentialDecorrelatedJittedBackoffRetry(int maxRetries, TimeSpan medianDelay) 20 | { 21 | _retryTimes = new Lazy>(() => new Queue(Backoff.DecorrelatedJitterBackoffV2(medianFirstRetryDelay: medianDelay, retryCount: maxRetries))); 22 | } 23 | 24 | public bool ShouldRetry(int currentRetry) 25 | { 26 | return _retryTimes.Value.Count > 0; 27 | } 28 | 29 | public TimeSpan? GetDelayBetweenRetries(int currentRetry) 30 | { 31 | return _retryTimes.Value.Dequeue(); 32 | } 33 | } -------------------------------------------------------------------------------- /Job.Scheduler/Job/IJobContainerBuilder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using JetBrains.Annotations; 5 | 6 | #nullable enable 7 | namespace Job.Scheduler.Job; 8 | 9 | /// 10 | /// Container of a job used to wrap a job and handle case of disposing 11 | /// 12 | public interface IJobContainerBuilder where TJob : IJob 13 | { 14 | /// 15 | /// Ran when the job has finished running 16 | /// 17 | /// 18 | /// 19 | public Task OnCompletedAsync(CancellationToken token); 20 | 21 | /// 22 | /// Build the job to be run 23 | /// 24 | /// 25 | public IJobContainer BuildJob(); 26 | 27 | /// 28 | /// Type of the contained job 29 | /// 30 | public Type JobType { get; } 31 | 32 | /// 33 | /// Key of the job 34 | /// 35 | public string Key { get; } 36 | 37 | /// 38 | /// Id of queue if present 39 | /// 40 | public string? QueueId { get; } 41 | } 42 | 43 | public interface IJobContainer : IDisposable where TJob : IJob 44 | { 45 | /// 46 | /// The job 47 | /// 48 | public TJob Job { get; } 49 | } -------------------------------------------------------------------------------- /Job.Scheduler/LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020, Antoine Aflalo 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 3. Neither the name of the Antoine Aflalo nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 9 | 10 | THIS SOFTWARE IS PROVIDED BY ''AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 11 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Job.Scheduler", 3 | "release": { 4 | "plugins": [ 5 | "@semantic-release/commit-analyzer", 6 | "@semantic-release/release-notes-generator", 7 | "@semantic-release/changelog", 8 | [ 9 | "@semantic-release/exec", 10 | { 11 | "prepareCmd": "dotnet pack -v normal -c Release --include-symbols --include-source -o nupkg -p:Version=${nextRelease.version} -p:PackageVersion=${nextRelease.version} -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg ${process.env.PROJECT_NAME}/${process.env.PROJECT_NAME}.*proj && dotnet pack -v normal -c Release --include-symbols --include-source -o nupkg -p:PackageVersion=${nextRelease.version} -p:Version=${nextRelease.version} -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg ${process.env.PROJECT_NAME}.AspNetCore/${process.env.PROJECT_NAME}.AspNetCore.*proj", 12 | "publishCmd": "dotnet nuget push nupkg/*.nupkg --source ${process.env.NUGET_FEED} --skip-duplicate --api-key ${process.env.NUGET_KEY}" 13 | } 14 | ], 15 | "@semantic-release/git", 16 | [ 17 | "@semantic-release/github", 18 | { 19 | "assets": [ 20 | { 21 | "path": "nupkg/*.*nupkg" 22 | } 23 | ] 24 | } 25 | ] 26 | ] 27 | }, 28 | "devDependencies": { 29 | "@semantic-release/changelog": "^6.0.3", 30 | "@semantic-release/commit-analyzer": "^13.0.0", 31 | "@semantic-release/exec": "^6.0.3", 32 | "@semantic-release/git": "^10.0.1", 33 | "@semantic-release/github": "^11.0.1", 34 | "@semantic-release/release-notes-generator": "^14.0.1", 35 | "semantic-release": "^24.2.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Job.Scheduler/Job/Runner/IJobRunner.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | 5 | namespace Job.Scheduler.Job.Runner 6 | { 7 | /// 8 | /// Used to run the job 9 | /// 10 | public interface IJobRunner 11 | { 12 | /// 13 | /// Unique ID of the job runner 14 | /// 15 | Guid UniqueId { get; } 16 | 17 | /// 18 | /// Type of the job that is run by the runner 19 | /// 20 | Type JobType { get; } 21 | 22 | /// 23 | /// Is the job still running 24 | /// 25 | bool IsRunning { get; } 26 | 27 | /// 28 | /// For how long is the job running 29 | /// 30 | TimeSpan Elapsed { get; } 31 | 32 | /// 33 | /// Number of time the job has been retried 34 | /// 35 | int Retries { get; } 36 | 37 | /// 38 | /// Key of the job, used for deduplication 39 | /// 40 | string Key { get; } 41 | 42 | /// 43 | /// Run the job 44 | /// 45 | /// Optional token to sync with it 46 | /// 47 | void Start(CancellationToken token = default); 48 | 49 | /// 50 | /// Stop the task and wait for it to terminate 51 | /// 52 | /// 53 | public Task StopAsync(CancellationToken token); 54 | 55 | /// 56 | /// Wait for the task to end 57 | /// 58 | /// 59 | internal Task WaitForJob(); 60 | } 61 | } -------------------------------------------------------------------------------- /Job.Scheduler.AspNetCore/Extensions/ServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using Job.Scheduler.AspNetCore.Background; 2 | using Job.Scheduler.AspNetCore.Builder; 3 | using Job.Scheduler.AspNetCore.Configuration; 4 | using Job.Scheduler.Builder; 5 | using Job.Scheduler.Job; 6 | using Job.Scheduler.Scheduler; 7 | using Microsoft.Extensions.DependencyInjection; 8 | 9 | namespace Job.Scheduler.AspNetCore.Extensions; 10 | 11 | public static class ServiceCollectionExtensions 12 | { 13 | /// 14 | /// Register the job scheduler 15 | /// 16 | /// 17 | /// Used to setup Startup Jobs 18 | /// 19 | public static IServiceCollection AddJobScheduler(this IServiceCollection services, Action? config = null) 20 | { 21 | services.AddSingleton(); 22 | services.AddSingleton(); 23 | services.AddSingleton(provider => 24 | { 25 | var startupConfig = new JobSchedulerStartupConfig(provider.GetRequiredService()); 26 | config?.Invoke(startupConfig); 27 | return startupConfig; 28 | }); 29 | services.AddHostedService(); 30 | services.AddSingleton(); 31 | 32 | return services; 33 | } 34 | 35 | /// 36 | /// Register the job in the service collection 37 | /// 38 | /// 39 | /// 40 | /// 41 | public static IServiceCollection AddJob(this IServiceCollection services) where T : IJob 42 | { 43 | services.AddTransient(typeof(T)); 44 | return services; 45 | } 46 | } -------------------------------------------------------------------------------- /Job.Scheduler.AspNetCore/Background/JobSchedulerHostedService.cs: -------------------------------------------------------------------------------- 1 | #region 2 | 3 | using Job.Scheduler.AspNetCore.Configuration; 4 | using Job.Scheduler.Job; 5 | using Job.Scheduler.Scheduler; 6 | using Microsoft.Extensions.Hosting; 7 | 8 | #endregion 9 | 10 | namespace Job.Scheduler.AspNetCore.Background; 11 | 12 | public class JobSchedulerHostedService : IHostedService 13 | { 14 | private readonly IJobScheduler _jobScheduler; 15 | private readonly JobSchedulerStartupConfig _config; 16 | 17 | public JobSchedulerHostedService(IJobScheduler jobScheduler, JobSchedulerStartupConfig config) 18 | { 19 | _jobScheduler = jobScheduler; 20 | _config = config; 21 | } 22 | 23 | public Task StartAsync(CancellationToken cancellationToken) 24 | { 25 | foreach (var queueSetting in _config.QueueSettings) 26 | { 27 | _jobScheduler.RegisterQueue(queueSetting); 28 | } 29 | 30 | foreach (var containerJob in _config.QueueJobs) 31 | { 32 | _jobScheduler.ScheduleJob(containerJob, cancellationToken); 33 | } 34 | 35 | foreach (var containerJob in _config.DebounceJobs) 36 | { 37 | _jobScheduler.ScheduleJob(containerJob, cancellationToken); 38 | } 39 | 40 | foreach (var containerJob in _config.DelayedJobs) 41 | { 42 | _jobScheduler.ScheduleJob(containerJob, cancellationToken); 43 | } 44 | 45 | foreach (var containerJob in _config.RecurringJobs) 46 | { 47 | _jobScheduler.ScheduleJob(containerJob, cancellationToken); 48 | } 49 | 50 | foreach (var containerJob in _config.OneTimeJobs) 51 | { 52 | _jobScheduler.ScheduleJob(containerJob, cancellationToken); 53 | } 54 | 55 | return Task.CompletedTask; 56 | } 57 | 58 | public Task StopAsync(CancellationToken cancellationToken) 59 | { 60 | return _jobScheduler.StopAsync(cancellationToken); 61 | } 62 | } -------------------------------------------------------------------------------- /Job.Scheduler.AspNetCore/Job.Scheduler.AspNetCore.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.1 5 | enable 6 | enable 7 | latest 8 | 9 | 10 | 11 | Job.Scheduler.AspNetCore 12 | Antoine Aflalo 13 | A simple job scheduling library relying on the async/await pattern in C#. Supports Recurring Jobs, Delayed Jobs and One Time Jobs. Helper for ASP.NET Core. 14 | MIT 15 | https://github.com/Belphemur/Job.Scheduler 16 | https://github.com/Belphemur/Job.Scheduler 17 | README.md 18 | 19 | 20 | 21 | true 22 | true 23 | true 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | <_Parameter1>Job.Scheduler.AspNetCore.Tests 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /Job.Scheduler/Builder/JobRunnerBuilder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using Job.Scheduler.Job; 7 | using Job.Scheduler.Job.Runner; 8 | 9 | namespace Job.Scheduler.Builder 10 | { 11 | /// 12 | /// Take care of building the runner for different type of 13 | /// 14 | public class JobRunnerBuilder : IJobRunnerBuilder 15 | { 16 | private readonly Dictionary _jobTypeToRunnerTypeDictionary; 17 | private readonly ConcurrentDictionary _jobToRunner = new(); 18 | 19 | public JobRunnerBuilder() 20 | { 21 | var jobRunnerType = typeof(IJobRunner); 22 | _jobTypeToRunnerTypeDictionary = jobRunnerType.Assembly.GetTypes() 23 | .Where(type => type.IsClass && !type.IsAbstract) 24 | .Where(type => jobRunnerType.IsAssignableFrom(type) && type.BaseType?.IsAbstract == true) 25 | .ToDictionary(type => type.BaseType.GetGenericArguments().First()); 26 | } 27 | 28 | /// 29 | /// Build a Job runner for the given job 30 | /// 31 | public IJobRunner Build(IJobContainerBuilder builder, Func jobDone, TaskScheduler taskScheduler) where TJob : IJob 32 | { 33 | var mainTypeJob = builder.JobType; 34 | 35 | _jobTypeToRunnerTypeDictionary.TryGetValue(mainTypeJob, out var runner); 36 | 37 | if (runner == null && !_jobToRunner.TryGetValue(mainTypeJob, out runner)) 38 | { 39 | var typeOfJob = mainTypeJob.GetInterfaces().Intersect(_jobTypeToRunnerTypeDictionary.Keys).First(); 40 | runner = _jobTypeToRunnerTypeDictionary[typeOfJob]; 41 | _jobToRunner.TryAdd(mainTypeJob, runner); 42 | } 43 | 44 | return (IJobRunner)Activator.CreateInstance(runner, builder, jobDone, taskScheduler); 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /Job.Scheduler/Job.Scheduler.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.1 5 | latest 6 | 7 | 8 | 9 | Job.Scheduler 10 | Antoine Aflalo 11 | A simple job scheduling library relying on the async/await pattern in C#. Supports Recurring Jobs, Delayed Jobs and One Time Jobs. 12 | MIT 13 | https://github.com/Belphemur/Job.Scheduler 14 | https://github.com/Belphemur/Job.Scheduler 15 | true 16 | README.md 17 | 18 | 19 | true 20 | true 21 | true 22 | 23 | 24 | 25 | all 26 | runtime; build; native; contentfiles; analyzers; buildtransitive 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | <_Parameter1>Job.Scheduler.Tests 39 | 40 | 41 | <_Parameter1>Job.Scheduler.AspNetCore.Tests 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /Job.Scheduler.Tests/Mocks/MockTaskScheduler.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | 7 | namespace Job.Scheduler.Tests.Mocks 8 | { 9 | public class MockTaskScheduler : TaskScheduler, IDisposable 10 | { 11 | private readonly BlockingCollection _tasksCollection = new(); 12 | public Thread MainThread { get; } 13 | private readonly CancellationTokenSource _cts = new(); 14 | public int Scheduled { get; private set; } 15 | 16 | public MockTaskScheduler() 17 | { 18 | MainThread = new Thread(Execute) 19 | { 20 | Name = "Mock Thread" 21 | }; 22 | if (!MainThread.IsAlive) 23 | { 24 | MainThread.Start(); 25 | } 26 | } 27 | 28 | private void Execute() 29 | { 30 | try 31 | { 32 | foreach (var task in _tasksCollection.GetConsumingEnumerable(_cts.Token)) 33 | { 34 | Scheduled++; 35 | TryExecuteTask(task); 36 | } 37 | } 38 | catch (OperationCanceledException) 39 | { 40 | //ignored, just stop 41 | } 42 | catch (ObjectDisposedException) 43 | { 44 | //ignored, just stop 45 | } 46 | } 47 | 48 | protected override IEnumerable GetScheduledTasks() 49 | { 50 | return _tasksCollection.ToArray(); 51 | } 52 | 53 | protected override void QueueTask(Task task) 54 | { 55 | if (task != null) 56 | _tasksCollection.Add(task); 57 | } 58 | 59 | protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) 60 | { 61 | return false; 62 | } 63 | 64 | private void Dispose(bool disposing) 65 | { 66 | if (!disposing) return; 67 | _tasksCollection.CompleteAdding(); 68 | _cts.Cancel(); 69 | _cts.Dispose(); 70 | _tasksCollection.Dispose(); 71 | MainThread.Join(); 72 | } 73 | 74 | public void Dispose() 75 | { 76 | Dispose(true); 77 | GC.SuppressFinalize(this); 78 | } 79 | } 80 | } -------------------------------------------------------------------------------- /Job.Scheduler.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Job.Scheduler", "Job.Scheduler\Job.Scheduler.csproj", "{8C1B3F75-D170-4727-9CFD-000BACFDBA83}" 4 | EndProject 5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Job.Scheduler.Tests", "Job.Scheduler.Tests\Job.Scheduler.Tests.csproj", "{459E1A79-D974-4506-BFEB-7A29BC9A350B}" 6 | EndProject 7 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Job.Scheduler.AspNetCore", "Job.Scheduler.AspNetCore\Job.Scheduler.AspNetCore.csproj", "{284EA199-B357-4C25-B551-2ABF68ED5D70}" 8 | EndProject 9 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Job.Scheduler.AspNetCore.Tests", "Job.Scheduler.AspNetCore.Tests\Job.Scheduler.AspNetCore.Tests.csproj", "{F224EA1E-F83F-4EDE-84D3-2A258C7E1E14}" 10 | EndProject 11 | Global 12 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 13 | Debug|Any CPU = Debug|Any CPU 14 | Release|Any CPU = Release|Any CPU 15 | EndGlobalSection 16 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 17 | {8C1B3F75-D170-4727-9CFD-000BACFDBA83}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 18 | {8C1B3F75-D170-4727-9CFD-000BACFDBA83}.Debug|Any CPU.Build.0 = Debug|Any CPU 19 | {8C1B3F75-D170-4727-9CFD-000BACFDBA83}.Release|Any CPU.ActiveCfg = Release|Any CPU 20 | {8C1B3F75-D170-4727-9CFD-000BACFDBA83}.Release|Any CPU.Build.0 = Release|Any CPU 21 | {459E1A79-D974-4506-BFEB-7A29BC9A350B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 22 | {459E1A79-D974-4506-BFEB-7A29BC9A350B}.Debug|Any CPU.Build.0 = Debug|Any CPU 23 | {459E1A79-D974-4506-BFEB-7A29BC9A350B}.Release|Any CPU.ActiveCfg = Release|Any CPU 24 | {459E1A79-D974-4506-BFEB-7A29BC9A350B}.Release|Any CPU.Build.0 = Release|Any CPU 25 | {284EA199-B357-4C25-B551-2ABF68ED5D70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 26 | {284EA199-B357-4C25-B551-2ABF68ED5D70}.Debug|Any CPU.Build.0 = Debug|Any CPU 27 | {284EA199-B357-4C25-B551-2ABF68ED5D70}.Release|Any CPU.ActiveCfg = Release|Any CPU 28 | {284EA199-B357-4C25-B551-2ABF68ED5D70}.Release|Any CPU.Build.0 = Release|Any CPU 29 | {F224EA1E-F83F-4EDE-84D3-2A258C7E1E14}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 30 | {F224EA1E-F83F-4EDE-84D3-2A258C7E1E14}.Debug|Any CPU.Build.0 = Debug|Any CPU 31 | {F224EA1E-F83F-4EDE-84D3-2A258C7E1E14}.Release|Any CPU.ActiveCfg = Release|Any CPU 32 | {F224EA1E-F83F-4EDE-84D3-2A258C7E1E14}.Release|Any CPU.Build.0 = Release|Any CPU 33 | EndGlobalSection 34 | EndGlobal 35 | -------------------------------------------------------------------------------- /Job.Scheduler.AspNetCore.Tests/JobSchedulerAspNetCoreTests.cs: -------------------------------------------------------------------------------- 1 | using FluentAssertions; 2 | using Job.Scheduler.AspNetCore.Background; 3 | using Job.Scheduler.AspNetCore.Builder; 4 | using Job.Scheduler.AspNetCore.Configuration; 5 | using Job.Scheduler.AspNetCore.Extensions; 6 | using Job.Scheduler.AspNetCore.Tests.Mock; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Microsoft.Extensions.Hosting; 9 | 10 | namespace Job.Scheduler.AspNetCore.Tests; 11 | 12 | public class Tests 13 | { 14 | private ServiceCollection _services; 15 | 16 | [SetUp] 17 | public void Setup() 18 | { 19 | _services = new ServiceCollection(); 20 | } 21 | 22 | [Test] 23 | public void TestJobBuilder() 24 | { 25 | _services.AddJob(); 26 | _services.AddSingleton(); 27 | 28 | var serviceProvider = _services.BuildServiceProvider(); 29 | 30 | var builder = serviceProvider.GetRequiredService(); 31 | var container = builder.Create() 32 | .Build(); 33 | container.Should().BeOfType>(); 34 | 35 | var job = container.BuildJob().Job; 36 | 37 | job.Should().BeOfType(); 38 | } 39 | 40 | [Test] 41 | public void TestJobConfig() 42 | { 43 | _services.AddJob(); 44 | _services.AddJobScheduler(config => { config.AddStartupJob(builder => builder.Create().Build()); }); 45 | var container = _services.BuildServiceProvider(); 46 | 47 | var config = container.GetRequiredService(); 48 | config.OneTimeJobs.Should().ContainSingle(job => job.JobType == typeof(OneTimeJob)); 49 | } 50 | 51 | [Test] 52 | public async Task TestJobHostedService() 53 | { 54 | var run = new HasRunJob.Runstate(); 55 | _services.AddJobScheduler(config => 56 | { 57 | config.AddStartupJob(builder => builder.Create() 58 | .Configure(runJob => runJob.Run = run) 59 | .Build()); 60 | }); 61 | _services.AddJob(); 62 | 63 | var container = _services.BuildServiceProvider(); 64 | 65 | var hostedServices = container.GetRequiredService>(); 66 | var hostedService = hostedServices.First(); 67 | 68 | hostedService.Should().BeOfType(); 69 | 70 | await hostedService.StartAsync(CancellationToken.None); 71 | 72 | await Task.Delay(TimeSpan.FromMilliseconds(10)); 73 | 74 | await hostedService.StopAsync(CancellationToken.None); 75 | 76 | run.HasRun.Should().BeTrue("Job has run part of startup"); 77 | } 78 | } -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ develop, master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ develop ] 20 | schedule: 21 | - cron: '33 6 * * 4' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'csharp' ] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v4 39 | - name: NuGet Deps Cache 40 | uses: actions/cache@v4 41 | with: 42 | path: ~/.nuget/packages 43 | # Look to see if there is a cache hit for the corresponding requirements file 44 | key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }} 45 | restore-keys: | 46 | ${{ runner.os }}-nuget 47 | 48 | # Initializes the CodeQL tools for scanning. 49 | - name: Initialize CodeQL 50 | uses: github/codeql-action/init@v3 51 | with: 52 | languages: ${{ matrix.language }} 53 | # If you wish to specify custom queries, you can do so here or in a config file. 54 | # By default, queries listed here will override any specified in a config file. 55 | # Prefix the list here with "+" to use these queries and those in the config file. 56 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 57 | 58 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 59 | # If this step fails, then you should remove it and run the build manually (see below) 60 | - name: Autobuild 61 | uses: github/codeql-action/autobuild@v3 62 | 63 | # ℹ️ Command-line programs to run using the OS shell. 64 | # 📚 https://git.io/JvXDl 65 | 66 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 67 | # and modify them (or add more) to build your code if your project 68 | # uses a compiled language 69 | 70 | #- run: | 71 | # make bootstrap 72 | # make release 73 | 74 | - name: Perform CodeQL Analysis 75 | uses: github/codeql-action/analyze@v3 76 | -------------------------------------------------------------------------------- /Job.Scheduler/Scheduler/IJobScheduler.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using Job.Scheduler.Job; 5 | using Job.Scheduler.Job.Data; 6 | using Job.Scheduler.Job.Runner; 7 | using Job.Scheduler.Queue; 8 | 9 | namespace Job.Scheduler.Scheduler 10 | { 11 | public interface IJobScheduler 12 | { 13 | /// 14 | /// Schedule a new job to run 15 | /// 16 | /// The job to run 17 | /// If you want to cancel easily this specific job later. Default = None 18 | /// In which TaskScheduler should the job be run. Default = TaskScheduler.Default 19 | public JobId ScheduleJob(TJob job, CancellationToken token = default, TaskScheduler taskScheduler = null) where TJob : IJob; 20 | 21 | /// 22 | /// Stop asynchronously any running job 23 | /// Use the token to stop the job earlier if needed 24 | /// 25 | /// 26 | /// 27 | Task StopAsync(CancellationToken token = default); 28 | 29 | /// 30 | /// Stop the given job 31 | /// 32 | Task StopAsync(JobId jobId, CancellationToken token); 33 | 34 | /// 35 | /// Is the job present in the scheduler 36 | /// 37 | bool HasJob(JobId jobId); 38 | 39 | /// 40 | /// Schedule a new job to run, internal 41 | /// 42 | /// 43 | /// 44 | /// 45 | internal IJobRunner ScheduleJobInternal(IJobContainerBuilder builderJobContainer, TaskScheduler taskScheduler = null, CancellationToken token = default) where TJob : IJob; 46 | 47 | /// 48 | /// Schedule a new job to run through a container setup 49 | /// 50 | /// The container of the job to run 51 | /// If you want to cancel easily this specific job later. Default = None 52 | /// In which TaskScheduler should the job be run. Default = TaskScheduler.Default 53 | JobId ScheduleJob(IJobContainerBuilder builderJobContainer, CancellationToken token = default, TaskScheduler taskScheduler = null) where TJob : IJob; 54 | 55 | /// 56 | /// Register a queue 57 | /// 58 | /// 59 | /// Queue already exists 60 | void RegisterQueue(QueueSettings queueSettings); 61 | 62 | /// 63 | /// Get the queue from the id 64 | /// 65 | /// 66 | /// 67 | internal Queue.Queue GetQueue(string queueId); 68 | } 69 | } -------------------------------------------------------------------------------- /Job.Scheduler.Tests/QueueJobSchedulerTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using FluentAssertions; 5 | using Job.Scheduler.Builder; 6 | using Job.Scheduler.Queue; 7 | using Job.Scheduler.Scheduler; 8 | using Job.Scheduler.Tests.Mocks; 9 | using NUnit.Framework; 10 | 11 | namespace Job.Scheduler.Tests; 12 | 13 | [Parallelizable(ParallelScope.Children)] 14 | public class QueueJobSchedulerTests 15 | { 16 | private IJobRunnerBuilder _builder; 17 | 18 | [OneTimeSetUp] 19 | public void OneTimeSetup() 20 | { 21 | _builder = new JobRunnerBuilder(); 22 | } 23 | 24 | [Test] 25 | public void ThrowExceptionRegisterTwiceQueue() 26 | { 27 | var scheduler = new JobScheduler(_builder); 28 | var settings = new QueueSettings("test", 1); 29 | scheduler.RegisterQueue(settings); 30 | var action = () => { scheduler.RegisterQueue(settings); }; 31 | action.Should().ThrowExactly(); 32 | } 33 | 34 | [Test] 35 | public void ThrowExceptionScheduleJobWithoutQueue() 36 | { 37 | var scheduler = new JobScheduler(_builder); 38 | 39 | var settings = new QueueSettings("test", 1); 40 | var job = new OneTimeQueueJob(TimeSpan.FromSeconds(5)) 41 | { 42 | QueueId = settings.QueueId 43 | }; 44 | var action = () => { scheduler.ScheduleJob(job); }; 45 | action.Should().ThrowExactly(); 46 | } 47 | 48 | [Test] 49 | public async Task AddJobToQueue() 50 | { 51 | IJobScheduler scheduler = new JobScheduler(_builder); 52 | 53 | var settings = new QueueSettings("test", 1); 54 | var job = new OneTimeQueueJob(TimeSpan.FromMilliseconds(500)) 55 | { 56 | QueueId = settings.QueueId 57 | }; 58 | 59 | scheduler.RegisterQueue(settings); 60 | var queue = scheduler.GetQueue(settings.QueueId); 61 | scheduler.ScheduleJob(job); 62 | var jobRunner = queue.RunningJobs.First(); 63 | await jobRunner.WaitForJob(); 64 | job.HasRun.Should().BeTrue(); 65 | queue.RunningJobs.Count().Should().Be(0); 66 | } 67 | 68 | [Test] 69 | public async Task AddMultipleJobQueue() 70 | { 71 | IJobScheduler scheduler = new JobScheduler(_builder); 72 | 73 | var settings = new QueueSettings("test", 1); 74 | scheduler.RegisterQueue(settings); 75 | var queue = scheduler.GetQueue(settings.QueueId); 76 | for (var i = 0; i < 5; i++) 77 | { 78 | var job = new OneTimeQueueJob(TimeSpan.FromMilliseconds(500)) 79 | { 80 | QueueId = settings.QueueId, 81 | Key = $"Hello {i}" 82 | }; 83 | 84 | scheduler.ScheduleJob(job); 85 | } 86 | 87 | var jobRunner = queue.RunningJobs.First(); 88 | await jobRunner.WaitForJob(); 89 | queue.RunningJobs.Count().Should().Be(1); 90 | queue.QueuedJobs.Count().Should().Be(3); 91 | await scheduler.StopAsync(); 92 | queue.RunningJobs.Count().Should().Be(0); 93 | queue.QueuedJobs.Count().Should().Be(0); 94 | } 95 | } -------------------------------------------------------------------------------- /Job.Scheduler/Job/IJob.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using Job.Scheduler.Job.Action; 6 | using Job.Scheduler.Job.Exception; 7 | 8 | namespace Job.Scheduler.Job 9 | { 10 | /// 11 | /// Interface to implement to define your job 12 | /// 13 | public interface IJob 14 | { 15 | /// 16 | /// What to do if the job fails 17 | /// , or . 18 | /// 19 | /// If null, it's considered to be 20 | /// 21 | public IRetryAction FailRule { get; } 22 | 23 | /// 24 | /// Define the max runtime of a job before it's considered to have failed. 25 | /// 26 | /// Set to NULL if no maximum 27 | /// 28 | public TimeSpan? MaxRuntime { get; } 29 | 30 | /// 31 | /// Execute the job 32 | /// 33 | /// 34 | /// 35 | Task ExecuteAsync(CancellationToken cancellationToken); 36 | 37 | /// 38 | /// What to do on failure 39 | /// 40 | /// 41 | /// What action to take now, doesn't have to be the one that was taken before. 42 | Task OnFailure(JobException exception); 43 | } 44 | 45 | /// 46 | /// Job that is run recurringly with a delay between execution 47 | /// 48 | public interface IRecurringJob : IJob 49 | { 50 | /// 51 | /// Delay between job execution 52 | /// 53 | public TimeSpan Delay { get; } 54 | } 55 | 56 | /// 57 | /// Job that is run once after the delay has expired 58 | /// 59 | public interface IDelayedJob : IJob 60 | { 61 | /// 62 | /// Delay before executing the job 63 | /// 64 | public TimeSpan Delay { get; } 65 | } 66 | 67 | /// 68 | /// Represent the usage of a key 69 | /// 70 | public interface HasKey 71 | { 72 | /// 73 | /// UniqueID of the job 74 | /// 75 | string Key { get; } 76 | } 77 | 78 | /// 79 | /// Job executed once per per 80 | /// 81 | public interface IDebounceJob : IJob, HasKey 82 | { 83 | /// 84 | /// Delay to wait to execute the job, to be sure there isn't any other job of the same type scheduled 85 | /// 86 | public TimeSpan DebounceTime { get; } 87 | } 88 | 89 | /// 90 | /// A job that is queued and follow the setting of the queue to be run 91 | /// 92 | public interface IQueueJob : IJob, HasKey 93 | { 94 | 95 | /// 96 | /// UniqueID of the queue where the job is run 97 | /// 98 | public string QueueId { get; } 99 | } 100 | } -------------------------------------------------------------------------------- /Job.Scheduler.AspNetCore/Configuration/JobSchedulerStartupConfig.cs: -------------------------------------------------------------------------------- 1 | using Job.Scheduler.AspNetCore.Builder; 2 | using Job.Scheduler.Job; 3 | using Job.Scheduler.Queue; 4 | 5 | namespace Job.Scheduler.AspNetCore.Configuration; 6 | 7 | /// 8 | /// Help setting up the job we want to have running at startup 9 | /// 10 | public class JobSchedulerStartupConfig 11 | { 12 | private readonly IJobBuilder _jobBuilder; 13 | 14 | public JobSchedulerStartupConfig(IJobBuilder jobBuilder) 15 | { 16 | _jobBuilder = jobBuilder; 17 | } 18 | 19 | internal readonly List> QueueJobs = new(); 20 | internal readonly List> OneTimeJobs = new(); 21 | 22 | internal readonly List> DebounceJobs = new(); 23 | 24 | internal readonly List> DelayedJobs = new(); 25 | 26 | internal readonly List> RecurringJobs = new(); 27 | 28 | private readonly List _queueSettings = new(); 29 | 30 | /// 31 | /// Add job that will be run at startup 32 | /// 33 | /// 34 | /// 35 | public JobSchedulerStartupConfig AddStartupJob(Func> jobBuilder) 36 | { 37 | QueueJobs.Add(jobBuilder(_jobBuilder)); 38 | return this; 39 | } 40 | 41 | /// 42 | /// Add job that will be run at startup 43 | /// 44 | /// 45 | /// 46 | public JobSchedulerStartupConfig AddStartupJob(Func> jobBuilder) 47 | { 48 | OneTimeJobs.Add(jobBuilder(_jobBuilder)); 49 | return this; 50 | } 51 | 52 | /// 53 | /// Add job that will be run at startup 54 | /// 55 | /// 56 | /// 57 | public JobSchedulerStartupConfig AddStartupJob(Func> jobBuilder) 58 | { 59 | DebounceJobs.Add(jobBuilder(_jobBuilder)); 60 | return this; 61 | } 62 | 63 | /// 64 | /// Add job that will be run at startup 65 | /// 66 | /// 67 | /// 68 | public JobSchedulerStartupConfig AddStartupJob(Func> jobBuilder) 69 | { 70 | DelayedJobs.Add(jobBuilder(_jobBuilder)); 71 | return this; 72 | } 73 | 74 | /// 75 | /// Add job that will be run at startup 76 | /// 77 | /// 78 | /// 79 | public JobSchedulerStartupConfig AddStartupJob(Func> jobBuilder) 80 | { 81 | RecurringJobs.Add(jobBuilder(_jobBuilder)); 82 | return this; 83 | } 84 | 85 | /// 86 | /// Register specific queue 87 | /// 88 | /// 89 | /// 90 | public JobSchedulerStartupConfig RegisterQueue(QueueSettings queueSettings) 91 | { 92 | _queueSettings.Add(queueSettings); 93 | return this; 94 | } 95 | 96 | internal IEnumerable QueueSettings => _queueSettings; 97 | } -------------------------------------------------------------------------------- /Job.Scheduler/Queue/Queue.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using Job.Scheduler.Builder; 8 | using Job.Scheduler.Job.Runner; 9 | 10 | namespace Job.Scheduler.Queue; 11 | 12 | internal class Queue 13 | { 14 | private readonly QueueSettings Settings; 15 | private readonly IJobRunnerBuilder _jobRunnerBuilder; 16 | private readonly ConcurrentQueue _jobs = new(); 17 | private readonly ConcurrentDictionary _jobsPerKey = new(); 18 | private readonly ConcurrentDictionary _runningJobs = new(); 19 | private bool _stopping; 20 | 21 | 22 | public Queue(QueueSettings settings, IJobRunnerBuilder jobRunnerBuilder) 23 | { 24 | Settings = settings; 25 | _jobRunnerBuilder = jobRunnerBuilder; 26 | } 27 | 28 | internal IEnumerable RunningJobs => _runningJobs.Values; 29 | 30 | internal IEnumerable QueuedJobs => _jobsPerKey.Values; 31 | 32 | internal int QueuedJobsCount => _jobsPerKey.Count; 33 | 34 | /// 35 | /// Add a job to the queue 36 | /// 37 | /// 38 | /// 39 | /// 40 | public bool AddJob(QueueJobContainer queueJobContainer) 41 | { 42 | if (_stopping) 43 | { 44 | return false; 45 | } 46 | 47 | var container = queueJobContainer.JobContainer; 48 | if (container.QueueId != Settings.QueueId) 49 | { 50 | throw new ArgumentException($"Can't schedule a job with wrong queueID. Expected {Settings.QueueId} got {container.QueueId}", nameof(queueJobContainer)); 51 | } 52 | 53 | if (_jobsPerKey.ContainsKey(container.Key)) 54 | { 55 | return false; 56 | } 57 | 58 | _jobsPerKey.TryAdd(container.Key, queueJobContainer); 59 | _jobs.Enqueue(queueJobContainer); 60 | 61 | ScheduleJobsToConcurrency(); 62 | 63 | return true; 64 | } 65 | 66 | private void ScheduleJobsToConcurrency() 67 | { 68 | if (_stopping) 69 | { 70 | return; 71 | } 72 | 73 | if (_runningJobs.Count >= Settings.MaxConcurrency) 74 | { 75 | return; 76 | } 77 | 78 | while (_runningJobs.Count < Settings.MaxConcurrency && _jobs.TryDequeue(out var job)) 79 | { 80 | ScheduleJob(job); 81 | _jobsPerKey.TryRemove(job.Key, out _); 82 | } 83 | } 84 | 85 | private void ScheduleJob(QueueJobContainer containerJob) 86 | { 87 | var jobRunner = _jobRunnerBuilder.Build(containerJob.JobContainer, async (runner, stoppedManually) => 88 | { 89 | try 90 | { 91 | await containerJob.JobContainer.OnCompletedAsync(containerJob.Token); 92 | } 93 | finally 94 | { 95 | _runningJobs.TryRemove(runner.UniqueId, out _); 96 | ScheduleJobsToConcurrency(); 97 | } 98 | }, containerJob.TaskScheduler); 99 | _runningJobs.TryAdd(jobRunner.UniqueId, jobRunner); 100 | jobRunner.Start(containerJob.Token); 101 | } 102 | 103 | /// 104 | /// Stop the queue and any of its running jobs 105 | /// 106 | /// 107 | /// 108 | public Task StopAsync(CancellationToken token) 109 | { 110 | _stopping = true; 111 | _jobs.Clear(); 112 | _jobsPerKey.Clear(); 113 | return Task.WhenAll(_runningJobs.Values.Select(runner => runner.StopAsync(token))); 114 | } 115 | } -------------------------------------------------------------------------------- /Job.Scheduler.AspNetCore/Builder/JobBuilder.cs: -------------------------------------------------------------------------------- 1 | using Job.Scheduler.Job; 2 | using Microsoft.Extensions.DependencyInjection; 3 | 4 | namespace Job.Scheduler.AspNetCore.Builder; 5 | 6 | public class JobBuilder : IJobBuilder 7 | { 8 | private readonly IServiceProvider _serviceProvider; 9 | 10 | public JobBuilder(IServiceProvider serviceProvider) 11 | { 12 | _serviceProvider = serviceProvider; 13 | } 14 | 15 | public class Container where T : IJob 16 | { 17 | private readonly IServiceProvider _serviceProvider; 18 | private readonly List> _configurators = new(); 19 | 20 | public Container(IServiceProvider serviceProvider) 21 | { 22 | _serviceProvider = serviceProvider; 23 | } 24 | 25 | /// 26 | /// Configure the job 27 | /// 28 | /// 29 | /// 30 | public Container Configure(Action configuration) 31 | { 32 | _configurators.Add(configuration); 33 | return this; 34 | } 35 | 36 | /// 37 | /// Build the 38 | /// 39 | /// 40 | public IJobContainerBuilder Build() 41 | { 42 | return new ScopedBuilderJobContainer(_serviceProvider, _configurators); 43 | } 44 | } 45 | 46 | internal class ScopedJobContainer : IJobContainer where TJob : IJob 47 | { 48 | private readonly IServiceScope _serviceScope; 49 | private bool _isDisposed; 50 | 51 | public ScopedJobContainer(TJob job, IServiceScope serviceScope) 52 | { 53 | _serviceScope = serviceScope; 54 | Job = job; 55 | } 56 | 57 | public void Dispose() 58 | { 59 | if (_isDisposed) 60 | { 61 | return; 62 | } 63 | _serviceScope.Dispose(); 64 | _isDisposed = true; 65 | } 66 | 67 | public TJob Job { get; } 68 | } 69 | 70 | internal class ScopedBuilderJobContainer : IJobContainerBuilder where TJob : IJob 71 | { 72 | private readonly IServiceProvider _serviceProvider; 73 | private readonly List> _configurators; 74 | private readonly List> _jobBuilt = new(); 75 | 76 | public ScopedBuilderJobContainer(IServiceProvider serviceProvider, List> configurators) 77 | { 78 | _serviceProvider = serviceProvider; 79 | _configurators = configurators; 80 | using var jobContainer = BuildJob(); 81 | var job = jobContainer.Job; 82 | Key = job is HasKey keyed ? keyed.Key : Guid.NewGuid().ToString(); 83 | QueueId = job is IQueueJob queueJob ? queueJob.QueueId : null; 84 | } 85 | 86 | public Task OnCompletedAsync(CancellationToken token) 87 | { 88 | foreach (var container in _jobBuilt) 89 | { 90 | container.Dispose(); 91 | } 92 | _configurators.Clear(); 93 | _jobBuilt.Clear(); 94 | return Task.CompletedTask; 95 | } 96 | 97 | public IJobContainer BuildJob() 98 | { 99 | var serviceScope = _serviceProvider.CreateScope(); 100 | var job = serviceScope.ServiceProvider.GetRequiredService(); 101 | foreach (var action in _configurators) 102 | { 103 | action(job); 104 | } 105 | 106 | var scopedJobContainer = new ScopedJobContainer(job, serviceScope); 107 | _jobBuilt.Add(scopedJobContainer); 108 | return scopedJobContainer; 109 | } 110 | 111 | public Type JobType => typeof(TJob); 112 | public string Key { get; } 113 | public string? QueueId { get; } 114 | } 115 | 116 | public Container Create() where T : IJob => new(_serviceProvider); 117 | } -------------------------------------------------------------------------------- /Job.Scheduler.AspNetCore.Tests/QueueJobSchedulerTests.cs: -------------------------------------------------------------------------------- 1 | using FluentAssertions; 2 | using Job.Scheduler.AspNetCore.Background; 3 | using Job.Scheduler.AspNetCore.Builder; 4 | using Job.Scheduler.AspNetCore.Extensions; 5 | using Job.Scheduler.AspNetCore.Tests.Mock; 6 | using Job.Scheduler.Builder; 7 | using Job.Scheduler.Queue; 8 | using Job.Scheduler.Scheduler; 9 | using Job.Scheduler.Utils; 10 | using Microsoft.Extensions.DependencyInjection; 11 | using Microsoft.Extensions.Hosting; 12 | 13 | namespace Job.Scheduler.AspNetCore.Tests; 14 | 15 | [TestFixture] 16 | public class QueueJobSchedulerTests 17 | { 18 | private ServiceCollection _services; 19 | private QueueSettings _settings; 20 | private JobSchedulerHostedService _jobSchedulerHostedService; 21 | private ServiceProvider _provider; 22 | 23 | [OneTimeSetUp] 24 | public async Task Setup() 25 | { 26 | _services = new ServiceCollection(); 27 | _settings = new QueueSettings("test", 1); 28 | _services.AddJobScheduler(config => { config.RegisterQueue(_settings); }) 29 | .AddJob(); 30 | 31 | _provider = await GetProvider(); 32 | } 33 | 34 | [OneTimeTearDown] 35 | public async Task TearDown() 36 | { 37 | await _jobSchedulerHostedService.StopAsync(CancellationToken.None); 38 | await _provider.DisposeAsync(); 39 | } 40 | 41 | [Test] 42 | public async Task AddJobToQueue() 43 | { 44 | var builder = _provider.GetRequiredService(); 45 | var runState = new OneTimeQueueJob.RunStateInfo(); 46 | var job = builder.Create() 47 | .Configure(queueJob => 48 | { 49 | queueJob.QueueId = _settings.QueueId; 50 | queueJob.WaitTime = TimeSpan.FromMilliseconds(500); 51 | queueJob.RunState = runState; 52 | }) 53 | .Build(); 54 | 55 | var scheduler = _provider.GetRequiredService(); 56 | 57 | var queue = scheduler.GetQueue(_settings.QueueId); 58 | scheduler.ScheduleJob(job); 59 | var jobRunner = queue.RunningJobs.First(); 60 | await jobRunner.WaitForJob(); 61 | runState.HasRun.Should().BeTrue(); 62 | queue.RunningJobs.Count().Should().Be(0); 63 | } 64 | 65 | private async Task GetProvider() 66 | { 67 | var provider = _services.BuildServiceProvider(); 68 | var hostedServices = provider.GetRequiredService>(); 69 | var hostedService = hostedServices.First(); 70 | 71 | _jobSchedulerHostedService = (JobSchedulerHostedService)hostedService; 72 | await _jobSchedulerHostedService.StartAsync(CancellationToken.None); 73 | return provider; 74 | } 75 | 76 | [Test] 77 | public async Task AddMultipleJobQueue() 78 | { 79 | var builder = _provider.GetRequiredService(); 80 | var runState = new OneTimeQueueJob.RunStateInfo(); 81 | 82 | var scheduler = _provider.GetRequiredService(); 83 | 84 | var queue = scheduler.GetQueue(_settings.QueueId); 85 | for (var i = 0; i < 5; i++) 86 | { 87 | var job = builder.Create() 88 | .Configure(queueJob => 89 | { 90 | queueJob.QueueId = _settings.QueueId; 91 | queueJob.WaitTime = TimeSpan.FromMilliseconds(500); 92 | queueJob.RunState = runState; 93 | queueJob.Key = $"Hello {i}"; 94 | }) 95 | .Build(); 96 | 97 | scheduler.ScheduleJob(job); 98 | } 99 | 100 | var jobRunner = queue.RunningJobs.First(); 101 | await jobRunner.WaitForJob(); 102 | await TaskUtils.WaitForDelayOrCancellation(TimeSpan.FromMilliseconds(100), CancellationToken.None); 103 | queue.RunningJobs.Count().Should().Be(1); 104 | queue.QueuedJobsCount.Should().Be(3); 105 | await scheduler.StopAsync(); 106 | queue.RunningJobs.Count().Should().Be(0); 107 | queue.QueuedJobsCount.Should().Be(0); 108 | } 109 | } -------------------------------------------------------------------------------- /Job.Scheduler/Utils/DebounceDispatcher.cs: -------------------------------------------------------------------------------- 1 | #nullable enable 2 | namespace Job.Scheduler.Utils; 3 | 4 | using System; 5 | 6 | /// 7 | /// Provides Debounce() and Throttle() methods. 8 | /// Use these methods to ensure that events aren't handled too frequently. 9 | /// 10 | /// Throttle() ensures that events are throttled by the interval specified. 11 | /// Only the last event in the interval sequence of events fires. 12 | /// 13 | /// Debounce() fires an event only after the specified interval has passed 14 | /// in which no other pending event has fired. Only the last event in the 15 | /// sequence is fired. 16 | /// See: https://weblog.west-wind.com/posts/2017/jul/02/debouncing-and-throttling-dispatcher-events 17 | /// 18 | public class DebounceDispatcher : IDisposable where T : class 19 | { 20 | private readonly TimeSpan _interval; 21 | private readonly Action _action; 22 | private readonly T? _param; 23 | private System.Timers.Timer? _timer; 24 | private DateTime TimerStarted { get; set; } = DateTime.UtcNow.AddYears(-1); 25 | 26 | public DebounceDispatcher(TimeSpan interval, Action action, T? param) 27 | { 28 | _interval = interval; 29 | _action = action; 30 | _param = param; 31 | } 32 | 33 | /// 34 | /// Debounce an event by resetting the event timeout every time the event is 35 | /// fired. The behavior is that the Action passed is fired only after events 36 | /// stop firing for the given timeout period. 37 | /// 38 | /// Use Debounce when you want events to fire only after events stop firing 39 | /// after the given interval timeout period. 40 | /// 41 | /// Wrap the logic you would normally use in your event code into 42 | /// the Action you pass to this method to debounce the event. 43 | /// Example: https://gist.github.com/RickStrahl/0519b678f3294e27891f4d4f0608519a 44 | /// 45 | /// optional priorty for the dispatcher 46 | /// optional dispatcher. If not passed or null CurrentDispatcher is used. 47 | public void Debounce() 48 | { 49 | // kill pending timer and pending ticks 50 | _timer?.Stop(); 51 | _timer?.Dispose(); 52 | _timer = null; 53 | 54 | // timer is recreated for each event and effectively 55 | // resets the timeout. Action only fires after timeout has fully 56 | // elapsed without other events firing in between 57 | _timer = new System.Timers.Timer(_interval.TotalMilliseconds) { AutoReset = false }; 58 | _timer.Elapsed += (sender, args) => 59 | { 60 | if (_timer == null) 61 | return; 62 | 63 | _timer?.Stop(); 64 | _timer = null; 65 | _action.Invoke(_param); 66 | }; 67 | _timer.Start(); 68 | } 69 | 70 | /// 71 | /// This method throttles events by allowing only 1 event to fire for the given 72 | /// timeout period. Only the last event fired is handled - all others are ignored. 73 | /// Throttle will fire events every timeout ms even if additional events are pending. 74 | /// 75 | /// Use Throttle where you need to ensure that events fire at given intervals. 76 | /// 77 | /// optional priorty for the dispatcher 78 | /// optional dispatcher. If not passed or null CurrentDispatcher is used. 79 | public void Throttle() 80 | { 81 | // kill pending timer and pending ticks 82 | _timer?.Stop(); 83 | _timer?.Dispose(); 84 | _timer = null; 85 | 86 | 87 | var curTime = DateTime.UtcNow; 88 | var interval = _interval.TotalMilliseconds; 89 | 90 | // if timeout is not up yet - adjust timeout to fire 91 | // with potentially new Action parameters 92 | if (curTime.Subtract(TimerStarted).TotalMilliseconds < interval) 93 | interval -= (int)curTime.Subtract(TimerStarted).TotalMilliseconds; 94 | 95 | _timer = new System.Timers.Timer(interval); 96 | _timer.Elapsed += (sender, args) => 97 | { 98 | if (_timer == null) 99 | return; 100 | 101 | _timer?.Stop(); 102 | _timer = null; 103 | _action.Invoke(_param); 104 | }; 105 | _timer.Start(); 106 | 107 | TimerStarted = curTime; 108 | } 109 | 110 | public void Dispose() 111 | { 112 | _timer?.Stop(); 113 | _timer?.Dispose(); 114 | _timer = null; 115 | } 116 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Job Scheduler 2 | 3 | [![.NET](https://github.com/Belphemur/Job.Scheduler/actions/workflows/dotnet.yml/badge.svg)](https://github.com/Belphemur/Job.Scheduler/actions/workflows/dotnet.yml) 4 | 5 | A simple job scheduling library relying on the async/await pattern in C#. 6 | 7 | ## Type of Jobs 8 | 9 | ### One Time Job 10 | 11 | By implementing the `IJob` interface you tell the scheduler that you just want this job to be executed once and directly 12 | upon being scheduled. 13 | 14 | ### Recurring Job 15 | 16 | By implementing the `IRecurringJob` the scheduler will run indefinitely your job with the given delay between execution. 17 | 18 | ### Delayed Job 19 | 20 | By implementing the `IDelayedJob` you tell the scheduler to wait a delay before executing your job. 21 | 22 | ### Debounce Job 23 | 24 | By implementing the `IDebounceJob` you tell the scheduler to only run the latest encounter of the job sharing the same key. 25 | 26 | ### Queue Job 27 | You can register your own queue with their defined concurrency and schedule on them `IQueueJob`. 28 | 29 | ## Usage 30 | 31 | I advise you to use a Dependency Injection (DI) engine (like SimpleInjector) to register the `JobRunnerBuilder`and `JobScheduler` as singleton. 32 | 33 | ### Example: 34 | 35 | ```c# 36 | public class MyJob : IRecurringJob 37 | { 38 | //Set the retry rule in case of failure of the job, in this case we want 39 | //to retry the job 3 times 40 | //Works for any type of job 41 | public IRetryAction FailRule { get; } = new RetryNTimes(3); 42 | 43 | //Optional MaxRuntime for the job before its canncellationToke get cancelled 44 | //Keep in mind, this only cancel the token, we have no clean way of stopping a running task 45 | //then cancelling the token. 46 | public TimeSpan? MaxRuntime { get; } = TimeSpan.FromSeconds(5); 47 | 48 | 49 | public async Task ExecuteAsync(CancellationToken cancellationToken) 50 | { 51 | //Your complex recurring code, here pretty simple 52 | await Console.Out.WriteLineAsync("Hello World"); 53 | } 54 | 55 | public Task OnFailure(JobException exception) 56 | { 57 | //Any exception that occured when executing your job will be wrapped in a JobException, check the InnerException 58 | //for you to be able to handle a failure without breaking your application neither needed a try/catch in ExecuteAsync 59 | 60 | 61 | return Task.CompletedTask; 62 | } 63 | //This job will run every 15 seconds 64 | 65 | public TimeSpan Delay { get; } = TimeSpan.FromSeconds(15); 66 | } 67 | 68 | var builder = new JobRunnerBuilder(); 69 | var scheduler = new JobScheduler(builder); 70 | 71 | //If you have already a cancellation token that you want to be used for stopping your job, you can pass it as second param 72 | scheduler.Start(new MyJob()); 73 | 74 | //At the end of your application, you can ask the Scheduler to gracefully stop the running jobs and wait for them to stop. 75 | //You can also pass a cancellationToken to force a non graceful cancellation of the jobs. 76 | await scheduler.StopAsync(); 77 | ``` 78 | 79 | ### Advanced 80 | You can also use your own TaskScheduler. It's useful if you want to control in which thread your task is run. 81 | ```c# 82 | var builder = new JobRunnerBuilder(); 83 | var scheduler = new JobScheduler(builder); 84 | var taskScheduler = new MyTaskScheduler(); 85 | 86 | // This way, this specific instance of the job will be run in your defined task scheduler 87 | scheduler.Start(new MyJob(), CancellationToken.None, taskScheduler); 88 | ``` 89 | 90 | ### Queue usage 91 | ```c# 92 | public class OneTimeQueueJob : IQueueJob 93 | { 94 | 95 | public bool HasRun { get; set; } 96 | 97 | public IRetryAction FailRule { get; } = new NoRetry(); 98 | public TimeSpan? MaxRuntime { get; } 99 | 100 | public virtual async Task ExecuteAsync(CancellationToken cancellationToken) 101 | { 102 | HasRun = true; 103 | } 104 | 105 | public Task OnFailure(JobException exception) 106 | { 107 | return Task.CompletedTask; 108 | } 109 | 110 | //Unique key for this job. The queue won't accept twice the same job unless it has finished running. 111 | public string Key { get; set; } = "test"; 112 | //Unique ID of the queue 113 | public string QueueId { get; set; } = "test"; 114 | 115 | } 116 | var builder = new JobRunnerBuilder(); 117 | var scheduler = new JobScheduler(builder); 118 | //queue with a maximum of 1 job running at a time 119 | var settings = new QueueSettings("test", 1); 120 | scheduler.RegisterQueue(settings); 121 | 122 | //Schedule the job as normal, it will be schedule in the queue 123 | scheduler.Start(new OneTimeQueueJob()); 124 | ``` 125 | ### Disposable 126 | If your job implement `IAsyncDisposable` the disposing will be called when the job has finished running. 127 | -------------------------------------------------------------------------------- /.github/workflows/dotnet.yml: -------------------------------------------------------------------------------- 1 | name: .NET 6.0 2 | 3 | on: 4 | push: 5 | branches: [ develop, master ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ develop, master ] 9 | env: 10 | # Stop wasting time caching packages 11 | DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true 12 | # Disable sending usage data to Microsoft 13 | DOTNET_CLI_TELEMETRY_OPTOUT: true 14 | # Project name to pack and publish 15 | PROJECT_NAME: Job.Scheduler 16 | # GitHub Packages Feed settings 17 | GITHUB_FEED: https://nuget.pkg.github.com/Belphemur/ 18 | GITHUB_USER: Belphemur 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | # Official NuGet Feed settings 21 | NUGET_FEED: https://api.nuget.org/v3/index.json 22 | NUGET_KEY: ${{ secrets.NUGET_KEY }} 23 | jobs: 24 | build: 25 | runs-on: ${{ matrix.os }} 26 | strategy: 27 | matrix: 28 | os: [ubuntu-latest] 29 | steps: 30 | - name: Checkout 31 | uses: actions/checkout@v4 32 | - name: Setup .NET Core 33 | uses: actions/setup-dotnet@v4 34 | with: 35 | dotnet-version: 8.0.x 36 | - uses: actions/cache@v4 37 | with: 38 | path: ~/.nuget/packages 39 | # Look to see if there is a cache hit for the corresponding requirements file 40 | key: ${{ runner.os }}-nuget-${{ hashFiles('Job.Scheduler/Job.Scheduler.csproj') }} 41 | restore-keys: | 42 | ${{ runner.os }}-nuget 43 | - name: Restore 44 | run: dotnet restore 45 | - name: Build 46 | run: dotnet build -c Release --no-restore 47 | - name: Test 48 | run: dotnet test -c Release --logger trx --results-directory "TestResults-${{ matrix.dotnet-version }}" 49 | - name: Upload test results 50 | uses: actions/upload-artifact@v4 51 | with: 52 | name: dotnet-results-${{ matrix.dotnet-version }} 53 | path: TestResults-${{ matrix.dotnet-version }} 54 | # Use always() to always run this step to publish test results when there are test failures 55 | if: ${{ always() }} 56 | - name: Pack Main 57 | if: matrix.os == 'ubuntu-latest' 58 | run: dotnet pack -v normal -c Release --no-restore --include-source -o nupkg -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg -p:PackageVersion="0.0.0-nightly${GITHUB_RUN_ID}" $PROJECT_NAME/$PROJECT_NAME.*proj 59 | - name: Pack Asp.Net Core 60 | if: matrix.os == 'ubuntu-latest' 61 | run: dotnet pack -v normal -c Release --no-restore --include-source -o nupkg -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg -p:PackageVersion="0.0.0-nightly${GITHUB_RUN_ID}" ${PROJECT_NAME}.AspNetCore/${PROJECT_NAME}.AspNetCore.*proj 62 | - name: Upload Artifact 63 | if: matrix.os == 'ubuntu-latest' 64 | uses: actions/upload-artifact@v4 65 | with: 66 | name: nupkg 67 | path: nupkg/*.*nupkg 68 | prerelease: 69 | needs: build 70 | if: github.ref == 'refs/heads/develop' 71 | runs-on: ubuntu-latest 72 | steps: 73 | - name: Download Artifact 74 | uses: actions/download-artifact@v4 75 | with: 76 | name: nupkg 77 | path: nupkg 78 | - name: Push to GitHub Feed 79 | run: | 80 | for f in ./nupkg/*.nupkg 81 | do 82 | curl -vX PUT -u "$GITHUB_USER:$GITHUB_TOKEN" -F package=@$f $GITHUB_FEED 83 | done 84 | - name: Clean up old Main packages 85 | uses: actions/delete-package-versions@v5 86 | with: 87 | package-name: "${{env.PROJECT_NAME}}" 88 | min-versions-to-keep: 5 89 | package-type: 'nuget' 90 | ignore-versions: '^\\d{1,8}\\.\\d+\\.\\d+$' 91 | - name: Clean up old Asp.net Core packages 92 | uses: actions/delete-package-versions@v5 93 | with: 94 | package-name: "${{env.PROJECT_NAME}}.AspNetCore" 95 | min-versions-to-keep: 5 96 | package-type: 'nuget' 97 | ignore-versions: '^\\d{1,8}\\.\\d+\\.\\d+$' 98 | deploy: 99 | needs: build 100 | if: github.ref == 'refs/heads/master' 101 | runs-on: ubuntu-latest 102 | steps: 103 | - uses: actions/checkout@v4 104 | - name: Setup .NET Core 105 | uses: actions/setup-dotnet@v4 106 | with: 107 | dotnet-version: 8.0.x 108 | - name: Cache .NET deps 109 | uses: actions/cache@v4 110 | with: 111 | path: ~/.nuget/packages 112 | # Look to see if there is a cache hit for the corresponding requirements file 113 | key: ${{ runner.os }}-nuget-${{ hashFiles('Job.Scheduler/Job.Scheduler.csproj') }} 114 | restore-keys: | 115 | ${{ runner.os }}-nuget 116 | - name: Setup Node.js 117 | uses: actions/setup-node@v4 118 | with: 119 | node-version: lts/* 120 | - name: Cache node modules 121 | uses: actions/cache@v4 122 | env: 123 | cache-name: cache-node-modules 124 | with: 125 | # npm cache files are stored in `~/.npm` on Linux/macOS 126 | path: ~/.npm 127 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 128 | restore-keys: | 129 | ${{ runner.os }}-build-${{ env.cache-name }}- 130 | ${{ runner.os }}-build- 131 | ${{ runner.os }}- 132 | - name: Install semantic-release dependencies 133 | run: npm ci 134 | - name: Release 135 | run: npx semantic-release 136 | - name: Push to GitHub Feed 137 | run: | 138 | for f in ./nupkg/*.nupkg 139 | do 140 | curl -vX PUT -u "$GITHUB_USER:$GITHUB_TOKEN" -F package=@$f $GITHUB_FEED 141 | done 142 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | 33 | # Visual Studio 2015/2017 cache/options directory 34 | .vs/ 35 | # Uncomment if you have tasks that create the project's static files in wwwroot 36 | #wwwroot/ 37 | 38 | # Visual Studio 2017 auto generated files 39 | Generated\ Files/ 40 | 41 | # MSTest test Results 42 | [Tt]est[Rr]esult*/ 43 | [Bb]uild[Ll]og.* 44 | 45 | # NUNIT 46 | *.VisualState.xml 47 | TestResult.xml 48 | 49 | # Build Results of an ATL Project 50 | [Dd]ebugPS/ 51 | [Rr]eleasePS/ 52 | dlldata.c 53 | 54 | # Benchmark Results 55 | BenchmarkDotNet.Artifacts/ 56 | 57 | # .NET Core 58 | project.lock.json 59 | project.fragment.lock.json 60 | artifacts/ 61 | 62 | # StyleCop 63 | StyleCopReport.xml 64 | 65 | # Files built by Visual Studio 66 | *_i.c 67 | *_p.c 68 | *_h.h 69 | *.ilk 70 | *.meta 71 | *.obj 72 | *.iobj 73 | *.pch 74 | *.pdb 75 | *.ipdb 76 | *.pgc 77 | *.pgd 78 | *.rsp 79 | *.sbr 80 | *.tlb 81 | *.tli 82 | *.tlh 83 | *.tmp 84 | *.tmp_proj 85 | *_wpftmp.csproj 86 | *.log 87 | *.vspscc 88 | *.vssscc 89 | .builds 90 | *.pidb 91 | *.svclog 92 | *.scc 93 | 94 | # Chutzpah Test files 95 | _Chutzpah* 96 | 97 | # Visual C++ cache files 98 | ipch/ 99 | *.aps 100 | *.ncb 101 | *.opendb 102 | *.opensdf 103 | *.sdf 104 | *.cachefile 105 | *.VC.db 106 | *.VC.VC.opendb 107 | 108 | # Visual Studio profiler 109 | *.psess 110 | *.vsp 111 | *.vspx 112 | *.sap 113 | 114 | # Visual Studio Trace Files 115 | *.e2e 116 | 117 | # TFS 2012 Local Workspace 118 | $tf/ 119 | 120 | # Guidance Automation Toolkit 121 | *.gpState 122 | 123 | # ReSharper is a .NET coding add-in 124 | _ReSharper*/ 125 | *.[Rr]e[Ss]harper 126 | *.DotSettings.user 127 | 128 | # JustCode is a .NET coding add-in 129 | .JustCode 130 | 131 | # TeamCity is a build add-in 132 | _TeamCity* 133 | 134 | # DotCover is a Code Coverage Tool 135 | *.dotCover 136 | 137 | # AxoCover is a Code Coverage Tool 138 | .axoCover/* 139 | !.axoCover/settings.json 140 | 141 | # Visual Studio code coverage results 142 | *.coverage 143 | *.coveragexml 144 | 145 | # NCrunch 146 | _NCrunch_* 147 | .*crunch*.local.xml 148 | nCrunchTemp_* 149 | 150 | # MightyMoose 151 | *.mm.* 152 | AutoTest.Net/ 153 | 154 | # Web workbench (sass) 155 | .sass-cache/ 156 | 157 | # Installshield output folder 158 | [Ee]xpress/ 159 | 160 | # DocProject is a documentation generator add-in 161 | DocProject/buildhelp/ 162 | DocProject/Help/*.HxT 163 | DocProject/Help/*.HxC 164 | DocProject/Help/*.hhc 165 | DocProject/Help/*.hhk 166 | DocProject/Help/*.hhp 167 | DocProject/Help/Html2 168 | DocProject/Help/html 169 | 170 | # Click-Once directory 171 | publish/ 172 | 173 | # Publish Web Output 174 | *.[Pp]ublish.xml 175 | *.azurePubxml 176 | # Note: Comment the next line if you want to checkin your web deploy settings, 177 | # but database connection strings (with potential passwords) will be unencrypted 178 | *.pubxml 179 | *.publishproj 180 | 181 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 182 | # checkin your Azure Web App publish settings, but sensitive information contained 183 | # in these scripts will be unencrypted 184 | PublishScripts/ 185 | 186 | # NuGet Packages 187 | *.nupkg 188 | # NuGet Symbol Packages 189 | *.snupkg 190 | # The packages folder can be ignored because of Package Restore 191 | **/[Pp]ackages/* 192 | # except build/, which is used as an MSBuild target. 193 | !**/[Pp]ackages/build/ 194 | # Uncomment if necessary however generally it will be regenerated when needed 195 | #!**/[Pp]ackages/repositories.config 196 | # NuGet v3's project.json files produces more ignorable files 197 | *.nuget.props 198 | *.nuget.targets 199 | 200 | # Microsoft Azure Build Output 201 | csx/ 202 | *.build.csdef 203 | 204 | # Microsoft Azure Emulator 205 | ecf/ 206 | rcf/ 207 | 208 | # Windows Store app package directories and files 209 | AppPackages/ 210 | BundleArtifacts/ 211 | Package.StoreAssociation.xml 212 | _pkginfo.txt 213 | *.appx 214 | *.appxbundle 215 | *.appxupload 216 | 217 | # Visual Studio cache files 218 | # files ending in .cache can be ignored 219 | *.[Cc]ache 220 | # but keep track of directories ending in .cache 221 | !?*.[Cc]ache/ 222 | 223 | # Others 224 | ClientBin/ 225 | ~$* 226 | *~ 227 | *.dbmdl 228 | *.dbproj.schemaview 229 | *.jfm 230 | *.pfx 231 | *.publishsettings 232 | orleans.codegen.cs 233 | 234 | # Including strong name files can present a security risk 235 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 236 | #*.snk 237 | 238 | # Since there are multiple workflows, uncomment next line to ignore bower_components 239 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 240 | #bower_components/ 241 | 242 | # RIA/Silverlight projects 243 | Generated_Code/ 244 | 245 | # Backup & report files from converting an old project file 246 | # to a newer Visual Studio version. Backup files are not needed, 247 | # because we have git ;-) 248 | _UpgradeReport_Files/ 249 | Backup*/ 250 | UpgradeLog*.XML 251 | UpgradeLog*.htm 252 | ServiceFabricBackup/ 253 | *.rptproj.bak 254 | 255 | # SQL Server files 256 | *.mdf 257 | *.ldf 258 | *.ndf 259 | 260 | # Business Intelligence projects 261 | *.rdl.data 262 | *.bim.layout 263 | *.bim_*.settings 264 | *.rptproj.rsuser 265 | *- [Bb]ackup.rdl 266 | *- [Bb]ackup ([0-9]).rdl 267 | *- [Bb]ackup ([0-9][0-9]).rdl 268 | 269 | # Microsoft Fakes 270 | FakesAssemblies/ 271 | 272 | # GhostDoc plugin setting file 273 | *.GhostDoc.xml 274 | 275 | # Node.js Tools for Visual Studio 276 | .ntvs_analysis.dat 277 | node_modules/ 278 | 279 | # Visual Studio 6 build log 280 | *.plg 281 | 282 | # Visual Studio 6 workspace options file 283 | *.opt 284 | 285 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 286 | *.vbw 287 | 288 | # Visual Studio LightSwitch build output 289 | **/*.HTMLClient/GeneratedArtifacts 290 | **/*.DesktopClient/GeneratedArtifacts 291 | **/*.DesktopClient/ModelManifest.xml 292 | **/*.Server/GeneratedArtifacts 293 | **/*.Server/ModelManifest.xml 294 | _Pvt_Extensions 295 | 296 | # Paket dependency manager 297 | .paket/paket.exe 298 | paket-files/ 299 | 300 | # FAKE - F# Make 301 | .fake/ 302 | 303 | # CodeRush personal settings 304 | .cr/personal 305 | 306 | # Python Tools for Visual Studio (PTVS) 307 | __pycache__/ 308 | *.pyc 309 | 310 | # Cake - Uncomment if you are using it 311 | # tools/** 312 | # !tools/packages.config 313 | 314 | # Tabs Studio 315 | *.tss 316 | 317 | # Telerik's JustMock configuration file 318 | *.jmconfig 319 | 320 | # BizTalk build output 321 | *.btp.cs 322 | *.btm.cs 323 | *.odx.cs 324 | *.xsd.cs 325 | 326 | # OpenCover UI analysis results 327 | OpenCover/ 328 | 329 | # Azure Stream Analytics local run output 330 | ASALocalRun/ 331 | 332 | # MSBuild Binary and Structured Log 333 | *.binlog 334 | 335 | # NVidia Nsight GPU debugger configuration file 336 | *.nvuser 337 | 338 | # MFractors (Xamarin productivity tool) working folder 339 | .mfractor/ 340 | 341 | # Local History for Visual Studio 342 | .localhistory/ 343 | 344 | # BeatPulse healthcheck temp database 345 | healthchecksdb 346 | 347 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 348 | MigrationBackup/ 349 | /Build 350 | -------------------------------------------------------------------------------- /Job.Scheduler/Job/Runner/JobRunner.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using JetBrains.Annotations; 7 | using Job.Scheduler.Job.Action; 8 | using Job.Scheduler.Job.Exception; 9 | using Job.Scheduler.Utils; 10 | 11 | namespace Job.Scheduler.Job.Runner 12 | { 13 | /// 14 | /// Base implementation of 15 | /// 16 | /// 17 | internal abstract class JobRunner : IJobRunner where TJob : IJob 18 | { 19 | protected readonly IJobContainerBuilder BuilderJobContainer; 20 | private CancellationTokenSource _cancellationTokenSource; 21 | private Task _runningTask; 22 | private Task _runningTaskWithDone; 23 | private static readonly IRetryAction DefaultFailRule = new NoRetry(); 24 | private readonly Stopwatch _stopwatch = new(); 25 | private readonly Func _jobDone; 26 | 27 | [CanBeNull] 28 | private readonly TaskScheduler _taskScheduler; 29 | 30 | private static readonly ActivitySource _activitySource = new("Job.Scheduler::Runner"); 31 | 32 | public Guid UniqueId { get; } = Guid.NewGuid(); 33 | public bool IsRunning => _cancellationTokenSource is { IsCancellationRequested: false } && _runningTaskWithDone is not null; 34 | public TimeSpan Elapsed => _stopwatch.Elapsed; 35 | public int Retries { get; private set; } 36 | 37 | public Type JobType => typeof(TJob); 38 | public virtual string Key => UniqueId.ToString(); 39 | 40 | private bool _manuallyStopped = false; 41 | 42 | 43 | protected JobRunner(IJobContainerBuilder builderJobContainer, Func jobDone, [CanBeNull] TaskScheduler taskScheduler) 44 | { 45 | BuilderJobContainer = builderJobContainer; 46 | _jobDone = jobDone; 47 | _taskScheduler = taskScheduler; 48 | } 49 | 50 | /// 51 | /// Start the job 52 | /// 53 | protected abstract Task StartJobAsync(IJobContainerBuilder builderJobContainer, CancellationToken token); 54 | 55 | /// 56 | /// Run the job 57 | /// 58 | /// Optional token to sync with it 59 | /// 60 | public void Start(CancellationToken token = default) 61 | { 62 | _manuallyStopped = false; 63 | if (IsRunning) 64 | { 65 | throw new InvalidOperationException("Can't start a running job"); 66 | } 67 | 68 | _cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); 69 | 70 | _runningTask = _taskScheduler == null 71 | ? Task.Factory.StartNew( _ => StartJobAsync(BuilderJobContainer, _cancellationTokenSource.Token), null, _cancellationTokenSource.Token).Unwrap() 72 | : Task.Factory.StartNew(_ => StartJobAsync(BuilderJobContainer, _cancellationTokenSource.Token), null, _cancellationTokenSource.Token, TaskCreationOptions.None, _taskScheduler).Unwrap(); 73 | 74 | _runningTaskWithDone = _runningTask.ContinueWith(async _ => 75 | { 76 | if (BuilderJobContainer is IAsyncDisposable asyncDisposable) 77 | { 78 | await asyncDisposable.DisposeAsync(); 79 | } 80 | 81 | await _jobDone(this, _manuallyStopped); 82 | _runningTask.Dispose(); 83 | _cancellationTokenSource.Dispose(); 84 | }, CancellationToken.None).Unwrap(); 85 | } 86 | 87 | 88 | /// 89 | /// Stop the job and wait for either the cancellation token or the task to finish 90 | /// 91 | /// 92 | /// How long did the job run in total 93 | public async Task StopAsync(CancellationToken token) 94 | { 95 | if (!IsRunning) 96 | { 97 | return TimeSpan.Zero; 98 | } 99 | 100 | _manuallyStopped = true; 101 | _cancellationTokenSource.Cancel(); 102 | await Task.WhenAny(TaskUtils.WaitForDelayOrCancellation(TimeSpan.FromMilliseconds(-1), token), _runningTaskWithDone); 103 | _runningTaskWithDone.Dispose(); 104 | _runningTaskWithDone = null; 105 | _cancellationTokenSource.Dispose(); 106 | _stopwatch.Stop(); 107 | return _stopwatch.Elapsed; 108 | } 109 | 110 | Task IJobRunner.WaitForJob() 111 | { 112 | return _runningTaskWithDone ?? Task.CompletedTask; 113 | } 114 | 115 | /// 116 | /// Execute the job 117 | /// 118 | /// 119 | /// 120 | /// true if the job should still be running, false if it shouldn't 121 | /// 122 | protected async Task InnerExecuteJob(IJob job, CancellationToken cancellationToken) 123 | { 124 | using var maxRuntimeCts = job.MaxRuntime.HasValue ? new CancellationTokenSource(job.MaxRuntime.Value) : new CancellationTokenSource(); 125 | using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, maxRuntimeCts.Token); 126 | var runtimeMaxLinkedToken = linkedCts.Token; 127 | _stopwatch.Restart(); 128 | using var activity = _activitySource.StartActivity("Running Job", ActivityKind.Internal, null, new[] 129 | { 130 | new KeyValuePair("JobClass", job.GetType().Name), 131 | new KeyValuePair("Retries", Retries) 132 | }); 133 | try 134 | { 135 | await job.ExecuteAsync(runtimeMaxLinkedToken); 136 | Retries = 0; 137 | } 138 | catch (System.Exception e) 139 | { 140 | try 141 | { 142 | var jobException = new JobException("Job Failed", e); 143 | if (e is OperationCanceledException && maxRuntimeCts.IsCancellationRequested) 144 | { 145 | jobException = new MaxRuntimeJobException("Job reached max runtime", e); 146 | } 147 | 148 | await job.OnFailure(jobException); 149 | 150 | var retryRule = job.FailRule ?? DefaultFailRule; 151 | if (retryRule.ShouldRetry(Retries)) 152 | { 153 | var delay = retryRule.GetDelayBetweenRetries(Retries); 154 | Retries++; 155 | 156 | if (delay.HasValue) 157 | { 158 | await TaskUtils.WaitForDelayOrCancellation(delay.Value, cancellationToken); 159 | } 160 | 161 | if (cancellationToken.IsCancellationRequested) 162 | { 163 | return; 164 | } 165 | 166 | await InnerExecuteJob(job, cancellationToken); 167 | return; 168 | } 169 | 170 | _cancellationTokenSource.Cancel(); 171 | } 172 | catch (System.Exception failureException) 173 | { 174 | throw new JobException("Fail to handle failure of job", failureException); 175 | } 176 | finally 177 | { 178 | _stopwatch.Stop(); 179 | } 180 | } 181 | } 182 | } 183 | } -------------------------------------------------------------------------------- /Job.Scheduler/Scheduler/JobScheduler.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using Job.Scheduler.Builder; 8 | using Job.Scheduler.Job; 9 | using Job.Scheduler.Job.Data; 10 | using Job.Scheduler.Job.Runner; 11 | using Job.Scheduler.Queue; 12 | 13 | namespace Job.Scheduler.Scheduler 14 | { 15 | /// 16 | /// Takes care of scheduling new and managing them. 17 | /// 18 | public class JobScheduler : IJobScheduler 19 | { 20 | private readonly ConcurrentDictionary _jobs = new(); 21 | private readonly ConcurrentDictionary _debouncedJobs = new(); 22 | private readonly IJobRunnerBuilder _jobRunnerBuilder; 23 | private readonly ConcurrentDictionary _queues = new(); 24 | 25 | internal class BuilderJobContainer : IJobContainerBuilder where TJob : IJob 26 | { 27 | internal class JobContainer : IJobContainer 28 | { 29 | public JobContainer(TJob job) 30 | { 31 | Job = job; 32 | } 33 | 34 | public void Dispose() 35 | { 36 | } 37 | 38 | public TJob Job { get; } 39 | } 40 | 41 | private readonly TJob _job; 42 | 43 | 44 | public Type JobType => typeof(TJob); 45 | public string Key { get; } 46 | public string QueueId { get; } 47 | 48 | public Task OnCompletedAsync(CancellationToken token) 49 | { 50 | return Task.CompletedTask; 51 | } 52 | 53 | public IJobContainer BuildJob() 54 | { 55 | return new JobContainer(_job); 56 | } 57 | 58 | public BuilderJobContainer(TJob job) 59 | { 60 | _job = job; 61 | Key = _job is HasKey keyed ? keyed.Key : Guid.NewGuid().ToString(); 62 | QueueId = _job is IQueueJob queueJob ? queueJob.QueueId : null; 63 | } 64 | } 65 | 66 | public JobScheduler(IJobRunnerBuilder jobRunnerBuilder) 67 | { 68 | _jobRunnerBuilder = jobRunnerBuilder; 69 | } 70 | 71 | 72 | /// 73 | /// Register a queue 74 | /// 75 | /// 76 | /// Queue already exists 77 | public void RegisterQueue(QueueSettings queueSettings) 78 | { 79 | if (!_queues.TryAdd(queueSettings.QueueId, new Queue.Queue(queueSettings, _jobRunnerBuilder))) 80 | { 81 | throw new ArgumentException($"Already have a queue registered with that Id: {queueSettings.QueueId}", nameof(queueSettings)); 82 | } 83 | } 84 | 85 | Queue.Queue IJobScheduler.GetQueue(string queueId) 86 | { 87 | _queues.TryGetValue(queueId, out var queue); 88 | return queue; 89 | } 90 | 91 | /// 92 | /// Schedule a new job to run through a container setup 93 | /// 94 | /// The container of the job to run 95 | /// If you want to cancel easily this specific job later. Default = None 96 | /// In which TaskScheduler should the job be run. Default = TaskScheduler.Default 97 | public JobId ScheduleJob(IJobContainerBuilder builderJobContainer, CancellationToken token = default, TaskScheduler taskScheduler = null) where TJob : IJob 98 | { 99 | var runner = ((IJobScheduler)this).ScheduleJobInternal(builderJobContainer, taskScheduler, token); 100 | return runner == null ? new JobId() : new JobId(runner.UniqueId); 101 | } 102 | 103 | /// 104 | /// Schedule a new job to run 105 | /// 106 | /// The job to run 107 | /// If you want to cancel easily this specific job later. Default = None 108 | /// In which TaskScheduler should the job be run. Default = TaskScheduler.Default 109 | public JobId ScheduleJob(TJob job, CancellationToken token = default, TaskScheduler taskScheduler = null) where TJob : IJob => ScheduleJob(new BuilderJobContainer(job), token, taskScheduler); 110 | 111 | /// 112 | /// Stop the given job 113 | /// 114 | public async Task StopAsync(JobId jobId, CancellationToken token) 115 | { 116 | _jobs.TryGetValue(jobId.UniqueId, out var jobRunner); 117 | if (jobRunner == null) 118 | { 119 | return; 120 | } 121 | 122 | await jobRunner.StopAsync(token); 123 | } 124 | 125 | /// 126 | /// Is the job present in the scheduler 127 | /// 128 | public bool HasJob(JobId jobId) => _jobs.TryGetValue(jobId.UniqueId, out _); 129 | 130 | IJobRunner IJobScheduler.ScheduleJobInternal(IJobContainerBuilder builderJobContainer, TaskScheduler taskScheduler, CancellationToken token) 131 | { 132 | if (builderJobContainer is IJobContainerBuilder queueJobContainer) 133 | { 134 | using var jobContainer = queueJobContainer.BuildJob(); 135 | var job = jobContainer.Job; 136 | return HandleQueueJobs(queueJobContainer, taskScheduler, job.QueueId, token); 137 | } 138 | 139 | if (builderJobContainer is IJobContainerBuilder debounceContainer) 140 | { 141 | return HandleDebounceJobs(taskScheduler, debounceContainer, token); 142 | } 143 | 144 | 145 | var runner = _jobRunnerBuilder.Build(builderJobContainer, async (jobRunner, stoppedManually) => 146 | { 147 | _jobs.TryRemove(jobRunner.UniqueId, out _); 148 | await builderJobContainer.OnCompletedAsync(token); 149 | }, taskScheduler); 150 | _jobs.TryAdd(runner.UniqueId, runner); 151 | 152 | 153 | runner.Start(token); 154 | return runner; 155 | } 156 | 157 | private IJobRunner HandleDebounceJobs(TaskScheduler taskScheduler, IJobContainerBuilder debounceContainer, CancellationToken token) 158 | { 159 | DebounceJobRunner BuildDebounceRunner(HasKey job) 160 | { 161 | var debounceJobRunner = (_jobRunnerBuilder.Build(debounceContainer, async (jobRunner, stoppedManually) => 162 | { 163 | _jobs.TryRemove(jobRunner.UniqueId, out _); 164 | if (!stoppedManually) 165 | { 166 | _debouncedJobs.TryRemove(job.Key, out var finishedDebouncer); 167 | finishedDebouncer?.Dispose(); 168 | } 169 | 170 | await debounceContainer.OnCompletedAsync(token); 171 | }, taskScheduler) as DebounceJobRunner)!; 172 | 173 | _jobs.TryAdd(debounceJobRunner.UniqueId, debounceJobRunner); 174 | return debounceJobRunner; 175 | } 176 | 177 | using var jobContainer = debounceContainer.BuildJob(); 178 | var debounceJob = jobContainer.Job; 179 | if (_debouncedJobs.TryGetValue(debounceJob.Key, out var debouncer)) 180 | { 181 | debouncer.Debounce(BuildDebounceRunner(debounceJob)); 182 | return debouncer.JobRunner; 183 | } 184 | 185 | var debounceRunner = BuildDebounceRunner(debounceJob); 186 | 187 | debouncer = new Debouncer(debounceJob, debounceRunner); 188 | _debouncedJobs.TryAdd(debounceJob.Key, debouncer); 189 | 190 | debouncer.Start(); 191 | return debouncer.JobRunner; 192 | } 193 | 194 | private IJobRunner HandleQueueJobs(IJobContainerBuilder builderJobContainer, TaskScheduler taskScheduler, string queueId, CancellationToken cancellationToken) 195 | { 196 | if (!_queues.TryGetValue(queueId, out var queue)) 197 | { 198 | throw new ArgumentException($"Can't schedule job on a non registered queue: {queueId}. Use {nameof(RegisterQueue)} with your {nameof(QueueSettings)}."); 199 | } 200 | 201 | queue.AddJob(new QueueJobContainer(builderJobContainer, taskScheduler, cancellationToken)); 202 | return null; 203 | } 204 | 205 | /// 206 | /// Stop the task and wait for it to terminate. 207 | /// Use the token to stop the task earlier 208 | /// 209 | public async Task StopAsync(CancellationToken token = default) 210 | { 211 | await Task.WhenAll(_jobs.Values.Select(runner => runner.StopAsync(token)).Concat(_queues.Values.Select(queue => queue.StopAsync(token)))); 212 | foreach (var (key, debouncer) in _debouncedJobs) 213 | { 214 | debouncer?.Dispose(); 215 | } 216 | _debouncedJobs.Clear(); 217 | } 218 | } 219 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [3.1.7](https://github.com/Belphemur/Job.Scheduler/compare/v3.1.6...v3.1.7) (2023-12-14) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * **JobRunner:** Be sure that all jobs are run into tasks ([7bb4ac8](https://github.com/Belphemur/Job.Scheduler/commit/7bb4ac8e773f52a168a01051a02bfaf5435d5d91)) 7 | 8 | ## [3.1.6](https://github.com/Belphemur/Job.Scheduler/compare/v3.1.5...v3.1.6) (2023-02-09) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * **Debounce:** Be sure to only remove the debouncing key when the DebounceJob has succeeded, not when it was debounced. ([33bb8ff](https://github.com/Belphemur/Job.Scheduler/commit/33bb8ff016610845ea1c27657ee6d595e6d351c4)) 14 | 15 | ## [3.1.5](https://github.com/Belphemur/Job.Scheduler/compare/v3.1.4...v3.1.5) (2023-02-09) 16 | 17 | 18 | ### Bug Fixes 19 | 20 | * **Job:** fix possible null case when stopping a job ([cf7efc6](https://github.com/Belphemur/Job.Scheduler/commit/cf7efc6c949fabeb1c4323c29da2915c5fb5cfd0)) 21 | 22 | ## [3.1.4](https://github.com/Belphemur/Job.Scheduler/compare/v3.1.3...v3.1.4) (2023-02-09) 23 | 24 | 25 | ### Bug Fixes 26 | 27 | * **debounce:** Be sure to interrupt old running job when debouncing ([a45735c](https://github.com/Belphemur/Job.Scheduler/commit/a45735cde9c51cb38e89351daa0daf68587cc333)) 28 | 29 | ## [3.1.3](https://github.com/Belphemur/Job.Scheduler/compare/v3.1.2...v3.1.3) (2023-02-05) 30 | 31 | 32 | ### Bug Fixes 33 | 34 | * **debounce:** Be sure only the last job is ran in case of debounce. ([ae8e50a](https://github.com/Belphemur/Job.Scheduler/commit/ae8e50af57dea844d6b7ae25b6326a609f2233d1)) 35 | 36 | ## [3.1.2](https://github.com/Belphemur/Job.Scheduler/compare/v3.1.1...v3.1.2) (2023-02-05) 37 | 38 | 39 | ### Performance Improvements 40 | 41 | * **DebounceJob:** Improve the logic of the debounce job ([fed865b](https://github.com/Belphemur/Job.Scheduler/commit/fed865bd3433120baaad4a8366ce27fe23bb1244)) 42 | 43 | ## [3.1.1](https://github.com/Belphemur/Job.Scheduler/compare/v3.1.0...v3.1.1) (2022-11-27) 44 | 45 | 46 | ### Bug Fixes 47 | 48 | * **Scope::Dispose:** Fix issue where we dispose the scope too early ([f23e9ae](https://github.com/Belphemur/Job.Scheduler/commit/f23e9ae05a339e77b95aec93e6539e5040f7ecf3)) 49 | 50 | # [3.1.0](https://github.com/Belphemur/Job.Scheduler/compare/v3.0.2...v3.1.0) (2022-11-27) 51 | 52 | 53 | ### Features 54 | 55 | * **Scope:** Create a new scope every time we build a job ([6ae54b8](https://github.com/Belphemur/Job.Scheduler/commit/6ae54b85922af76d165fa8c8882c0cf16c8ef199)) 56 | 57 | ## [3.0.2](https://github.com/Belphemur/Job.Scheduler/compare/v3.0.1...v3.0.2) (2022-10-05) 58 | 59 | 60 | ### Bug Fixes 61 | 62 | * **Scope:** Fix issue with scope being discarded and we still need access to the JobKey ([55a495f](https://github.com/Belphemur/Job.Scheduler/commit/55a495f65bb73274786c1d9ffc8f913abf8cc12a)) 63 | 64 | ## [3.0.1](https://github.com/Belphemur/Job.Scheduler/compare/v3.0.0...v3.0.1) (2022-10-04) 65 | 66 | 67 | ### Bug Fixes 68 | 69 | * **Scope:** Be sure to register job as transient ([dac2828](https://github.com/Belphemur/Job.Scheduler/commit/dac2828d45218a88ff851f808786ac4b839917d5)) 70 | 71 | # [3.0.0](https://github.com/Belphemur/Job.Scheduler/compare/v2.9.0...v3.0.0) (2022-10-04) 72 | 73 | 74 | ### Features 75 | 76 | * **Scope:** Respect the scope of the job by building it on-demand and always rebuilding it for recurring jobs ([bd623f4](https://github.com/Belphemur/Job.Scheduler/commit/bd623f40825e048f75cb006b1564e40b43f0b6b1)) 77 | 78 | 79 | ### BREAKING CHANGES 80 | 81 | * **Scope:** The scheduling API is now type with generic. It shouldn't impact too much your code unless you've implemented your own IContainerJob which use a generic now. 82 | 83 | # [2.9.0](https://github.com/Belphemur/Job.Scheduler/compare/v2.8.0...v2.9.0) (2022-07-13) 84 | 85 | 86 | ### Features 87 | 88 | * **Queue:** Add registration of queue part of ASP.NET JobScheduler configuration ([eeb07fb](https://github.com/Belphemur/Job.Scheduler/commit/eeb07fb794cf6b6dabf5246e014dd5b200f835ce)) 89 | * **Queue:** Add support for queue. It's possible to register queues with their own max concurrency. ([1f0ae91](https://github.com/Belphemur/Job.Scheduler/commit/1f0ae91c868606e3931d43c85db33efa04efd6a5)) 90 | 91 | # [2.8.0](https://github.com/Belphemur/Job.Scheduler/compare/v2.7.3...v2.8.0) (2022-07-12) 92 | 93 | 94 | ### Features 95 | 96 | * **ExponentialDecorrelatedJittedBackoffRetry:** Add a new retry strategy that is better than the normal exponential retry ([4c0277e](https://github.com/Belphemur/Job.Scheduler/commit/4c0277ecbda7fa3ef37070568cba4e10bb7ec405)) 97 | 98 | ## [2.7.3](https://github.com/Belphemur/Job.Scheduler/compare/v2.7.2...v2.7.3) (2022-05-09) 99 | 100 | 101 | ### Bug Fixes 102 | 103 | * **Docs:** Finally found the way to have the source code in symbol package ([8b00d50](https://github.com/Belphemur/Job.Scheduler/commit/8b00d50c767496f4cb710a23beedfcc0ad1b8039)) 104 | 105 | ## [2.7.2](https://github.com/Belphemur/Job.Scheduler/compare/v2.7.1...v2.7.2) (2022-05-09) 106 | 107 | 108 | ### Bug Fixes 109 | 110 | * **Docs:** Include source code in the symbol package ([f44c536](https://github.com/Belphemur/Job.Scheduler/commit/f44c536fb2577eae446a64bbf6c3b52cf6ca94d8)) 111 | 112 | ## [2.7.1](https://github.com/Belphemur/Job.Scheduler/compare/v2.7.0...v2.7.1) (2022-04-17) 113 | 114 | 115 | ### Bug Fixes 116 | 117 | * **Asp.Net Core:** Add missing documentation ([8d62525](https://github.com/Belphemur/Job.Scheduler/commit/8d6252524a90e26f1954c99b4eac3e05a6504ca6)) 118 | 119 | # [2.7.0](https://github.com/Belphemur/Job.Scheduler/compare/v2.6.0...v2.7.0) (2022-04-16) 120 | 121 | 122 | ### Bug Fixes 123 | 124 | * **Docs:** Fix documentation ([b2cc37f](https://github.com/Belphemur/Job.Scheduler/commit/b2cc37f8952d47eb15a3774789c0b43455d102fe)) 125 | 126 | 127 | ### Features 128 | 129 | * **ServiceCollection:** Add easy method to add Job as scoped ([62e4a5e](https://github.com/Belphemur/Job.Scheduler/commit/62e4a5e4e0a9c881f17417b6021649d34dd96abf)) 130 | 131 | # [2.6.0](https://github.com/Belphemur/Job.Scheduler/compare/v2.5.1...v2.6.0) (2022-04-16) 132 | 133 | 134 | ### Features 135 | 136 | * **Asp.net Core:** Add support for ASP.NET Core ([a4a1ce5](https://github.com/Belphemur/Job.Scheduler/commit/a4a1ce57df87610c9ea57bac9e6fd64e1e02fd67)) 137 | * **ContainerJob:** Add concept of Container job for advance usage ([e664657](https://github.com/Belphemur/Job.Scheduler/commit/e664657f78227ac1ea56112d3f81adca892cc431)) 138 | * **IDisposableAsync:** Add support for Async Disposable job ([0ce73fe](https://github.com/Belphemur/Job.Scheduler/commit/0ce73fe9ca3e923031b4248acbf3e3fc51a588d1)) 139 | 140 | ## [2.5.1](https://github.com/Belphemur/Job.Scheduler/compare/v2.5.0...v2.5.1) (2022-04-16) 141 | 142 | 143 | ### Bug Fixes 144 | 145 | * **Debounce:** Make debounce wait for the inner job ([687aca7](https://github.com/Belphemur/Job.Scheduler/commit/687aca7b0078a997908c57a14648ef6a8e4810bd)) 146 | 147 | # [2.5.0](https://github.com/Belphemur/Job.Scheduler/compare/v2.4.1...v2.5.0) (2021-10-05) 148 | 149 | 150 | ### Bug Fixes 151 | 152 | * **Debounce:** Job already removed when second debouncing job comes ([1fcf630](https://github.com/Belphemur/Job.Scheduler/commit/1fcf630dce12e2c451078a52fe483dba5f1f4c6a)) 153 | 154 | 155 | ### Features 156 | 157 | * **DebounceJob:** Add new feature to implement debouncing jobs ([7976d03](https://github.com/Belphemur/Job.Scheduler/commit/7976d038da7018026fa9dae27d10e84d623f3a70)) 158 | 159 | ## [2.4.1](https://github.com/Belphemur/Job.Scheduler/compare/v2.4.0...v2.4.1) (2021-09-28) 160 | 161 | 162 | ### Bug Fixes 163 | 164 | * **TaskScheduler:** Be sure the task are run in proper thread depending if a TaskScheduler was given or not. ([d3d0aea](https://github.com/Belphemur/Job.Scheduler/commit/d3d0aea9224e4b150b567a7cfb2288e679e8a2cd)) 165 | * **Tests:** Wait for the right task ([99cd679](https://github.com/Belphemur/Job.Scheduler/commit/99cd679f87726515ecd1e3a7148859cc010c10ca)) 166 | 167 | # [2.4.0](https://github.com/Belphemur/Job.Scheduler/compare/v2.3.0...v2.4.0) (2021-09-27) 168 | 169 | 170 | ### Features 171 | 172 | * **TaskScheduler:** Add option to run Job on a specific TaskScheduler ([812c396](https://github.com/Belphemur/Job.Scheduler/commit/812c396a0803c21fa3e262cc1d646dcbe8cb3d86)) 173 | 174 | # [2.3.0](https://github.com/Belphemur/Job.Scheduler/compare/v2.2.2...v2.3.0) (2021-07-27) 175 | 176 | 177 | ### Features 178 | 179 | * **BackoffRetry:** Add 2 different backoff retry using the new delay between retries ([0484803](https://github.com/Belphemur/Job.Scheduler/commit/0484803cf58a83cf046c5c8f135f56ea5785ec0b)) 180 | * **Retry:** Let the user be able to define their own delay strategy between retries instead of a hardcoded value. ([c26802a](https://github.com/Belphemur/Job.Scheduler/commit/c26802a4b522df46259885060ec25345c721867e)) 181 | 182 | ## [2.2.2](https://github.com/Belphemur/Job.Scheduler/compare/v2.2.1...v2.2.2) (2021-07-15) 183 | 184 | 185 | ### Bug Fixes 186 | 187 | * **Disposing:** Dispose of task when possible ([1f31de5](https://github.com/Belphemur/Job.Scheduler/commit/1f31de5346903de90a366dbf182fc68f9c45bbd7)) 188 | 189 | ## [2.2.1](https://github.com/Belphemur/Job.Scheduler/compare/v2.2.0...v2.2.1) (2021-05-22) 190 | 191 | 192 | ### Bug Fixes 193 | 194 | * **Deadlock:** deadlock of task when job end ([b538f8d](https://github.com/Belphemur/Job.Scheduler/commit/b538f8d542e23b3a535e125125ed52bb5822efc7)) 195 | 196 | # [2.2.0](https://github.com/Belphemur/Job.Scheduler/compare/v2.1.1...v2.2.0) (2021-05-15) 197 | 198 | 199 | ### Features 200 | 201 | * **AlwaysRetry:** Add configurable delay for always retry between each retry of the job ([4d6dde2](https://github.com/Belphemur/Job.Scheduler/commit/4d6dde2f8faacdada5318a42dd482b49dfd6eb7b)) 202 | 203 | ## [2.1.1](https://github.com/Belphemur/Job.Scheduler/compare/v2.1.0...v2.1.1) (2021-04-13) 204 | 205 | 206 | ### Bug Fixes 207 | 208 | * **Symbols:** Have symbols uploaded ([42b3dd5](https://github.com/Belphemur/Job.Scheduler/commit/42b3dd551ee6085239ba6c1ee45954187c5fc087)) 209 | 210 | # [2.1.0](https://github.com/Belphemur/Job.Scheduler/compare/v2.0.2...v2.1.0) (2021-04-08) 211 | 212 | 213 | ### Features 214 | 215 | * **OpenTelemetry:** Add support for Open Telemetry ([16766dc](https://github.com/Belphemur/Job.Scheduler/commit/16766dc6749128718a37012b49ffb2a9d5e87beb)) 216 | 217 | ## [2.0.2](https://github.com/Belphemur/Job.Scheduler/compare/v2.0.1...v2.0.2) (2021-03-22) 218 | 219 | 220 | ### Bug Fixes 221 | 222 | * **ci:** Add semantic release NPM ([436871a](https://github.com/Belphemur/Job.Scheduler/commit/436871aeca53b30a32712219d534366e09c4b1d2)) 223 | * **Release:** Fix release script ([efa65cd](https://github.com/Belphemur/Job.Scheduler/commit/efa65cd86a035267021744535360afd968324d74)) 224 | -------------------------------------------------------------------------------- /Job.Scheduler.Tests/JobSchedulerTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using FluentAssertions; 6 | using Job.Scheduler.Builder; 7 | using Job.Scheduler.Job; 8 | using Job.Scheduler.Job.Action; 9 | using Job.Scheduler.Scheduler; 10 | using Job.Scheduler.Tests.Mocks; 11 | using Job.Scheduler.Utils; 12 | using NSubstitute; 13 | using NUnit.Framework; 14 | 15 | namespace Job.Scheduler.Tests 16 | { 17 | [Parallelizable(ParallelScope.Children)] 18 | public class Tests 19 | { 20 | private IJobRunnerBuilder _builder; 21 | 22 | [OneTimeSetUp] 23 | public void OneTimeSetup() 24 | { 25 | _builder = new JobRunnerBuilder(); 26 | } 27 | 28 | [Test] 29 | public async Task OneTimeJob() 30 | { 31 | IJobScheduler scheduler = new JobScheduler(_builder); 32 | var job = new OneTimeJob(); 33 | var jobRunner = scheduler.ScheduleJobInternal(new JobScheduler.BuilderJobContainer(job)); 34 | await jobRunner.WaitForJob(); 35 | job.HasRun.Should().BeTrue(); 36 | } 37 | 38 | [Test] 39 | public async Task OneTimeJobWithOnCompleted() 40 | { 41 | IJobScheduler scheduler = new JobScheduler(_builder); 42 | var job = new OneTimeJob(); 43 | 44 | var container = Substitute.For>(); 45 | container.BuildJob().Returns(new JobScheduler.BuilderJobContainer.JobContainer(job)); 46 | container.JobType.Returns(typeof(IJob)); 47 | 48 | var jobRunner = scheduler.ScheduleJobInternal(container); 49 | await jobRunner.WaitForJob(); 50 | job.HasRun.Should().BeTrue(); 51 | await container.Received(1).OnCompletedAsync(Arg.Any()); 52 | } 53 | 54 | 55 | [Test] 56 | public async Task FailingJobShouldRetry() 57 | { 58 | IJobScheduler scheduler = new JobScheduler(_builder); 59 | var maxRetries = 3; 60 | var job = new FailingRetringJob(new RetryNTimes(maxRetries)); 61 | var jobRunner = scheduler.ScheduleJobInternal(new JobScheduler.BuilderJobContainer(job)); 62 | await jobRunner.WaitForJob(); 63 | job.Ran.Should().Be(4); 64 | jobRunner.Retries.Should().Be(maxRetries); 65 | } 66 | 67 | [Test] 68 | public async Task MaxRuntimeIsRespected() 69 | { 70 | IJobScheduler scheduler = new JobScheduler(_builder); 71 | var job = new MaxRuntimeJob(new NoRetry(), TimeSpan.FromMilliseconds(50)); 72 | var jobRunner = scheduler.ScheduleJobInternal(new JobScheduler.BuilderJobContainer(job)); 73 | await jobRunner.WaitForJob(); 74 | jobRunner.Elapsed.Should().BeCloseTo(job.MaxRuntime!.Value, TimeSpan.FromMilliseconds(20)); 75 | } 76 | 77 | [Test] 78 | public async Task MaxRuntimeIsRespectedAndTaskRetried() 79 | { 80 | IJobScheduler scheduler = new JobScheduler(_builder); 81 | var maxRetries = 2; 82 | var job = new MaxRuntimeJob(new RetryNTimes(maxRetries), TimeSpan.FromMilliseconds(50)); 83 | var jobRunner = scheduler.ScheduleJobInternal(new JobScheduler.BuilderJobContainer(job)); 84 | await jobRunner.WaitForJob(); 85 | jobRunner.Elapsed.Should().BeCloseTo(job.MaxRuntime!.Value, TimeSpan.FromMilliseconds(20)); 86 | jobRunner.Retries.Should().Be(maxRetries); 87 | } 88 | 89 | 90 | [Test] 91 | public async Task MaxRuntimeIsRespectedAndTaskRetriedWithBackoff() 92 | { 93 | IJobScheduler scheduler = new JobScheduler(_builder); 94 | var maxRetries = 3; 95 | var job = new MaxRuntimeJob(new ExponentialBackoffRetry(TimeSpan.FromMilliseconds(10), maxRetries), TimeSpan.FromMilliseconds(80)); 96 | var jobRunner = scheduler.ScheduleJobInternal(new JobScheduler.BuilderJobContainer(job)); 97 | await jobRunner.WaitForJob(); 98 | jobRunner.Elapsed.Should().BeCloseTo(job.MaxRuntime!.Value, TimeSpan.FromMilliseconds(20)); 99 | jobRunner.Retries.Should().Be(maxRetries); 100 | } 101 | 102 | [Test] 103 | public async Task ExecuteInOwnScheduler() 104 | { 105 | IJobScheduler scheduler = new JobScheduler(_builder); 106 | using var taskScheduler = new MockTaskScheduler(); 107 | var job = new ThreadJob(Thread.CurrentThread); 108 | var jobRunner = scheduler.ScheduleJobInternal(new JobScheduler.BuilderJobContainer(job), taskScheduler); 109 | await jobRunner.WaitForJob(); 110 | job.HasRun.Should().BeTrue(); 111 | jobRunner.Retries.Should().Be(0); 112 | taskScheduler.Scheduled.Should().Be(1); 113 | job.InitThread.Should().NotBe(job.RunThread); 114 | job.RunThread.Should().Be(taskScheduler.MainThread); 115 | } 116 | 117 | [Test] 118 | public async Task ExecuteInTask() 119 | { 120 | IJobScheduler scheduler = new JobScheduler(_builder); 121 | var job = new ThreadJob(Thread.CurrentThread); 122 | var jobRunner = scheduler.ScheduleJobInternal(new JobScheduler.BuilderJobContainer(job)); 123 | await jobRunner.WaitForJob(); 124 | job.HasRun.Should().BeTrue(); 125 | jobRunner.Retries.Should().Be(0); 126 | job.TaskId.Should().NotBeNull(); 127 | } 128 | 129 | [Test] 130 | public async Task DebounceJobTest() 131 | { 132 | IJobScheduler scheduler = new JobScheduler(_builder); 133 | var list = new List(); 134 | var jobRunnerFirst = scheduler.ScheduleJobInternal(new JobScheduler.BuilderJobContainer(new DebounceJob(list, "Single", 0))); 135 | await TaskUtils.WaitForDelayOrCancellation(TimeSpan.FromMilliseconds(10), CancellationToken.None); 136 | var jobRunnerSecond = scheduler.ScheduleJobInternal(new JobScheduler.BuilderJobContainer(new DebounceJob(list, "Single", 1))); 137 | await TaskUtils.WaitForDelayOrCancellation(TimeSpan.FromMilliseconds(150), CancellationToken.None); 138 | await jobRunnerFirst.WaitForJob(); 139 | await jobRunnerSecond.WaitForJob(); 140 | list.Should().OnlyContain(s => s == "Single1").And.HaveCount(1); 141 | } 142 | 143 | [Test] 144 | public async Task DebounceJobAlreadyFinishedTest() 145 | { 146 | IJobScheduler scheduler = new JobScheduler(_builder); 147 | var list = new List(); 148 | var jobRunnerFirst = scheduler.ScheduleJobInternal(new JobScheduler.BuilderJobContainer(new DebounceJob(list, "Multiple", 0))); 149 | await TaskUtils.WaitForDelayOrCancellation(TimeSpan.FromMilliseconds(150), CancellationToken.None); 150 | await jobRunnerFirst.WaitForJob(); 151 | var jobRunnerSecond = scheduler.ScheduleJobInternal(new JobScheduler.BuilderJobContainer(new DebounceJob(list, "Multiple", 1))); 152 | await TaskUtils.WaitForDelayOrCancellation(TimeSpan.FromMilliseconds(150), CancellationToken.None); 153 | await jobRunnerSecond.WaitForJob(); 154 | 155 | list.Should().HaveCount(2).And.ContainInOrder(new[] { "Multiple0", "Multiple1" }); 156 | } 157 | 158 | 159 | [Test] 160 | public async Task LongRunningDebounceInterruptedJobTest() 161 | { 162 | IJobScheduler scheduler = new JobScheduler(_builder); 163 | var list = new List(); 164 | var longRunningDebounceJob = new LongRunningDebounceJob(list, "Single", 0); 165 | var jobRunnerFirst = scheduler.ScheduleJobInternal(new JobScheduler.BuilderJobContainer(longRunningDebounceJob)); 166 | await TaskUtils.WaitForDelayOrCancellation(TimeSpan.FromMilliseconds(130), CancellationToken.None); 167 | var jobRunnerSecond = scheduler.ScheduleJobInternal(new JobScheduler.BuilderJobContainer(new DebounceJob(list, "Single", 1))); 168 | await TaskUtils.WaitForDelayOrCancellation(TimeSpan.FromMilliseconds(150), CancellationToken.None); 169 | await jobRunnerFirst.WaitForJob(); 170 | await jobRunnerSecond.WaitForJob(); 171 | longRunningDebounceJob.HasBeenInterrupted.Should().BeTrue(); 172 | list.Should().OnlyContain(s => s == "Single1").And.HaveCount(1); 173 | } 174 | [Test] 175 | public async Task LongRunningDebounceInterruptedJobRemovedOnlyOnSuccessTest() 176 | { 177 | IJobScheduler scheduler = new JobScheduler(_builder); 178 | var list = new List(); 179 | var longRunningDebounceJob = new LongRunningDebounceJob(list, "Single", 0); 180 | var jobRunnerFirst = scheduler.ScheduleJobInternal(new JobScheduler.BuilderJobContainer(longRunningDebounceJob)); 181 | await TaskUtils.WaitForDelayOrCancellation(TimeSpan.FromMilliseconds(130), CancellationToken.None); 182 | 183 | var longRunningDebounceJob2 = new LongRunningDebounceJob(list, "Single", 0); 184 | var jobRunner2 = scheduler.ScheduleJobInternal(new JobScheduler.BuilderJobContainer(longRunningDebounceJob2)); 185 | await TaskUtils.WaitForDelayOrCancellation(TimeSpan.FromMilliseconds(130), CancellationToken.None); 186 | var jobRunnerSecond = scheduler.ScheduleJobInternal(new JobScheduler.BuilderJobContainer(new DebounceJob(list, "Single", 1))); 187 | await TaskUtils.WaitForDelayOrCancellation(TimeSpan.FromMilliseconds(150), CancellationToken.None); 188 | await jobRunnerFirst.WaitForJob(); 189 | await jobRunnerSecond.WaitForJob(); 190 | await jobRunner2.WaitForJob(); 191 | longRunningDebounceJob.HasBeenInterrupted.Should().BeTrue(); 192 | longRunningDebounceJob2.HasBeenInterrupted.Should().BeTrue(); 193 | list.Should().OnlyContain(s => s == "Single1").And.HaveCount(1); 194 | } 195 | [Test] 196 | public void DecorrelatedBackOffTest() 197 | { 198 | var max = 10; 199 | var backoff = new ExponentialDecorrelatedJittedBackoffRetry(max, TimeSpan.FromSeconds(5)); 200 | for (var i = 0; i < max; i++) 201 | { 202 | backoff.ShouldRetry(i).Should().BeTrue(); 203 | backoff.GetDelayBetweenRetries(i); 204 | } 205 | 206 | backoff.ShouldRetry(max).Should().BeFalse(); 207 | } 208 | } 209 | } --------------------------------------------------------------------------------