├── docs ├── stint_coordination_diagram.png └── stint_coordination_diagram.mermaid ├── src ├── global.json ├── Stint │ ├── AnchorStore │ │ ├── IAnchorStoreFactory.cs │ │ ├── IAnchorStore.cs │ │ └── FileSystem │ │ │ ├── FileSystemAnchorStoreFactory.cs │ │ │ └── FileSystemAnchorStore.cs │ ├── Options │ │ ├── BaseTriggerConfig.cs │ │ ├── ScheduledTriggerConfig.cs │ │ ├── JobCompletedTriggerConfig.cs │ │ ├── JobsConfig.cs │ │ ├── JobConfig.cs │ │ └── TriggersConfig.cs │ ├── Triggers │ │ ├── ManualInvoke │ │ │ ├── IJobManualTriggerInvoker.cs │ │ │ ├── IJobManualTriggerRegistry.cs │ │ │ ├── JobManualTriggerRegistry.cs │ │ │ ├── JobManualTriggerExtensions.cs │ │ │ ├── JobManualTriggerInvoker.cs │ │ │ └── ManualInvokeTriggerProvider.cs │ │ ├── ITriggerProvider.cs │ │ ├── Schedule │ │ │ ├── ScheduleTriggerExtensions.cs │ │ │ └── ScheduleTriggerProvider.cs │ │ └── JobCompletion │ │ │ ├── JobCompletionTriggerExtensions.cs │ │ │ └── JobCompletionTriggerProvider.cs │ ├── appsettings.json │ ├── appsettings.Development.json │ ├── IJob.cs │ ├── PubSub │ │ ├── IMediatorFactory.cs │ │ ├── IPublisher.cs │ │ ├── ISubscriber.cs │ │ ├── IMediator.cs │ │ ├── Mediator.cs │ │ ├── MediatorFactory.cs │ │ ├── Publisher.cs │ │ └── Subscriber.cs │ ├── IJobRunnerFactory.cs │ ├── ILockProvider.cs │ ├── IJobRunner.cs │ ├── ExecutionInfo.cs │ ├── IJobChangeTokenProducerFactory.cs │ ├── JobCompletedEventArgs.cs │ ├── ActivityOptions.cs │ ├── ActivityExtensions.cs │ ├── EmptyLockProvider.cs │ ├── Stint.csproj │ ├── Utils │ │ ├── ActionOnDispose.cs │ │ ├── CollectionsExtensions.cs │ │ ├── BackgroundService.cs │ │ └── PolymorphicBaseClassConverter.cs │ ├── ServiceCollectionExtensions.cs │ ├── JobChangeTokenProducerFactory.cs │ ├── JobRunnerFactory.cs │ ├── StintServicesBuilder.cs │ ├── Worker.cs │ └── JobRunner.cs ├── Stint.Cli │ ├── HostBuilderExtensions.cs │ ├── StartCommand.cs │ ├── CommandLine.cs │ ├── Stint.Cli.csproj │ ├── embedded │ │ └── systemd.service │ ├── SystemdConfigInstaller.cs │ └── InstallSystemdCommand.cs ├── Stint.Tests │ ├── Utils │ │ ├── MockAnchorStoreFactory.cs │ │ ├── MockAnchorStore.cs │ │ └── SingletonLockProvider.cs │ ├── configtest.json │ ├── Stint.Tests.csproj │ ├── JobChangeTokenProducerFactoryTests.cs │ └── StintTests.cs ├── Directory.Build.props ├── stint.sln ├── Directory.Packages.props └── .editorconfig ├── .config └── dotnet-tools.json ├── appveyor.yml ├── azure-pipelines.yml ├── .github └── workflows │ └── dotnet.yml ├── .gitignore └── README.md /docs/stint_coordination_diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dazinator/stint/HEAD/docs/stint_coordination_diagram.png -------------------------------------------------------------------------------- /src/global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "8.0.300", 4 | "rollForward": "latestFeature" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/Stint/AnchorStore/IAnchorStoreFactory.cs: -------------------------------------------------------------------------------- 1 | namespace Stint 2 | { 3 | public interface IAnchorStoreFactory 4 | { 5 | IAnchorStore GetAnchorStore(string name); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/Stint/Options/BaseTriggerConfig.cs: -------------------------------------------------------------------------------- 1 | namespace Stint 2 | { 3 | public abstract class BaseTriggerConfig 4 | { 5 | // public abstract string Type { get; set; } 6 | 7 | } 8 | 9 | } 10 | -------------------------------------------------------------------------------- /src/Stint/Options/ScheduledTriggerConfig.cs: -------------------------------------------------------------------------------- 1 | namespace Stint 2 | { 3 | public class ScheduledTriggerConfig : BaseTriggerConfig 4 | { 5 | public string Schedule { get; set; } 6 | 7 | } 8 | 9 | } 10 | -------------------------------------------------------------------------------- /src/Stint/Triggers/ManualInvoke/IJobManualTriggerInvoker.cs: -------------------------------------------------------------------------------- 1 | namespace Stint.Triggers.ManualInvoke 2 | { 3 | public interface IJobManualTriggerInvoker 4 | { 5 | bool Trigger(string jobName); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/Stint/Options/JobCompletedTriggerConfig.cs: -------------------------------------------------------------------------------- 1 | namespace Stint 2 | { 3 | public class JobCompletedTriggerConfig : BaseTriggerConfig 4 | { 5 | public string JobName { get; set; } 6 | 7 | } 8 | 9 | } 10 | -------------------------------------------------------------------------------- /src/Stint/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Stint/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Stint/IJob.cs: -------------------------------------------------------------------------------- 1 | namespace Stint 2 | { 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | 6 | public interface IJob 7 | { 8 | Task ExecuteAsync(ExecutionInfo runInfo, CancellationToken token); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/Stint/PubSub/IMediatorFactory.cs: -------------------------------------------------------------------------------- 1 | namespace Stint.PubSub 2 | { 3 | using System; 4 | 5 | public interface IMediatorFactory where TEventArgs : EventArgs 6 | { 7 | IMediator GetMediator(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Stint/IJobRunnerFactory.cs: -------------------------------------------------------------------------------- 1 | namespace Stint 2 | { 3 | using System.Threading; 4 | 5 | public interface IJobRunnerFactory 6 | { 7 | IJobRunner CreateJobRunner(string jobName, JobConfig config, CancellationToken stoppingToken); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Stint/PubSub/IPublisher.cs: -------------------------------------------------------------------------------- 1 | namespace Stint.PubSub 2 | { 3 | using System; 4 | 5 | public interface IPublisher 6 | where TEventArgs : EventArgs 7 | { 8 | void Publish(object sender, TEventArgs args); 9 | } 10 | 11 | } 12 | -------------------------------------------------------------------------------- /src/Stint/PubSub/ISubscriber.cs: -------------------------------------------------------------------------------- 1 | namespace Stint.PubSub 2 | { 3 | using System; 4 | 5 | public interface ISubscriber 6 | where TEventArgs : EventArgs 7 | { 8 | IDisposable Subscribe(EventHandler handler); 9 | } 10 | 11 | } 12 | -------------------------------------------------------------------------------- /src/Stint/PubSub/IMediator.cs: -------------------------------------------------------------------------------- 1 | namespace Stint.PubSub 2 | { 3 | using System; 4 | 5 | public interface IMediator where TEventArgs : EventArgs 6 | { 7 | event EventHandler OnEvent; 8 | 9 | void Raise(object sender, TEventArgs args); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Stint/ILockProvider.cs: -------------------------------------------------------------------------------- 1 | namespace Stint 2 | { 3 | using System; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | 7 | public interface ILockProvider 8 | { 9 | Task TryAcquireAsync(string name, CancellationToken cancellationToken); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Stint/Options/JobsConfig.cs: -------------------------------------------------------------------------------- 1 | namespace Stint 2 | { 3 | using System.Collections.Generic; 4 | 5 | public class JobsConfig 6 | { 7 | public JobsConfig() => Jobs = new Dictionary(); 8 | 9 | public Dictionary Jobs { get; set; } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Stint/IJobRunner.cs: -------------------------------------------------------------------------------- 1 | namespace Stint 2 | { 3 | using System; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | 7 | public interface IJobRunner : IDisposable 8 | { 9 | JobConfig Config { get; } 10 | Task RunAsync(CancellationToken cancellationToken); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Stint/ExecutionInfo.cs: -------------------------------------------------------------------------------- 1 | namespace Stint 2 | { 3 | using System; 4 | 5 | public class ExecutionInfo 6 | { 7 | public ExecutionInfo(string name) => Name = name; 8 | 9 | /// 10 | /// The name for this job. 11 | /// 12 | public string Name { get; } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Stint/Triggers/ManualInvoke/IJobManualTriggerRegistry.cs: -------------------------------------------------------------------------------- 1 | namespace Stint.Triggers.ManualInvoke 2 | { 3 | using System; 4 | 5 | public interface IJobManualTriggerRegistry 6 | { 7 | void AddUpdateTrigger(string jobName, Action trigger); 8 | bool TryGetTrigger(string jobName, out Action trigger); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/Stint/AnchorStore/IAnchorStore.cs: -------------------------------------------------------------------------------- 1 | namespace Stint 2 | { 3 | using System; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | 7 | public interface IAnchorStore 8 | { 9 | Task GetAnchorAsync(CancellationToken token); 10 | Task DropAnchorAsync(CancellationToken token); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Stint/IJobChangeTokenProducerFactory.cs: -------------------------------------------------------------------------------- 1 | namespace Stint 2 | { 3 | using System.Threading; 4 | using Microsoft.Extensions.Primitives; 5 | 6 | public interface IJobChangeTokenProducerFactory 7 | { 8 | IChangeTokenProducer GetChangeTokenProducer(string jobName, JobConfig jobConfig, CancellationToken cancellationToken); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/Stint/JobCompletedEventArgs.cs: -------------------------------------------------------------------------------- 1 | namespace Stint 2 | { 3 | using System; 4 | 5 | //Define event argument you want to send while raising event. 6 | public class JobCompletedEventArgs : EventArgs 7 | { 8 | public string Name { get; set; } 9 | 10 | public JobCompletedEventArgs(string jobName) => Name = jobName; 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /.config/dotnet-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "isRoot": true, 4 | "tools": { 5 | "dotnet-format": { 6 | "version": "5.0.211103", 7 | "commands": [ 8 | "dotnet-format" 9 | ] 10 | }, 11 | "gitversion.tool": { 12 | "version": "5.11.1", 13 | "commands": [ 14 | "dotnet-gitversion" 15 | ] 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Stint.Cli/HostBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace Stint 2 | { 3 | using Microsoft.Extensions.Hosting; 4 | 5 | public static class HostBuilderExtensions 6 | { 7 | public static IHostBuilder UseCrossPlatformService(this IHostBuilder builder) => 8 | builder 9 | .UseWindowsService() 10 | .UseSystemd(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Stint/PubSub/Mediator.cs: -------------------------------------------------------------------------------- 1 | namespace Stint.PubSub 2 | { 3 | using System; 4 | 5 | public class Mediator : IMediator where TEventArgs : EventArgs 6 | { 7 | public event EventHandler OnEvent = delegate { }; 8 | 9 | public void Raise(object sender, TEventArgs args) => OnEvent(sender, args); 10 | } 11 | 12 | } 13 | -------------------------------------------------------------------------------- /src/Stint/Triggers/ITriggerProvider.cs: -------------------------------------------------------------------------------- 1 | namespace Stint.Triggers 2 | { 3 | using System.Threading; 4 | using Microsoft.Extensions.Primitives; 5 | 6 | public interface ITriggerProvider 7 | { 8 | void AddTriggerChangeTokens( 9 | string jobName, 10 | JobConfig jobConfig, 11 | ChangeTokenProducerBuilder builder, 12 | CancellationToken cancellationToken); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Stint/PubSub/MediatorFactory.cs: -------------------------------------------------------------------------------- 1 | namespace Stint.PubSub 2 | { 3 | using System; 4 | 5 | public class MediatorFactory : IMediatorFactory where TEventArgs : EventArgs 6 | { 7 | private readonly Lazy> _lazyInstance; 8 | public MediatorFactory() => _lazyInstance = new Lazy>(() => new Mediator()); 9 | public IMediator GetMediator() => _lazyInstance.Value; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Stint.Cli/StartCommand.cs: -------------------------------------------------------------------------------- 1 | namespace Stint.Cli 2 | 3 | { 4 | using System; 5 | using System.CommandLine; 6 | using System.CommandLine.Invocation; 7 | using System.Threading.Tasks; 8 | 9 | public class StartCommand : Command 10 | { 11 | private const string CommandName = "start"; 12 | 13 | public StartCommand(Func> startAysncCallback) : base(CommandName) => 14 | Handler = CommandHandler.Create(async () => await startAysncCallback()); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Stint/ActivityOptions.cs: -------------------------------------------------------------------------------- 1 | namespace Stint 2 | { 3 | using System.Collections.Generic; 4 | using System.Diagnostics; 5 | 6 | public class ActivityOptions 7 | { 8 | public ActivityOptions() 9 | { 10 | } 11 | /// 12 | /// Tags applied to all activities created by Stint. 13 | /// 14 | public List> GlobalTags { get; set; } = new List>(); 15 | 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Stint/PubSub/Publisher.cs: -------------------------------------------------------------------------------- 1 | namespace Stint.PubSub 2 | { 3 | using System; 4 | 5 | public class Publisher : IPublisher 6 | where TEventArgs : EventArgs 7 | { 8 | private readonly IMediator _mediator; 9 | 10 | public Publisher(IMediatorFactory mediatorFatory) => _mediator = mediatorFatory.GetMediator(); 11 | public void Publish(object sender, TEventArgs args) => _mediator.Raise(sender, args); 12 | 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/Stint/Triggers/Schedule/ScheduleTriggerExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace Stint.Triggers.Schedule 2 | { 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Stint.Triggers; 5 | 6 | public static class ScheduleTriggerExtensions 7 | { 8 | public static StintServicesBuilder AddScheduleTriggerProvider(this StintServicesBuilder builder) 9 | { 10 | builder.Services.AddSingleton(); 11 | return builder; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Stint.Tests/Utils/MockAnchorStoreFactory.cs: -------------------------------------------------------------------------------- 1 | namespace Stint.Tests 2 | { 3 | using System; 4 | 5 | public class MockAnchorStoreFactory : IAnchorStoreFactory 6 | { 7 | private readonly Func _getAnchorStore; 8 | 9 | public MockAnchorStoreFactory(Func getAnchorStore) => _getAnchorStore = getAnchorStore; 10 | public int CallCount { get; set; } 11 | public IAnchorStore GetAnchorStore(string name) => _getAnchorStore?.Invoke(name); 12 | 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Stint/Triggers/JobCompletion/JobCompletionTriggerExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace Stint.Triggers.JobCompletion 2 | { 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Stint.Triggers; 5 | 6 | public static class JobCompletionTriggerExtensions 7 | { 8 | public static StintServicesBuilder AddJobCompletionTriggerProvider(this StintServicesBuilder builder) 9 | { 10 | builder.Services.AddSingleton(); 11 | return builder; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Stint.Tests/Utils/MockAnchorStore.cs: -------------------------------------------------------------------------------- 1 | namespace Stint.Tests; 2 | 3 | using System; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | 7 | public class MockAnchorStore : IAnchorStore 8 | { 9 | 10 | public DateTime? CurrentAnchor { get; set; } 11 | public Task DropAnchorAsync(CancellationToken token) 12 | { 13 | CurrentAnchor = DateTime.UtcNow; 14 | return Task.FromResult(CurrentAnchor.Value); 15 | } 16 | 17 | public Task GetAnchorAsync(CancellationToken token) => Task.FromResult(CurrentAnchor); 18 | } 19 | -------------------------------------------------------------------------------- /src/Stint/ActivityExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | 4 | public static class ActivityExtensions 5 | { 6 | public static void RecordException(this Activity activity, Exception exception) 7 | { 8 | if (activity == null || exception == null) 9 | { 10 | return; 11 | } 12 | 13 | activity.SetTag("exception.type", exception.GetType().FullName); 14 | activity.SetTag("exception.message", exception.Message); 15 | activity.SetTag("exception.stacktrace", exception.StackTrace); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Stint.Cli/CommandLine.cs: -------------------------------------------------------------------------------- 1 | namespace Stint.Cli 2 | { 3 | using System; 4 | using System.CommandLine; 5 | using System.Threading.Tasks; 6 | 7 | public class CommandLine : RootCommand 8 | { 9 | public CommandLine(Func> startAsyncCallback) : base("Stint.SchedulerStint cli") 10 | { 11 | AddCommand(new StartCommand(startAsyncCallback)); 12 | // TODO: Only register systemd command when on linux, if on windows register an alternative command for installing using sc? 13 | AddCommand(new InstallSystemdCommand(new SystemdConfigInstaller())); 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Stint/PubSub/Subscriber.cs: -------------------------------------------------------------------------------- 1 | namespace Stint.PubSub 2 | { 3 | using System; 4 | using Stint.Utils; 5 | 6 | public class Subscriber : ISubscriber 7 | where TEventArgs : EventArgs 8 | { 9 | private readonly IMediator _mediator; 10 | 11 | public Subscriber(IMediatorFactory mediatorFatory) => _mediator = mediatorFatory.GetMediator(); 12 | 13 | public IDisposable Subscribe(EventHandler handler) 14 | { 15 | _mediator.OnEvent += handler; 16 | return new ActionOnDispose(() => _mediator.OnEvent -= handler); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Stint/EmptyLockProvider.cs: -------------------------------------------------------------------------------- 1 | namespace Stint 2 | { 3 | using System; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | 7 | /// 8 | /// A lock provider that always works returning an empty lock - essentially its meaningless as its not locking anything. 9 | /// 10 | public class EmptyLockProvider : ILockProvider 11 | { 12 | private readonly Task _emptyLock = Task.FromResult(EmptyDisposable.Instance); 13 | 14 | public EmptyLockProvider() 15 | { 16 | } 17 | 18 | public Task TryAcquireAsync(string name, CancellationToken cancellationToken) => _emptyLock; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Stint/Options/JobConfig.cs: -------------------------------------------------------------------------------- 1 | namespace Stint 2 | { 3 | using System; 4 | 5 | public class JobConfig 6 | { 7 | public JobConfig() => Triggers = new TriggersConfig(); 8 | 9 | public TriggersConfig Triggers { get; set; } 10 | 11 | public string Type { get; set; } 12 | 13 | public override bool Equals(object obj) => Equals(obj as JobConfig); 14 | 15 | public bool Equals(JobConfig obj) 16 | { 17 | var equal = obj != null && 18 | obj.Type == Type && 19 | obj.Triggers.Equals(this.Triggers); 20 | return equal; 21 | } 22 | 23 | public override int GetHashCode() => throw new NotImplementedException(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Stint.Cli/Stint.Cli.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/Stint/AnchorStore/FileSystem/FileSystemAnchorStoreFactory.cs: -------------------------------------------------------------------------------- 1 | namespace Stint 2 | { 3 | using Microsoft.Extensions.Logging; 4 | 5 | public class FileSystemAnchorStoreFactory : IAnchorStoreFactory 6 | { 7 | private readonly string _basePath; 8 | private readonly ILoggerFactory _loggerFacory; 9 | 10 | public FileSystemAnchorStoreFactory(string basePath, ILoggerFactory loggerFacory) 11 | { 12 | _basePath = basePath; 13 | _loggerFacory = loggerFacory; 14 | } 15 | public IAnchorStore GetAnchorStore(string name) 16 | { 17 | var logger = _loggerFacory.CreateLogger(); 18 | return new FileSystemAnchorStore(_basePath, name, logger); 19 | 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Stint/Triggers/ManualInvoke/JobManualTriggerRegistry.cs: -------------------------------------------------------------------------------- 1 | namespace Stint.Triggers.ManualInvoke 2 | { 3 | using System; 4 | using System.Collections.Concurrent; 5 | 6 | public class JobManualTriggerRegistry : IJobManualTriggerRegistry 7 | { 8 | private readonly ConcurrentDictionary _jobTriggerDelegates = new ConcurrentDictionary(); 9 | 10 | public bool TryGetTrigger(string jobName, out Action trigger) 11 | { 12 | var result = _jobTriggerDelegates.TryGetValue(jobName, out trigger); 13 | return result; 14 | } 15 | 16 | public void AddUpdateTrigger(string jobName, Action trigger) 17 | { 18 | var result = _jobTriggerDelegates.AddOrUpdate(jobName, trigger, (key, oldValue) => trigger); 19 | return; 20 | } 21 | 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | skip_tags: true 2 | image: Visual Studio 2019 Preview 3 | 4 | install: 5 | - cmd: choco install gitversion.portable --version 5.0.1 -y 6 | - cmd: choco install dotnetcore-sdk --version 5.0.202 -y 7 | 8 | before_build: 9 | - ps: gitversion /l console /output buildserver 10 | 11 | build: 12 | verbosity: detailed 13 | build_script: 14 | - cmd: dotnet tool install -g dotnet-format 15 | - cmd: dotnet-format -w ./src -v normal --check 16 | - cmd: dotnet restore src --disable-parallel 17 | - cmd: dotnet build src -c Release --disable-parallel 18 | - cmd: dotnet pack src -c Release --output %APPVEYOR_BUILD_FOLDER%/artifacts/ 19 | artifacts: 20 | - path: artifacts/* 21 | deploy: 22 | provider: NuGet 23 | api_key: 24 | secure: u8JpW5kkti8pMi+ra2QcXTJPhkHCA8pkKSiiZOJbcS/vFVHNvF3W8qw1Fy2If6a7 25 | skip_symbols: false 26 | artifact: /.*\.nupkg/ 27 | -------------------------------------------------------------------------------- /src/Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | true 5 | 6 | true 7 | 8 | true 9 | snupkg 10 | 11 | 12 | 13 | all 14 | runtime; build; native; contentfiles; analyzers 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/Stint.Tests/configtest.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | }, 9 | "Scheduler": { 10 | "Jobs": { 11 | "TestJob": { 12 | "Type": "MyCoolJob", 13 | "Triggers": { 14 | "Schedules": [ 15 | { 16 | "Schedule": "* * * * *" 17 | } 18 | ], 19 | "JobCompletions": [ 20 | { 21 | "JobName": "AnotherTestJob" 22 | } 23 | ] 24 | } 25 | }, 26 | "AnotherTestJob": { 27 | "Type": "MyCoolJob", 28 | "Triggers": { 29 | "Schedules": [ 30 | { 31 | "Schedule": "* * * * *" 32 | } 33 | ], 34 | "Manual": true 35 | } 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Stint/Stint.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | stint-Scheduler-E8179A4C-7A71-4C4D-A098-61C34BA1CD42 6 | Stint.Scheduler 7 | preview 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/Stint/Triggers/ManualInvoke/JobManualTriggerExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace Stint.Triggers.ManualInvoke 2 | { 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Stint.Triggers; 5 | 6 | public static class JobManualTriggerExtensions 7 | { 8 | public static StintServicesBuilder AddManualInvokeTriggerProvider(this StintServicesBuilder builder) 9 | { 10 | builder.Services.AddSingleton(); 11 | 12 | // jobs that can be manually triggered have a trigger callback added to the registry, looked up by job name. 13 | // the IJobManualTriggerInvoker can then be injected and used to trigger any of these jobs, using the job name as an argument. 14 | builder.Services.AddSingleton(); 15 | builder.Services.AddSingleton(); 16 | 17 | return builder; 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Stint/Utils/ActionOnDispose.cs: -------------------------------------------------------------------------------- 1 | namespace Stint.Utils 2 | { 3 | using System; 4 | 5 | public class ActionOnDispose : IDisposable 6 | { 7 | 8 | public ActionOnDispose(Action onDispose) => _onDispose = onDispose; 9 | 10 | private bool _disposedValue; 11 | private readonly Action _onDispose; 12 | 13 | protected virtual void Dispose(bool disposing) 14 | { 15 | if (!_disposedValue) 16 | { 17 | if (disposing) 18 | { 19 | // TODO: dispose managed state (managed objects) 20 | _onDispose?.Invoke(); 21 | } 22 | 23 | // TODO: free unmanaged resources (unmanaged objects) and override finalizer 24 | // TODO: set large fields to null 25 | _disposedValue = true; 26 | } 27 | } 28 | 29 | public void Dispose() 30 | { 31 | // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method 32 | Dispose(disposing: true); 33 | GC.SuppressFinalize(this); 34 | } 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/Stint.Tests/Stint.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | false 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | runtime; build; native; contentfiles; analyzers; buildtransitive 16 | all 17 | 18 | 19 | runtime; build; native; contentfiles; analyzers; buildtransitive 20 | all 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/Stint/Triggers/ManualInvoke/JobManualTriggerInvoker.cs: -------------------------------------------------------------------------------- 1 | namespace Stint.Triggers.ManualInvoke 2 | { 3 | using Microsoft.Extensions.Logging; 4 | 5 | public class JobManualTriggerInvoker : IJobManualTriggerInvoker 6 | { 7 | private readonly ILogger _logger; 8 | private readonly IJobManualTriggerRegistry _registry; 9 | 10 | public JobManualTriggerInvoker(ILogger logger, IJobManualTriggerRegistry registry) 11 | { 12 | _logger = logger; 13 | _registry = registry; 14 | } 15 | public bool Trigger(string jobName) 16 | { 17 | //_logger.LogInformation("Invoking manual trigger for {jobname}", jobName); 18 | if (_registry.TryGetTrigger(jobName, out var trigger)) 19 | { 20 | _logger.LogDebug("Invoking manual trigger for {jobname}", jobName); 21 | trigger?.Invoke(); 22 | _logger.LogDebug("Manual trigger invoked for {jobname}", jobName); 23 | return true; 24 | } 25 | 26 | _logger.LogWarning("Manual trigger not found for job {jobname}. Job wasn't triggered.", jobName); 27 | return false; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Stint/Triggers/ManualInvoke/ManualInvokeTriggerProvider.cs: -------------------------------------------------------------------------------- 1 | namespace Stint.Triggers.ManualInvoke 2 | { 3 | using System.Threading; 4 | using Microsoft.Extensions.Logging; 5 | using Microsoft.Extensions.Primitives; 6 | using Stint; 7 | using Stint.Triggers; 8 | 9 | public class ManualInvokeTriggerProvider : ITriggerProvider 10 | { 11 | private readonly ILogger _logger; 12 | private readonly IJobManualTriggerRegistry _triggers; 13 | 14 | public ManualInvokeTriggerProvider(ILogger logger, 15 | IJobManualTriggerRegistry triggers) 16 | { 17 | _logger = logger; 18 | _triggers = triggers; 19 | } 20 | 21 | public void AddTriggerChangeTokens(string jobName, 22 | JobConfig jobConfig, 23 | ChangeTokenProducerBuilder builder, 24 | CancellationToken cancellationToken) 25 | { 26 | 27 | if (jobConfig?.Triggers?.Manual ?? false) 28 | { 29 | // register a delegate that can trigger this job, by the job name. 30 | builder.IncludeTrigger(out var triggerDelegate); 31 | _triggers.AddUpdateTrigger(jobName, triggerDelegate); 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Stint/Options/TriggersConfig.cs: -------------------------------------------------------------------------------- 1 | namespace Stint 2 | { 3 | using System.Collections.Generic; 4 | 5 | public class TriggersConfig 6 | { 7 | public TriggersConfig() 8 | { 9 | Schedules = new List(); 10 | JobCompletions = new List(); 11 | } 12 | public List Schedules { get; set; } 13 | public List JobCompletions { get; set; } 14 | 15 | /// 16 | /// Whether the job can be manually triggered. 17 | /// 18 | public bool Manual { get; set; } = false; 19 | //private int GetTriggersHashCode() 20 | //{ 21 | // unchecked 22 | // { 23 | // var hash = 19; 24 | // foreach (var foo in ScheduledTriggers) 25 | // { 26 | // hash = (hash * 31) + foo.GetHashCode(); 27 | // } 28 | // return hash; 29 | // } 30 | //} 31 | 32 | public bool Equals(TriggersConfig obj) 33 | { 34 | var equal = obj != null && 35 | obj.Schedules.ScrambledEquals(this.Schedules) 36 | && obj.JobCompletions.ScrambledEquals(this.JobCompletions); 37 | return equal; 38 | } 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /src/Stint.Cli/embedded/systemd.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Scheduler service application 3 | 4 | [Service] 5 | Type=notify 6 | # will set the Current Working Directory (CWD). Worker service will have issues without this setting 7 | WorkingDirectory={working-dir} 8 | # systemd will run this executable to start the service 9 | ExecStart={start-command} 10 | # to query logs using journalctl, set a logical name here 11 | SyslogIdentifier={SyslogIdentifier} 12 | 13 | # Use your username to keep things simple. 14 | # If you pick a different user, make sure dotnet and all permissions are set correctly to run the app 15 | # To update permissions, use 'chown yourusername -R /srv/HelloWorld' to take ownership of the folder and files, 16 | # Use 'chmod +x /srv/HelloWorld/HelloWorld' to allow execution of the executable file 17 | User={username} 18 | 19 | # ensure the service restarts after crashing 20 | # Restart=always 21 | # amount of time to wait before restarting the service 22 | # RestartSec=55 23 | 24 | # This environment variable is necessary when dotnet isn't loaded for the specified user. 25 | # To figure out this value, run 'env | grep DOTNET_ROOT' when dotnet has been loaded into your shell. 26 | # Note: `env | grep DOTNET_ROOT` didn't work for me but i think `whereis dotnet` from a terminal also works - 27 | # as long as the user above has access to execute dotnet.exe from one of those paths. 28 | # Environment=DOTNET_ROOT=/usr/bin/dotnet 29 | {env-dotnet-root} 30 | 31 | [Install] 32 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /src/Stint.Tests/Utils/SingletonLockProvider.cs: -------------------------------------------------------------------------------- 1 | namespace Stint 2 | { 3 | using System; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | 7 | /// 8 | /// A lock provider that only allows one job to run at a time, irrespective of the type / name of the job. 9 | /// 10 | public class SingletonLockProvider : ILockProvider 11 | { 12 | private IDisposable _acquiredLock = null; 13 | private readonly object _lock = new object(); 14 | private readonly Task _nullLock = Task.FromResult(null); 15 | 16 | public SingletonLockProvider() 17 | { 18 | } 19 | 20 | public Task TryAcquireAsync(string name, CancellationToken cancellationToken) 21 | { 22 | lock (_lock) 23 | { 24 | if (_acquiredLock != null) 25 | { 26 | // If the lock is already taken, return a null lock indicating the acquisition failed. 27 | return _nullLock; 28 | } 29 | 30 | // Acquire the lock by setting _acquiredLock to a new disposable that will release the lock. 31 | _acquiredLock = new InvokeOnDispose(() => ReleaseLock()); 32 | return Task.FromResult(_acquiredLock); 33 | } 34 | } 35 | 36 | private void ReleaseLock() 37 | { 38 | lock (_lock) 39 | { 40 | _acquiredLock = null; 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Stint/ServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace Stint 2 | { 3 | using System; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using Stint.PubSub; 6 | using Stint.Triggers.ManualInvoke; 7 | using Stint.Triggers.Schedule; 8 | using Triggers.JobCompletion; 9 | 10 | public static class ServiceCollectionExtensions 11 | { 12 | public static IServiceCollection AddScheduledJobs( 13 | this IServiceCollection services, 14 | Action configure) 15 | { 16 | services.AddHostedService(); 17 | var builder = new StintServicesBuilder(services); 18 | builder.AddFileSystemAnchorStore() 19 | .AddLockProvider() 20 | .AddJobChangeTokenProducerFactory() 21 | .AddJobRunnerFactory(); 22 | 23 | // Add the default set of trigger providers. 24 | builder.AddJobCompletionTriggerProvider() 25 | .AddManualInvokeTriggerProvider() 26 | .AddScheduleTriggerProvider(); 27 | 28 | // A simple pub sub implementation for decoupling publshers of events from subscribers. 29 | services.AddSingleton(typeof(IMediatorFactory<>), typeof(MediatorFactory<>)); 30 | services.AddSingleton(typeof(IPublisher<>), typeof(Publisher<>)); 31 | services.AddSingleton(typeof(ISubscriber<>), typeof(Subscriber<>)); 32 | 33 | 34 | configure?.Invoke(builder); 35 | return services; 36 | } 37 | 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Stint/Utils/CollectionsExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace Stint 2 | { 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | 6 | public static class CollectionsExtensions 7 | { 8 | /// 9 | /// compares two lists and returns true if they contain equal elements, order insensitive. 10 | /// 11 | /// 12 | /// 13 | /// 14 | /// 15 | public static bool ScrambledEquals(this IEnumerable list1, IEnumerable list2) 16 | { 17 | var cnt = new Dictionary(); 18 | foreach (var s in list1) 19 | { 20 | if (cnt.ContainsKey(s)) 21 | { 22 | cnt[s]++; 23 | } 24 | else 25 | { 26 | cnt.Add(s, 1); 27 | } 28 | } 29 | foreach (var s in list2) 30 | { 31 | if (cnt.ContainsKey(s)) 32 | { 33 | cnt[s]--; 34 | } 35 | else 36 | { 37 | return false; 38 | } 39 | } 40 | return cnt.Values.All(c => c == 0); 41 | } 42 | 43 | public static Dictionary BuildReverseLookupDictionary(this IDictionary source) 44 | { 45 | var dictionary = new Dictionary(); 46 | foreach (var entry in source) 47 | { 48 | if (!dictionary.ContainsKey(entry.Value)) 49 | { 50 | dictionary.Add(entry.Value, entry.Key); 51 | } 52 | } 53 | return dictionary; 54 | } 55 | 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /docs/stint_coordination_diagram.mermaid: -------------------------------------------------------------------------------- 1 | sequenceDiagram 2 | participant I1 as Instance 1 3 | participant I2 as Instance 2 4 | participant I3 as Instance 3 5 | participant Lock as Distributed Lock Provider 6 | participant Anchor as Anchor Store 7 | 8 | Note over I1,I3: Job "DailyReport" scheduled via cron (e.g., 0 2 * * *) 9 | Note over I1,I3: All instances detect it's time to run the job 10 | 11 | par Instance 1 attempts 12 | I1->>Lock: TryAcquire("DailyReport") 13 | Lock-->>I1: Success (Lock acquired) 14 | and Instance 2 attempts 15 | I2->>Lock: TryAcquire("DailyReport") 16 | Lock-->>I2: Failed (Lock held by I1) 17 | and Instance 3 attempts 18 | I3->>Lock: TryAcquire("DailyReport") 19 | Lock-->>I3: Failed (Lock held by I1) 20 | end 21 | 22 | Note over I1: Instance 1 has the lock 23 | I1->>Anchor: GetLastRun("DailyReport") 24 | Anchor-->>I1: 2024-01-04 02:00:00 25 | 26 | Note over I1: Checks if job should run based on cron and last run 27 | alt Job should run 28 | I1->>I1: Execute Job Logic 29 | I1->>Anchor: UpdateLastRun("DailyReport", now) 30 | Note over I1: Job completed successfully 31 | else Job already ran (anchor recent) 32 | Note over I1: Skip execution - another instance already ran it 33 | end 34 | 35 | I1->>Lock: Release("DailyReport") 36 | 37 | Note over I2,I3: Failed instances wait and retry 38 | 39 | I2->>Lock: TryAcquire("DailyReport") (after delay) 40 | Lock-->>I2: Success (I1 released the lock) 41 | 42 | I2->>Anchor: GetLastRun("DailyReport") 43 | Anchor-->>I2: 2024-01-04 02:00:00 (updated by I1) 44 | 45 | Note over I2: Sees job already executed - anchor is recent 46 | I2->>Lock: Release("DailyReport") 47 | 48 | Note over I1,I3: All instances now wait for next scheduled time 49 | Note over I1,I3: Process repeats - any instance can win the lock -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | # ASP.NET Core (.NET Framework) 2 | # Build and test ASP.NET Core projects targeting the full .NET Framework. 3 | # Add steps that publish symbols, save build artifacts, and more: 4 | # https://docs.microsoft.com/azure/devops/pipelines/languages/dotnet-core 5 | 6 | trigger: 7 | - master 8 | - develop 9 | 10 | pr: 11 | - feature/* 12 | 13 | pool: 14 | vmImage: 'windows-latest' 15 | 16 | variables: 17 | solution: '**/*.sln' 18 | buildPlatform: 'Any CPU' 19 | buildConfiguration: 'Release' 20 | GitVersion.SemVer: '' 21 | 22 | steps: 23 | - task: gittools.gitversion.gitversion-task.GitVersion@5 24 | displayName: gitversion 25 | 26 | - task: UseDotNet@2 27 | displayName: 'Use .NET Core sdk' 28 | inputs: 29 | packageType: sdk 30 | useGlobalJson: true 31 | installationPath: $(Agent.ToolsDirectory)/dotnet 32 | 33 | - script: dotnet tool install -g dotnet-format 34 | displayName: Install dotnet-format 35 | 36 | - script: dotnet-format -w "$(Build.SourcesDirectory)/src" -v normal --check 37 | displayName: Formatting Check 38 | 39 | - task: NuGetToolInstaller@1 40 | inputs: 41 | versionSpec: '5.8.0' 42 | 43 | - task: DotNetCoreCLI@2 44 | displayName: Restore 45 | inputs: 46 | command: restore 47 | projects: '$(solution)' 48 | 49 | - task: DotNetCoreCLI@2 50 | displayName: Build 51 | inputs: 52 | command: build 53 | projects: '$(solution)' 54 | configuration: '$(buildConfiguration)' 55 | versioningScheme: byEnvVar 56 | versionEnvVar: 'GitVersion.SemVer' 57 | 58 | - task: DotNetCoreCLI@2 59 | displayName: test 60 | inputs: 61 | command: test 62 | projects: '$(solution)' 63 | configuration: '$(buildConfiguration)' 64 | 65 | - task: DotNetCoreCLI@2 66 | inputs: 67 | command: 'pack' 68 | versioningScheme: byEnvVar 69 | versionEnvVar: 'GitVersion.SemVer' 70 | 71 | - task: NuGetCommand@2 72 | inputs: 73 | command: push 74 | nuGetFeedType: external 75 | publishFeedCredentials: 'NuGet' 76 | versioningScheme: byEnvVar 77 | versionEnvVar: 'GitVersion.SemVer' 78 | -------------------------------------------------------------------------------- /src/Stint/JobChangeTokenProducerFactory.cs: -------------------------------------------------------------------------------- 1 | namespace Stint 2 | { 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Threading; 6 | using Microsoft.Extensions.Logging; 7 | using Microsoft.Extensions.Primitives; 8 | using Stint.Triggers; 9 | 10 | public class JobChangeTokenProducerFactory : IJobChangeTokenProducerFactory 11 | { 12 | 13 | private readonly ILogger _logger; 14 | private readonly ITriggerProvider[] _triggerProviders; 15 | 16 | public JobChangeTokenProducerFactory( 17 | ILogger logger, 18 | IEnumerable triggerProviders) 19 | { 20 | _logger = logger; 21 | _triggerProviders = triggerProviders?.ToArray(); 22 | } 23 | 24 | /// 25 | /// Build an for a job that will produce 's that will be signalled when the job needs to be executed. 26 | /// 27 | /// 28 | /// 29 | /// 30 | /// 31 | public IChangeTokenProducer GetChangeTokenProducer( 32 | string jobName, 33 | JobConfig jobConfig, 34 | CancellationToken cancellationToken) 35 | { 36 | _logger.LogDebug("Building change token for job: {jobname}", jobName); 37 | var tokenProducerBuilder = new ChangeTokenProducerBuilder(); 38 | // allow trigger providers to include their own ChangeToken's in the composite. 39 | // trigger providers is an extension point, so that we can support novel ways of triggering jobs. 40 | // examples are: Schedule (e.g cron) and Manual invoke. 41 | foreach (var triggerProvider in _triggerProviders) 42 | { 43 | triggerProvider.AddTriggerChangeTokens(jobName, jobConfig, tokenProducerBuilder, cancellationToken); 44 | } 45 | 46 | var tokenProducer = tokenProducerBuilder.Build(); 47 | return tokenProducer; 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Stint/Triggers/JobCompletion/JobCompletionTriggerProvider.cs: -------------------------------------------------------------------------------- 1 | namespace Stint.Triggers.JobCompletion 2 | { 3 | using System.Linq; 4 | using System.Threading; 5 | using Microsoft.Extensions.Logging; 6 | using Microsoft.Extensions.Primitives; 7 | using Stint; 8 | using Stint.PubSub; 9 | using Stint.Triggers; 10 | 11 | public class JobCompletionTriggerProvider : ITriggerProvider 12 | { 13 | private readonly ILogger _logger; 14 | private readonly ISubscriber _subscriber; 15 | 16 | 17 | public JobCompletionTriggerProvider(ILogger logger, 18 | ISubscriber subscriber) 19 | { 20 | _logger = logger; 21 | _subscriber = subscriber; 22 | } 23 | 24 | public void AddTriggerChangeTokens( 25 | string jobName, 26 | JobConfig jobConfig, 27 | ChangeTokenProducerBuilder builder, 28 | CancellationToken cancellationToken) 29 | { 30 | // If job is configured to run when other jobs complete, 31 | // then subscribe to job completion events, and when an event is received, if the job name that completed 32 | // matches the job name that should trigger this job, then invoke the trigger for this job! 33 | var onJobCompletedTriggers = jobConfig.Triggers?.JobCompletions; 34 | if (onJobCompletedTriggers?.Any() ?? false) 35 | { 36 | builder.IncludeSubscribingHandlerTrigger((trigger) => _subscriber.Subscribe((s, e) => 37 | { 38 | foreach (var jobCompletedTrigger in onJobCompletedTriggers) 39 | { 40 | if (string.Equals(jobCompletedTrigger.JobName, e.Name)) 41 | { 42 | _logger.LogInformation("Triggering job {jobName} because job {CompletedJobName} completed", jobName, jobCompletedTrigger.JobName); 43 | trigger?.Invoke(); 44 | break; 45 | } 46 | } 47 | })); 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Stint/JobRunnerFactory.cs: -------------------------------------------------------------------------------- 1 | namespace Stint 2 | { 3 | using System.Threading; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using Microsoft.Extensions.Logging; 6 | using Microsoft.Extensions.Options; 7 | using PubSub; 8 | 9 | public class JobRunnerFactory : IJobRunnerFactory 10 | { 11 | private readonly ILogger _logger; 12 | private readonly IJobChangeTokenProducerFactory _jobChangeTokenProducerFactory; 13 | private readonly IAnchorStoreFactory _anchorStoreFactory; 14 | private readonly ILogger _jobRunnerLogger; 15 | private readonly IServiceScopeFactory _serviceScopeFactory; 16 | private readonly IPublisher _publisher; 17 | private readonly ILockProvider _lockProvider; 18 | private readonly IOptions _activityOptions; 19 | 20 | public JobRunnerFactory( 21 | ILogger logger, 22 | IJobChangeTokenProducerFactory jobChangeTokenProducerFactory, 23 | IAnchorStoreFactory anchorStoreFactory, 24 | ILogger jobRunnerLogger, 25 | IServiceScopeFactory serviceScopeFactory, 26 | IPublisher publisher, 27 | ILockProvider lockProvider, 28 | IOptions activityOptions) 29 | { 30 | _logger = logger; 31 | _jobChangeTokenProducerFactory = jobChangeTokenProducerFactory; 32 | _anchorStoreFactory = anchorStoreFactory; 33 | _jobRunnerLogger = jobRunnerLogger; 34 | _serviceScopeFactory = serviceScopeFactory; 35 | _publisher = publisher; 36 | this._lockProvider = lockProvider; 37 | _activityOptions = activityOptions; 38 | } 39 | 40 | public IJobRunner CreateJobRunner(string jobName, JobConfig config, CancellationToken stoppingToken) 41 | { 42 | _logger.LogDebug("Creating job runner for job {jobName}", jobName); 43 | var anchorStore = _anchorStoreFactory.GetAnchorStore(jobName); 44 | var changeTokenProducer = _jobChangeTokenProducerFactory.GetChangeTokenProducer(jobName, config, stoppingToken); 45 | var newJobRunner = new JobRunner(jobName, _lockProvider, config, anchorStore, _jobRunnerLogger, _serviceScopeFactory, changeTokenProducer, _publisher, _activityOptions.Value); 46 | return newJobRunner; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Stint.Cli/SystemdConfigInstaller.cs: -------------------------------------------------------------------------------- 1 | namespace Stint.Cli 2 | 3 | { 4 | using System; 5 | using System.Diagnostics; 6 | using System.IO; 7 | using System.Threading.Tasks; 8 | using Microsoft.Extensions.FileProviders; 9 | 10 | public class SystemdConfigInstaller 11 | { 12 | private const string TargetDir = "/etc/systemd/system/"; 13 | 14 | public async Task DeploySystemdConfig(string execStart, string user, string envDotnetRoot, 15 | string serviceUnitFileName, string workingDirectory, string syslogIdentifier) 16 | { 17 | var manifestEmbeddedProvider = new ManifestEmbeddedFileProvider(typeof(SystemdConfigInstaller).Assembly); 18 | var template = manifestEmbeddedProvider.GetFileInfo("/embedded/systemd.service"); 19 | var templateText = string.Empty; 20 | 21 | using (var reader = new StreamReader(template.CreateReadStream())) 22 | { 23 | templateText = await reader.ReadToEndAsync(); 24 | } 25 | 26 | // replace tokens 27 | templateText = templateText.Replace("{start-command}", execStart); 28 | templateText = templateText.Replace("{username}", user); 29 | templateText = templateText.Replace("{env-dotnet-root}", envDotnetRoot ?? string.Empty); 30 | templateText = templateText.Replace("{working-dir}", workingDirectory ?? "/"); 31 | templateText = templateText.Replace("{SyslogIdentifier}", syslogIdentifier); 32 | 33 | // deploy 34 | var path = Path.Combine(TargetDir, serviceUnitFileName); 35 | await File.WriteAllTextAsync(path, templateText); 36 | Console.WriteLine("Service unit file written to: {0}", path); 37 | Console.WriteLine(templateText); 38 | } 39 | 40 | public void ReloadDaemon() 41 | { 42 | // systemctl daemon-reload 43 | var startInfo = new ProcessStartInfo 44 | { 45 | FileName = "systemctl", 46 | Arguments = "daemon-reload", 47 | UseShellExecute = false, 48 | //Set output of program to be written to process output stream 49 | RedirectStandardOutput = true, 50 | //Optional 51 | WorkingDirectory = Environment.CurrentDirectory 52 | }; 53 | 54 | using (var process = Process.Start(startInfo)) 55 | { 56 | var strOutput = process.StandardOutput.ReadToEnd(); 57 | //Wait for process to finish 58 | process.WaitForExit(); 59 | Console.Write(strOutput); 60 | } 61 | // sudo systemctl start HelloWorld 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Stint/Triggers/Schedule/ScheduleTriggerProvider.cs: -------------------------------------------------------------------------------- 1 | namespace Stint.Triggers.Schedule 2 | { 3 | using System; 4 | using System.Linq; 5 | using System.Threading; 6 | using Cronos; 7 | using Microsoft.Extensions.Logging; 8 | using Microsoft.Extensions.Primitives; 9 | using Stint; 10 | using Stint.Triggers; 11 | 12 | public class ScheduleTriggerProvider : ITriggerProvider 13 | { 14 | private readonly ILogger _logger; 15 | private readonly IAnchorStoreFactory _anchorStoreFactory; 16 | 17 | public ScheduleTriggerProvider(ILogger logger, IAnchorStoreFactory anchorStoreFactory) 18 | { 19 | _logger = logger; 20 | _anchorStoreFactory = anchorStoreFactory; 21 | } 22 | 23 | public void AddTriggerChangeTokens( 24 | string jobName, 25 | JobConfig jobConfig, 26 | ChangeTokenProducerBuilder builder, 27 | CancellationToken cancellationToken) 28 | { 29 | 30 | var scheduleTriggerConfigs = jobConfig.Triggers?.Schedules; 31 | if (scheduleTriggerConfigs?.Any() ?? false) 32 | { 33 | foreach (var scheduleTriggerConfig in scheduleTriggerConfigs) 34 | { 35 | var expression = CronExpression.Parse(scheduleTriggerConfig.Schedule); 36 | builder.IncludeDatetimeScheduledTokenProducer(async () => 37 | { 38 | // This token producer will signal tokens at the specified datetime. Will calculate the next datetime a job should run based on looking at when it last ran, and its schedule etc. 39 | var anchorStore = _anchorStoreFactory.GetAnchorStore(jobName); 40 | if (cancellationToken.IsCancellationRequested) 41 | { 42 | _logger.LogWarning("cancellation already requested"); 43 | } 44 | var previousOccurrence = await anchorStore.GetAnchorAsync(cancellationToken); 45 | if (previousOccurrence == null) 46 | { 47 | _logger.LogInformation("Job {jobname} has not previously run", jobName); 48 | } 49 | 50 | var fromWhenShouldItNextRun = 51 | previousOccurrence ?? DateTime.UtcNow; // if we have never run before, get next occurrence from now, otherwise get next occurrence from when it last ran! 52 | 53 | var nextOccurence = expression.GetNextOccurrence(fromWhenShouldItNextRun); 54 | _logger.LogInformation("Next occurrence of {jobname} is @ {nextOccurence} using cron {cronSchedule}", jobName, nextOccurence, scheduleTriggerConfig.Schedule); 55 | return nextOccurence; 56 | }, cancellationToken, 57 | () => _logger.LogWarning("Mo more occurrences for job {jobName}", jobName), 58 | (delayMs) => _logger.LogDebug("Will delay for {delayMs} ms to execute {jobName}.", delayMs, jobName)); 59 | } 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Stint/Utils/BackgroundService.cs: -------------------------------------------------------------------------------- 1 | namespace Stint.Utils 2 | { 3 | using System; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using Microsoft.Extensions.Hosting; 7 | 8 | /// 9 | /// Base class for implementing a long running . 10 | /// 11 | public abstract class BackgroundService : IHostedService, IDisposable 12 | { 13 | private Task _executeTask; 14 | private CancellationTokenSource _stoppingCts; 15 | 16 | /// 17 | /// Gets the Task that executes the background operation. 18 | /// 19 | /// 20 | /// Will return if the background operation hasn't started. 21 | /// 22 | public virtual Task ExecuteTask => _executeTask; 23 | 24 | public virtual void Dispose() => _stoppingCts?.Cancel(); 25 | 26 | /// 27 | /// Triggered when the application host is ready to start the service. 28 | /// 29 | /// Indicates that the start process has been aborted. 30 | public virtual Task StartAsync(CancellationToken cancellationToken) 31 | { 32 | // Create linked token to allow cancelling executing task from provided token 33 | _stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); 34 | 35 | // Store the task we're executing 36 | _executeTask = ExecuteAsync(_stoppingCts.Token); 37 | 38 | // If the task is completed then return it, this will bubble cancellation and failure to the caller 39 | if (_executeTask.IsCompleted) 40 | { 41 | return _executeTask; 42 | } 43 | 44 | // Otherwise it's running 45 | return Task.CompletedTask; 46 | } 47 | 48 | /// 49 | /// Triggered when the application host is performing a graceful shutdown. 50 | /// 51 | /// Indicates that the shutdown process should no longer be graceful. 52 | public virtual async Task StopAsync(CancellationToken cancellationToken) 53 | { 54 | // Stop called without start 55 | if (_executeTask == null) 56 | { 57 | return; 58 | } 59 | 60 | try 61 | { 62 | // Signal cancellation to the executing method 63 | _stoppingCts.Cancel(); 64 | } 65 | finally 66 | { 67 | // Wait until the task completes or the stop token triggers 68 | await Task.WhenAny(_executeTask, Task.Delay(Timeout.Infinite, cancellationToken)).ConfigureAwait(false); 69 | } 70 | } 71 | 72 | /// 73 | /// This method is called when the starts. The implementation should return a task that 74 | /// represents 75 | /// the lifetime of the long running operation(s) being performed. 76 | /// 77 | /// Triggered when is called. 78 | /// A that represents the long running operations. 79 | protected abstract Task ExecuteAsync(CancellationToken stoppingToken); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /.github/workflows/dotnet.yml: -------------------------------------------------------------------------------- 1 | name: 'Build Source Branch' 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'master' 7 | - 'develop' 8 | - 'feature/*' 9 | - 'release/*' 10 | # pull_request: 11 | # branches: [ master, develop ] 12 | 13 | jobs: 14 | dotnet-format: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout repo 18 | uses: actions/checkout@v2 19 | with: 20 | persist-credentials: false # otherwise, the token used is the GITHUB_TOKEN, instead of your personal token 21 | fetch-depth: 0 # otherwise, you will failed to push refs to dest repo 22 | 23 | - name: Setup .NET 24 | uses: actions/setup-dotnet@v1 25 | # with: 26 | # dotnet-version: 3.1.x 27 | 28 | - name: Restore dotnet tools 29 | run: dotnet tool restore 30 | 31 | - name: Apply formatting fixes 32 | run: dotnet format src 33 | 34 | - name: Check if there are changes 35 | id: changes 36 | uses: UnicornGlobal/has-changes-action@v1.0.11 37 | 38 | - name: Configure git safe dir 39 | run: | 40 | git config --local --add safe.directory /github/workspace 41 | 42 | - name: Commit files 43 | if: steps.changes.outputs.changed == 1 44 | run: | 45 | git config --local user.name "github-actions[bot]" 46 | git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" 47 | git commit -a -m 'Automated dotnet-format update 48 | 49 | Co-authored-by: ${{ github.event.comment.user.login }} <${{ github.event.comment.user.id }}+${{ github.event.comment.user.login }}@users.noreply.github.com>' 50 | 51 | - name: Push changes 52 | if: steps.changes.outputs.changed == 1 53 | #if: steps.command.outputs.command-name && steps.command.outputs.command-arguments == 'format' && steps.format.outputs.has-changes == 'true' 54 | uses: ad-m/github-push-action@master 55 | #ad-m/github-push-action@v0.5.0 56 | with: 57 | branch: ${{ github.ref }} 58 | github_token: ${{ secrets.GITHUB_TOKEN }} 59 | 60 | build: 61 | runs-on: ubuntu-latest 62 | env: 63 | GitVersion.SemVer: 64 | steps: 65 | - name: Checkout 66 | uses: actions/checkout@v3 67 | with: 68 | fetch-depth: 0 69 | 70 | - name: Setup .NET 71 | uses: actions/setup-dotnet@v1 72 | 73 | - name: Restore dotnet tools 74 | run: dotnet tool restore 75 | 76 | - name: Determine Version 77 | run: dotnet gitversion /l console /output buildserver 78 | 79 | - name: Restore dependencies 80 | run: dotnet restore src 81 | 82 | - name: Build 83 | run: dotnet build src --no-restore --configuration Release /p:Version=${{ env.GitVersion_SemVer }} 84 | 85 | - name: Test 86 | run: dotnet test src -c Release --no-build --no-restore --verbosity normal --filter Category!=Exploratory 87 | 88 | - name: Pack 89 | run: dotnet pack src -c Release --no-build --no-restore -p:PackageVersion=${{ env.GitVersion_SemVer }} --verbosity normal 90 | 91 | - name: Publish 92 | if: startsWith(github.ref, 'refs/heads/master') || startsWith(github.ref, 'refs/heads/develop') 93 | run: dotnet nuget push "**/*.nupkg" --source "https://api.nuget.org/v3/index.json" --api-key ${{ secrets.NUGET_API_KEY }} 94 | 95 | 96 | # - name: Publish 97 | # if: startsWith(github.ref, 'refs/heads/master') || startsWith(github.ref, 'refs/heads/develop') 98 | # run: nuget push **\*.nupkg -Source 'https://api.nuget.org/v3/index.json' -ApiKey ${{secrets.NUGET_API_KEY}} 99 | -------------------------------------------------------------------------------- /src/Stint.Tests/JobChangeTokenProducerFactoryTests.cs: -------------------------------------------------------------------------------- 1 | namespace Stint.Tests 2 | { 3 | using System; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using Dazinator.Extensions.DependencyInjection; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Microsoft.Extensions.Hosting; 9 | using Microsoft.Extensions.Options; 10 | using Xunit; 11 | 12 | public class JobChangeTokenProducerFactoryTests 13 | { 14 | [Fact(Skip = "Not yet working consistently on github build server")] 15 | public void Can_Get_ChangeToken_ForJobWithMultipleSchedules() 16 | { 17 | 18 | var jobRanEvent = new AutoResetEvent(false); 19 | 20 | var hoursNow = DateTime.UtcNow.Hour; 21 | 22 | // services.Configure(configuration); 23 | // a => a.AddTransient(nameof(TestJob), (sp) => new TestJob(onJobExecuted)) 24 | 25 | var host = CreateHostBuilder(new SingletonLockProvider(), 26 | // JobsConfig jobsConfig 27 | (config) => config.Jobs.Add("TestJob", new JobConfig() 28 | { 29 | Type = nameof(TestJob), 30 | Triggers = new TriggersConfig() 31 | { 32 | Schedules = { 33 | new ScheduledTriggerConfig() { Schedule = $"*/1 {hoursNow}-{hoursNow+1} * * *" }, 34 | new ScheduledTriggerConfig() { Schedule = $"*/1 {hoursNow-2}-{hoursNow-1} * * *" } // in the past means next occurence is tomorrow 35 | // new ScheduledTriggerConfig() { Schedule = "*/10 14 * * *" } 36 | } 37 | } 38 | }), 39 | 40 | (jobTypes) => jobTypes.AddTransient(nameof(TestJob), (sp) => new TestJob(async () => jobRanEvent.Set()))) 41 | .Build(); 42 | 43 | 44 | var sut = host.Services.GetRequiredService(); 45 | var config = host.Services.GetRequiredService>(); 46 | var jobConfig = config.Value.Jobs["TestJob"]; 47 | 48 | var changeTokenProducer = sut.GetChangeTokenProducer("TestJob", jobConfig, default); 49 | // changeTokenProducer.Produce() 50 | var token = changeTokenProducer.Produce(); 51 | var listening = token.RegisterChangeCallback((s) => jobRanEvent.Set(), null); 52 | 53 | var signalled = jobRanEvent.WaitOne(62000); 54 | Assert.True(signalled); 55 | } 56 | 57 | 58 | public static IHostBuilder CreateHostBuilder( 59 | ILockProvider lockProvider, 60 | Action configureScheduler, 61 | Action> registerJobTypes) => 62 | 63 | Host.CreateDefaultBuilder() 64 | .ConfigureServices((hostContext, services) => 65 | { 66 | services.Configure(configureScheduler); 67 | 68 | services.AddScheduledJobs((options) => options.AddLockProviderInstance(lockProvider) 69 | .RegisterJobTypes(registerJobTypes)); 70 | }); 71 | 72 | public class TestJob : IJob 73 | { 74 | private readonly Func _onJobExecuted; 75 | 76 | public TestJob(Func onJobExecuted) => _onJobExecuted = onJobExecuted; 77 | 78 | public async Task ExecuteAsync(ExecutionInfo runInfo, CancellationToken token) => await _onJobExecuted(); 79 | } 80 | 81 | public class TestChainedJob : IJob 82 | { 83 | private readonly Func _onJobExecuted; 84 | 85 | public TestChainedJob(Func onJobExecuted) => _onJobExecuted = onJobExecuted; 86 | 87 | public async Task ExecuteAsync(ExecutionInfo runInfo, CancellationToken token) => await _onJobExecuted(); 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Stint/StintServicesBuilder.cs: -------------------------------------------------------------------------------- 1 | namespace Stint 2 | { 3 | using System; 4 | using Dazinator.Extensions.DependencyInjection; 5 | using Microsoft.Extensions.DependencyInjection; 6 | using Microsoft.Extensions.Hosting; 7 | using Microsoft.Extensions.Logging; 8 | 9 | public class StintServicesBuilder 10 | { 11 | public StintServicesBuilder(IServiceCollection services) => Services = services; 12 | 13 | public IServiceCollection Services { get; } 14 | 15 | /// 16 | /// Saves anchors to the file system at the specified location. 17 | /// 18 | /// 19 | /// 20 | public StintServicesBuilder AddFileSystemAnchorStore(string basePath) 21 | { 22 | Services.AddSingleton((sp) => 23 | { 24 | var loggerFactory = sp.GetRequiredService(); 25 | return new FileSystemAnchorStoreFactory(basePath, loggerFactory); 26 | }); 27 | return this; 28 | } 29 | 30 | /// 31 | /// Saves anchors to the file system at the location. 32 | /// 33 | /// 34 | /// 35 | public StintServicesBuilder AddFileSystemAnchorStore() 36 | { 37 | Services.AddSingleton((sp) => 38 | { 39 | var env = sp.GetRequiredService(); 40 | var loggerFactory = sp.GetRequiredService(); 41 | return new FileSystemAnchorStoreFactory(env.ContentRootPath, loggerFactory); 42 | }); 43 | return this; 44 | } 45 | 46 | public StintServicesBuilder AddLockProvider() 47 | where TLockProvider : class, ILockProvider 48 | { 49 | Services.AddSingleton(); 50 | return this; 51 | } 52 | 53 | public StintServicesBuilder AddLockProviderInstance(ILockProvider instance) 54 | { 55 | Services.AddSingleton(instance); 56 | return this; 57 | } 58 | 59 | public StintServicesBuilder AddJobChangeTokenProducerFactory(IJobChangeTokenProducerFactory factoryInstance) 60 | { 61 | Services.AddSingleton(factoryInstance); 62 | return this; 63 | } 64 | 65 | public StintServicesBuilder AddJobChangeTokenProducerFactory() 66 | where TJobChangeTokenProducerFactory : class, IJobChangeTokenProducerFactory 67 | { 68 | Services.AddSingleton(); 69 | return this; 70 | } 71 | 72 | public StintServicesBuilder AddJobRunnerFactory(IJobRunnerFactory factoryInstance) 73 | { 74 | Services.AddSingleton(factoryInstance); 75 | return this; 76 | } 77 | 78 | public StintServicesBuilder AddJobRunnerFactory() 79 | where TJobRunnerFactory : class, IJobRunnerFactory 80 | { 81 | Services.AddSingleton(); 82 | return this; 83 | } 84 | 85 | public StintServicesBuilder RegisterJobTypes(Action> registerJobTypes = null) 86 | { 87 | var builder = new NamedServiceRegistrationsBuilder(Services); 88 | registerJobTypes?.Invoke(builder); 89 | return this; 90 | } 91 | 92 | public StintServicesBuilder ConfigureActivityTags(Action configure = null) 93 | { 94 | Services.Configure(configure); 95 | return this; 96 | } 97 | 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Stint.Cli/InstallSystemdCommand.cs: -------------------------------------------------------------------------------- 1 | namespace Stint.Cli 2 | { 3 | using System; 4 | using System.CommandLine; 5 | using System.CommandLine.Invocation; 6 | using System.IO; 7 | using System.Reflection; 8 | 9 | public class InstallSystemdCommand : Command 10 | { 11 | public const string CommandName = "install"; 12 | 13 | private readonly SystemdConfigInstaller _installer; 14 | // private const string DefaultServiceUnitFileName = "IBlocklistDownloader.Service.service"; 15 | 16 | public InstallSystemdCommand(SystemdConfigInstaller installer) : base(CommandName) 17 | { 18 | _installer = installer; 19 | var location = Assembly.GetEntryAssembly().Location; 20 | string locationWithoutFileExtension; 21 | if (location.EndsWith(".dll")) 22 | { 23 | locationWithoutFileExtension = location.Remove(location.Length - 4); 24 | } 25 | else 26 | { 27 | locationWithoutFileExtension = location; 28 | } 29 | 30 | Console.WriteLine(locationWithoutFileExtension); 31 | 32 | var startCommand = $"{locationWithoutFileExtension} start"; 33 | 34 | var execStart = new Option( 35 | "--exec-start", 36 | () => startCommand, 37 | "The command to launch the service executable."); 38 | 39 | AddOption(execStart); 40 | 41 | var user = new Option( 42 | "--user", 43 | () => Environment.UserName, 44 | "The command to launch the service executable."); 45 | 46 | AddOption(user); 47 | 48 | var envDotnetRoot = new Option( 49 | "--env-dotnet-root", 50 | "If the user does not have dotnet.exe on path specify the path to the directory where dotnet.exe can be found."); 51 | AddOption(envDotnetRoot); 52 | 53 | var reloadSystemd = new Option( 54 | "--reload", 55 | () => false, 56 | "Whether to reload systemd atfer installing the service unit file"); 57 | 58 | AddOption(reloadSystemd); 59 | 60 | var pwd = new Option( 61 | "--pwd", 62 | () => Path.GetDirectoryName(location), 63 | "The working directory in which the service will read its content / config."); 64 | AddOption(pwd); 65 | 66 | // Note that the parameters of the handler method are matched according to the names of the options 67 | Handler = CommandHandler.Create( 68 | async (execStart, user, envDotnetRoot, reload, pwd) => 69 | { 70 | try 71 | { 72 | var appName = Assembly.GetEntryAssembly()?.GetName().Name; 73 | var serviceUnitFileName = $"{appName}.service"; 74 | await _installer.DeploySystemdConfig(execStart, user, envDotnetRoot, serviceUnitFileName, pwd, 75 | appName); 76 | if (reload) 77 | { 78 | Console.WriteLine("Reloading daemon.."); 79 | _installer.ReloadDaemon(); 80 | Console.WriteLine( 81 | "Daemon reloaded successfully. Start service with sudo systemctl start {0}", 82 | serviceUnitFileName); 83 | } 84 | else 85 | { 86 | Console.WriteLine( 87 | "Not relaoding daemon.. You should reload before attempting to start the service. sudo systemctl daemon-reload"); 88 | } 89 | 90 | return 0; 91 | } 92 | catch (UnauthorizedAccessException uex) 93 | { 94 | Console.WriteLine("{0} - Hint: try using sudo!", uex.Message); 95 | return 1; 96 | } 97 | }); 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Stint/Utils/PolymorphicBaseClassConverter.cs: -------------------------------------------------------------------------------- 1 | namespace Stint 2 | { 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Text.Json; 6 | using System.Text.Json.Serialization; 7 | 8 | public class PolymorphicBaseClassConverter : JsonConverter 9 | { 10 | public string TypeDescriminatorPropertyName { get; } 11 | 12 | public Dictionary DerivedTypeMapping { get; } 13 | 14 | public Lazy> ReverseLookupDerivedTypeMapping { get; } 15 | 16 | 17 | public PolymorphicBaseClassConverter(Dictionary derivedTypeMapping, string typeDescriminatorPropertyName = "TypeDiscriminator") 18 | { 19 | TypeDescriminatorPropertyName = typeDescriminatorPropertyName; 20 | DerivedTypeMapping = derivedTypeMapping; 21 | // lazy so we avoid doing work in construcotr and defer until first use. 22 | ReverseLookupDerivedTypeMapping = new Lazy>(() => DerivedTypeMapping.BuildReverseLookupDictionary()); 23 | } 24 | 25 | public override bool CanConvert(Type type) => typeof(TBaseClass).IsAssignableFrom(type); 26 | 27 | public override TBaseClass Read( 28 | ref Utf8JsonReader reader, 29 | Type typeToConvert, 30 | JsonSerializerOptions options) 31 | { 32 | if (reader.TokenType != JsonTokenType.StartObject) 33 | { 34 | throw new JsonException(); 35 | } 36 | 37 | // copy the reader at this position so we can use it to deserialize the entire object after validating the type descriminator property. 38 | var derivedObjectReader = reader; 39 | 40 | if (!reader.Read() 41 | || reader.TokenType != JsonTokenType.PropertyName 42 | || reader.GetString() != TypeDescriminatorPropertyName) 43 | { 44 | throw new JsonException(); 45 | } 46 | 47 | if (!reader.Read() || reader.TokenType != JsonTokenType.String) 48 | { 49 | throw new JsonException(); 50 | } 51 | 52 | TBaseClass baseClass; 53 | var derivedTypeName = reader.GetString(); 54 | 55 | // TypeDiscriminator typeDiscriminator = (TypeDiscriminator)reader.GetInt32(); 56 | if (!DerivedTypeMapping.TryGetValue(derivedTypeName, out var derivedType)) 57 | { 58 | throw new NotSupportedException(); 59 | } 60 | 61 | // use copy of reader at previous postition to read entire object. 62 | baseClass = (TBaseClass)JsonSerializer.Deserialize(ref derivedObjectReader, derivedType); 63 | 64 | if (!derivedObjectReader.Read() || derivedObjectReader.TokenType != JsonTokenType.EndObject) 65 | { 66 | throw new JsonException(); 67 | } 68 | 69 | return baseClass; 70 | } 71 | 72 | public override void Write( 73 | Utf8JsonWriter writer, 74 | TBaseClass value, 75 | JsonSerializerOptions options) 76 | { 77 | if (value == null) 78 | { 79 | throw new ArgumentNullException(nameof(value)); 80 | } 81 | 82 | var reverseLookup = ReverseLookupDerivedTypeMapping.Value; 83 | if (!reverseLookup.TryGetValue(value.GetType(), out var name)) 84 | { 85 | throw new NotSupportedException(); 86 | } 87 | 88 | writer.WriteStartObject(); 89 | writer.WriteString(TypeDescriminatorPropertyName, name); 90 | 91 | var json = JsonSerializer.Serialize(value, value.GetType()); 92 | var document = JsonDocument.Parse(json); 93 | 94 | var root = document.RootElement; 95 | if (root.ValueKind != JsonValueKind.Object) 96 | { 97 | throw new NotSupportedException(); 98 | } 99 | 100 | foreach (var property in root.EnumerateObject()) 101 | { 102 | property.WriteTo(writer); 103 | } 104 | 105 | writer.WriteEndObject(); 106 | writer.Flush(); 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/stint.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.5.33424.131 5 | MinimumVisualStudioVersion = 15.0.26124.0 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stint", "Stint\Stint.csproj", "{ADA80539-AE17-4908-806D-39D697AEB06F}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stint.Cli", "Stint.Cli\Stint.Cli.csproj", "{64C04914-9B3C-49CF-A98C-78139EDBFF02}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stint.Tests", "Stint.Tests\Stint.Tests.csproj", "{07DB0D04-B0F3-42F9-8620-75A713F3848E}" 11 | EndProject 12 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{5C475EB7-22FE-4D6B-A9FD-190F3DC27FA5}" 13 | ProjectSection(SolutionItems) = preProject 14 | Directory.Build.props = Directory.Build.props 15 | Directory.Packages.props = Directory.Packages.props 16 | global.json = global.json 17 | ..\README.md = ..\README.md 18 | EndProjectSection 19 | EndProject 20 | Global 21 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 22 | Debug|Any CPU = Debug|Any CPU 23 | Debug|x64 = Debug|x64 24 | Debug|x86 = Debug|x86 25 | Release|Any CPU = Release|Any CPU 26 | Release|x64 = Release|x64 27 | Release|x86 = Release|x86 28 | EndGlobalSection 29 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 30 | {ADA80539-AE17-4908-806D-39D697AEB06F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 31 | {ADA80539-AE17-4908-806D-39D697AEB06F}.Debug|Any CPU.Build.0 = Debug|Any CPU 32 | {ADA80539-AE17-4908-806D-39D697AEB06F}.Debug|x64.ActiveCfg = Debug|Any CPU 33 | {ADA80539-AE17-4908-806D-39D697AEB06F}.Debug|x64.Build.0 = Debug|Any CPU 34 | {ADA80539-AE17-4908-806D-39D697AEB06F}.Debug|x86.ActiveCfg = Debug|Any CPU 35 | {ADA80539-AE17-4908-806D-39D697AEB06F}.Debug|x86.Build.0 = Debug|Any CPU 36 | {ADA80539-AE17-4908-806D-39D697AEB06F}.Release|Any CPU.ActiveCfg = Release|Any CPU 37 | {ADA80539-AE17-4908-806D-39D697AEB06F}.Release|Any CPU.Build.0 = Release|Any CPU 38 | {ADA80539-AE17-4908-806D-39D697AEB06F}.Release|x64.ActiveCfg = Release|Any CPU 39 | {ADA80539-AE17-4908-806D-39D697AEB06F}.Release|x64.Build.0 = Release|Any CPU 40 | {ADA80539-AE17-4908-806D-39D697AEB06F}.Release|x86.ActiveCfg = Release|Any CPU 41 | {ADA80539-AE17-4908-806D-39D697AEB06F}.Release|x86.Build.0 = Release|Any CPU 42 | {64C04914-9B3C-49CF-A98C-78139EDBFF02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 43 | {64C04914-9B3C-49CF-A98C-78139EDBFF02}.Debug|Any CPU.Build.0 = Debug|Any CPU 44 | {64C04914-9B3C-49CF-A98C-78139EDBFF02}.Debug|x64.ActiveCfg = Debug|Any CPU 45 | {64C04914-9B3C-49CF-A98C-78139EDBFF02}.Debug|x64.Build.0 = Debug|Any CPU 46 | {64C04914-9B3C-49CF-A98C-78139EDBFF02}.Debug|x86.ActiveCfg = Debug|Any CPU 47 | {64C04914-9B3C-49CF-A98C-78139EDBFF02}.Debug|x86.Build.0 = Debug|Any CPU 48 | {64C04914-9B3C-49CF-A98C-78139EDBFF02}.Release|Any CPU.ActiveCfg = Release|Any CPU 49 | {64C04914-9B3C-49CF-A98C-78139EDBFF02}.Release|Any CPU.Build.0 = Release|Any CPU 50 | {64C04914-9B3C-49CF-A98C-78139EDBFF02}.Release|x64.ActiveCfg = Release|Any CPU 51 | {64C04914-9B3C-49CF-A98C-78139EDBFF02}.Release|x64.Build.0 = Release|Any CPU 52 | {64C04914-9B3C-49CF-A98C-78139EDBFF02}.Release|x86.ActiveCfg = Release|Any CPU 53 | {64C04914-9B3C-49CF-A98C-78139EDBFF02}.Release|x86.Build.0 = Release|Any CPU 54 | {07DB0D04-B0F3-42F9-8620-75A713F3848E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 55 | {07DB0D04-B0F3-42F9-8620-75A713F3848E}.Debug|Any CPU.Build.0 = Debug|Any CPU 56 | {07DB0D04-B0F3-42F9-8620-75A713F3848E}.Debug|x64.ActiveCfg = Debug|Any CPU 57 | {07DB0D04-B0F3-42F9-8620-75A713F3848E}.Debug|x64.Build.0 = Debug|Any CPU 58 | {07DB0D04-B0F3-42F9-8620-75A713F3848E}.Debug|x86.ActiveCfg = Debug|Any CPU 59 | {07DB0D04-B0F3-42F9-8620-75A713F3848E}.Debug|x86.Build.0 = Debug|Any CPU 60 | {07DB0D04-B0F3-42F9-8620-75A713F3848E}.Release|Any CPU.ActiveCfg = Release|Any CPU 61 | {07DB0D04-B0F3-42F9-8620-75A713F3848E}.Release|Any CPU.Build.0 = Release|Any CPU 62 | {07DB0D04-B0F3-42F9-8620-75A713F3848E}.Release|x64.ActiveCfg = Release|Any CPU 63 | {07DB0D04-B0F3-42F9-8620-75A713F3848E}.Release|x64.Build.0 = Release|Any CPU 64 | {07DB0D04-B0F3-42F9-8620-75A713F3848E}.Release|x86.ActiveCfg = Release|Any CPU 65 | {07DB0D04-B0F3-42F9-8620-75A713F3848E}.Release|x86.Build.0 = Release|Any CPU 66 | EndGlobalSection 67 | GlobalSection(SolutionProperties) = preSolution 68 | HideSolutionNode = FALSE 69 | EndGlobalSection 70 | GlobalSection(ExtensibilityGlobals) = postSolution 71 | SolutionGuid = {B54954F8-B799-41A9-9CE6-36FE46FA7A24} 72 | EndGlobalSection 73 | EndGlobal 74 | -------------------------------------------------------------------------------- /src/Directory.Packages.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | false 5 | true 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /.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 | *.suo 8 | *.user 9 | *.userosscache 10 | *.sln.docstates 11 | 12 | # User-specific files (MonoDevelop/Xamarin Studio) 13 | *.userprefs 14 | 15 | # Build results 16 | [Dd]ebug/ 17 | [Dd]ebugPublic/ 18 | [Rr]elease/ 19 | [Rr]eleases/ 20 | x64/ 21 | x86/ 22 | bld/ 23 | [Bb]in/ 24 | [Oo]bj/ 25 | [Ll]og/ 26 | 27 | # Visual Studio 2015/2017 cache/options directory 28 | .vs/ 29 | # Uncomment if you have tasks that create the project's static files in wwwroot 30 | #wwwroot/ 31 | 32 | # Visual Studio 2017 auto generated files 33 | Generated\ Files/ 34 | 35 | # MSTest test Results 36 | [Tt]est[Rr]esult*/ 37 | [Bb]uild[Ll]og.* 38 | 39 | # NUNIT 40 | *.VisualState.xml 41 | TestResult.xml 42 | 43 | # Build Results of an ATL Project 44 | [Dd]ebugPS/ 45 | [Rr]eleasePS/ 46 | dlldata.c 47 | 48 | # Benchmark Results 49 | BenchmarkDotNet.Artifacts/ 50 | 51 | # .NET Core 52 | project.lock.json 53 | project.fragment.lock.json 54 | artifacts/ 55 | **/Properties/launchSettings.json 56 | 57 | # StyleCop 58 | StyleCopReport.xml 59 | 60 | # Files built by Visual Studio 61 | *_i.c 62 | *_p.c 63 | *_i.h 64 | *.ilk 65 | *.meta 66 | *.obj 67 | *.iobj 68 | *.pch 69 | *.pdb 70 | *.ipdb 71 | *.pgc 72 | *.pgd 73 | *.rsp 74 | *.sbr 75 | *.tlb 76 | *.tli 77 | *.tlh 78 | *.tmp 79 | *.tmp_proj 80 | *.log 81 | *.vspscc 82 | *.vssscc 83 | .builds 84 | *.pidb 85 | *.svclog 86 | *.scc 87 | 88 | # Chutzpah Test files 89 | _Chutzpah* 90 | 91 | # Visual C++ cache files 92 | ipch/ 93 | *.aps 94 | *.ncb 95 | *.opendb 96 | *.opensdf 97 | *.sdf 98 | *.cachefile 99 | *.VC.db 100 | *.VC.VC.opendb 101 | 102 | # Visual Studio profiler 103 | *.psess 104 | *.vsp 105 | *.vspx 106 | *.sap 107 | 108 | # Visual Studio Trace Files 109 | *.e2e 110 | 111 | # TFS 2012 Local Workspace 112 | $tf/ 113 | 114 | # Guidance Automation Toolkit 115 | *.gpState 116 | 117 | # ReSharper is a .NET coding add-in 118 | _ReSharper*/ 119 | *.[Rr]e[Ss]harper 120 | *.DotSettings.user 121 | 122 | # JustCode is a .NET coding add-in 123 | .JustCode 124 | 125 | # TeamCity is a build add-in 126 | _TeamCity* 127 | 128 | # DotCover is a Code Coverage Tool 129 | *.dotCover 130 | 131 | # AxoCover is a Code Coverage Tool 132 | .axoCover/* 133 | !.axoCover/settings.json 134 | 135 | # Visual Studio code coverage results 136 | *.coverage 137 | *.coveragexml 138 | 139 | # NCrunch 140 | _NCrunch_* 141 | .*crunch*.local.xml 142 | nCrunchTemp_* 143 | 144 | # MightyMoose 145 | *.mm.* 146 | AutoTest.Net/ 147 | 148 | # Web workbench (sass) 149 | .sass-cache/ 150 | 151 | # Installshield output folder 152 | [Ee]xpress/ 153 | 154 | # DocProject is a documentation generator add-in 155 | DocProject/buildhelp/ 156 | DocProject/Help/*.HxT 157 | DocProject/Help/*.HxC 158 | DocProject/Help/*.hhc 159 | DocProject/Help/*.hhk 160 | DocProject/Help/*.hhp 161 | DocProject/Help/Html2 162 | DocProject/Help/html 163 | 164 | # Click-Once directory 165 | publish/ 166 | 167 | # Publish Web Output 168 | *.[Pp]ublish.xml 169 | *.azurePubxml 170 | # Note: Comment the next line if you want to checkin your web deploy settings, 171 | # but database connection strings (with potential passwords) will be unencrypted 172 | *.pubxml 173 | *.publishproj 174 | 175 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 176 | # checkin your Azure Web App publish settings, but sensitive information contained 177 | # in these scripts will be unencrypted 178 | PublishScripts/ 179 | 180 | # NuGet Packages 181 | *.nupkg 182 | # The packages folder can be ignored because of Package Restore 183 | **/[Pp]ackages/* 184 | # except build/, which is used as an MSBuild target. 185 | !**/[Pp]ackages/build/ 186 | # Uncomment if necessary however generally it will be regenerated when needed 187 | #!**/[Pp]ackages/repositories.config 188 | # NuGet v3's project.json files produces more ignorable files 189 | *.nuget.props 190 | *.nuget.targets 191 | 192 | # Microsoft Azure Build Output 193 | csx/ 194 | *.build.csdef 195 | 196 | # Microsoft Azure Emulator 197 | ecf/ 198 | rcf/ 199 | 200 | # Windows Store app package directories and files 201 | AppPackages/ 202 | BundleArtifacts/ 203 | Package.StoreAssociation.xml 204 | _pkginfo.txt 205 | *.appx 206 | 207 | # Visual Studio cache files 208 | # files ending in .cache can be ignored 209 | *.[Cc]ache 210 | # but keep track of directories ending in .cache 211 | !*.[Cc]ache/ 212 | 213 | # Others 214 | ClientBin/ 215 | ~$* 216 | *~ 217 | *.dbmdl 218 | *.dbproj.schemaview 219 | *.jfm 220 | *.pfx 221 | *.publishsettings 222 | orleans.codegen.cs 223 | 224 | # Including strong name files can present a security risk 225 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 226 | #*.snk 227 | 228 | # Since there are multiple workflows, uncomment next line to ignore bower_components 229 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 230 | #bower_components/ 231 | 232 | # RIA/Silverlight projects 233 | Generated_Code/ 234 | 235 | # Backup & report files from converting an old project file 236 | # to a newer Visual Studio version. Backup files are not needed, 237 | # because we have git ;-) 238 | _UpgradeReport_Files/ 239 | Backup*/ 240 | UpgradeLog*.XML 241 | UpgradeLog*.htm 242 | ServiceFabricBackup/ 243 | *.rptproj.bak 244 | 245 | # SQL Server files 246 | *.mdf 247 | *.ldf 248 | *.ndf 249 | 250 | # Business Intelligence projects 251 | *.rdl.data 252 | *.bim.layout 253 | *.bim_*.settings 254 | *.rptproj.rsuser 255 | 256 | # Microsoft Fakes 257 | FakesAssemblies/ 258 | 259 | # GhostDoc plugin setting file 260 | *.GhostDoc.xml 261 | 262 | # Node.js Tools for Visual Studio 263 | .ntvs_analysis.dat 264 | node_modules/ 265 | 266 | # Visual Studio 6 build log 267 | *.plg 268 | 269 | # Visual Studio 6 workspace options file 270 | *.opt 271 | 272 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 273 | *.vbw 274 | 275 | # Visual Studio LightSwitch build output 276 | **/*.HTMLClient/GeneratedArtifacts 277 | **/*.DesktopClient/GeneratedArtifacts 278 | **/*.DesktopClient/ModelManifest.xml 279 | **/*.Server/GeneratedArtifacts 280 | **/*.Server/ModelManifest.xml 281 | _Pvt_Extensions 282 | 283 | # Paket dependency manager 284 | .paket/paket.exe 285 | paket-files/ 286 | 287 | # FAKE - F# Make 288 | .fake/ 289 | 290 | # JetBrains Rider 291 | .idea/ 292 | *.sln.iml 293 | 294 | # CodeRush 295 | .cr/ 296 | 297 | # Python Tools for Visual Studio (PTVS) 298 | __pycache__/ 299 | *.pyc 300 | 301 | # Cake - Uncomment if you are using it 302 | # tools/** 303 | # !tools/packages.config 304 | 305 | # Tabs Studio 306 | *.tss 307 | 308 | # Telerik's JustMock configuration file 309 | *.jmconfig 310 | 311 | # BizTalk build output 312 | *.btp.cs 313 | *.btm.cs 314 | *.odx.cs 315 | *.xsd.cs 316 | 317 | # OpenCover UI analysis results 318 | OpenCover/ 319 | 320 | # Azure Stream Analytics local run output 321 | ASALocalRun/ 322 | 323 | # MSBuild Binary and Structured Log 324 | *.binlog 325 | 326 | # NVidia Nsight GPU debugger configuration file 327 | *.nvuser 328 | 329 | # MFractors (Xamarin productivity tool) working folder 330 | .mfractor/ 331 | -------------------------------------------------------------------------------- /src/Stint/AnchorStore/FileSystem/FileSystemAnchorStore.cs: -------------------------------------------------------------------------------- 1 | namespace Stint 2 | { 3 | using System; 4 | using System.Globalization; 5 | using System.IO; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | using Microsoft.Extensions.Logging; 9 | 10 | public class FileSystemAnchorStore : IAnchorStore 11 | { 12 | private readonly string _contentPath; 13 | private readonly string _name; 14 | private readonly ILogger _logger; 15 | 16 | public FileSystemAnchorStore(string contentPath, string name, ILogger logger) 17 | { 18 | _contentPath = contentPath; 19 | _name = name; 20 | _logger = logger; 21 | } 22 | 23 | public async Task GetAnchorAsync(CancellationToken token) 24 | { 25 | var path = Path.Combine(_contentPath, _name + "-anchor.txt"); 26 | _logger.LogDebug("Getting anchor from {path}", path); 27 | 28 | var retryCount = 0; 29 | var maxRetries = 3; 30 | var delayMilliseconds = 1000; // Initial delay of 1 second 31 | 32 | while (true) 33 | { 34 | try 35 | { 36 | if (!File.Exists(path)) 37 | { 38 | _logger.LogDebug("No anchor file exists at {path}, returning null anchor.", path); 39 | return null; 40 | } 41 | 42 | using (var outputFile = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) 43 | { 44 | using (var reader = new StreamReader(outputFile)) 45 | { 46 | var anchorText = await reader.ReadToEndAsync(); 47 | if (!DateTime.TryParse(anchorText, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var result)) 48 | { 49 | _logger.LogWarning("Anchor file {path} did not contain valid datetime. Returning null anchor.", path); 50 | return null; 51 | } 52 | 53 | _logger.LogDebug("Anchor {anchorDateTime} loaded from file: {path}", result, path); 54 | return result; 55 | } 56 | } 57 | } 58 | catch (IOException ex) 59 | { 60 | _logger.LogWarning(ex, "Attempt {retry} failed to read anchor file: {path}. Retrying...", retryCount + 1, path); 61 | if (retryCount++ >= maxRetries || token.IsCancellationRequested) 62 | { 63 | _logger.LogError("Maximum retry attempts reached or operation cancelled, failing with IOException."); 64 | throw; 65 | } 66 | 67 | await Task.Delay(delayMilliseconds, token); 68 | delayMilliseconds *= 2; // Exponential backoff: double the delay each retry 69 | } 70 | 71 | // if (File.Exists(path)) 72 | // { 73 | // try 74 | // { 75 | // 76 | // // seems to be not working on linux with cifs file share 77 | // // // var anchorText = await File.ReadAllTextAsync(path, token,); 78 | // using (var outputFile = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) 79 | // { 80 | // using (var reader = new StreamReader(outputFile)) 81 | // { 82 | // var anchorText = await reader.ReadToEndAsync(); 83 | // if (!DateTime.TryParse(anchorText, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var result)) 84 | // { 85 | // _logger.LogWarning("Anchor file {path} did not contain valid datetime. Returning null anchor.", path); 86 | // return null; 87 | // } 88 | // 89 | // _logger.LogDebug("Anchor {anchorDateTime} loaded from file: {path}", result, path); 90 | // return result; 91 | // } 92 | // } 93 | // 94 | // // using (var outputFile = new StreamReader(path, true)) 95 | // // { 96 | // // var anchorText = await outputFile.ReadToEndAsync(); 97 | // // if (!DateTime.TryParse(anchorText, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var result)) 98 | // // { 99 | // // _logger.LogWarning("Anchor file {path} did not contain valid datetime. Returning null anchor.", path); 100 | // // return null; 101 | // // } 102 | // // 103 | // // _logger.LogDebug("Anchor {anchorDateTime} loaded from file: {path}", result, path); 104 | // // return result; 105 | // // } 106 | // } 107 | catch (Exception e) 108 | { 109 | _logger.LogError(e, "Unable to read contents of anchor file: {path}", path); 110 | throw; 111 | } 112 | } 113 | } 114 | 115 | public Task DropAnchorAsync(CancellationToken token) 116 | { 117 | var path = Path.Combine(_contentPath, _name + "-anchor.txt"); 118 | var anchor = DateTime.UtcNow; 119 | var anchorDateText = anchor.ToString("O"); 120 | 121 | _logger.LogDebug("Writing anchor {anchorDateTime} to anchor file: {path}", anchorDateText, path); 122 | 123 | // seeing odd issues with async io on linux writing to cifs share- empty file is left orphaned. 124 | // so switching to sync io, with async writes to the stream. 125 | 126 | // https://github.com/dotnet/runtime/issues/23196 127 | // await File.WriteAllTextAsync(path, anchorDateText, token); 128 | 129 | try 130 | { 131 | // https://github.com/dotnet/runtime/issues/42790#issuecomment-700362617 132 | using (var outputFile = new FileStream(path, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None)) 133 | { 134 | using (var writer = new StreamWriter(outputFile)) 135 | { 136 | writer.Write(anchorDateText); 137 | } 138 | } 139 | 140 | _logger.LogDebug("Anchor {anchorDateText} successfully written to anchor file: {path}", anchorDateText, path); 141 | return Task.FromResult(anchor); 142 | } 143 | catch (Exception e) 144 | { 145 | _logger.LogError(e, "Unable to write anchor {anchorDateText} to file: {path}", anchorDateText, path); 146 | throw; 147 | } 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/Stint/Worker.cs: -------------------------------------------------------------------------------- 1 | namespace Stint 2 | { 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | using Microsoft.Extensions.Logging; 9 | using Microsoft.Extensions.Options; 10 | using Microsoft.Extensions.Primitives; 11 | using BackgroundService = Utils.BackgroundService; 12 | public class Worker : BackgroundService 13 | { 14 | private readonly Func _changeTokenProducer; 15 | private readonly IDisposable _changeTokenProducerLifetime; 16 | 17 | private readonly Dictionary _jobs = new Dictionary(); 18 | private readonly ILogger _logger; 19 | private readonly IOptionsMonitor _optionsMonitor; 20 | private readonly IJobRunnerFactory _jobRunnerFactory; 21 | private Task _allRunningJobs; 22 | 23 | private CancellationTokenSource _cts; 24 | private IDisposable _listeningForJobsChanges; 25 | private TaskCompletionSource _taskCompletionSource; 26 | 27 | public Worker( 28 | ILogger logger, 29 | IOptionsMonitor optionsMonitor, 30 | IJobRunnerFactory jobRunnerFactory) 31 | { 32 | _logger = logger; 33 | _optionsMonitor = optionsMonitor; 34 | _jobRunnerFactory = jobRunnerFactory; 35 | _changeTokenProducer = new ChangeTokenProducerBuilder() 36 | .IncludeOptionsChangeTrigger(_optionsMonitor) 37 | .Build(out var producerLifetime); 38 | _changeTokenProducerLifetime = producerLifetime; 39 | } 40 | 41 | private void StartListeningForJobConfigChanges() => 42 | // reload if tokens signalled. 43 | _listeningForJobsChanges = ChangeToken.OnChange(_changeTokenProducer, OnJobsChanged); 44 | 45 | private void StopListeningForJobConfigChanges() => 46 | // reload if tokens signalled. 47 | _listeningForJobsChanges?.Dispose(); 48 | 49 | private void OnJobsChanged() 50 | { 51 | _logger.LogInformation("Jobs config changed at: {time}", DateTimeOffset.Now); 52 | var latestConfig = _optionsMonitor.CurrentValue; 53 | _logger.LogInformation("There are now {count} jobs configured.", latestConfig.Jobs?.Count ?? 0); 54 | ReloadJobs(_cts.Token); // load jobs based on current config. 55 | } 56 | 57 | protected override async Task ExecuteAsync(CancellationToken stoppingToken) 58 | { 59 | _logger.LogInformation("Worker executed at: {time}", DateTimeOffset.Now); 60 | _cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); 61 | _taskCompletionSource = new TaskCompletionSource(); 62 | ReloadJobs(_cts.Token); // load jobs based on current config. 63 | StartListeningForJobConfigChanges(); // listen for changes to jobs and apply them. 64 | 65 | 66 | // somehow need to await all the jobs to complete. 67 | // jobs can be added / removed and deleted! :-( 68 | // maybe we just wait forever by return a task completion source token, that we won't signal - until stoppingToken is cancelled. 69 | 70 | stoppingToken.Register(() => 71 | { 72 | _logger.LogInformation("Worker cancellation signalled at: {time}", DateTimeOffset.Now); 73 | if (_allRunningJobs.IsCompleted) 74 | { 75 | // no running jobs, so we can signal exit now. 76 | _taskCompletionSource.SetResult(true); 77 | } 78 | }); 79 | 80 | // Note: This _taskCompletionSource is signalled when: 81 | // 1. The host is cancelled, and there are no running jobs - i.e _allRunningJobs is complete - as shown above. 82 | // 2. The host is cancelled, and there are still running jobs, in which case when all the _allRunningJobs = complete, there is a continuation that signals the task completion source if the host cancellation token has been signalled. 83 | // This means this tasks runs until the host is cancelled, and if there is no ongoing work, exits quickly, otherwise if there are ongoing tasks, exits after they are all complete. The running tasks should all be honoring the hosts cancellation token, so should all exit pretty swiftly but i may depend on the job. 84 | await _taskCompletionSource.Task; 85 | _logger.LogInformation("Worker exiting at: {time}", DateTimeOffset.Now); 86 | } 87 | 88 | /// 89 | /// When scheduled job configuration changes, we need to add / update / delete our in memory scheduled jobs to match the new config. 90 | /// 91 | /// 92 | private void ReloadJobs(CancellationToken stoppingToken) 93 | { 94 | var currentConfig = _optionsMonitor.CurrentValue; 95 | var jobsKeys = _jobs.Keys; 96 | var configKeys = currentConfig?.Jobs?.Select(a => a.Key)?.ToArray() ?? new string[0]; 97 | 98 | var jobsToRemove = jobsKeys.Except(configKeys).ToArray(); 99 | _logger.LogInformation("{x} jobs to remove.", jobsToRemove?.Length ?? 0); 100 | 101 | var jobsToAdd = configKeys.Except(jobsKeys).ToArray(); 102 | _logger.LogInformation("{x} jobs to add.", jobsToAdd?.Length ?? 0); 103 | 104 | // yuck, clean this up later. 105 | // Basically getting all jobs where the options in the config are now actually different that to what they were previously. 106 | var jobsToUpdate = 107 | configKeys.Join(jobsKeys, a => a, b => b, (a, b) => a) 108 | .ToArray(); // match the keys in config to jobs running now. 109 | jobsToUpdate = jobsToUpdate.Where(key => 110 | !currentConfig?.Jobs.Single(a => a.Key == key).Equals(_jobs[key].Config) ?? false).ToArray(); 111 | _logger.LogInformation("{x} jobs to update.", jobsToUpdate?.Length ?? 0); 112 | 113 | 114 | // Note: once we have got the new set of tasks representing our new scheduled jobs, we can await it and dispose of the previous awaited tasks. 115 | var jobTasks = new List(); 116 | 117 | foreach (var key in jobsToAdd) 118 | { 119 | var existed = _jobs.TryGetValue(key, out var outJob); 120 | if (existed) 121 | { 122 | // should probably throw an error here as this shouldn't actually happen. 123 | _logger.LogWarning("New job detected but it already exists, skipping {name}", key); 124 | continue; 125 | } 126 | 127 | var exists = currentConfig.Jobs.TryGetValue(key, out var jobConfig); 128 | if (!exists) 129 | { 130 | // should probably throw an error here as this shouldn't actually happen. 131 | _logger.LogWarning("New job detected but unable to get config, skipping {name}", key); 132 | continue; 133 | } 134 | 135 | var newJob = _jobRunnerFactory.CreateJobRunner(key, jobConfig, stoppingToken); 136 | var started = newJob.RunAsync(stoppingToken); 137 | jobTasks.Add(started); 138 | _jobs.Add(key, newJob); 139 | _logger.LogInformation("New job added: {name}", key); 140 | } 141 | 142 | // Update any currently running jobs that have changed. 143 | foreach (var key in jobsToUpdate) 144 | { 145 | var wasRemoved = _jobs.Remove(key, out var existingJob); 146 | if (!wasRemoved) 147 | { 148 | _logger.LogWarning("Could not remove job named: {name}", key); 149 | } 150 | 151 | existingJob?.Dispose(); 152 | var exists = currentConfig.Jobs.TryGetValue(key, out var newConfig); 153 | if (!exists) 154 | { 155 | // should probably throw an error here as this shouldn't actually happen. 156 | _logger.LogWarning("Updated job detected, but unable to get config, skipping {name}", key); 157 | continue; 158 | } 159 | 160 | var jobLifetime = _jobRunnerFactory.CreateJobRunner(key, newConfig, stoppingToken); 161 | var started = jobLifetime.RunAsync(stoppingToken); 162 | jobTasks.Add(started); 163 | _jobs.Add(key, jobLifetime); 164 | _logger.LogInformation("Job reloaded: {name}", key); 165 | } 166 | 167 | foreach (var key in jobsToRemove) 168 | { 169 | if (!_jobs.Remove(key, out var oldJob)) 170 | { 171 | _logger.LogWarning("No running job found named: {name}", key); 172 | } 173 | 174 | oldJob?.Dispose(); 175 | _logger.LogInformation("Job removed: {name}", key); 176 | } 177 | 178 | var allJobs = Task.WhenAll(jobTasks).ContinueWith(t => 179 | { 180 | // all jobs have complete because service is terminating 181 | if (stoppingToken.IsCancellationRequested) 182 | { 183 | _logger.LogInformation("All jobs cancelled."); 184 | _taskCompletionSource.SetResult(false); 185 | } 186 | else 187 | { 188 | _logger.LogInformation("All jobs have finished, host still running, waiting for more jobs to be configured."); 189 | } 190 | }); 191 | 192 | var oldTask = Interlocked.Exchange(ref _allRunningJobs, allJobs); 193 | 194 | // best effort dispose 195 | // we can't dispose of non completed tasks, so if its completed we will. Otherwise leave it for finaliser. 196 | // https://stackoverflow.com/questions/5985973/do-i-need-to-dispose-of-a-task 197 | if (oldTask?.IsCompleted ?? false) 198 | { 199 | oldTask.Dispose(); 200 | _logger.LogInformation("Disposed of old completed task."); 201 | } 202 | } 203 | 204 | public override void Dispose() 205 | { 206 | _logger.LogInformation("Disposing worker."); 207 | StopJobs(); 208 | _cts?.Dispose(); 209 | _taskCompletionSource?.TrySetResult(false); 210 | _changeTokenProducerLifetime.Dispose(); 211 | base.Dispose(); 212 | } 213 | 214 | private void StopJobs() 215 | { 216 | _logger.LogInformation("Worker stopping jobs."); 217 | StopListeningForJobConfigChanges(); 218 | if (!_cts.IsCancellationRequested) 219 | { 220 | _cts.Cancel(); 221 | } 222 | } 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stint 2 | 3 | > a fixed period of time during which a person holds a job or position 4 | 5 | Stint allows your existing dotnet application to run jobs. 6 | It can be scaled to multiple nodes, ensuring that scheduled jobs are not run concurrently on multiple nodes. 7 | 8 | ## Features 9 | 10 | - Jobs: 11 | - Are configured using the `IOptions` pattern so can be configured from a wide variety of sources, and are responsive to config changes at runtime. 12 | - Are classes with async methods which are run with cancellation tokens so you can exit gracefully, if for example the host is shutting down, or the job needs to be terminated for a config reload. 13 | - Support locking, so you can implement your own `ILockProvider` to prevent multiple instances of a job from being signalled concurrently when scaling to multiple nodes. Default `ILockProvider` provides a no-op lock. 14 | 15 | - triggers 16 | - Schedule (i.e provide one or many cron schedules) 17 | - Manual Invoke (i.e inject `IJobManualTriggerInvoker` and call `bool Trigger(string jobName)` ) 18 | - JobCompletion (i.e a job can be automatically triggered when another job with the specified name completes) 19 | - Can implement your own `ITriggerProvider`s. The above three are good reference implementations. 20 | 21 | # Getting Started 22 | 23 | Implement a job class. This is just a class that implements the `IJob` interface: 24 | 25 | ```csharp 26 | 27 | public class MyCoolJob : IJob 28 | { 29 | 30 | private ILogger _logger; 31 | 32 | public TestJob(ILogger logger) 33 | { 34 | _logger = logger; 35 | } 36 | 37 | public Task ExecuteAsync(ExecutionInfo runInfo, CancellationToken token) 38 | { 39 | _logger.LogDebug("Working.."); 40 | return Task.CompletedTask; 41 | } 42 | } 43 | 44 | 45 | ``` 46 | 47 | Note: You can use DI as usual for injecting dependencies into job classes. 48 | 49 | Add `AddScheduledJobs` services, and register your available job classes. 50 | 51 | ```csharp 52 | 53 | services.AddScheduledJobs((options) => options.RegisterJobTypes((jobTypes) => 54 | jobTypes.AddTransient(nameof(MyCoolJob), (sp) => new MyCoolJob()) 55 | .AddTransient(nameof(MyOtherCoolJob)))) 56 | 57 | ``` 58 | 59 | Each Job class is registered with a job type name, which is used to refer to it when configuring jobs of that type. 60 | 61 | Next configure your job instances, and their triggers. 62 | This uses the standard `IOptions` pattern, so you can bind the config from `Json` config, pre or post configure hooks, or any other sources that support this pattern. 63 | 64 | ```csharp 65 | services.Configure((config) => 66 | { 67 | config.Jobs.Add("TestJob", new JobConfig() 68 | { 69 | Type = nameof(TestJob), 70 | Triggers = new TriggersConfig() 71 | { 72 | Schedules = { 73 | new ScheduledTriggerConfig() { Schedule = "* * * * *" } 74 | } 75 | } 76 | }); 77 | 78 | // example of chaining, this job has a trigger that causes it to run when the other job completes. 79 | config.Jobs.Add("TestChainedJob", new JobConfig() 80 | { 81 | Type = nameof(TestJob), 82 | Triggers = new TriggersConfig() 83 | { 84 | JobCompletions = { 85 | new JobCompletedTriggerConfig(){ JobName ="TestJob" } 86 | } 87 | } 88 | }); 89 | }); 90 | 91 | ``` 92 | 93 | You can add multiple triggers for each job. The job will run when any of the triggers signal. 94 | So if you add a schedule trigger, and a JobCompletion trigger, the job will run when either the schedule trigger signals its time, or the specified job completes for the completion trigger. 95 | 96 | ### Manual triggers 97 | 98 | To allow manually triggering a job, you have to enable the `Manual` trigger: 99 | 100 | ```csharp 101 | config.Jobs.Add("TestChainedJob", new JobConfig() 102 | { 103 | Type = nameof(TestJob), 104 | Triggers = new TriggersConfig() 105 | { 106 | Manual = true, 107 | JobCompletions = { 108 | new JobCompletedTriggerConfig(){ JobName ="TestJob" } 109 | } 110 | } 111 | }); 112 | ``` 113 | 114 | You can then trigger the job to run from a button click or api call or any other event in your application: 115 | 116 | ```csharp 117 | 118 | IJobManualTriggerInvoker manualTriggerInvoker = GetOrInjectThisService(); 119 | bool triggered = manualTriggerInvoker.Trigger("TestChainedJob"); 120 | 121 | ``` 122 | 123 | Note: `triggered` will be false if the job name specified does not have a manual trigger enabled. 124 | 125 | ## Using config 126 | 127 | If you want to bind the scheduler jobs to a json config file, you'll json will need to look like this: 128 | 129 | ```json 130 | 131 | "Stint": { 132 | "Jobs": { 133 | "TestJob": { 134 | "Type": "MyCoolJob", 135 | "Triggers": { 136 | "Schedules": [ 137 | { "Schedule": "* * * * *" } 138 | ], 139 | "JobCompletions": [ 140 | { "JobName": "AnotherTestJob" } 141 | ] 142 | } 143 | }, 144 | "AnotherTestJob": { 145 | "Type": "MyCoolJob", 146 | "Triggers": { 147 | "Schedules": [ 148 | { "Schedule": "* * * * *" } 149 | ], 150 | "Manual": true 151 | } 152 | } 153 | } 154 | } 155 | 156 | ``` 157 | 158 | - Jobs have unique names - i.e "AnotherTestJob", "DifferentJob" etc as shown above. 159 | - Each job has a "Type" which is a name that maps to a specific registered job class in the code - i.e "MyCoolJob" as shown above. 160 | This tells the job runner which job class to execute for this job. 161 | - Each job has a `Triggers` section where different kinds of triggers can be configured for the job. 162 | - You can change the configuration whilst the application is running and the scheduler will reload / reconfigure any necessary jobs in memory as necessary to reflect latest configuration. If a jobs configuration is updated and it is currently executing, it will be signalled for cancellation. 163 | 164 | ## Schedule Syntax (cron) 165 | 166 | 167 | For the CRON expression syntax, see: https://github.com/HangfireIO/Cronos#cron-format 168 | 169 | ```ascii 170 | Allowed values Allowed special characters Comment 171 | 172 | ┌───────────── second (optional) 0-59 * , - / 173 | │ ┌───────────── minute 0-59 * , - / 174 | │ │ ┌───────────── hour 0-23 * , - / 175 | │ │ │ ┌───────────── day of month 1-31 * , - / L W ? 176 | │ │ │ │ ┌───────────── month 1-12 or JAN-DEC * , - / 177 | │ │ │ │ │ ┌───────────── day of week 0-6 or SUN-SAT * , - / # L ? Both 0 and 7 means SUN 178 | │ │ │ │ │ │ 179 | * * * * * * 180 | ``` 181 | 182 | ## How does job scheduling work 183 | 184 | After a scheduled job has been executed, a file / anchor is saved using the `IAnchorStore` implementation, which by default saves an anchor file to your applications content root directory. 185 | The anchor contains the date and time that the job last executed. 186 | Jobs that have scheduled triggers, compare the configured `schedule` you've specified, to the anchor file for the job. 187 | - If there is no anchor file then it is assumed the job has never been run, and the next occurrence will be calculated from `now`. 188 | - If there is an anchor file, then the next occurrence is calculated from that last anchor time. 189 | If the next occurrence is calculated to be in the past (i.e becuase there was an anchor file, but the current configured schedule should dictate the job has run since then) then the job is presumed to be `overdue` and it will be run immdiately. 190 | If the next occurrence is in the future, then the scheduler asynchronously delays until the next occurrence. 191 | 192 | ### What about retries 193 | 194 | The scheduler does not handle retries. If you need to retry, you should add that logic within your job itself. 195 | Once the job has completed - even if it throws an exception, the scheduler will drop a new anchor and not try to execute it again until the next appointed time. 196 | 197 | ### What about scaling? 198 | 199 | #### Locking 200 | 201 | If you run multiple instances of the job runner application, you'll want to configure two key abstractions 202 | 203 | - `ILockProvider` 204 | - `IAnchorStore` 205 | 206 | The `ILockProvider` is used to impelement your distributed lock using whatever technology you like (redis, sql etc) 207 | The `IAnchorStore` is used to ensure all instances can get and persist the job anchor to a location all nodes can see - eg. a central database, or a shared file system etc. 208 | 209 | 210 | ```csharp 211 | public interface ILockProvider 212 | { 213 | Task TryAcquireAsync(string name); 214 | } 215 | 216 | ``` 217 | 218 | For example, this could return an `IDisposable` representing a lock file, or a lock held by the database etc. The `name` argument is the job name. 219 | You should return `null` if the lock cannot be acquired, in which case the scheduler will write a log entry, and skip running the job as it assumed it is already running somewhere else. 220 | 221 | Then register your lock provider: 222 | 223 | ```csharp 224 | 225 | services.AddScheduledJobs((options) => options.RegisterJobTypes((jobTypes) => 226 | jobTypes.AddTransient(nameof(MyCoolJob), (sp) => new MyCoolJob()) 227 | .AddTransient(nameof(MyOtherCoolJob))) 228 | options.AddLockProvider()); 229 | 230 | 231 | ``` 232 | 233 | The lock provider that is registered by default, is an empty lock provider, which means there is no locking, and jobs will be allowed to execute simultaneosly. 234 | 235 | This is how it works: 236 | 237 | ![Stint Library - Multi-Instance Job Coordination](./docs/stint_coordination_diagram.png "How Stint coordinates scheduled jobs across multiple instances") 238 | 239 | #### Events 240 | 241 | `Job Completion` triggers use a `pub sub` mechanism. 242 | When a job has completed, an event is published with the name of the job that completed. 243 | The `Job Completion` trigger subscribes to this event, and triggers when the completed job name matches the job name for the trigger. 244 | 245 | All this means, job chaning works by default in the same process, becuase the pub / sub mechanism is in process, and is not distrubuted. 246 | If you want to allow other worker nodes to run jobs in the chain you'll have to register custom implementations of `IPublisher' and `ISubscriber'. 247 | When the job completed message is published, you can then take control of the publish and publish a message to a distributed pub sub system. 248 | Likewise when the JobCompletion trigger subscribes you can take control of the subscription and subsribe to your distributed pub sub topic. 249 | 250 | 251 | #### Instrumentation 252 | 253 | `stint` creates `Activity` for each job execution. You can configure some global tags to be applied on all job activities (useful for tenant name, environment name etc): 254 | 255 | ```csharp 256 | 257 | options 258 | .ConfigureActivityTags((activityOptions) => { 259 | activityOptions.GlobalTags.Add(new KeyValuePair("TestTag", "TestValue")); 260 | }); 261 | ``` 262 | 263 | -------------------------------------------------------------------------------- /src/Stint/JobRunner.cs: -------------------------------------------------------------------------------- 1 | namespace Stint 2 | { 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Diagnostics; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | using Microsoft.Extensions.DependencyInjection; 9 | using Microsoft.Extensions.Logging; 10 | using Microsoft.Extensions.Primitives; 11 | using PubSub; 12 | 13 | public class JobRunner : IJobRunner 14 | { 15 | private readonly ILockProvider _lockProvider; 16 | private readonly IAnchorStore _anchorStore; 17 | private readonly ILogger _logger; 18 | private readonly IServiceScopeFactory _serviceScopeFactory; 19 | private readonly IChangeTokenProducer _changeTokenProducer; 20 | private readonly IPublisher _publisher; 21 | private readonly ActivityOptions _activityOptions; 22 | private static readonly ActivitySource ActivitySource = new("Stint"); 23 | 24 | public JobRunner( 25 | string name, 26 | ILockProvider lockProvider, 27 | JobConfig config, 28 | IAnchorStore anchorStore, 29 | ILogger logger, 30 | IServiceScopeFactory serviceScopeFactory, 31 | IChangeTokenProducer changeTokenProducer, 32 | IPublisher publisher, 33 | ActivityOptions activityOptions) 34 | { 35 | Name = name; 36 | Config = config; 37 | _lockProvider = lockProvider; 38 | _anchorStore = anchorStore; 39 | _logger = logger; 40 | _serviceScopeFactory = serviceScopeFactory; 41 | _changeTokenProducer = changeTokenProducer; 42 | _publisher = publisher; 43 | _activityOptions = activityOptions; 44 | } 45 | 46 | private CancellationTokenSource CancellationTokenSource { get; set; } 47 | public string Name { get; } 48 | public JobConfig Config { get; } 49 | 50 | public DateTime? Anchor { get; private set; } 51 | 52 | public void Dispose() 53 | { 54 | CancellationTokenSource?.Cancel(); 55 | CancellationTokenSource?.Dispose(); 56 | } 57 | 58 | public Task RunAsync(CancellationToken cancellationToken) 59 | { 60 | CancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); 61 | return ExecuteWhenSignalledAsync(CancellationTokenSource.Token); 62 | } 63 | 64 | private async Task ExecuteWhenSignalledAsync(CancellationToken token) 65 | { 66 | // DateTime? previousOccurrence = null; 67 | using var _jobScope = _logger.BeginScope(new Dictionary 68 | { 69 | { 70 | "StintJobName", Name 71 | }, 72 | }); 73 | 74 | 75 | // get the current version of the anchor. 76 | _logger.LogDebug("Loading Anchor.."); 77 | await LoadAnchor(token); 78 | 79 | while (!token.IsCancellationRequested && !Disabled) 80 | { 81 | await _changeTokenProducer.WaitOneAsync(token); 82 | var ran = await RunJobOnce(token); 83 | if (!ran) 84 | { 85 | // if the job did not run, we should wait for the next signal. 86 | continue; 87 | } 88 | 89 | // We ran. To prevent tight loop of executions in case WaitOneAsync() throws constantly or in case job cron is constantly triggering and job is instantly running, we add a delay. 90 | // Important: We deliberately don't put this delay at the start of the loop, before WaitOneAsync() above because we want the JobRunner to grab a change token asap, so that IJobManualTriggerInvoker can trigger the job via that change token. 91 | // If we trigger a job before the JobRunner has grabbed a change token, that signal will be lost. (TODO this could be fixed in future by queing the signal from IJobManualTriggerInvoker and processing it later) 92 | await Task.Delay(TimeSpan.FromSeconds(2)); 93 | } 94 | 95 | _logger.LogWarning("Job runner cancelled."); 96 | } 97 | 98 | private const string ActivityNameRunJobOnce = "JobRunner.RunJobOnce"; 99 | private const string ActivityNameWaitForLock = "JobRunner.WaitForLock"; 100 | private const string ActivityNameJobExecute = "Job.Execute"; 101 | 102 | 103 | private async Task RunJobOnce(CancellationToken token) 104 | { 105 | 106 | using var activity = ActivitySource.StartActivity( 107 | ActivityNameRunJobOnce, 108 | ActivityKind.Internal, 109 | tags: _activityOptions.GlobalTags, 110 | parentContext: default // explicitly no parent 111 | ); 112 | 113 | activity?.SetTag("job.name", Name); 114 | activity?.SetTag("job.type", Config?.Type); 115 | 116 | if (token.IsCancellationRequested) 117 | { 118 | _logger.LogInformation("Cancelled.."); 119 | activity?.SetStatus(ActivityStatusCode.Error, "Cancelled"); 120 | return false; 121 | } 122 | 123 | try 124 | { 125 | // run now! 126 | 127 | // wait for a lock, keep trying to aquire it in periods 128 | //var lockAttemptCount = 0; 129 | using var lockActivity = ActivitySource.StartActivity(name: ActivityNameWaitForLock, kind: ActivityKind.Internal, tags: _activityOptions.GlobalTags); 130 | using var acquiredLock = await WaitForLockWithIncreasingDelays(token, (attemptCount) => 131 | { 132 | // lockAttemptCount = attemptCount; 133 | var multiplier = Math.Max(attemptCount, 10); 134 | var timeoutSecs = multiplier * 10; 135 | return TimeSpan.FromSeconds(timeoutSecs); 136 | }, 137 | 1); 138 | 139 | lockActivity?.SetTag("lock.acquired", acquiredLock != null); 140 | if (acquiredLock == null) 141 | { 142 | // if we are unable to acquire the lock, we take this as a sign that the job is already running - perhaps on another instance in a distributed scenario. 143 | // therefore this isn't necessarily an error, so we log it as a warning. 144 | _logger.LogWarning("Unable to acquire lock"); 145 | activity?.SetStatus(ActivityStatusCode.Ok, "Lock not acquired"); 146 | return false; 147 | } 148 | 149 | // We are inside the lock, let's check if the anchor has changed since we last loaded it. This would be a sign that another instance of the job has run and updated the anchor, since our signal. 150 | var isAnchorValid = await CheckAnchorHasNotBeenModified(token); 151 | activity?.SetTag("anchor.valid", isAnchorValid); 152 | if (!isAnchorValid) 153 | { 154 | // We log warning and skip executing the job again. 155 | _logger.LogWarning("Job anchor has changed, perhaps job executed by another process."); 156 | activity?.SetStatus(ActivityStatusCode.Ok, "Anchor changed - skip"); 157 | activity?.SetTag("job.outcome", "skipped"); 158 | return false; 159 | } 160 | 161 | var jobInfo = new ExecutionInfo(Name); 162 | // TODO: Add options for retrying when failure. 163 | using var jobExecActivity = ActivitySource.StartActivity(name: ActivityNameJobExecute, kind: ActivityKind.Internal, tags: _activityOptions.GlobalTags); 164 | await ExecuteJob(Config.Type, jobInfo, token); 165 | jobExecActivity?.SetStatus(ActivityStatusCode.Ok); 166 | 167 | Anchor = await _anchorStore.DropAnchorAsync(token); 168 | _publisher.Publish(this, new JobCompletedEventArgs(this.Name)); 169 | activity?.SetStatus(ActivityStatusCode.Ok, "Job completed"); 170 | activity?.SetTag("job.outcome", "success"); 171 | activity?.SetTag("job.anchor.updated", Anchor?.ToString("O")); // ISO 8601 format 172 | 173 | _logger.LogInformation("Job completed."); 174 | return true; 175 | 176 | // var jobRan = await ExecuteJobWithinLock(_lockProvider, _anchorStore, _publisher, token); 177 | // if (!jobRan) 178 | // { 179 | // // the job could not be run - it is likely already running. 180 | // // we should wait for the next signal. 181 | // _logger.LogWarning("Job did not execute. Will wait for next signal."); 182 | // return false; 183 | // } 184 | 185 | 186 | } 187 | catch (Exception e) 188 | { 189 | activity?.SetStatus(ActivityStatusCode.Error, e.Message); 190 | activity?.RecordException(e); 191 | _logger.LogError(e, "Job errored"); 192 | activity?.SetTag("job.outcome", "failure"); 193 | return false; 194 | } 195 | } 196 | 197 | private async Task WaitForLockWithIncreasingDelays(CancellationToken token, Func getLockAcquisitionTimeout, int delayIntervalInMinsBeforeRetry = 1) 198 | { 199 | //const int attemptIntervalMinutes = 1; // Define how often to retry acquiring the lock 200 | 201 | var attemptCount = 0; 202 | 203 | while (!token.IsCancellationRequested) 204 | { 205 | attemptCount++; 206 | var lockAcquisitionAttemptTimeout = getLockAcquisitionTimeout(attemptCount); 207 | using var timeoutCts = new CancellationTokenSource(lockAcquisitionAttemptTimeout); 208 | using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(token, timeoutCts.Token); 209 | 210 | var acquiredLock = await _lockProvider.TryAcquireAsync(Name, linkedCts.Token); 211 | if (acquiredLock == null) 212 | { 213 | // unable to acquire lock, keep waiting 214 | _logger.LogWarning("Unable to acquire lock, another instance might be running. Retrying in {0} min.", delayIntervalInMinsBeforeRetry); 215 | await Task.Delay(TimeSpan.FromMinutes(delayIntervalInMinsBeforeRetry), token); 216 | continue; 217 | } 218 | 219 | 220 | _logger.LogDebug("Lock acquired."); 221 | return acquiredLock; 222 | } 223 | 224 | return null; 225 | } 226 | 227 | private async Task LoadAnchor(CancellationToken token) => Anchor = await _anchorStore.GetAnchorAsync(token); 228 | 229 | private async Task CheckAnchorHasNotBeenModified(CancellationToken token) 230 | { 231 | var latestAnchor = await _anchorStore.GetAnchorAsync(token); 232 | var isValid = latestAnchor == Anchor; 233 | Anchor = latestAnchor; 234 | return isValid; 235 | } 236 | 237 | private bool Disabled { get; set; } = false; 238 | 239 | protected virtual async Task ExecuteJob(string jobTypeName, ExecutionInfo runInfo, CancellationToken token) 240 | { 241 | using (var scope = _serviceScopeFactory.CreateScope()) 242 | { 243 | var factory = scope.ServiceProvider.GetRequiredService>(); 244 | 245 | // Do all the work we need to do! 246 | IJob job; 247 | try 248 | 249 | { 250 | job = factory.Invoke(jobTypeName); 251 | if (job == null) 252 | { 253 | // no such job registered.. 254 | _logger.LogWarning("No job type named {name} is registered.", jobTypeName); 255 | return; 256 | } 257 | } 258 | catch (KeyNotFoundException) 259 | { 260 | // if we can't actviate the job, disable it. 261 | Disabled = true; 262 | // ExceptionCount = ExceptionCount + 1; 263 | // unable to find job type specified.. 264 | _logger.LogWarning("No job type named {name} is registered. This job will be disabled.", jobTypeName); 265 | return; 266 | // throw; 267 | } 268 | catch (Exception ex) 269 | { 270 | // ExceptionCount = ExceptionCount + 1; 271 | Disabled = true; 272 | _logger.LogError(ex, "Unable to create job type named {name} - it might be missing dependencies. This job will be disabled.", 273 | jobTypeName); 274 | return; 275 | } 276 | 277 | await job.ExecuteAsync(runInfo, token); 278 | } 279 | } 280 | } 281 | } 282 | -------------------------------------------------------------------------------- /src/Stint.Tests/StintTests.cs: -------------------------------------------------------------------------------- 1 | namespace Stint.Tests 2 | { 3 | using System; 4 | using System.Collections.Concurrent; 5 | using System.Collections.Generic; 6 | using System.Diagnostics; 7 | using System.Globalization; 8 | using System.Linq; 9 | using System.Threading; 10 | using System.Threading.Tasks; 11 | using Cronos; 12 | using Dazinator.Extensions.DependencyInjection; 13 | using Microsoft.Extensions.DependencyInjection; 14 | using Microsoft.Extensions.Hosting; 15 | using Microsoft.Extensions.Logging; 16 | using Stint.Triggers.ManualInvoke; 17 | using Xunit; 18 | using Xunit.Abstractions; 19 | using Xunit.Categories; 20 | 21 | [IntegrationTest] 22 | public class StintTests 23 | { 24 | private readonly ITestOutputHelper _testOutputHelper; 25 | 26 | public StintTests(ITestOutputHelper testOutputHelper) 27 | { 28 | _testOutputHelper = testOutputHelper; 29 | DefaultServices = new ServiceCollection(); 30 | DefaultServices.AddLogging(a => 31 | { 32 | a.AddXUnit(testOutputHelper); 33 | a.SetMinimumLevel(LogLevel.Debug); 34 | }); 35 | } 36 | 37 | public ServiceCollection DefaultServices { get; set; } 38 | 39 | [Fact] 40 | public void Can_Run_Scheduled_Job() 41 | { 42 | var jobRanEvent = new AutoResetEvent(false); 43 | 44 | 45 | // services.Configure(configuration); 46 | // a => a.AddTransient(nameof(TestJob), (sp) => new TestJob(onJobExecuted)) 47 | 48 | var hostBuilderTask = CreateHostBuilder(new SingletonLockProvider(), 49 | (config) => config.Jobs.Add("Can_Run_Scheduled_Job", new JobConfig() 50 | { 51 | Type = nameof(TestJob), 52 | Triggers = new TriggersConfig() 53 | { 54 | Schedules = 55 | { 56 | new ScheduledTriggerConfig() 57 | { 58 | Schedule = "* * * * *" 59 | } 60 | } 61 | } 62 | }), 63 | (jobTypes) => jobTypes.AddTransient(nameof(TestJob), (sp) => new TestJob(async () => jobRanEvent.Set()))) 64 | .Build() 65 | .RunAsync(); 66 | 67 | 68 | var signalled = jobRanEvent.WaitOne(62000); 69 | Assert.True(signalled); 70 | } 71 | 72 | [Fact] 73 | public void Can_Set_Activity_GlobalTags() 74 | { 75 | var jobRanEvent = new AutoResetEvent(false); 76 | var capturedActivities = new ConcurrentBag(); 77 | 78 | // Set up ActivityListener to capture activities - must be done BEFORE creating the host 79 | using var activityListener = new ActivityListener 80 | { 81 | ShouldListenTo = source => 82 | { 83 | _testOutputHelper.WriteLine($"ActivitySource detected: {source.Name}"); 84 | return source.Name.StartsWith("Stint") || source.Name.Contains("job", StringComparison.OrdinalIgnoreCase); 85 | }, 86 | Sample = (ref ActivityCreationOptions options) => 87 | { 88 | _testOutputHelper.WriteLine($"Activity sampling: {options.Name}"); 89 | return ActivitySamplingResult.AllDataAndRecorded; 90 | }, 91 | ActivityStarted = activity => 92 | { 93 | _testOutputHelper.WriteLine($"Activity started: {activity.OperationName}, Tags: {string.Join(", ", activity.Tags.Select(t => $"{t.Key}={t.Value}"))}"); 94 | capturedActivities.Add(activity); 95 | } 96 | }; 97 | 98 | ActivitySource.AddActivityListener(activityListener); 99 | 100 | var hostBuilderTask = CreateHostBuilder(new SingletonLockProvider(), 101 | configureJobsConfig: 102 | (config) => config.Jobs.Add("Can_Set_Activity_GlobalTags", new JobConfig() 103 | { 104 | Type = nameof(TestJob), 105 | Triggers = new TriggersConfig() 106 | { 107 | Schedules = 108 | { 109 | new ScheduledTriggerConfig() 110 | { 111 | Schedule = "* * * * *" 112 | } 113 | } 114 | } 115 | }), 116 | registerJobTypes: 117 | (jobTypes) => jobTypes.AddTransient(nameof(TestJob), (sp) => new TestJob(async () => jobRanEvent.Set())), 118 | configureStint: 119 | (builder) => { 120 | // Replace this incorrect line: 121 | // activityOptions.GlobalTags.Add(new ["TestTag", "TestValue"]); 122 | 123 | // With the correct usage: 124 | 125 | builder.ConfigureActivityTags((activityOptions) => { 126 | activityOptions.GlobalTags.Add(new KeyValuePair("TestTag", "TestValue")); 127 | //activityOptions.GlobalTags.Add(new ["TestTag", "TestValue"]); 128 | }); 129 | 130 | }) 131 | .Build() 132 | .RunAsync(); 133 | 134 | var signalled = jobRanEvent.WaitOne(62000); 135 | Assert.True(signalled); 136 | 137 | // Debug: Log all captured activities 138 | _testOutputHelper.WriteLine($"Captured {capturedActivities.Count} activities"); 139 | foreach (var activity in capturedActivities) 140 | { 141 | _testOutputHelper.WriteLine($"Activity: {activity.OperationName}, Tags: {string.Join(", ", activity.Tags.Select(t => $"{t.Key}={t.Value}"))}"); 142 | } 143 | 144 | // Based on the output, we can see that activities are created but don't have the global tags 145 | // Let's check specifically for the TestTag 146 | var activitiesWithGlobalTag = capturedActivities 147 | .Where(a => a.Tags.Any(tag => tag.Key == "TestTag" && tag.Value == "TestValue")) 148 | .ToList(); 149 | 150 | // If this fails, it means the global tags configuration isn't working 151 | Assert.NotEmpty(activitiesWithGlobalTag); 152 | 153 | // Alternative assertion - check that at least the main job activity has the global tag 154 | var runJobOnceActivity = capturedActivities 155 | .FirstOrDefault(a => a.OperationName == "JobRunner.RunJobOnce"); 156 | 157 | Assert.NotNull(runJobOnceActivity); 158 | Assert.Contains(runJobOnceActivity.Tags, tag => tag.Key == "TestTag" && tag.Value == "TestValue"); 159 | } 160 | 161 | [Fact] 162 | public async Task Only_One_Instance_Of_Scheduled_Job_Executed_Concurrently() 163 | { 164 | var hostCount = 3; 165 | var jobRanEvent = new ManualResetEvent(false); 166 | var hosts = new List(); 167 | var lockProvider = new SingletonLockProvider(); 168 | var failed = false; 169 | object isRunningDetection = null; 170 | 171 | 172 | for (var i = 0; i < hostCount; i++) 173 | { 174 | ILogger logger = null; 175 | 176 | var host = CreateHostBuilder(lockProvider, 177 | (config) => config.Jobs.Add("Only_One_Instance_Of_Scheduled_Job_Executed_Concurrently", new JobConfig() 178 | { 179 | Type = nameof(TestJob), 180 | Triggers = new TriggersConfig() 181 | { 182 | Schedules = 183 | { 184 | new ScheduledTriggerConfig() 185 | { 186 | Schedule = "* * * * *" 187 | } 188 | } 189 | } 190 | }), 191 | (jobTypes) => jobTypes.AddTransient(nameof(TestJob), (sp) => new TestJob(async () => 192 | { 193 | logger?.LogInformation("TestJob Ran"); 194 | var thisInstance = new object(); 195 | var oldIsRunning = Interlocked.Exchange(ref isRunningDetection, thisInstance); 196 | if (oldIsRunning != null) 197 | { 198 | // duplicate running 199 | logger?.LogInformation("Another instance was already running.."); 200 | failed = true; 201 | } 202 | 203 | if (!jobRanEvent.Set()) 204 | { 205 | logger?.LogInformation("Unable to set signal.."); 206 | failed = true; 207 | } 208 | 209 | logger?.LogInformation("Artificial job processing delay.."); 210 | await Task.Delay(2000); 211 | 212 | oldIsRunning = Interlocked.Exchange(ref isRunningDetection, null); 213 | if (oldIsRunning != thisInstance) 214 | { 215 | logger?.LogInformation("Another instance of the job ran before this one completed.."); 216 | // duplicate running 217 | failed = true; 218 | } 219 | }))).Build(); 220 | 221 | logger = host.Services.GetRequiredService>(); 222 | 223 | hosts.Add(host); 224 | } 225 | 226 | var tasks = hosts.Select(a => a.StartAsync()); 227 | await Task.WhenAll(tasks); 228 | 229 | var jobRan = jobRanEvent.WaitOne(65000); 230 | Assert.True(jobRan); 231 | 232 | // // give more time for more jobs to run. 233 | await Task.Delay(TimeSpan.FromSeconds(30)); 234 | Assert.False(failed); 235 | } 236 | 237 | [Fact] 238 | public async Task Can_Run_Overdue_Job() 239 | { 240 | var jobRanEvent = new AutoResetEvent(false); 241 | 242 | var mockAnchorStore = new MockAnchorStore 243 | { 244 | // simulate a job that is overdue. 245 | CurrentAnchor = DateTime.UtcNow.AddDays(-1) 246 | }; 247 | 248 | var host = Host.CreateDefaultBuilder() 249 | .ConfigureServices((hostContext, services) => 250 | { 251 | services.Configure((config) => config.Jobs.Add("Can_Run_Overdue_Job", new JobConfig() 252 | { 253 | Type = nameof(TestJob), 254 | Triggers = new TriggersConfig() 255 | { 256 | Schedules = 257 | { 258 | new ScheduledTriggerConfig() 259 | { 260 | Schedule = "* * * * *" 261 | } 262 | } 263 | } 264 | })); 265 | 266 | services.AddScheduledJobs((options) => options.AddLockProviderInstance(new SingletonLockProvider()) 267 | .RegisterJobTypes((jobTypes) => jobTypes.AddTransient(nameof(TestJob), (sp) => new TestJob(async () => jobRanEvent.Set())))) 268 | .AddSingleton(new MockAnchorStoreFactory((jobName) => mockAnchorStore)); 269 | }).Build().RunAsync(); 270 | 271 | 272 | var signalled = jobRanEvent.WaitOne(9000); 273 | jobRanEvent.Reset(); 274 | Assert.True(signalled); 275 | 276 | // should run again in another minute. 277 | signalled = jobRanEvent.WaitOne(63000); 278 | 279 | Assert.True(signalled); 280 | } 281 | 282 | [Fact] 283 | public async Task Can_Chain_Jobs() 284 | { 285 | // var jobRanEvent = new AutoResetEvent(false)var chainedJobRanEvent = new AutoResetEvent(false); 286 | 287 | var jobRan = false; 288 | var jobTwoRan = false; 289 | 290 | var mockAnchors = new Dictionary() 291 | { 292 | { 293 | "Can_Chain_Jobs", new MockAnchorStore 294 | { 295 | CurrentAnchor = DateTime.UtcNow.AddDays(-1) 296 | } 297 | }, 298 | { 299 | "Can_Chain_Jobs_TestChainedJob", new MockAnchorStore 300 | { 301 | CurrentAnchor = DateTime.UtcNow.AddDays(-1) 302 | } 303 | } 304 | }; 305 | 306 | ILogger logger = null; 307 | 308 | var host = Host.CreateDefaultBuilder() 309 | .ConfigureServices((hostContext, services) => 310 | { 311 | foreach (var service in DefaultServices) 312 | { 313 | services.Add(service); 314 | } 315 | 316 | services.Configure((config) => 317 | { 318 | // overdue job will run immdiately 319 | config.Jobs.Add("Can_Chain_Jobs", new JobConfig() 320 | { 321 | Type = nameof(TestJob), 322 | Triggers = new TriggersConfig() 323 | { 324 | Manual = true, 325 | //Schedules = { 326 | // new ScheduledTriggerConfig() { Schedule = "* * * * *" } 327 | //} 328 | } 329 | }); 330 | 331 | // we want this job to run off the back of the other job completing so we add a job completion trigger 332 | config.Jobs.Add("Can_Chain_Jobs_TestChainedJob", new JobConfig() 333 | { 334 | Type = nameof(TestChainedJob), 335 | Triggers = new TriggersConfig() 336 | { 337 | JobCompletions = 338 | { 339 | new JobCompletedTriggerConfig() 340 | { 341 | JobName = "Can_Chain_Jobs" 342 | } 343 | } 344 | } 345 | }); 346 | }); 347 | 348 | services.AddScheduledJobs(a => a.RegisterJobTypes((jobTypes) => 349 | jobTypes.AddTransient(nameof(TestJob), (sp) => new TestJob(() => 350 | { 351 | logger?.LogInformation("Can_Chain_Jobs Ran"); 352 | jobRan = true; 353 | return Task.CompletedTask; 354 | })) 355 | .AddTransient(nameof(TestChainedJob), (sp) => new TestChainedJob(() => 356 | { 357 | logger?.LogInformation("Can_Chain_Jobs_TestChainedJob Ran"); 358 | jobTwoRan = true; 359 | return Task.CompletedTask; 360 | })))) 361 | .AddSingleton(new MockAnchorStoreFactory((jobName) => mockAnchors[jobName])); 362 | }).Build(); 363 | 364 | logger = host.Services.GetRequiredService>(); 365 | 366 | var hostCts = new CancellationTokenSource(TimeSpan.FromMinutes(1)); 367 | var hostRunTask = host.RunAsync(hostCts.Token); 368 | 369 | using (var scope = host.Services.CreateScope()) 370 | { 371 | var manualTriggerInvoker = scope.ServiceProvider.GetRequiredService(); 372 | var success = false; 373 | 374 | // the issue here, is that if we trigger a job manually, but the JobRunner has not yet subscribed / picked up the next token 375 | // (there is a delay before it gets one on starting), 376 | // then our signal can be lost - so this won't reliably trigger the job. 377 | manualTriggerInvoker.Trigger("Can_Chain_Jobs"); 378 | await Task.WhenAny(Task.Delay(TimeSpan.FromSeconds(10), hostCts.Token), Task.Run(async () => 379 | { 380 | while (!hostCts.IsCancellationRequested) 381 | { 382 | if (!jobRan) 383 | { 384 | await Task.Delay(TimeSpan.FromSeconds(1), hostCts.Token); 385 | continue; 386 | } 387 | 388 | if (!jobTwoRan) 389 | { 390 | await Task.Delay(TimeSpan.FromSeconds(1), hostCts.Token); 391 | continue; 392 | } 393 | 394 | success = true; 395 | } 396 | 397 | return false; 398 | }, hostCts.Token)); 399 | 400 | Assert.True(success); 401 | } 402 | 403 | 404 | //// signalled = chainedJobRanEvent.WaitOne(65000); 405 | //Assert.True(signalled); 406 | } 407 | 408 | [Theory] 409 | [InlineData("* * * * *", "23/01/2023 11:00", "23/01/2023 11:01")] 410 | [InlineData("*/10 7-9 * * *", "23/01/2023 07:10", "23/01/2023 07:20")] // 07:00 - 09:59 UTC – every 10 mins 411 | [InlineData("*/10 7-9 * * *", "23/01/2023 10:00", "24/01/2023 07:00")] // 07:00 - 09:59 UTC – every 10 mins - next occurrence tomorrow. 412 | [InlineData("*/30 10-13 * * *", "23/01/2023 10:10", "23/01/2023 10:30")] // 10:00 - 13:59 UTC – every 30 mins 413 | [InlineData("*/10 14 * * *", "23/01/2023 14:00", "23/01/2023 14:10")] // 14:00 - 14:59 UTC – every 10 mins 414 | public void Can_Use_Cron_Expression(string cron, string lastOccurrencUtc, string expectedNextOccurrenceUtc) 415 | { 416 | var expression = CronExpression.Parse(cron); 417 | 418 | var lastOccurrenceDateTime = DateTime.ParseExact(lastOccurrencUtc, "dd/MM/yyyy HH:mm", CultureInfo.InvariantCulture).ToUniversalTime(); 419 | 420 | var expectedNextOccurrenceDateTime = DateTime.ParseExact(expectedNextOccurrenceUtc, "dd/MM/yyyy HH:mm", CultureInfo.InvariantCulture); 421 | 422 | 423 | // var fromWhenShouldItNextRun = DateTime.UtcNow; 424 | var nextOccurence = expression.GetNextOccurrence(lastOccurrenceDateTime); 425 | 426 | Assert.Equal(expectedNextOccurrenceDateTime, nextOccurence); 427 | } 428 | 429 | [Exploratory] 430 | [Theory] 431 | [InlineData("*/1 * * * *", 180, 3)] // every minute 432 | public async Task Runs_To_Schedule_LongRunning(string cron, int testDurationInSeconds, int expectedRunCount) 433 | { 434 | var jobRanCount = 0; 435 | var successEvent = new AutoResetEvent(false); 436 | 437 | var hostBuilderTask = CreateHostBuilder(new SingletonLockProvider(), 438 | (config) => config.Jobs.Add("Runs_To_Schedule_LongRunning", new JobConfig() 439 | { 440 | Type = nameof(TestJob), 441 | Triggers = new TriggersConfig() 442 | { 443 | Schedules = 444 | { 445 | new ScheduledTriggerConfig() 446 | { 447 | Schedule = cron 448 | } 449 | } 450 | } 451 | }), 452 | (jobTypes) => jobTypes.AddTransient(nameof(TestJob), (sp) => new TestJob(async () => 453 | { 454 | var totalRuns = Interlocked.Increment(ref jobRanCount); 455 | if (totalRuns == expectedRunCount) 456 | { 457 | successEvent.Set(); 458 | } 459 | }))) 460 | .Build() 461 | .RunAsync(); 462 | 463 | var waitTimeSpan = TimeSpan.FromSeconds(testDurationInSeconds + 10); // plus a buffer 464 | var signalled = successEvent.WaitOne(waitTimeSpan); 465 | Assert.True(signalled); 466 | } 467 | 468 | public IHostBuilder CreateHostBuilder( 469 | ILockProvider lockProvider, 470 | Action configureJobsConfig, 471 | Action> registerJobTypes, 472 | Action configureStint = null 473 | ) => 474 | Host.CreateDefaultBuilder() 475 | .ConfigureServices((hostContext, services) => 476 | { 477 | foreach (var service in DefaultServices) 478 | { 479 | services.Add(service); 480 | } 481 | 482 | services.Configure(configureJobsConfig); 483 | 484 | services.AddScheduledJobs((options) => 485 | { 486 | options.AddLockProviderInstance(lockProvider) 487 | .RegisterJobTypes(registerJobTypes); 488 | 489 | configureStint?.Invoke(options); 490 | 491 | }); 492 | }); 493 | 494 | public class TestJob : IJob 495 | { 496 | private readonly Func _onJobExecuted; 497 | 498 | public TestJob(Func onJobExecuted) => _onJobExecuted = onJobExecuted; 499 | 500 | public async Task ExecuteAsync(ExecutionInfo runInfo, CancellationToken token) => await _onJobExecuted(); 501 | } 502 | 503 | public class TestChainedJob : IJob 504 | { 505 | private readonly Func _onJobExecuted; 506 | 507 | public TestChainedJob(Func onJobExecuted) => _onJobExecuted = onJobExecuted; 508 | 509 | public async Task ExecuteAsync(ExecutionInfo runInfo, CancellationToken token) => await _onJobExecuted(); 510 | } 511 | } 512 | } 513 | -------------------------------------------------------------------------------- /src/.editorconfig: -------------------------------------------------------------------------------- 1 | # Version: 1.5.0 (Using https://semver.org/) 2 | # Updated: 2020-03-09 3 | # See https://github.com/RehanSaeed/EditorConfig/releases for release notes. 4 | # See https://github.com/RehanSaeed/EditorConfig for updates to this file. 5 | # See http://EditorConfig.org for more information about .editorconfig files. 6 | 7 | ########################################## 8 | # Common Settings 9 | ########################################## 10 | 11 | # This file is the top-most EditorConfig file 12 | root = true 13 | 14 | # All Files 15 | [*] 16 | charset = utf-8 17 | indent_style = space 18 | indent_size = 4 19 | insert_final_newline = true 20 | trim_trailing_whitespace = true 21 | 22 | ########################################## 23 | # File Extension Settings 24 | ########################################## 25 | 26 | # Visual Studio Solution Files 27 | [*.sln] 28 | indent_style = tab 29 | 30 | # Visual Studio XML Project Files 31 | [*.{csproj,vbproj,vcxproj.filters,proj,projitems,shproj}] 32 | indent_size = 2 33 | 34 | # XML Configuration Files 35 | [*.{xml,config,props,targets,nuspec,resx,ruleset,vsixmanifest,vsct}] 36 | indent_size = 2 37 | 38 | # JSON Files 39 | [*.{json,json5,webmanifest}] 40 | indent_size = 2 41 | 42 | # YAML Files 43 | [*.{yml,yaml}] 44 | indent_size = 2 45 | 46 | # Markdown Files 47 | [*.md] 48 | trim_trailing_whitespace = false 49 | 50 | # Web Files 51 | [*.{htm,html,js,jsm,ts,tsx,css,sass,scss,less,svg,vue}] 52 | indent_size = 2 53 | 54 | # Batch Files 55 | [*.{cmd,bat}] 56 | end_of_line = crlf 57 | 58 | # Bash Files 59 | [*.sh] 60 | end_of_line = lf 61 | 62 | # Makefiles 63 | [Makefile] 64 | indent_style = tab 65 | 66 | ########################################## 67 | # .NET Language Conventions 68 | # https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions 69 | ########################################## 70 | 71 | # .NET Code Style Settings 72 | # https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions#net-code-style-settings 73 | [*.{cs,csx,cake,vb,vbx}] 74 | # "this." and "Me." qualifiers 75 | # https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions#this-and-me 76 | #DT: I have disabled these because I prefer _ for accessing prviate fields as commented elsewhere in this doc. 77 | dotnet_style_qualification_for_event = false:silent 78 | dotnet_style_qualification_for_field = false:silent 79 | dotnet_style_qualification_for_method = false:silent 80 | dotnet_style_qualification_for_property = false:silent 81 | 82 | # Language keywords instead of framework type names for type references 83 | # https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions#language-keywords 84 | dotnet_style_predefined_type_for_locals_parameters_members = true:warning 85 | dotnet_style_predefined_type_for_member_access = true:warning 86 | # Modifier preferences 87 | # https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions#normalize-modifiers 88 | dotnet_style_require_accessibility_modifiers = always:warning 89 | csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async 90 | visual_basic_preferred_modifier_order = Partial,Default,Private,Protected,Public,Friend,NotOverridable,Overridable,MustOverride,Overloads,Overrides,MustInherit,NotInheritable,Static,Shared,Shadows,ReadOnly,WriteOnly,Dim,Const,WithEvents,Widening,Narrowing,Custom,Async 91 | dotnet_style_readonly_field = true:warning 92 | # Parentheses preferences 93 | # https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions#parentheses-preferences 94 | dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:warning 95 | dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:warning 96 | dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:warning 97 | dotnet_style_parentheses_in_other_operators = never_if_unnecessary:suggestion 98 | # Expression-level preferences 99 | # https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions#expression-level-preferences 100 | dotnet_style_object_initializer = true:warning 101 | dotnet_style_collection_initializer = true:warning 102 | dotnet_style_explicit_tuple_names = true:warning 103 | dotnet_style_prefer_inferred_tuple_names = true:warning 104 | dotnet_style_prefer_inferred_anonymous_type_member_names = true:warning 105 | dotnet_style_prefer_auto_properties = true:warning 106 | dotnet_style_prefer_is_null_check_over_reference_equality_method = true:warning 107 | dotnet_style_prefer_conditional_expression_over_assignment = false:suggestion 108 | dotnet_style_prefer_conditional_expression_over_return = false:suggestion 109 | dotnet_style_prefer_compound_assignment = true:warning 110 | # Null-checking preferences 111 | # https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions#null-checking-preferences 112 | dotnet_style_coalesce_expression = true:warning 113 | dotnet_style_null_propagation = true:warning 114 | # Parameter preferences 115 | # https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions#parameter-preferences 116 | dotnet_code_quality_unused_parameters = all:warning 117 | # More style options (Undocumented) 118 | # https://github.com/MicrosoftDocs/visualstudio-docs/issues/3641 119 | dotnet_style_operator_placement_when_wrapping = end_of_line 120 | # https://github.com/dotnet/roslyn/pull/40070 121 | dotnet_style_prefer_simplified_interpolation = true:warning 122 | 123 | # C# Code Style Settings 124 | # https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions#c-code-style-settings 125 | [*.{cs,csx,cake}] 126 | # Implicit and explicit types 127 | # https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions#implicit-and-explicit-types 128 | csharp_style_var_for_built_in_types = true:warning 129 | csharp_style_var_when_type_is_apparent = true:warning 130 | csharp_style_var_elsewhere = true:warning 131 | # Expression-bodied members 132 | # https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions#expression-bodied-members 133 | csharp_style_expression_bodied_methods = true:warning 134 | csharp_style_expression_bodied_constructors = true:warning 135 | csharp_style_expression_bodied_operators = true:warning 136 | csharp_style_expression_bodied_properties = true:warning 137 | csharp_style_expression_bodied_indexers = true:warning 138 | csharp_style_expression_bodied_accessors = true:warning 139 | csharp_style_expression_bodied_lambdas = true:warning 140 | csharp_style_expression_bodied_local_functions = true:warning 141 | # Pattern matching 142 | # https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions#pattern-matching 143 | csharp_style_pattern_matching_over_is_with_cast_check = true:warning 144 | csharp_style_pattern_matching_over_as_with_null_check = true:warning 145 | # Inlined variable declarations 146 | # https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions#inlined-variable-declarations 147 | csharp_style_inlined_variable_declaration = true:warning 148 | # Expression-level preferences 149 | # https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions#expression-level-preferences 150 | csharp_prefer_simple_default_expression = true:warning 151 | # "Null" checking preferences 152 | # https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions#c-null-checking-preferences 153 | csharp_style_throw_expression = true:warning 154 | csharp_style_conditional_delegate_call = true:warning 155 | # Code block preferences 156 | # https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions#code-block-preferences 157 | csharp_prefer_braces = true:warning 158 | # Unused value preferences 159 | # https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions#unused-value-preferences 160 | csharp_style_unused_value_expression_statement_preference = discard_variable:suggestion 161 | csharp_style_unused_value_assignment_preference = discard_variable:suggestion 162 | # Index and range preferences 163 | # https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions#index-and-range-preferences 164 | csharp_style_prefer_index_operator = true:warning 165 | csharp_style_prefer_range_operator = true:warning 166 | # Miscellaneous preferences 167 | # https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions#miscellaneous-preferences 168 | csharp_style_deconstructed_variable_declaration = true:warning 169 | csharp_style_pattern_local_over_anonymous_function = true:warning 170 | csharp_using_directive_placement = inside_namespace:warning 171 | csharp_prefer_static_local_function = true:warning 172 | csharp_prefer_simple_using_statement = true:suggestion 173 | 174 | ########################################## 175 | # .NET Formatting Conventions 176 | # https://docs.microsoft.com/visualstudio/ide/editorconfig-code-style-settings-reference#formatting-conventions 177 | ########################################## 178 | 179 | # Organize usings 180 | # https://docs.microsoft.com/visualstudio/ide/editorconfig-formatting-conventions#organize-using-directives 181 | dotnet_sort_system_directives_first = true 182 | # Newline options 183 | # https://docs.microsoft.com/visualstudio/ide/editorconfig-formatting-conventions#new-line-options 184 | csharp_new_line_before_open_brace = all 185 | csharp_new_line_before_else = true 186 | csharp_new_line_before_catch = true 187 | csharp_new_line_before_finally = true 188 | csharp_new_line_before_members_in_object_initializers = true 189 | csharp_new_line_before_members_in_anonymous_types = true 190 | csharp_new_line_between_query_expression_clauses = true 191 | # Indentation options 192 | # https://docs.microsoft.com/visualstudio/ide/editorconfig-formatting-conventions#indentation-options 193 | csharp_indent_case_contents = true 194 | csharp_indent_switch_labels = true 195 | csharp_indent_labels = no_change 196 | csharp_indent_block_contents = true 197 | csharp_indent_braces = false 198 | csharp_indent_case_contents_when_block = false 199 | # Spacing options 200 | # https://docs.microsoft.com/visualstudio/ide/editorconfig-formatting-conventions#spacing-options 201 | csharp_space_after_cast = false 202 | csharp_space_after_keywords_in_control_flow_statements = true 203 | csharp_space_between_parentheses = false 204 | csharp_space_before_colon_in_inheritance_clause = true 205 | csharp_space_after_colon_in_inheritance_clause = true 206 | csharp_space_around_binary_operators = before_and_after 207 | csharp_space_between_method_declaration_parameter_list_parentheses = false 208 | csharp_space_between_method_declaration_empty_parameter_list_parentheses = false 209 | csharp_space_between_method_declaration_name_and_open_parenthesis = false 210 | csharp_space_between_method_call_parameter_list_parentheses = false 211 | csharp_space_between_method_call_empty_parameter_list_parentheses = false 212 | csharp_space_between_method_call_name_and_opening_parenthesis = false 213 | csharp_space_after_comma = true 214 | csharp_space_before_comma = false 215 | csharp_space_after_dot = false 216 | csharp_space_before_dot = false 217 | csharp_space_after_semicolon_in_for_statement = true 218 | csharp_space_before_semicolon_in_for_statement = false 219 | csharp_space_around_declaration_statements = false 220 | csharp_space_before_open_square_brackets = false 221 | csharp_space_between_empty_square_brackets = false 222 | csharp_space_between_square_brackets = false 223 | # Wrapping options 224 | # https://docs.microsoft.com/visualstudio/ide/editorconfig-formatting-conventions#wrap-options 225 | csharp_preserve_single_line_statements = false 226 | csharp_preserve_single_line_blocks = true 227 | 228 | ########################################## 229 | # .NET Naming Conventions 230 | # https://docs.microsoft.com/visualstudio/ide/editorconfig-naming-conventions 231 | ########################################## 232 | 233 | [*.{cs,csx,cake,vb,vbx}] 234 | 235 | ########################################## 236 | # Styles 237 | ########################################## 238 | 239 | # camel_case_style - Define the camelCase style 240 | dotnet_naming_style.camel_case_style.capitalization = camel_case 241 | # pascal_case_style - Define the PascalCase style 242 | dotnet_naming_style.pascal_case_style.capitalization = pascal_case 243 | # first_upper_style - The first character must start with an upper-case character 244 | dotnet_naming_style.first_upper_style.capitalization = first_word_upper 245 | # prefix_interface_with_i_style - Interfaces must be PascalCase and the first character of an interface must be an 'I' 246 | dotnet_naming_style.prefix_interface_with_i_style.capitalization = pascal_case 247 | dotnet_naming_style.prefix_interface_with_i_style.required_prefix = I 248 | # prefix_type_parameters_with_t_style - Generic Type Parameters must be PascalCase and the first character must be a 'T' 249 | dotnet_naming_style.prefix_type_parameters_with_t_style.capitalization = pascal_case 250 | dotnet_naming_style.prefix_type_parameters_with_t_style.required_prefix = T 251 | # disallowed_style - Anything that has this style applied is marked as disallowed 252 | dotnet_naming_style.disallowed_style.capitalization = pascal_case 253 | dotnet_naming_style.disallowed_style.required_prefix = ____RULE_VIOLATION____ 254 | dotnet_naming_style.disallowed_style.required_suffix = ____RULE_VIOLATION____ 255 | # internal_error_style - This style should never occur... if it does, it's indicates a bug in file or in the parser using the file 256 | dotnet_naming_style.internal_error_style.capitalization = pascal_case 257 | dotnet_naming_style.internal_error_style.required_prefix = ____INTERNAL_ERROR____ 258 | dotnet_naming_style.internal_error_style.required_suffix = ____INTERNAL_ERROR____ 259 | 260 | ########################################## 261 | # .NET Design Guideline Field Naming Rules 262 | # Naming rules for fields follow the .NET Framework design guidelines 263 | # https://docs.microsoft.com/dotnet/standard/design-guidelines/index 264 | ########################################## 265 | 266 | # All public/protected/protected_internal constant fields must be PascalCase 267 | # https://docs.microsoft.com/dotnet/standard/design-guidelines/field 268 | dotnet_naming_symbols.public_protected_constant_fields_group.applicable_accessibilities = public, protected, protected_internal 269 | dotnet_naming_symbols.public_protected_constant_fields_group.required_modifiers = const 270 | dotnet_naming_symbols.public_protected_constant_fields_group.applicable_kinds = field 271 | dotnet_naming_rule.public_protected_constant_fields_must_be_pascal_case_rule.symbols = public_protected_constant_fields_group 272 | dotnet_naming_rule.public_protected_constant_fields_must_be_pascal_case_rule.style = pascal_case_style 273 | dotnet_naming_rule.public_protected_constant_fields_must_be_pascal_case_rule.severity = warning 274 | 275 | # All public/protected/protected_internal static readonly fields must be PascalCase 276 | # https://docs.microsoft.com/dotnet/standard/design-guidelines/field 277 | dotnet_naming_symbols.public_protected_static_readonly_fields_group.applicable_accessibilities = public, protected, protected_internal 278 | dotnet_naming_symbols.public_protected_static_readonly_fields_group.required_modifiers = static, readonly 279 | dotnet_naming_symbols.public_protected_static_readonly_fields_group.applicable_kinds = field 280 | dotnet_naming_rule.public_protected_static_readonly_fields_must_be_pascal_case_rule.symbols = public_protected_static_readonly_fields_group 281 | dotnet_naming_rule.public_protected_static_readonly_fields_must_be_pascal_case_rule.style = pascal_case_style 282 | dotnet_naming_rule.public_protected_static_readonly_fields_must_be_pascal_case_rule.severity = warning 283 | 284 | # No other public/protected/protected_internal fields are allowed 285 | # https://docs.microsoft.com/dotnet/standard/design-guidelines/field 286 | dotnet_naming_symbols.other_public_protected_fields_group.applicable_accessibilities = public, protected, protected_internal 287 | dotnet_naming_symbols.other_public_protected_fields_group.applicable_kinds = field 288 | dotnet_naming_rule.other_public_protected_fields_disallowed_rule.symbols = other_public_protected_fields_group 289 | dotnet_naming_rule.other_public_protected_fields_disallowed_rule.style = disallowed_style 290 | dotnet_naming_rule.other_public_protected_fields_disallowed_rule.severity = error 291 | 292 | ########################################## 293 | # StyleCop Field Naming Rules 294 | # Naming rules for fields follow the StyleCop analyzers 295 | # This does not override any rules using disallowed_style above 296 | # https://github.com/DotNetAnalyzers/StyleCopAnalyzers 297 | ########################################## 298 | 299 | # All constant fields must be PascalCase 300 | # https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1303.md 301 | dotnet_naming_symbols.stylecop_constant_fields_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected, private 302 | dotnet_naming_symbols.stylecop_constant_fields_group.required_modifiers = const 303 | dotnet_naming_symbols.stylecop_constant_fields_group.applicable_kinds = field 304 | dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.symbols = stylecop_constant_fields_group 305 | dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.style = pascal_case_style 306 | dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.severity = warning 307 | 308 | # All static readonly fields must be PascalCase 309 | # https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1311.md 310 | dotnet_naming_symbols.stylecop_static_readonly_fields_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected, private 311 | dotnet_naming_symbols.stylecop_static_readonly_fields_group.required_modifiers = static, readonly 312 | dotnet_naming_symbols.stylecop_static_readonly_fields_group.applicable_kinds = field 313 | dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.symbols = stylecop_static_readonly_fields_group 314 | dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.style = pascal_case_style 315 | dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.severity = warning 316 | 317 | # No non-private instance fields are allowed 318 | # https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1401.md 319 | dotnet_naming_symbols.stylecop_fields_must_be_private_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected 320 | dotnet_naming_symbols.stylecop_fields_must_be_private_group.applicable_kinds = field 321 | dotnet_naming_rule.stylecop_instance_fields_must_be_private_rule.symbols = stylecop_fields_must_be_private_group 322 | dotnet_naming_rule.stylecop_instance_fields_must_be_private_rule.style = disallowed_style 323 | dotnet_naming_rule.stylecop_instance_fields_must_be_private_rule.severity = error 324 | 325 | # Private fields must be camelCase 326 | # https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1306.md 327 | dotnet_naming_symbols.stylecop_private_fields_group.applicable_accessibilities = private 328 | dotnet_naming_symbols.stylecop_private_fields_group.applicable_kinds = field 329 | dotnet_naming_rule.stylecop_private_fields_must_be_camel_case_rule.symbols = stylecop_private_fields_group 330 | dotnet_naming_rule.stylecop_private_fields_must_be_camel_case_rule.style = camel_case_style 331 | dotnet_naming_rule.stylecop_private_fields_must_be_camel_case_rule.severity = warning 332 | 333 | # Private and internal fields start with _ 334 | # DT: Why? I know this against the official guidelines, but this lets you easily see if a variable is a parameter of the method or a member of the class. 335 | # which negates the need to use "this.". The official guidelines are to use "this." wheever you need to access a private variable but as this is not enforced this can lead to bugs where 336 | # a developer thinks they are accessing a variable passed in to a method: e.g "username" but are instead accessing the private field "username". Using the policy of 337 | # _username eliminates this kind of mistake. 338 | dotnet_naming_rule.private_or_internal_field_should_be_private_begins_with__.severity = suggestion 339 | dotnet_naming_rule.private_or_internal_field_should_be_private_begins_with__.symbols = private_or_internal_field 340 | dotnet_naming_rule.private_or_internal_field_should_be_private_begins_with__.style = private_begins_with__ 341 | 342 | dotnet_naming_symbols.private_or_internal_field.applicable_kinds = field 343 | dotnet_naming_symbols.private_or_internal_field.applicable_accessibilities = internal, private, private_protected 344 | dotnet_naming_symbols.private_or_internal_field.required_modifiers = 345 | 346 | dotnet_naming_style.private_begins_with__.required_prefix = _ 347 | dotnet_naming_style.private_begins_with__.required_suffix = 348 | dotnet_naming_style.private_begins_with__.word_separator = 349 | dotnet_naming_style.private_begins_with__.capitalization = camel_case 350 | 351 | # Local variables must be camelCase 352 | # https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1312.md 353 | dotnet_naming_symbols.stylecop_local_fields_group.applicable_accessibilities = local 354 | dotnet_naming_symbols.stylecop_local_fields_group.applicable_kinds = local 355 | dotnet_naming_rule.stylecop_local_fields_must_be_camel_case_rule.symbols = stylecop_local_fields_group 356 | dotnet_naming_rule.stylecop_local_fields_must_be_camel_case_rule.style = camel_case_style 357 | dotnet_naming_rule.stylecop_local_fields_must_be_camel_case_rule.severity = silent 358 | 359 | # This rule should never fire. However, it's included for at least two purposes: 360 | # First, it helps to understand, reason about, and root-case certain types of issues, such as bugs in .editorconfig parsers. 361 | # Second, it helps to raise immediate awareness if a new field type is added (as occurred recently in C#). 362 | dotnet_naming_symbols.sanity_check_uncovered_field_case_group.applicable_accessibilities = * 363 | dotnet_naming_symbols.sanity_check_uncovered_field_case_group.applicable_kinds = field 364 | dotnet_naming_rule.sanity_check_uncovered_field_case_rule.symbols = sanity_check_uncovered_field_case_group 365 | dotnet_naming_rule.sanity_check_uncovered_field_case_rule.style = internal_error_style 366 | dotnet_naming_rule.sanity_check_uncovered_field_case_rule.severity = error 367 | 368 | 369 | ########################################## 370 | # Other Naming Rules 371 | ########################################## 372 | 373 | # All of the following must be PascalCase: 374 | # - Namespaces 375 | # https://docs.microsoft.com/dotnet/standard/design-guidelines/names-of-namespaces 376 | # https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1300.md 377 | # - Classes and Enumerations 378 | # https://docs.microsoft.com/dotnet/standard/design-guidelines/names-of-classes-structs-and-interfaces 379 | # https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1300.md 380 | # - Delegates 381 | # https://docs.microsoft.com/dotnet/standard/design-guidelines/names-of-classes-structs-and-interfaces#names-of-common-types 382 | # - Constructors, Properties, Events, Methods 383 | # https://docs.microsoft.com/dotnet/standard/design-guidelines/names-of-type-members 384 | dotnet_naming_symbols.element_group.applicable_kinds = namespace, class, enum, struct, delegate, event, method, property 385 | dotnet_naming_rule.element_rule.symbols = element_group 386 | dotnet_naming_rule.element_rule.style = pascal_case_style 387 | dotnet_naming_rule.element_rule.severity = warning 388 | 389 | # Interfaces use PascalCase and are prefixed with uppercase 'I' 390 | # https://docs.microsoft.com/dotnet/standard/design-guidelines/names-of-classes-structs-and-interfaces 391 | dotnet_naming_symbols.interface_group.applicable_kinds = interface 392 | dotnet_naming_rule.interface_rule.symbols = interface_group 393 | dotnet_naming_rule.interface_rule.style = prefix_interface_with_i_style 394 | dotnet_naming_rule.interface_rule.severity = warning 395 | 396 | # Generics Type Parameters use PascalCase and are prefixed with uppercase 'T' 397 | # https://docs.microsoft.com/dotnet/standard/design-guidelines/names-of-classes-structs-and-interfaces 398 | dotnet_naming_symbols.type_parameter_group.applicable_kinds = type_parameter 399 | dotnet_naming_rule.type_parameter_rule.symbols = type_parameter_group 400 | dotnet_naming_rule.type_parameter_rule.style = prefix_type_parameters_with_t_style 401 | dotnet_naming_rule.type_parameter_rule.severity = warning 402 | 403 | # Function parameters use camelCase 404 | # https://docs.microsoft.com/dotnet/standard/design-guidelines/naming-parameters 405 | dotnet_naming_symbols.parameters_group.applicable_kinds = parameter 406 | dotnet_naming_rule.parameters_rule.symbols = parameters_group 407 | dotnet_naming_rule.parameters_rule.style = camel_case_style 408 | dotnet_naming_rule.parameters_rule.severity = warning 409 | 410 | ########################################## 411 | # License 412 | ########################################## 413 | # The following applies as to the .editorconfig file ONLY, and is 414 | # included below for reference, per the requirements of the license 415 | # corresponding to this .editorconfig file. 416 | # See: https://github.com/RehanSaeed/EditorConfig 417 | # 418 | # MIT License 419 | # 420 | # Copyright (c) 2017-2019 Muhammad Rehan Saeed 421 | # Copyright (c) 2019 Henry Gabryjelski 422 | # 423 | # Permission is hereby granted, free of charge, to any 424 | # person obtaining a copy of this software and associated 425 | # documentation files (the "Software"), to deal in the 426 | # Software without restriction, including without limitation 427 | # the rights to use, copy, modify, merge, publish, distribute, 428 | # sublicense, and/or sell copies of the Software, and to permit 429 | # persons to whom the Software is furnished to do so, subject 430 | # to the following conditions: 431 | # 432 | # The above copyright notice and this permission notice shall be 433 | # included in all copies or substantial portions of the Software. 434 | # 435 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 436 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 437 | # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 438 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 439 | # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 440 | # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 441 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 442 | # OTHER DEALINGS IN THE SOFTWARE. 443 | ########################################## 444 | --------------------------------------------------------------------------------