From c8f9f3712fdf33f8bb0289e736e32179a0cce7ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BB=D0=B0=D0=B1=D1=83=D0=BA=D0=BE=D0=B2=20=D0=AD?= =?UTF-8?q?=D1=80=D0=B8=D0=BA=20=D0=9A=D0=BE=D0=BD=D1=81=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D1=82=D0=B8=D0=BD=D0=BE=D0=B2=D0=B8=D1=87?= Date: Sun, 29 Mar 2026 00:03:09 +0500 Subject: [PATCH 1/8] MongoDB Job Scheduler init --- Influunt.sln | 70 +++ .../Influunt.Scheduler.Abstractions.csproj | 7 + .../Models/ExecutionStatus.cs | 9 + .../Models/IJob.cs | 10 + .../Models/JobDefinition.cs | 11 + .../Models/JobExecution.cs | 15 + .../Models/JobTrigger.cs | 13 + .../Services/IScheduler.cs | 15 + src/Influunt.Scheduler/CronHelper.cs | 12 + .../Influunt.Scheduler.csproj | 16 + .../JobDefinitionClassMap.cs | 19 + .../JobExecutionClassMap.cs | 20 + src/Influunt.Scheduler/JobScheduler.cs | 455 ++++++++++++++++++ src/Influunt.Scheduler/JobSchedulerOptions.cs | 19 + src/Influunt.Scheduler/JobTriggerClassMap.cs | 21 + src/Influunt.Scheduler/SchedulerModule.cs | 25 + 16 files changed, 737 insertions(+) create mode 100644 src/Influunt.Scheduler.Abstractions/Influunt.Scheduler.Abstractions.csproj create mode 100644 src/Influunt.Scheduler.Abstractions/Models/ExecutionStatus.cs create mode 100644 src/Influunt.Scheduler.Abstractions/Models/IJob.cs create mode 100644 src/Influunt.Scheduler.Abstractions/Models/JobDefinition.cs create mode 100644 src/Influunt.Scheduler.Abstractions/Models/JobExecution.cs create mode 100644 src/Influunt.Scheduler.Abstractions/Models/JobTrigger.cs create mode 100644 src/Influunt.Scheduler.Abstractions/Services/IScheduler.cs create mode 100644 src/Influunt.Scheduler/CronHelper.cs create mode 100644 src/Influunt.Scheduler/Influunt.Scheduler.csproj create mode 100644 src/Influunt.Scheduler/JobDefinitionClassMap.cs create mode 100644 src/Influunt.Scheduler/JobExecutionClassMap.cs create mode 100644 src/Influunt.Scheduler/JobScheduler.cs create mode 100644 src/Influunt.Scheduler/JobSchedulerOptions.cs create mode 100644 src/Influunt.Scheduler/JobTriggerClassMap.cs create mode 100644 src/Influunt.Scheduler/SchedulerModule.cs diff --git a/Influunt.sln b/Influunt.sln index b1b0565..46a831a 100644 --- a/Influunt.sln +++ b/Influunt.sln @@ -11,32 +11,102 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Influunt.Storage", "src\Inf EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Influunt.Feed.Rss", "src\Influunt.Feed.Rss\Influunt.Feed.Rss.csproj", "{C8997ACE-506F-470F-8BD5-EC955BE522E0}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Influunt.Scheduler", "src\Influunt.Scheduler\Influunt.Scheduler.csproj", "{44AC6B8E-C694-48E7-95B6-5481EB122559}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Influunt.Scheduler.Abstractions", "src\Influunt.Scheduler.Abstractions\Influunt.Scheduler.Abstractions.csproj", "{D065CA50-272A-4E29-8D75-3A373EBCB98B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {D5232164-9233-4B32-8022-75799161F113}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D5232164-9233-4B32-8022-75799161F113}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D5232164-9233-4B32-8022-75799161F113}.Debug|x64.ActiveCfg = Debug|Any CPU + {D5232164-9233-4B32-8022-75799161F113}.Debug|x64.Build.0 = Debug|Any CPU + {D5232164-9233-4B32-8022-75799161F113}.Debug|x86.ActiveCfg = Debug|Any CPU + {D5232164-9233-4B32-8022-75799161F113}.Debug|x86.Build.0 = Debug|Any CPU {D5232164-9233-4B32-8022-75799161F113}.Release|Any CPU.ActiveCfg = Release|Any CPU {D5232164-9233-4B32-8022-75799161F113}.Release|Any CPU.Build.0 = Release|Any CPU + {D5232164-9233-4B32-8022-75799161F113}.Release|x64.ActiveCfg = Release|Any CPU + {D5232164-9233-4B32-8022-75799161F113}.Release|x64.Build.0 = Release|Any CPU + {D5232164-9233-4B32-8022-75799161F113}.Release|x86.ActiveCfg = Release|Any CPU + {D5232164-9233-4B32-8022-75799161F113}.Release|x86.Build.0 = Release|Any CPU {82E2087D-00F6-47A4-90DE-47BFAF40DF39}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {82E2087D-00F6-47A4-90DE-47BFAF40DF39}.Debug|Any CPU.Build.0 = Debug|Any CPU + {82E2087D-00F6-47A4-90DE-47BFAF40DF39}.Debug|x64.ActiveCfg = Debug|Any CPU + {82E2087D-00F6-47A4-90DE-47BFAF40DF39}.Debug|x64.Build.0 = Debug|Any CPU + {82E2087D-00F6-47A4-90DE-47BFAF40DF39}.Debug|x86.ActiveCfg = Debug|Any CPU + {82E2087D-00F6-47A4-90DE-47BFAF40DF39}.Debug|x86.Build.0 = Debug|Any CPU {82E2087D-00F6-47A4-90DE-47BFAF40DF39}.Release|Any CPU.ActiveCfg = Release|Any CPU {82E2087D-00F6-47A4-90DE-47BFAF40DF39}.Release|Any CPU.Build.0 = Release|Any CPU + {82E2087D-00F6-47A4-90DE-47BFAF40DF39}.Release|x64.ActiveCfg = Release|Any CPU + {82E2087D-00F6-47A4-90DE-47BFAF40DF39}.Release|x64.Build.0 = Release|Any CPU + {82E2087D-00F6-47A4-90DE-47BFAF40DF39}.Release|x86.ActiveCfg = Release|Any CPU + {82E2087D-00F6-47A4-90DE-47BFAF40DF39}.Release|x86.Build.0 = Release|Any CPU {9F87FB47-0B6A-435A-BC52-5AE6AC25245C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9F87FB47-0B6A-435A-BC52-5AE6AC25245C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9F87FB47-0B6A-435A-BC52-5AE6AC25245C}.Debug|x64.ActiveCfg = Debug|Any CPU + {9F87FB47-0B6A-435A-BC52-5AE6AC25245C}.Debug|x64.Build.0 = Debug|Any CPU + {9F87FB47-0B6A-435A-BC52-5AE6AC25245C}.Debug|x86.ActiveCfg = Debug|Any CPU + {9F87FB47-0B6A-435A-BC52-5AE6AC25245C}.Debug|x86.Build.0 = Debug|Any CPU {9F87FB47-0B6A-435A-BC52-5AE6AC25245C}.Release|Any CPU.ActiveCfg = Release|Any CPU {9F87FB47-0B6A-435A-BC52-5AE6AC25245C}.Release|Any CPU.Build.0 = Release|Any CPU + {9F87FB47-0B6A-435A-BC52-5AE6AC25245C}.Release|x64.ActiveCfg = Release|Any CPU + {9F87FB47-0B6A-435A-BC52-5AE6AC25245C}.Release|x64.Build.0 = Release|Any CPU + {9F87FB47-0B6A-435A-BC52-5AE6AC25245C}.Release|x86.ActiveCfg = Release|Any CPU + {9F87FB47-0B6A-435A-BC52-5AE6AC25245C}.Release|x86.Build.0 = Release|Any CPU {C8997ACE-506F-470F-8BD5-EC955BE522E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C8997ACE-506F-470F-8BD5-EC955BE522E0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C8997ACE-506F-470F-8BD5-EC955BE522E0}.Debug|x64.ActiveCfg = Debug|Any CPU + {C8997ACE-506F-470F-8BD5-EC955BE522E0}.Debug|x64.Build.0 = Debug|Any CPU + {C8997ACE-506F-470F-8BD5-EC955BE522E0}.Debug|x86.ActiveCfg = Debug|Any CPU + {C8997ACE-506F-470F-8BD5-EC955BE522E0}.Debug|x86.Build.0 = Debug|Any CPU {C8997ACE-506F-470F-8BD5-EC955BE522E0}.Release|Any CPU.ActiveCfg = Release|Any CPU {C8997ACE-506F-470F-8BD5-EC955BE522E0}.Release|Any CPU.Build.0 = Release|Any CPU + {C8997ACE-506F-470F-8BD5-EC955BE522E0}.Release|x64.ActiveCfg = Release|Any CPU + {C8997ACE-506F-470F-8BD5-EC955BE522E0}.Release|x64.Build.0 = Release|Any CPU + {C8997ACE-506F-470F-8BD5-EC955BE522E0}.Release|x86.ActiveCfg = Release|Any CPU + {C8997ACE-506F-470F-8BD5-EC955BE522E0}.Release|x86.Build.0 = Release|Any CPU + {44AC6B8E-C694-48E7-95B6-5481EB122559}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {44AC6B8E-C694-48E7-95B6-5481EB122559}.Debug|Any CPU.Build.0 = Debug|Any CPU + {44AC6B8E-C694-48E7-95B6-5481EB122559}.Debug|x64.ActiveCfg = Debug|Any CPU + {44AC6B8E-C694-48E7-95B6-5481EB122559}.Debug|x64.Build.0 = Debug|Any CPU + {44AC6B8E-C694-48E7-95B6-5481EB122559}.Debug|x86.ActiveCfg = Debug|Any CPU + {44AC6B8E-C694-48E7-95B6-5481EB122559}.Debug|x86.Build.0 = Debug|Any CPU + {44AC6B8E-C694-48E7-95B6-5481EB122559}.Release|Any CPU.ActiveCfg = Release|Any CPU + {44AC6B8E-C694-48E7-95B6-5481EB122559}.Release|Any CPU.Build.0 = Release|Any CPU + {44AC6B8E-C694-48E7-95B6-5481EB122559}.Release|x64.ActiveCfg = Release|Any CPU + {44AC6B8E-C694-48E7-95B6-5481EB122559}.Release|x64.Build.0 = Release|Any CPU + {44AC6B8E-C694-48E7-95B6-5481EB122559}.Release|x86.ActiveCfg = Release|Any CPU + {44AC6B8E-C694-48E7-95B6-5481EB122559}.Release|x86.Build.0 = Release|Any CPU + {D065CA50-272A-4E29-8D75-3A373EBCB98B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D065CA50-272A-4E29-8D75-3A373EBCB98B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D065CA50-272A-4E29-8D75-3A373EBCB98B}.Debug|x64.ActiveCfg = Debug|Any CPU + {D065CA50-272A-4E29-8D75-3A373EBCB98B}.Debug|x64.Build.0 = Debug|Any CPU + {D065CA50-272A-4E29-8D75-3A373EBCB98B}.Debug|x86.ActiveCfg = Debug|Any CPU + {D065CA50-272A-4E29-8D75-3A373EBCB98B}.Debug|x86.Build.0 = Debug|Any CPU + {D065CA50-272A-4E29-8D75-3A373EBCB98B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D065CA50-272A-4E29-8D75-3A373EBCB98B}.Release|Any CPU.Build.0 = Release|Any CPU + {D065CA50-272A-4E29-8D75-3A373EBCB98B}.Release|x64.ActiveCfg = Release|Any CPU + {D065CA50-272A-4E29-8D75-3A373EBCB98B}.Release|x64.Build.0 = Release|Any CPU + {D065CA50-272A-4E29-8D75-3A373EBCB98B}.Release|x86.ActiveCfg = Release|Any CPU + {D065CA50-272A-4E29-8D75-3A373EBCB98B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {44AC6B8E-C694-48E7-95B6-5481EB122559} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {D065CA50-272A-4E29-8D75-3A373EBCB98B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {FDF77C48-6495-4B15-9513-34DB5A584086} EndGlobalSection diff --git a/src/Influunt.Scheduler.Abstractions/Influunt.Scheduler.Abstractions.csproj b/src/Influunt.Scheduler.Abstractions/Influunt.Scheduler.Abstractions.csproj new file mode 100644 index 0000000..a6b9113 --- /dev/null +++ b/src/Influunt.Scheduler.Abstractions/Influunt.Scheduler.Abstractions.csproj @@ -0,0 +1,7 @@ + + + net8.0 + enable + enable + + \ No newline at end of file diff --git a/src/Influunt.Scheduler.Abstractions/Models/ExecutionStatus.cs b/src/Influunt.Scheduler.Abstractions/Models/ExecutionStatus.cs new file mode 100644 index 0000000..a1e5aef --- /dev/null +++ b/src/Influunt.Scheduler.Abstractions/Models/ExecutionStatus.cs @@ -0,0 +1,9 @@ +namespace Influunt.Scheduler.Abstractions.Models; + +public enum ExecutionStatus +{ + Pending, // Ожидает выполнения (блокировка не получена) + Running, // Выполняется (блокировка получена) + Completed, // Успешно завершено + Failed // Завершено с ошибкой +} \ No newline at end of file diff --git a/src/Influunt.Scheduler.Abstractions/Models/IJob.cs b/src/Influunt.Scheduler.Abstractions/Models/IJob.cs new file mode 100644 index 0000000..c22e21b --- /dev/null +++ b/src/Influunt.Scheduler.Abstractions/Models/IJob.cs @@ -0,0 +1,10 @@ +namespace Influunt.Scheduler.Abstractions.Models; +public interface IJob +{ + Task ExecuteAsync(CancellationToken cancellationToken); +} + +public interface IJob +{ + Task ExecuteAsync(TSettings settings, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Influunt.Scheduler.Abstractions/Models/JobDefinition.cs b/src/Influunt.Scheduler.Abstractions/Models/JobDefinition.cs new file mode 100644 index 0000000..747af6b --- /dev/null +++ b/src/Influunt.Scheduler.Abstractions/Models/JobDefinition.cs @@ -0,0 +1,11 @@ +namespace Influunt.Scheduler.Abstractions.Models; + +public class JobDefinition +{ + public string Id { get; set; } + public string Name { get; set; } + public string? Description { get; set; } + public string JobType { get; set; } + public string SettingsType { get; set; } + public bool AllowConcurrentExecution { get; set; } +} \ No newline at end of file diff --git a/src/Influunt.Scheduler.Abstractions/Models/JobExecution.cs b/src/Influunt.Scheduler.Abstractions/Models/JobExecution.cs new file mode 100644 index 0000000..1e435b1 --- /dev/null +++ b/src/Influunt.Scheduler.Abstractions/Models/JobExecution.cs @@ -0,0 +1,15 @@ +namespace Influunt.Scheduler.Abstractions.Models; + +public class JobExecution +{ + public string Id { get; set; } + public string TriggerId { get; set; } + public string JobType { get; set; } + public ExecutionStatus Status { get; set; } + public DateTime StartTime { get; set; } + public DateTime? EndTime { get; set; } + public string Result { get; set; } + public string LockId { get; set; } + public DateTime LockExpiry { get; set; } + public DateTime LastHeartbeat { get; set; } +} \ No newline at end of file diff --git a/src/Influunt.Scheduler.Abstractions/Models/JobTrigger.cs b/src/Influunt.Scheduler.Abstractions/Models/JobTrigger.cs new file mode 100644 index 0000000..baea87a --- /dev/null +++ b/src/Influunt.Scheduler.Abstractions/Models/JobTrigger.cs @@ -0,0 +1,13 @@ +namespace Influunt.Scheduler +{ + public class JobTrigger + { + public string Id { get; set; } + public string TriggerId { get; set; } + public string JobType { get; set; } + public DateTime NextRunTime { get; set; } + public bool IsRecurring => !string.IsNullOrEmpty(CronExpression); + public string? CronExpression { get; set; } + public string? SettingsJson { get; set; } + } +} \ No newline at end of file diff --git a/src/Influunt.Scheduler.Abstractions/Services/IScheduler.cs b/src/Influunt.Scheduler.Abstractions/Services/IScheduler.cs new file mode 100644 index 0000000..057e0f1 --- /dev/null +++ b/src/Influunt.Scheduler.Abstractions/Services/IScheduler.cs @@ -0,0 +1,15 @@ +using Influunt.Scheduler.Abstractions.Models; + +namespace Influunt.Scheduler.Abstractions.Services; +public interface IScheduler +{ + Task RegisterJobAsync(string name, string? description = null, bool allowConcurrentExecution = false) where TJob : IJob; + Task RegisterJobAsync(string name, string? description = null, bool allowConcurrentExecution = false) where TJob : IJob; + Task ScheduleOneTimeJobAsync(DateTime? runAt = null, string? triggerId = null) where TJob : IJob; + Task ScheduleRecurringJobAsync(string cronExpression, string? triggerId = null) where TJob : IJob; + Task ScheduleOneTimeJobAsync(TSettings settings, DateTime? runAt = null, string? triggerId = null) where TJob : IJob; + Task ScheduleRecurringJobAsync(string cronExpression, TSettings settings, string? triggerId = null) where TJob : IJob; + Task UnScheduleJobAsync(string triggerId); + Task> GetRegisteredJobsAsync(); + Task> GetScheduledTriggersAsync(); +} \ No newline at end of file diff --git a/src/Influunt.Scheduler/CronHelper.cs b/src/Influunt.Scheduler/CronHelper.cs new file mode 100644 index 0000000..6c7ca2a --- /dev/null +++ b/src/Influunt.Scheduler/CronHelper.cs @@ -0,0 +1,12 @@ +using Cronos; + +namespace Influunt.Scheduler; + +public static class CronHelper +{ + public static DateTime? GetNextOccurrence(string cronExpression, DateTime currentTime) + { + CronExpression expression = CronExpression.Parse(cronExpression); + return expression.GetNextOccurrence(currentTime); + } +} diff --git a/src/Influunt.Scheduler/Influunt.Scheduler.csproj b/src/Influunt.Scheduler/Influunt.Scheduler.csproj new file mode 100644 index 0000000..409783a --- /dev/null +++ b/src/Influunt.Scheduler/Influunt.Scheduler.csproj @@ -0,0 +1,16 @@ + + + net8.0 + enable + enable + + + + + + + + + + + \ No newline at end of file diff --git a/src/Influunt.Scheduler/JobDefinitionClassMap.cs b/src/Influunt.Scheduler/JobDefinitionClassMap.cs new file mode 100644 index 0000000..fe5f9cf --- /dev/null +++ b/src/Influunt.Scheduler/JobDefinitionClassMap.cs @@ -0,0 +1,19 @@ +using Influunt.Scheduler.Abstractions.Models; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.IdGenerators; +using MongoDB.Bson.Serialization.Serializers; + +namespace Influunt.Scheduler; + +public class JobDefinitionClassMap : BsonClassMap +{ + public JobDefinitionClassMap() + { + AutoMap(); + MapIdMember(x => x.Id) + .SetIdGenerator(StringObjectIdGenerator.Instance) + .SetSerializer(new StringSerializer(BsonType.ObjectId)); + SetIgnoreExtraElements(true); + } +} diff --git a/src/Influunt.Scheduler/JobExecutionClassMap.cs b/src/Influunt.Scheduler/JobExecutionClassMap.cs new file mode 100644 index 0000000..7dbd238 --- /dev/null +++ b/src/Influunt.Scheduler/JobExecutionClassMap.cs @@ -0,0 +1,20 @@ +using Influunt.Scheduler.Abstractions.Models; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Conventions; +using MongoDB.Bson.Serialization.IdGenerators; +using MongoDB.Bson.Serialization.Serializers; + +namespace Influunt.Scheduler; + +public class JobExecutionClassMap : BsonClassMap +{ + public JobExecutionClassMap() + { + AutoMap(); + MapIdMember(x => x.Id) + .SetIdGenerator(StringObjectIdGenerator.Instance) + .SetSerializer(new StringSerializer(BsonType.ObjectId)); + SetIgnoreExtraElements(true); + } +} diff --git a/src/Influunt.Scheduler/JobScheduler.cs b/src/Influunt.Scheduler/JobScheduler.cs new file mode 100644 index 0000000..2fb0f69 --- /dev/null +++ b/src/Influunt.Scheduler/JobScheduler.cs @@ -0,0 +1,455 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Influunt.Scheduler.Abstractions.Models; +using Influunt.Scheduler.Abstractions.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using MongoDB.Driver; + +namespace Influunt.Scheduler; + + internal class JobScheduler : IScheduler, IHostedService, IDisposable +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly JobSchedulerOptions _options; + private readonly MongoUrl _url; + private readonly Lazy _mongoClient; + private IMongoDatabase _database; + private readonly string _instanceId; // уникальный идентификатор экземпляра планировщика + private readonly ConcurrentDictionary _runningJobs = new(); + private Timer _scanTimer; + private Timer _heartbeatTimer; + private SemaphoreSlim _concurrencySemaphore; + private bool _disposed; + + public JobScheduler(IServiceProvider serviceProvider, IOptions options, ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + _options = options.Value; + + _url = MongoUrl.Create(_options.ConnectionString); + _mongoClient = new Lazy(() => new MongoClient(_url)); + + _instanceId = $"{Environment.MachineName}_{Guid.NewGuid():N}"; + _concurrencySemaphore = new SemaphoreSlim(_options.MaxConcurrentJobs, _options.MaxConcurrentJobs); + } + + private IMongoDatabase Database => _database ??= _mongoClient.Value.GetDatabase(_url.DatabaseName); + private IMongoCollection _jobDefinitions => Database.GetCollection("job_definitions"); + private IMongoCollection _triggers => Database.GetCollection("job_triggers"); + private IMongoCollection _executions => Database.GetCollection("job_executions"); + + public Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("JobScheduler starting. InstanceId: {InstanceId}", _instanceId); + + // Запускаем создание индексов + Task.Factory.StartNew(CreateIndexesAsync, TaskCreationOptions.LongRunning); + // Запускаем таймеры + _scanTimer = new Timer(ScanTriggers, null, TimeSpan.Zero, TimeSpan.FromSeconds(_options.ScanIntervalSeconds)); + _heartbeatTimer = new Timer(Heartbeat, null, TimeSpan.FromSeconds(_options.HeartbeatIntervalSeconds), TimeSpan.FromSeconds(_options.HeartbeatIntervalSeconds)); + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("JobScheduler stopping..."); + + _scanTimer?.Change(Timeout.Infinite, 0); + _heartbeatTimer?.Change(Timeout.Infinite, 0); + + // Отменяем все запущенные задания + foreach (var cts in _runningJobs.Values) + cts.Cancel(); + + // Ждём завершения всех заданий (упрощённо) + // В реальном проекте нужно более тщательно ожидать завершения + return Task.CompletedTask; + } + + public async Task RegisterJobAsync(string name, string? description = null, bool allowConcurrentExecution = false) + where TJob : IJob + { + var jobDef = new JobDefinition + { + Name = name, + Description = description, + JobType = typeof(TJob)!.AssemblyQualifiedName!, + SettingsType = typeof(TSettings)!.AssemblyQualifiedName!, + AllowConcurrentExecution = allowConcurrentExecution + }; + await _jobDefinitions.ReplaceOneAsync(d => d.Name == name, jobDef, new ReplaceOptions { IsUpsert = true }); + } + + public async Task RegisterJobAsync(string name, string? description = null, bool allowConcurrentExecution = false) + where TJob : IJob + { + var jobDef = new JobDefinition + { + Name = name, + Description = description, + JobType = typeof(TJob)!.AssemblyQualifiedName!, + AllowConcurrentExecution = allowConcurrentExecution + }; + await _jobDefinitions.ReplaceOneAsync(d => d.Name == name, jobDef, new ReplaceOptions { IsUpsert = true }); + } + + public async Task ScheduleOneTimeJobAsync(DateTime? runAt = null, string? triggerId = null) where TJob : IJob + { + var cursor = await _jobDefinitions.FindAsync(x => x.JobType == typeof(TJob).AssemblyQualifiedName); + var registeredJob = await cursor.FirstOrDefaultAsync(); + if (registeredJob is null) + throw new InvalidOperationException("Job not registered"); + + var trigger = new JobTrigger + { + TriggerId = string.IsNullOrEmpty(triggerId) ? Guid.NewGuid().ToString() : triggerId, + NextRunTime = runAt.HasValue ? runAt.Value.ToUniversalTime() : DateTime.UtcNow + }; + await _triggers.InsertOneAsync(trigger); + return trigger.TriggerId; + } + + public async Task ScheduleRecurringJobAsync(string cronExpression, string? triggerId = null) where TJob : IJob + { + var cursor = await _jobDefinitions.FindAsync(x => x.JobType == typeof(TJob).AssemblyQualifiedName); + var registeredJob = await cursor.FirstOrDefaultAsync(); + if (registeredJob is null) + throw new InvalidOperationException("Job not registered"); + + var nextRun = CronHelper.GetNextOccurrence(cronExpression, DateTime.UtcNow); + if (!nextRun.HasValue) + throw new ArgumentException("Cron expression does not produce any future occurrence"); + + var trigger = new JobTrigger + { + TriggerId = string.IsNullOrEmpty(triggerId) ? Guid.NewGuid().ToString() : triggerId, + NextRunTime = nextRun.Value, + CronExpression = cronExpression + }; + await _triggers.InsertOneAsync(trigger); + return trigger.TriggerId; + } + + public async Task ScheduleOneTimeJobAsync(TSettings settings, DateTime? runAt = null, string? triggerId = null) where TJob : IJob + { + var cursor = await _jobDefinitions.FindAsync(x => x.JobType == typeof(TJob).AssemblyQualifiedName); + var registeredJob = await cursor.FirstOrDefaultAsync(); + if (registeredJob is null) + throw new InvalidOperationException("Job not registered"); + if (registeredJob.SettingsType != typeof(TSettings).AssemblyQualifiedName) + throw new InvalidOperationException("The task settings type does not match the registered type."); + + var trigger = new JobTrigger + { + TriggerId = string.IsNullOrEmpty(triggerId) ? Guid.NewGuid().ToString() : triggerId, + NextRunTime = runAt.HasValue ? runAt.Value.ToUniversalTime() : DateTime.UtcNow, + SettingsJson = JsonSerializer.Serialize(settings) + }; + await _triggers.InsertOneAsync(trigger); + return trigger.TriggerId; + } + + public async Task ScheduleRecurringJobAsync(string cronExpression, TSettings settings, string? triggerId = null) where TJob : IJob + { + var cursor = await _jobDefinitions.FindAsync(x => x.JobType == typeof(TJob).AssemblyQualifiedName); + var registeredJob = await cursor.FirstOrDefaultAsync(); + if (registeredJob is null) + throw new InvalidOperationException("Job not registered"); + if (registeredJob.SettingsType != typeof(TSettings).AssemblyQualifiedName) + throw new InvalidOperationException("The task settings type does not match the registered type."); + + var nextRun = CronHelper.GetNextOccurrence(cronExpression, DateTime.UtcNow); + if (!nextRun.HasValue) + throw new ArgumentException("Cron expression does not produce any future occurrence"); + + var trigger = new JobTrigger + { + TriggerId = string.IsNullOrEmpty(triggerId) ? Guid.NewGuid().ToString() : triggerId, + NextRunTime = nextRun.Value, + CronExpression = cronExpression, + SettingsJson = JsonSerializer.Serialize(settings) + }; + await _triggers.InsertOneAsync(trigger); + return trigger.TriggerId; + } + + public async Task UnScheduleJobAsync(string triggerId) + { + await _triggers.DeleteOneAsync(x => x.TriggerId == triggerId); + } + + public async Task> GetRegisteredJobsAsync() + { + var cursor = await _jobDefinitions.FindAsync(x => true); + return await cursor.ToListAsync(); + } + + public async Task> GetScheduledTriggersAsync() + { + var cursor = await _triggers.FindAsync(x => true); + return await cursor.ToListAsync(); + } + + // ============================== Сканирование триггеров ============================== + + private async void ScanTriggers(object? state) + { + try + { + var now = DateTime.UtcNow; + // Находим триггеры, у которых NextRunTime <= now и которые активны + var filter = Builders.Filter.And( + Builders.Filter.Lte(t => t.NextRunTime, now)); + var triggers = await _triggers.Find(filter).ToListAsync(); + + foreach (var trigger in triggers) + { + // Для каждого триггера пытаемся создать выполнение + await ScheduleJobAsync(trigger); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error scanning triggers"); + } + } + + private async Task ScheduleJobAsync(JobTrigger trigger) + { + // Проверяем, не запущено ли уже задание с такой же блокировкой (если запрещён конкурентный запуск) + var jobDef = await _jobDefinitions.Find(d => d.JobType == trigger.JobType).FirstOrDefaultAsync(); + if (jobDef == null) + { + _logger.LogWarning("Job definition not found for trigger {TriggerId}", trigger.Id); + return; + } + + // Если конкурентный запуск запрещён, проверяем наличие выполняющихся экземпляров + if (!jobDef.AllowConcurrentExecution) + { + var runningFilter = Builders.Filter.And( + Builders.Filter.Eq(e => e.JobType, trigger.JobType), + Builders.Filter.Eq(e => e.Status, ExecutionStatus.Running)); + var runningCount = await _executions.CountDocumentsAsync(runningFilter); + if (runningCount > 0) + { + _logger.LogDebug("Job '{JobName}' with type '{JobType}' is already running and concurrent execution is disabled. Can't run trigger {TriggerId}", + jobDef.Name, jobDef.JobType, trigger.TriggerId ); + return; + } + } + + // TODO: Надо решить проблему зависшего триггера + // Создаём запись выполнения с блокировкой (атомарно) + var execution = new JobExecution + { + TriggerId = trigger.TriggerId, + JobType = trigger.JobType, + Status = ExecutionStatus.Pending, + StartTime = DateTime.UtcNow, + LockId = null, + LockExpiry = DateTime.UtcNow, + LastHeartbeat = DateTime.UtcNow + }; + + await _executions.InsertOneAsync(execution); + + // Пытаемся вставить документ и сразу захватить блокировку + // Используем FindOneAndUpdate с условием, что блокировка ещё не получена + var lockExpiry = DateTime.UtcNow.AddSeconds(_options.LockTimeoutSeconds); + var filter = Builders.Filter.And( + Builders.Filter.Eq(e => e.Id, execution.Id), // только что созданный Id + Builders.Filter.Eq(e => e.Status, ExecutionStatus.Pending), + Builders.Filter.Or( + Builders.Filter.Eq(e => e.LockId, null), + Builders.Filter.Lt(e => e.LockExpiry, DateTime.UtcNow) + ) + ); + var update = Builders.Update + .Set(e => e.Status, ExecutionStatus.Running) + .Set(e => e.LockId, _instanceId) + .Set(e => e.LockExpiry, lockExpiry) + .Set(e => e.LastHeartbeat, DateTime.UtcNow); + + var updated = await _executions.FindOneAndUpdateAsync(filter, update); + if (updated == null) + { + // Не удалось захватить блокировку — удаляем запись или оставляем как есть (другая реплика уже взяла) + await _executions.DeleteOneAsync(e => e.Id == execution.Id); + _logger.LogDebug("Failed to acquire lock for trigger {TriggerId}", trigger.TriggerId); + return; + } + + // Блокировка получена — запускаем выполнение + _logger.LogInformation("Starting job {JobName}. TriggerId: {TriggerId}. ExecutionId: {ExecutionId}", jobDef.Name, trigger.TriggerId, execution.Id); + var cts = new CancellationTokenSource(); + _runningJobs.TryAdd(execution.Id, cts); + + // Запускаем асинхронное выполнение + _ = Task.Run(() => ExecuteJobAsync(trigger, execution, jobDef, cts.Token)); + } + + private async Task ExecuteJobAsync(JobTrigger trigger, JobExecution execution, JobDefinition jobDef, CancellationToken cancellationToken) + { + try + { + // Получаем экземпляр задания из DI (transient или scoped) + using var scope = _serviceProvider.CreateScope(); + var jobType = Type.GetType(jobDef.JobType) ?? throw new InvalidOperationException($"Cannot load job type {jobDef.JobType}"); + + // Проверяем, есть ли настройки + if (string.IsNullOrEmpty(jobDef.SettingsType)) + { + // Задание без настроек + var job = scope.ServiceProvider.GetRequiredService(jobType) as IJob; + if (job == null) + throw new InvalidOperationException($"Job {jobDef.JobType} does not implement IJob"); + await job.ExecuteAsync(cancellationToken); + } + else + { + // Задание с настройками + var settingsType = Type.GetType(jobDef.SettingsType); + if (settingsType == null) + throw new InvalidOperationException($"Cannot load settings type {jobDef.SettingsType}"); + + var settings = string.IsNullOrEmpty(trigger.SettingsJson) + ? Activator.CreateInstance(settingsType) + : JsonSerializer.Deserialize(trigger.SettingsJson, settingsType); + + var jobTypeGeneric = typeof(IJob<>).MakeGenericType(settingsType); + var job = scope.ServiceProvider.GetRequiredService(jobType); + var method = jobType.GetMethod("ExecuteAsync"); + if (method == null) + throw new InvalidOperationException($"ExecuteAsync method not found on {jobDef.JobType}"); + await (Task)method.Invoke(job, [settings, cancellationToken]); + } + + // Успешное завершение + await UpdateExecutionStatus(execution.Id, ExecutionStatus.Completed, result: "Success"); + // Обновляем время следующего запуска триггера + await UpdateTriggerNextRun(trigger); + _logger.LogInformation("Job {JobName} (ExecutionId: {ExecutionId}) completed successfully", jobDef.Name, execution.Id); + } + catch (OperationCanceledException) + { + await UpdateExecutionStatus(execution.Id, ExecutionStatus.Failed, result: "Cancelled"); + _logger.LogWarning("Job {JobName} (TriggerId: {TriggerId}; ExecutionId: {ExecutionId}) was cancelled", jobDef.Name, trigger.TriggerId, execution.Id); + } + catch (Exception ex) + { + await UpdateExecutionStatus(execution.Id, ExecutionStatus.Failed, result: ex.ToString()); + _logger.LogError(ex, "Job {JobName} (TriggerId: {TriggerId}; ExecutionId: {ExecutionId}) failed", jobDef.Name, trigger.TriggerId, execution.Id); + } + finally + { + _runningJobs.TryRemove(execution.Id, out _); + _concurrencySemaphore.Release(); + } + } + + private async Task UpdateExecutionStatus(string executionId, ExecutionStatus status, string result) + { + var update = Builders.Update + .Set(e => e.Status, status) + .Set(e => e.EndTime, DateTime.UtcNow) + .Set(e => e.Result, result) + .Unset(e => e.LockId) // снимаем блокировку + .Unset(e => e.LockExpiry); + await _executions.UpdateOneAsync(e => e.Id == executionId, update); + } + + private async Task UpdateTriggerNextRun(JobTrigger trigger) + { + if (!trigger.IsRecurring || string.IsNullOrEmpty(trigger.CronExpression)) + { + // Одноразовый триггер — удаляем + await _triggers.DeleteOneAsync(t => t.Id == trigger.Id); + return; + } + + // Вычисляем следующее время запуска на основе Cron + var nextRun = CronHelper.GetNextOccurrence(trigger.CronExpression, DateTime.UtcNow); + if (!nextRun.HasValue) + { + // Cron больше не сработает (например, истёк) — удаляем + await _triggers.DeleteOneAsync(t => t.Id == trigger.Id); + return; + } + + await _triggers.UpdateOneAsync(t => t.Id == trigger.Id, + Builders.Update.Set(t => t.NextRunTime, nextRun.Value)); + } + + // ============================== Heartbeat ============================== + + private async void Heartbeat(object? state) + { + try + { + var now = DateTime.UtcNow; + var newExpiry = now.AddSeconds(_options.LockTimeoutSeconds); + + // Находим все выполнения, которые принадлежат этому экземпляру и находятся в статусе Running + var filter = Builders.Filter.And( + Builders.Filter.Eq(e => e.LockId, _instanceId), + Builders.Filter.Eq(e => e.Status, ExecutionStatus.Running)); + + var update = Builders.Update + .Set(e => e.LockExpiry, newExpiry) + .Set(e => e.LastHeartbeat, now); + + var result = await _executions.UpdateManyAsync(filter, update); + _logger.LogDebug("Heartbeat: updated {Count} executions", result.ModifiedCount); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during heartbeat"); + } + } + + private async Task CreateIndexesAsync() + { + // Индекс по имени задания + await _jobDefinitions.Indexes.CreateOneAsync( + new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.Name), + new CreateIndexOptions { Unique = true, Background = true })); + // Индекс по типу задания + await _jobDefinitions.Indexes.CreateOneAsync( + new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.JobType), + new CreateIndexOptions { Unique = true, Background = true })); + + // Индекс для поиска триггеров по следующему времени запуска + await _triggers.Indexes.CreateOneAsync( + new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.NextRunTime), + new CreateIndexOptions { Background = true })); + + // Индекс поиска триггера по JobName + await _triggers.Indexes.CreateOneAsync( + new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.TriggerId), + new CreateIndexOptions { Unique = true, Background = true })); + + // Индекс для поиска выполняющихся заданий по блокировке + await _executions.Indexes.CreateOneAsync( + new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.LockId).Ascending(x => x.Status), + new CreateIndexOptions { Background = true })); + await _executions.Indexes.CreateOneAsync( + new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.JobType).Ascending(x => x.Status), + new CreateIndexOptions { Background = true })); + } + + public void Dispose() + { + if (_disposed) return; + _scanTimer?.Dispose(); + _heartbeatTimer?.Dispose(); + _concurrencySemaphore?.Dispose(); + _disposed = true; + } +} \ No newline at end of file diff --git a/src/Influunt.Scheduler/JobSchedulerOptions.cs b/src/Influunt.Scheduler/JobSchedulerOptions.cs new file mode 100644 index 0000000..4e77dff --- /dev/null +++ b/src/Influunt.Scheduler/JobSchedulerOptions.cs @@ -0,0 +1,19 @@ +namespace Influunt.Scheduler; + + public class JobSchedulerOptions + { + /// Строка подключения к MongoDB. + public string ConnectionString { get; set; } = "mongodb://localhost:27017/JobScheduler"; + + /// Интервал сканирования триггеров (сек). + public int ScanIntervalSeconds { get; set; } = 60; + + /// Время жизни блокировки (сек). + public int LockTimeoutSeconds { get; set; } = 300; + + /// Интервал обновления heartbeat (сек). + public int HeartbeatIntervalSeconds { get; set; } = 60; + + /// Максимальное количество одновременно выполняемых заданий. + public int MaxConcurrentJobs { get; set; } = 10; + } \ No newline at end of file diff --git a/src/Influunt.Scheduler/JobTriggerClassMap.cs b/src/Influunt.Scheduler/JobTriggerClassMap.cs new file mode 100644 index 0000000..2f6e913 --- /dev/null +++ b/src/Influunt.Scheduler/JobTriggerClassMap.cs @@ -0,0 +1,21 @@ +using Influunt.Scheduler.Abstractions.Models; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Conventions; +using MongoDB.Bson.Serialization.IdGenerators; +using MongoDB.Bson.Serialization.Serializers; + +namespace Influunt.Scheduler; + +public class JobTriggerClassMap : BsonClassMap +{ + public JobTriggerClassMap() + { + AutoMap(); + MapIdMember(x => x.Id) + .SetIdGenerator(StringObjectIdGenerator.Instance) + .SetSerializer(new StringSerializer(BsonType.ObjectId)); + UnmapMember(x => x.IsRecurring); + SetIgnoreExtraElements(true); + } +} diff --git a/src/Influunt.Scheduler/SchedulerModule.cs b/src/Influunt.Scheduler/SchedulerModule.cs new file mode 100644 index 0000000..07c5ae2 --- /dev/null +++ b/src/Influunt.Scheduler/SchedulerModule.cs @@ -0,0 +1,25 @@ +using Influunt.Scheduler.Abstractions.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using MongoDB.Bson.Serialization; +using MongoDB.Driver; + +namespace Influunt.Scheduler; + +public static class SchedulerModule +{ + public static IServiceCollection AddJobScheduler(this IServiceCollection services, Action configureOptions) + { + services.Configure(configureOptions); + services.AddSingleton(); + return services; + } + + public static IServiceCollection AddJobSchedulerHost(this IServiceCollection services, Action configureOptions) + { + services.AddJobScheduler(configureOptions); + services.AddSingleton(s => (JobScheduler)s.GetRequiredService()); + BsonClassMap.RegisterClassMap(new JobDefinitionClassMap()); + return services; + } +} \ No newline at end of file From ea09399837f1d564f9edcf9fa3bfd03ca4b34da8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BB=D0=B0=D0=B1=D1=83=D0=BA=D0=BE=D0=B2=20=D0=AD?= =?UTF-8?q?=D1=80=D0=B8=D0=BA=20=D0=9A=D0=BE=D0=BD=D1=81=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D1=82=D0=B8=D0=BD=D0=BE=D0=B2=D0=B8=D1=87?= Date: Wed, 1 Apr 2026 00:04:48 +0500 Subject: [PATCH 2/8] Second stage scheduler implementation --- src/Influunt.Scheduler/Constants.cs | 22 ++ src/Influunt.Scheduler/IMongoContext.cs | 27 +++ .../IndexCreationBackgroundService.cs | 161 ++++++++++++++ src/Influunt.Scheduler/JobScheduler.cs | 199 +++++++++++------- src/Influunt.Scheduler/MongoContext.cs | 91 ++++++++ src/Influunt.Scheduler/SchedulerModule.cs | 17 +- src/Influunt.Storage/IMongoContext.cs | 27 +++ src/Influunt.Storage/MongoContext.cs | 88 ++++++++ 8 files changed, 554 insertions(+), 78 deletions(-) create mode 100644 src/Influunt.Scheduler/Constants.cs create mode 100644 src/Influunt.Scheduler/IMongoContext.cs create mode 100644 src/Influunt.Scheduler/IndexCreationBackgroundService.cs create mode 100644 src/Influunt.Scheduler/MongoContext.cs create mode 100644 src/Influunt.Storage/IMongoContext.cs create mode 100644 src/Influunt.Storage/MongoContext.cs diff --git a/src/Influunt.Scheduler/Constants.cs b/src/Influunt.Scheduler/Constants.cs new file mode 100644 index 0000000..c9c60c1 --- /dev/null +++ b/src/Influunt.Scheduler/Constants.cs @@ -0,0 +1,22 @@ +namespace Influunt.Scheduler; + +/// +/// Константы для модуля планировщика заданий. +/// +public static class Constants +{ + /// + /// Имя коллекции определений заданий. + /// + public const string JobDefinitionsCollectionName = "job_definitions"; + + /// + /// Имя коллекции триггеров заданий. + /// + public const string JobTriggersCollectionName = "job_triggers"; + + /// + /// Имя коллекции выполнений заданий. + /// + public const string JobExecutionsCollectionName = "job_executions"; +} \ No newline at end of file diff --git a/src/Influunt.Scheduler/IMongoContext.cs b/src/Influunt.Scheduler/IMongoContext.cs new file mode 100644 index 0000000..9562283 --- /dev/null +++ b/src/Influunt.Scheduler/IMongoContext.cs @@ -0,0 +1,27 @@ +using MongoDB.Driver; + +namespace Influunt.Scheduler; + +/// +/// Интерфейс для работы с MongoDB контекстом +/// +public interface IMongoContext +{ + /// + /// Получает базу данных MongoDB + /// + IMongoDatabase Database { get; } + + /// + /// Получает коллекцию указанного типа + /// + /// Тип документов коллекции + /// Имя коллекции (если null, будет использовано имя типа) + /// Коллекция MongoDB + IMongoCollection GetCollection(string collectionName = null); + + /// + /// Освобождает ресурсы + /// + void Dispose(); +} \ No newline at end of file diff --git a/src/Influunt.Scheduler/IndexCreationBackgroundService.cs b/src/Influunt.Scheduler/IndexCreationBackgroundService.cs new file mode 100644 index 0000000..ef952c6 --- /dev/null +++ b/src/Influunt.Scheduler/IndexCreationBackgroundService.cs @@ -0,0 +1,161 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Influunt.Scheduler.Abstractions.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using MongoDB.Driver; + +namespace Influunt.Scheduler; + +/// +/// Background service для создания индексов MongoDB для модуля планировщика. +/// Выполняется однократно при запуске приложения. +/// +public class IndexCreationBackgroundService : BackgroundService +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public IndexCreationBackgroundService( + IServiceProvider serviceProvider, + ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Index Creation Background Service запущен."); + + try + { + await CreateIndexesAsync(stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Ошибка при создании индексов MongoDB"); + throw; + } + finally + { + _logger.LogInformation("Index Creation Background Service завершил работу."); + } + } + + /// + /// Создает индексы для коллекций планировщика. + /// + private async Task CreateIndexesAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Начинаем создание индексов MongoDB для модуля планировщика..."); + + // Используем скоуп для получения зависимостей с правильным временем жизни + using var scope = _serviceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + await CreateJobDefinitionIndexesAsync(context, cancellationToken); + await CreateJobTriggerIndexesAsync(context, cancellationToken); + await CreateJobExecutionIndexesAsync(context, cancellationToken); + + _logger.LogInformation("Создание индексов MongoDB для модуля планировщика завершено."); + } + + private async Task CreateJobDefinitionIndexesAsync(IMongoContext context, CancellationToken cancellationToken) + { + var collection = context.GetCollection(Constants.JobDefinitionsCollectionName); + + // Уникальный индекс по имени задания + await collection.Indexes.CreateOneAsync( + new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.Name), + new CreateIndexOptions { Unique = true, Background = true }), + cancellationToken: cancellationToken); + + _logger.LogDebug("Создан уникальный индекс JobDefinition.Name"); + + // Уникальный индекс по типу задания + await collection.Indexes.CreateOneAsync( + new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.JobType), + new CreateIndexOptions { Unique = true, Background = true }), + cancellationToken: cancellationToken); + + _logger.LogDebug("Создан уникальный индекс JobDefinition.JobType"); + } + + private async Task CreateJobTriggerIndexesAsync(IMongoContext context, CancellationToken cancellationToken) + { + var collection = context.GetCollection(Constants.JobTriggersCollectionName); + + // Индекс для поиска триггеров по следующему времени запуска + await collection.Indexes.CreateOneAsync( + new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.NextRunTime), + new CreateIndexOptions { Background = true }), + cancellationToken: cancellationToken); + + _logger.LogDebug("Создан индекс JobTrigger.NextRunTime"); + + // Уникальный индекс по идентификатору триггера + await collection.Indexes.CreateOneAsync( + new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.TriggerId), + new CreateIndexOptions { Unique = true, Background = true }), + cancellationToken: cancellationToken); + + _logger.LogDebug("Создан уникальный индекс JobTrigger.TriggerId"); + } + + private async Task CreateJobExecutionIndexesAsync(IMongoContext context, CancellationToken cancellationToken) + { + var collection = context.GetCollection(Constants.JobExecutionsCollectionName); + + // Уникальный индекс по идентификатору триггера (чтобы только одно выполнение на триггер) + await collection.Indexes.CreateOneAsync( + new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.TriggerId), + new CreateIndexOptions { Unique = true, Background = true }), + cancellationToken: cancellationToken); + + _logger.LogDebug("Создан уникальный индекс JobExecution.TriggerId"); + + // Индекс для поиска выполняющихся заданий по блокировке + await collection.Indexes.CreateOneAsync( + new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.LockId).Ascending(x => x.Status), + new CreateIndexOptions { Background = true }), + cancellationToken: cancellationToken); + + _logger.LogDebug("Создан составной индекс JobExecution.LockId+Status"); + + // Индекс по типу задания и статусу + await collection.Indexes.CreateOneAsync( + new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.JobType).Ascending(x => x.Status), + new CreateIndexOptions { Background = true }), + cancellationToken: cancellationToken); + + _logger.LogDebug("Создан составной индекс JobExecution.JobType+Status"); + + // Индекс для поиска выполнений по триггеру и статусу (используется в ScheduleJobAsync) + await collection.Indexes.CreateOneAsync( + new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.TriggerId).Ascending(x => x.Status), + new CreateIndexOptions { Background = true }), + cancellationToken: cancellationToken); + + _logger.LogDebug("Создан составной индекс JobExecution.TriggerId+Status"); + + // Индекс для поиска устаревших блокировок (используется в CreateAndStartJobExecution и TryCaptureExistingExecutionLock) + await collection.Indexes.CreateOneAsync( + new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.LockExpiry), + new CreateIndexOptions { Background = true }), + cancellationToken: cancellationToken); + + _logger.LogDebug("Создан индекс JobExecution.LockExpiry"); + } +} \ No newline at end of file diff --git a/src/Influunt.Scheduler/JobScheduler.cs b/src/Influunt.Scheduler/JobScheduler.cs index 2fb0f69..e1db345 100644 --- a/src/Influunt.Scheduler/JobScheduler.cs +++ b/src/Influunt.Scheduler/JobScheduler.cs @@ -15,40 +15,31 @@ internal class JobScheduler : IScheduler, IHostedService, IDisposable private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; private readonly JobSchedulerOptions _options; - private readonly MongoUrl _url; - private readonly Lazy _mongoClient; - private IMongoDatabase _database; + private readonly IMongoContext _context; private readonly string _instanceId; // уникальный идентификатор экземпляра планировщика private readonly ConcurrentDictionary _runningJobs = new(); private Timer _scanTimer; private Timer _heartbeatTimer; - private SemaphoreSlim _concurrencySemaphore; private bool _disposed; - public JobScheduler(IServiceProvider serviceProvider, IOptions options, ILogger logger) + public JobScheduler(IServiceProvider serviceProvider, IOptions options, ILogger logger, IMongoContext context) { _serviceProvider = serviceProvider; _logger = logger; _options = options.Value; - - _url = MongoUrl.Create(_options.ConnectionString); - _mongoClient = new Lazy(() => new MongoClient(_url)); + _context = context; _instanceId = $"{Environment.MachineName}_{Guid.NewGuid():N}"; - _concurrencySemaphore = new SemaphoreSlim(_options.MaxConcurrentJobs, _options.MaxConcurrentJobs); } - private IMongoDatabase Database => _database ??= _mongoClient.Value.GetDatabase(_url.DatabaseName); - private IMongoCollection _jobDefinitions => Database.GetCollection("job_definitions"); - private IMongoCollection _triggers => Database.GetCollection("job_triggers"); - private IMongoCollection _executions => Database.GetCollection("job_executions"); + private IMongoCollection _jobDefinitions => _context.GetCollection(Constants.JobDefinitionsCollectionName); + private IMongoCollection _triggers => _context.GetCollection(Constants.JobTriggersCollectionName); + private IMongoCollection _executions => _context.GetCollection(Constants.JobExecutionsCollectionName); public Task StartAsync(CancellationToken cancellationToken) { _logger.LogInformation("JobScheduler starting. InstanceId: {InstanceId}", _instanceId); - // Запускаем создание индексов - Task.Factory.StartNew(CreateIndexesAsync, TaskCreationOptions.LongRunning); // Запускаем таймеры _scanTimer = new Timer(ScanTriggers, null, TimeSpan.Zero, TimeSpan.FromSeconds(_options.ScanIntervalSeconds)); _heartbeatTimer = new Timer(Heartbeat, null, TimeSpan.FromSeconds(_options.HeartbeatIntervalSeconds), TimeSpan.FromSeconds(_options.HeartbeatIntervalSeconds)); @@ -109,6 +100,7 @@ public async Task ScheduleOneTimeJobAsync(DateTime? runAt = null, var trigger = new JobTrigger { TriggerId = string.IsNullOrEmpty(triggerId) ? Guid.NewGuid().ToString() : triggerId, + JobType = registeredJob.JobType, NextRunTime = runAt.HasValue ? runAt.Value.ToUniversalTime() : DateTime.UtcNow }; await _triggers.InsertOneAsync(trigger); @@ -129,6 +121,7 @@ public async Task ScheduleRecurringJobAsync(string cronExpression, var trigger = new JobTrigger { TriggerId = string.IsNullOrEmpty(triggerId) ? Guid.NewGuid().ToString() : triggerId, + JobType = registeredJob.JobType, NextRunTime = nextRun.Value, CronExpression = cronExpression }; @@ -148,6 +141,7 @@ public async Task ScheduleOneTimeJobAsync(TSettings set var trigger = new JobTrigger { TriggerId = string.IsNullOrEmpty(triggerId) ? Guid.NewGuid().ToString() : triggerId, + JobType = registeredJob.JobType, NextRunTime = runAt.HasValue ? runAt.Value.ToUniversalTime() : DateTime.UtcNow, SettingsJson = JsonSerializer.Serialize(settings) }; @@ -171,6 +165,7 @@ public async Task ScheduleRecurringJobAsync(string cron var trigger = new JobTrigger { TriggerId = string.IsNullOrEmpty(triggerId) ? Guid.NewGuid().ToString() : triggerId, + JobType = registeredJob.JobType, NextRunTime = nextRun.Value, CronExpression = cronExpression, SettingsJson = JsonSerializer.Serialize(settings) @@ -208,10 +203,41 @@ private async void ScanTriggers(object? state) Builders.Filter.Lte(t => t.NextRunTime, now)); var triggers = await _triggers.Find(filter).ToListAsync(); + // Проверяем текущее количество запущенных заданий + var runningCount = _runningJobs.Count; + + // Если уже достигнут лимит, пропускаем все триггеры + if (runningCount >= _options.MaxConcurrentJobs) + { + _logger.LogDebug("Concurrency limit reached ({Running}/{Max}). Skipping all triggers in this scan cycle.", + runningCount, _options.MaxConcurrentJobs); + return; + } + + // Вычисляем сколько заданий еще можно запустить + var availableSlots = _options.MaxConcurrentJobs - runningCount; + var processedTriggers = 0; + foreach (var trigger in triggers) { - // Для каждого триггера пытаемся создать выполнение - await ScheduleJobAsync(trigger); + // Если уже обработали максимально возможное количество триггеров, выходим + if (processedTriggers >= availableSlots) + { + _logger.LogDebug("Available slots exhausted. Processed {Processed}/{Available} triggers in this cycle.", + processedTriggers, availableSlots); + break; + } + + try + { + // Для каждого триггера пытаемся создать выполнение + await ScheduleJobAsync(trigger); + processedTriggers++; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error scheduling job for trigger {TriggerId}", trigger.TriggerId); + } } } catch (Exception ex) @@ -222,7 +248,7 @@ private async void ScanTriggers(object? state) private async Task ScheduleJobAsync(JobTrigger trigger) { - // Проверяем, не запущено ли уже задание с такой же блокировкой (если запрещён конкурентный запуск) + // 1. Получение JobDefinition для определения можно ли запускать конкурентно один и тот же триггер у джобы var jobDef = await _jobDefinitions.Find(d => d.JobType == trigger.JobType).FirstOrDefaultAsync(); if (jobDef == null) { @@ -230,23 +256,58 @@ private async Task ScheduleJobAsync(JobTrigger trigger) return; } - // Если конкурентный запуск запрещён, проверяем наличие выполняющихся экземпляров - if (!jobDef.AllowConcurrentExecution) + // 2. Проверяем, есть ли блокировка для текущего ТРИГГЕРА + var existingExecutionFilter = Builders.Filter.And( + Builders.Filter.Eq(e => e.TriggerId, trigger.TriggerId), + Builders.Filter.Eq(e => e.Status, ExecutionStatus.Running) + ); + var existingExecution = await _executions.Find(existingExecutionFilter).FirstOrDefaultAsync(); + + if (existingExecution == null) { - var runningFilter = Builders.Filter.And( - Builders.Filter.Eq(e => e.JobType, trigger.JobType), - Builders.Filter.Eq(e => e.Status, ExecutionStatus.Running)); - var runningCount = await _executions.CountDocumentsAsync(runningFilter); - if (runningCount > 0) + // 3. Если JobExecution нет, то создать его, попытаться захватить блокировку, запустить джобу + await CreateAndStartJobExecution(trigger, jobDef); + return; + } + + // Триггер уже запущен, проверяем время + var now = DateTime.UtcNow; + + if (trigger.IsRecurring) + { + // 2.2 Если задание регулярное, проверяем, если jobExecution относится к текущему периоду запуска + // nextRunTime примерно равен startTime у JobExecution (эпсилон время равное 30 секундам) + var epsilon = TimeSpan.FromSeconds(30); + var timeDiff = Math.Abs((trigger.NextRunTime - existingExecution.StartTime).TotalSeconds); + + if (timeDiff <= epsilon.TotalSeconds) { - _logger.LogDebug("Job '{JobName}' with type '{JobType}' is already running and concurrent execution is disabled. Can't run trigger {TriggerId}", - jobDef.Name, jobDef.JobType, trigger.TriggerId ); + _logger.LogDebug("Recurring job '{JobName}' trigger {TriggerId} is already running for current period. Skipping.", + jobDef.Name, trigger.TriggerId); return; } } + else + { + // 2.1 Если задания одноразовые, то действие должно быть как при AllowConcurrentExecution=false + // не давать запуститься, но только если эта блокировка не устарела + // (другая реплика могла умереть и блокировка уже давно просрочена) + if (existingExecution.LockExpiry > now) + { + _logger.LogDebug("One-time job '{JobName}' trigger {TriggerId} is already running and lock is not expired. Skipping.", + jobDef.Name, trigger.TriggerId); + return; + } + } + + // Если блокировка устарела (для одноразовых) или для нового периода (для регулярных), + // пытаемся захватить блокировку существующего JobExecution + await TryCaptureExistingExecutionLock(trigger, jobDef, existingExecution); + } - // TODO: Надо решить проблему зависшего триггера - // Создаём запись выполнения с блокировкой (атомарно) + private async Task CreateAndStartJobExecution(JobTrigger trigger, JobDefinition jobDef) + { + // Создаем новую запись выполнения var execution = new JobExecution { TriggerId = trigger.TriggerId, @@ -254,25 +315,44 @@ private async Task ScheduleJobAsync(JobTrigger trigger) Status = ExecutionStatus.Pending, StartTime = DateTime.UtcNow, LockId = null, - LockExpiry = DateTime.UtcNow, + LockExpiry = DateTime.UtcNow.AddSeconds(_options.LockTimeoutSeconds), LastHeartbeat = DateTime.UtcNow }; - await _executions.InsertOneAsync(execution); + try + { + await _executions.InsertOneAsync(execution); + } + catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey || ex.WriteError?.Code == 11000) + { + // Дублирование ключа - другая реплика уже создала JobExecution для этого триггера + _logger.LogDebug("Duplicate JobExecution for trigger {TriggerId} detected. Another replica already created it.", trigger.TriggerId); + return; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating JobExecution for trigger {TriggerId}", trigger.TriggerId); + return; + } + + await TryCaptureExistingExecutionLock(trigger, jobDef, execution, ExecutionStatus.Pending); + } - // Пытаемся вставить документ и сразу захватить блокировку - // Используем FindOneAndUpdate с условием, что блокировка ещё не получена + private async Task TryCaptureExistingExecutionLock(JobTrigger trigger, JobDefinition jobDef, JobExecution existingExecution, ExecutionStatus expectedExecutionStatus = ExecutionStatus.Running ) + { var lockExpiry = DateTime.UtcNow.AddSeconds(_options.LockTimeoutSeconds); + + // Пытаемся захватить блокировку существующего JobExecution var filter = Builders.Filter.And( - Builders.Filter.Eq(e => e.Id, execution.Id), // только что созданный Id - Builders.Filter.Eq(e => e.Status, ExecutionStatus.Pending), + Builders.Filter.Eq(e => e.Id, existingExecution.Id), + Builders.Filter.Eq(e => e.Status, expectedExecutionStatus), Builders.Filter.Or( Builders.Filter.Eq(e => e.LockId, null), Builders.Filter.Lt(e => e.LockExpiry, DateTime.UtcNow) ) ); + var update = Builders.Update - .Set(e => e.Status, ExecutionStatus.Running) .Set(e => e.LockId, _instanceId) .Set(e => e.LockExpiry, lockExpiry) .Set(e => e.LastHeartbeat, DateTime.UtcNow); @@ -280,19 +360,20 @@ private async Task ScheduleJobAsync(JobTrigger trigger) var updated = await _executions.FindOneAndUpdateAsync(filter, update); if (updated == null) { - // Не удалось захватить блокировку — удаляем запись или оставляем как есть (другая реплика уже взяла) - await _executions.DeleteOneAsync(e => e.Id == execution.Id); - _logger.LogDebug("Failed to acquire lock for trigger {TriggerId}", trigger.TriggerId); + // Если блокировку не удалось захватить, то пропустить триггер + _logger.LogDebug("Failed to acquire lock for existing execution of trigger {TriggerId}", trigger.TriggerId); return; } - // Блокировка получена — запускаем выполнение - _logger.LogInformation("Starting job {JobName}. TriggerId: {TriggerId}. ExecutionId: {ExecutionId}", jobDef.Name, trigger.TriggerId, execution.Id); + // Если блокировка удалась, то запустить джобу + _logger.LogInformation("Starting job '{JobName}' with existing execution. TriggerId: {TriggerId}. ExecutionId: {ExecutionId}", + jobDef.Name, trigger.TriggerId, existingExecution.Id); + var cts = new CancellationTokenSource(); - _runningJobs.TryAdd(execution.Id, cts); + _runningJobs.TryAdd(existingExecution.Id, cts); // Запускаем асинхронное выполнение - _ = Task.Run(() => ExecuteJobAsync(trigger, execution, jobDef, cts.Token)); + _ = Task.Run(() => ExecuteJobAsync(trigger, existingExecution, jobDef, cts.Token)); } private async Task ExecuteJobAsync(JobTrigger trigger, JobExecution execution, JobDefinition jobDef, CancellationToken cancellationToken) @@ -350,7 +431,6 @@ private async Task ExecuteJobAsync(JobTrigger trigger, JobExecution execution, J finally { _runningJobs.TryRemove(execution.Id, out _); - _concurrencySemaphore.Release(); } } @@ -414,42 +494,11 @@ private async void Heartbeat(object? state) } } - private async Task CreateIndexesAsync() - { - // Индекс по имени задания - await _jobDefinitions.Indexes.CreateOneAsync( - new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.Name), - new CreateIndexOptions { Unique = true, Background = true })); - // Индекс по типу задания - await _jobDefinitions.Indexes.CreateOneAsync( - new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.JobType), - new CreateIndexOptions { Unique = true, Background = true })); - - // Индекс для поиска триггеров по следующему времени запуска - await _triggers.Indexes.CreateOneAsync( - new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.NextRunTime), - new CreateIndexOptions { Background = true })); - - // Индекс поиска триггера по JobName - await _triggers.Indexes.CreateOneAsync( - new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.TriggerId), - new CreateIndexOptions { Unique = true, Background = true })); - - // Индекс для поиска выполняющихся заданий по блокировке - await _executions.Indexes.CreateOneAsync( - new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.LockId).Ascending(x => x.Status), - new CreateIndexOptions { Background = true })); - await _executions.Indexes.CreateOneAsync( - new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.JobType).Ascending(x => x.Status), - new CreateIndexOptions { Background = true })); - } - public void Dispose() { if (_disposed) return; _scanTimer?.Dispose(); _heartbeatTimer?.Dispose(); - _concurrencySemaphore?.Dispose(); _disposed = true; } } \ No newline at end of file diff --git a/src/Influunt.Scheduler/MongoContext.cs b/src/Influunt.Scheduler/MongoContext.cs new file mode 100644 index 0000000..b356c42 --- /dev/null +++ b/src/Influunt.Scheduler/MongoContext.cs @@ -0,0 +1,91 @@ +using System; +using Microsoft.Extensions.Options; +using MongoDB.Driver; + +namespace Influunt.Scheduler; + +/// +/// Реализация контекста MongoDB +/// +public class MongoContext : IMongoContext, IDisposable +{ + private readonly MongoUrl _url; + private readonly Lazy _mongoClient; + private IMongoDatabase _database; + private bool _disposed; + + /// + /// Создает новый экземпляр MongoContext с указанной строкой подключения + /// + /// Настройки планировщика заданий + public MongoContext(IOptions options) + { + if (options?.Value?.ConnectionString == null) + throw new ArgumentException("Connection string cannot be null or empty", nameof(options)); + + var connectionString = options.Value.ConnectionString; + + // Парсинг строки подключения + _url = MongoUrl.Create(connectionString); + + // Lazy создание MongoClient + _mongoClient = new Lazy(() => new MongoClient(_url)); + } + + /// + /// Получает базу данных MongoDB + /// + public IMongoDatabase Database => _database ??= _mongoClient.Value.GetDatabase(_url.DatabaseName); + + /// + /// Получает коллекцию указанного типа + /// + /// Тип документов коллекции + /// Имя коллекции (если null, будет использовано имя типа) + /// Коллекция MongoDB + public IMongoCollection GetCollection(string? collectionName = null) + { + if (_disposed) + throw new ObjectDisposedException(nameof(MongoContext)); + + var name = collectionName ?? typeof(T).Name.ToLowerInvariant(); + return Database.GetCollection(name); + } + + /// + /// Освобождает ресурсы + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Освобождает ресурсы + /// + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + // Очищаем управляемые ресурсы + if (_mongoClient.IsValueCreated) + { + // MongoClient не требует явного Dispose, но если нужно, можно добавить + } + } + + _disposed = true; + } + } + + /// + /// Финализатор + /// + ~MongoContext() + { + Dispose(false); + } +} \ No newline at end of file diff --git a/src/Influunt.Scheduler/SchedulerModule.cs b/src/Influunt.Scheduler/SchedulerModule.cs index 07c5ae2..bdabfad 100644 --- a/src/Influunt.Scheduler/SchedulerModule.cs +++ b/src/Influunt.Scheduler/SchedulerModule.cs @@ -2,7 +2,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using MongoDB.Bson.Serialization; -using MongoDB.Driver; namespace Influunt.Scheduler; @@ -11,7 +10,20 @@ public static class SchedulerModule public static IServiceCollection AddJobScheduler(this IServiceCollection services, Action configureOptions) { services.Configure(configureOptions); + + // Register IMongoContext + services.AddSingleton(); + + // Register JobScheduler services.AddSingleton(); + + services.AddHostedService(); + + // Register BSON class maps + BsonClassMap.RegisterClassMap(new JobDefinitionClassMap()); + BsonClassMap.RegisterClassMap(new JobExecutionClassMap()); + BsonClassMap.RegisterClassMap(new JobTriggerClassMap()); + return services; } @@ -19,7 +31,6 @@ public static IServiceCollection AddJobSchedulerHost(this IServiceCollection ser { services.AddJobScheduler(configureOptions); services.AddSingleton(s => (JobScheduler)s.GetRequiredService()); - BsonClassMap.RegisterClassMap(new JobDefinitionClassMap()); return services; } -} \ No newline at end of file +} diff --git a/src/Influunt.Storage/IMongoContext.cs b/src/Influunt.Storage/IMongoContext.cs new file mode 100644 index 0000000..fe738e4 --- /dev/null +++ b/src/Influunt.Storage/IMongoContext.cs @@ -0,0 +1,27 @@ +using MongoDB.Driver; + +namespace Influunt.Storage; + +/// +/// Интерфейс для работы с MongoDB контекстом +/// +public interface IMongoContext +{ + /// + /// Получает базу данных MongoDB + /// + IMongoDatabase Database { get; } + + /// + /// Получает коллекцию указанного типа + /// + /// Тип документов коллекции + /// Имя коллекции (если null, будет использовано имя типа) + /// Коллекция MongoDB + IMongoCollection GetCollection(string collectionName = null); + + /// + /// Освобождает ресурсы + /// + void Dispose(); +} \ No newline at end of file diff --git a/src/Influunt.Storage/MongoContext.cs b/src/Influunt.Storage/MongoContext.cs new file mode 100644 index 0000000..a6aaa38 --- /dev/null +++ b/src/Influunt.Storage/MongoContext.cs @@ -0,0 +1,88 @@ +using System; +using MongoDB.Driver; + +namespace Influunt.Storage; + +/// +/// Реализация контекста MongoDB +/// +public class MongoContext : IMongoContext, IDisposable +{ + private readonly MongoUrl _url; + private readonly Lazy _mongoClient; + private IMongoDatabase _database; + private bool _disposed; + + /// + /// Создает новый экземпляр MongoContext с указанной строкой подключения + /// + /// Строка подключения к MongoDB + public MongoContext(string connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + throw new ArgumentException("Connection string cannot be null or empty", nameof(connectionString)); + + // Парсинг строки подключения + _url = MongoUrl.Create(connectionString); + + // Lazy создание MongoClient + _mongoClient = new Lazy(() => new MongoClient(_url)); + } + + /// + /// Получает базу данных MongoDB + /// + public IMongoDatabase Database => _database ??= _mongoClient.Value.GetDatabase(_url.DatabaseName); + + /// + /// Получает коллекцию указанного типа + /// + /// Тип документов коллекции + /// Имя коллекции (если null, будет использовано имя типа) + /// Коллекция MongoDB + public IMongoCollection GetCollection(string collectionName = null) + { + if (_disposed) + throw new ObjectDisposedException(nameof(MongoContext)); + + var name = collectionName ?? typeof(T).Name.ToLowerInvariant(); + return Database.GetCollection(name); + } + + /// + /// Освобождает ресурсы + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Освобождает ресурсы + /// + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + // Очищаем управляемые ресурсы + if (_mongoClient.IsValueCreated) + { + // MongoClient не требует явного Dispose, но если нужно, можно добавить + } + } + + _disposed = true; + } + } + + /// + /// Финализатор + /// + ~MongoContext() + { + Dispose(false); + } +} \ No newline at end of file From 2d2f821563b019d7c7b970abaf4b012a075dd679 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BB=D0=B0=D0=B1=D1=83=D0=BA=D0=BE=D0=B2=20=D0=AD?= =?UTF-8?q?=D1=80=D0=B8=D0=BA=20=D0=9A=D0=BE=D0=BD=D1=81=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D1=82=D0=B8=D0=BD=D0=BE=D0=B2=D0=B8=D1=87?= Date: Wed, 1 Apr 2026 00:32:16 +0500 Subject: [PATCH 3/8] translate comments --- .../Models/ExecutionStatus.cs | 8 +- src/Influunt.Scheduler/Constants.cs | 8 +- src/Influunt.Scheduler/IMongoContext.cs | 14 ++-- .../IndexCreationBackgroundService.cs | 54 ++++++------- src/Influunt.Scheduler/JobScheduler.cs | 80 +++++++++---------- src/Influunt.Scheduler/JobSchedulerOptions.cs | 12 +-- src/Influunt.Scheduler/MongoContext.cs | 30 +++---- 7 files changed, 103 insertions(+), 103 deletions(-) diff --git a/src/Influunt.Scheduler.Abstractions/Models/ExecutionStatus.cs b/src/Influunt.Scheduler.Abstractions/Models/ExecutionStatus.cs index a1e5aef..d9ee44d 100644 --- a/src/Influunt.Scheduler.Abstractions/Models/ExecutionStatus.cs +++ b/src/Influunt.Scheduler.Abstractions/Models/ExecutionStatus.cs @@ -2,8 +2,8 @@ namespace Influunt.Scheduler.Abstractions.Models; public enum ExecutionStatus { - Pending, // Ожидает выполнения (блокировка не получена) - Running, // Выполняется (блокировка получена) - Completed, // Успешно завершено - Failed // Завершено с ошибкой + Pending, // Awaiting execution (lock not acquired) + Running, // Running (lock acquired) + Completed, // Successfully completed + Failed // Failed } \ No newline at end of file diff --git a/src/Influunt.Scheduler/Constants.cs b/src/Influunt.Scheduler/Constants.cs index c9c60c1..fb57fb5 100644 --- a/src/Influunt.Scheduler/Constants.cs +++ b/src/Influunt.Scheduler/Constants.cs @@ -1,22 +1,22 @@ namespace Influunt.Scheduler; /// -/// Константы для модуля планировщика заданий. +/// Constants for the job scheduler module. /// public static class Constants { /// - /// Имя коллекции определений заданий. + /// Job definitions collection name. /// public const string JobDefinitionsCollectionName = "job_definitions"; /// - /// Имя коллекции триггеров заданий. + /// Job triggers collection name. /// public const string JobTriggersCollectionName = "job_triggers"; /// - /// Имя коллекции выполнений заданий. + /// Job executions collection name. /// public const string JobExecutionsCollectionName = "job_executions"; } \ No newline at end of file diff --git a/src/Influunt.Scheduler/IMongoContext.cs b/src/Influunt.Scheduler/IMongoContext.cs index 9562283..d787a5e 100644 --- a/src/Influunt.Scheduler/IMongoContext.cs +++ b/src/Influunt.Scheduler/IMongoContext.cs @@ -3,25 +3,25 @@ namespace Influunt.Scheduler; /// -/// Интерфейс для работы с MongoDB контекстом +/// Interface for working with MongoDB context /// public interface IMongoContext { /// - /// Получает базу данных MongoDB + /// Gets the MongoDB database /// IMongoDatabase Database { get; } /// - /// Получает коллекцию указанного типа + /// Gets the collection of the specified type /// - /// Тип документов коллекции - /// Имя коллекции (если null, будет использовано имя типа) - /// Коллекция MongoDB + /// Type of collection documents + /// Collection name (if null, the type name will be used) + /// MongoDB collection IMongoCollection GetCollection(string collectionName = null); /// - /// Освобождает ресурсы + /// Releases resources /// void Dispose(); } \ No newline at end of file diff --git a/src/Influunt.Scheduler/IndexCreationBackgroundService.cs b/src/Influunt.Scheduler/IndexCreationBackgroundService.cs index ef952c6..80ac0af 100644 --- a/src/Influunt.Scheduler/IndexCreationBackgroundService.cs +++ b/src/Influunt.Scheduler/IndexCreationBackgroundService.cs @@ -10,8 +10,8 @@ namespace Influunt.Scheduler; /// -/// Background service для создания индексов MongoDB для модуля планировщика. -/// Выполняется однократно при запуске приложения. +/// Background service for creating MongoDB indexes for the scheduler module. +/// Runs once when the application starts. /// public class IndexCreationBackgroundService : BackgroundService { @@ -28,7 +28,7 @@ public IndexCreationBackgroundService( protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - _logger.LogInformation("Index Creation Background Service запущен."); + _logger.LogInformation("Index Creation Background Service started."); try { @@ -36,23 +36,23 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } catch (Exception ex) { - _logger.LogError(ex, "Ошибка при создании индексов MongoDB"); + _logger.LogError(ex, "Error creating MongoDB indexes"); throw; } finally { - _logger.LogInformation("Index Creation Background Service завершил работу."); + _logger.LogInformation("Index Creation Background Service finished."); } } /// - /// Создает индексы для коллекций планировщика. + /// Creates indexes for scheduler collections. /// private async Task CreateIndexesAsync(CancellationToken cancellationToken) { - _logger.LogInformation("Начинаем создание индексов MongoDB для модуля планировщика..."); + _logger.LogInformation("Starting MongoDB index creation for scheduler module..."); - // Используем скоуп для получения зависимостей с правильным временем жизни + // Using scope to get dependencies with correct lifetime using var scope = _serviceProvider.CreateScope(); var context = scope.ServiceProvider.GetRequiredService(); @@ -60,102 +60,102 @@ private async Task CreateIndexesAsync(CancellationToken cancellationToken) await CreateJobTriggerIndexesAsync(context, cancellationToken); await CreateJobExecutionIndexesAsync(context, cancellationToken); - _logger.LogInformation("Создание индексов MongoDB для модуля планировщика завершено."); + _logger.LogInformation("MongoDB index creation for scheduler module completed."); } private async Task CreateJobDefinitionIndexesAsync(IMongoContext context, CancellationToken cancellationToken) { var collection = context.GetCollection(Constants.JobDefinitionsCollectionName); - // Уникальный индекс по имени задания + // Unique index by job name await collection.Indexes.CreateOneAsync( new CreateIndexModel( Builders.IndexKeys.Ascending(x => x.Name), new CreateIndexOptions { Unique = true, Background = true }), cancellationToken: cancellationToken); - _logger.LogDebug("Создан уникальный индекс JobDefinition.Name"); + _logger.LogDebug("Created unique index JobDefinition.Name"); - // Уникальный индекс по типу задания + // Unique index by job type await collection.Indexes.CreateOneAsync( new CreateIndexModel( Builders.IndexKeys.Ascending(x => x.JobType), new CreateIndexOptions { Unique = true, Background = true }), cancellationToken: cancellationToken); - _logger.LogDebug("Создан уникальный индекс JobDefinition.JobType"); + _logger.LogDebug("Created unique index JobDefinition.JobType"); } private async Task CreateJobTriggerIndexesAsync(IMongoContext context, CancellationToken cancellationToken) { var collection = context.GetCollection(Constants.JobTriggersCollectionName); - // Индекс для поиска триггеров по следующему времени запуска + // Index for searching triggers by next run time await collection.Indexes.CreateOneAsync( new CreateIndexModel( Builders.IndexKeys.Ascending(x => x.NextRunTime), new CreateIndexOptions { Background = true }), cancellationToken: cancellationToken); - _logger.LogDebug("Создан индекс JobTrigger.NextRunTime"); + _logger.LogDebug("Created index JobTrigger.NextRunTime"); - // Уникальный индекс по идентификатору триггера + // Unique index by trigger identifier await collection.Indexes.CreateOneAsync( new CreateIndexModel( Builders.IndexKeys.Ascending(x => x.TriggerId), new CreateIndexOptions { Unique = true, Background = true }), cancellationToken: cancellationToken); - _logger.LogDebug("Создан уникальный индекс JobTrigger.TriggerId"); + _logger.LogDebug("Created unique index JobTrigger.TriggerId"); } private async Task CreateJobExecutionIndexesAsync(IMongoContext context, CancellationToken cancellationToken) { var collection = context.GetCollection(Constants.JobExecutionsCollectionName); - // Уникальный индекс по идентификатору триггера (чтобы только одно выполнение на триггер) + // Unique index by trigger identifier (to ensure only one execution per trigger) await collection.Indexes.CreateOneAsync( new CreateIndexModel( Builders.IndexKeys.Ascending(x => x.TriggerId), new CreateIndexOptions { Unique = true, Background = true }), cancellationToken: cancellationToken); - _logger.LogDebug("Создан уникальный индекс JobExecution.TriggerId"); + _logger.LogDebug("Created unique index JobExecution.TriggerId"); - // Индекс для поиска выполняющихся заданий по блокировке + // Index for searching running jobs by lock await collection.Indexes.CreateOneAsync( new CreateIndexModel( Builders.IndexKeys.Ascending(x => x.LockId).Ascending(x => x.Status), new CreateIndexOptions { Background = true }), cancellationToken: cancellationToken); - _logger.LogDebug("Создан составной индекс JobExecution.LockId+Status"); + _logger.LogDebug("Created compound index JobExecution.LockId+Status"); - // Индекс по типу задания и статусу + // Index by job type and status await collection.Indexes.CreateOneAsync( new CreateIndexModel( Builders.IndexKeys.Ascending(x => x.JobType).Ascending(x => x.Status), new CreateIndexOptions { Background = true }), cancellationToken: cancellationToken); - _logger.LogDebug("Создан составной индекс JobExecution.JobType+Status"); + _logger.LogDebug("Created compound index JobExecution.JobType+Status"); - // Индекс для поиска выполнений по триггеру и статусу (используется в ScheduleJobAsync) + // Index for searching executions by trigger and status (used in ScheduleJobAsync) await collection.Indexes.CreateOneAsync( new CreateIndexModel( Builders.IndexKeys.Ascending(x => x.TriggerId).Ascending(x => x.Status), new CreateIndexOptions { Background = true }), cancellationToken: cancellationToken); - _logger.LogDebug("Создан составной индекс JobExecution.TriggerId+Status"); + _logger.LogDebug("Created compound index JobExecution.TriggerId+Status"); - // Индекс для поиска устаревших блокировок (используется в CreateAndStartJobExecution и TryCaptureExistingExecutionLock) + // Index for searching expired locks (used in CreateAndStartJobExecution and TryCaptureExistingExecutionLock) await collection.Indexes.CreateOneAsync( new CreateIndexModel( Builders.IndexKeys.Ascending(x => x.LockExpiry), new CreateIndexOptions { Background = true }), cancellationToken: cancellationToken); - _logger.LogDebug("Создан индекс JobExecution.LockExpiry"); + _logger.LogDebug("Created index JobExecution.LockExpiry"); } } \ No newline at end of file diff --git a/src/Influunt.Scheduler/JobScheduler.cs b/src/Influunt.Scheduler/JobScheduler.cs index e1db345..f26214a 100644 --- a/src/Influunt.Scheduler/JobScheduler.cs +++ b/src/Influunt.Scheduler/JobScheduler.cs @@ -16,7 +16,7 @@ internal class JobScheduler : IScheduler, IHostedService, IDisposable private readonly ILogger _logger; private readonly JobSchedulerOptions _options; private readonly IMongoContext _context; - private readonly string _instanceId; // уникальный идентификатор экземпляра планировщика + private readonly string _instanceId; // unique scheduler instance identifier private readonly ConcurrentDictionary _runningJobs = new(); private Timer _scanTimer; private Timer _heartbeatTimer; @@ -40,7 +40,7 @@ public Task StartAsync(CancellationToken cancellationToken) { _logger.LogInformation("JobScheduler starting. InstanceId: {InstanceId}", _instanceId); - // Запускаем таймеры + // Start timers _scanTimer = new Timer(ScanTriggers, null, TimeSpan.Zero, TimeSpan.FromSeconds(_options.ScanIntervalSeconds)); _heartbeatTimer = new Timer(Heartbeat, null, TimeSpan.FromSeconds(_options.HeartbeatIntervalSeconds), TimeSpan.FromSeconds(_options.HeartbeatIntervalSeconds)); @@ -54,12 +54,12 @@ public Task StopAsync(CancellationToken cancellationToken) _scanTimer?.Change(Timeout.Infinite, 0); _heartbeatTimer?.Change(Timeout.Infinite, 0); - // Отменяем все запущенные задания + // Cancel all running jobs foreach (var cts in _runningJobs.Values) cts.Cancel(); - // Ждём завершения всех заданий (упрощённо) - // В реальном проекте нужно более тщательно ожидать завершения + // Wait for all jobs to complete (simplified) + // In a real project, more careful waiting for completion is needed return Task.CompletedTask; } @@ -191,22 +191,22 @@ public async Task> GetScheduledTriggersAsync() return await cursor.ToListAsync(); } - // ============================== Сканирование триггеров ============================== + // ============================== Trigger Scanning ============================== private async void ScanTriggers(object? state) { try { var now = DateTime.UtcNow; - // Находим триггеры, у которых NextRunTime <= now и которые активны + // Find triggers where NextRunTime <= now and are active var filter = Builders.Filter.And( Builders.Filter.Lte(t => t.NextRunTime, now)); var triggers = await _triggers.Find(filter).ToListAsync(); - // Проверяем текущее количество запущенных заданий + // Check current number of running jobs var runningCount = _runningJobs.Count; - // Если уже достигнут лимит, пропускаем все триггеры + // If limit is already reached, skip all triggers if (runningCount >= _options.MaxConcurrentJobs) { _logger.LogDebug("Concurrency limit reached ({Running}/{Max}). Skipping all triggers in this scan cycle.", @@ -214,13 +214,13 @@ private async void ScanTriggers(object? state) return; } - // Вычисляем сколько заданий еще можно запустить + // Calculate how many more jobs can be started var availableSlots = _options.MaxConcurrentJobs - runningCount; var processedTriggers = 0; foreach (var trigger in triggers) { - // Если уже обработали максимально возможное количество триггеров, выходим + // If maximum possible number of triggers have been processed, exit if (processedTriggers >= availableSlots) { _logger.LogDebug("Available slots exhausted. Processed {Processed}/{Available} triggers in this cycle.", @@ -230,7 +230,7 @@ private async void ScanTriggers(object? state) try { - // Для каждого триггера пытаемся создать выполнение + // For each trigger, attempt to create execution await ScheduleJobAsync(trigger); processedTriggers++; } @@ -248,7 +248,7 @@ private async void ScanTriggers(object? state) private async Task ScheduleJobAsync(JobTrigger trigger) { - // 1. Получение JobDefinition для определения можно ли запускать конкурентно один и тот же триггер у джобы + // 1. Get JobDefinition to determine if the same trigger can be executed concurrently for a job var jobDef = await _jobDefinitions.Find(d => d.JobType == trigger.JobType).FirstOrDefaultAsync(); if (jobDef == null) { @@ -256,7 +256,7 @@ private async Task ScheduleJobAsync(JobTrigger trigger) return; } - // 2. Проверяем, есть ли блокировка для текущего ТРИГГЕРА + // 2. Check if there is a lock for the current TRIGGER var existingExecutionFilter = Builders.Filter.And( Builders.Filter.Eq(e => e.TriggerId, trigger.TriggerId), Builders.Filter.Eq(e => e.Status, ExecutionStatus.Running) @@ -265,18 +265,18 @@ private async Task ScheduleJobAsync(JobTrigger trigger) if (existingExecution == null) { - // 3. Если JobExecution нет, то создать его, попытаться захватить блокировку, запустить джобу + // 3. If JobExecution doesn't exist, create it, attempt to acquire lock, start the job await CreateAndStartJobExecution(trigger, jobDef); return; } - // Триггер уже запущен, проверяем время + // Trigger is already running, check the time var now = DateTime.UtcNow; if (trigger.IsRecurring) { - // 2.2 Если задание регулярное, проверяем, если jobExecution относится к текущему периоду запуска - // nextRunTime примерно равен startTime у JobExecution (эпсилон время равное 30 секундам) + // 2.2 If job is recurring, check if jobExecution belongs to the current run period + // nextRunTime is approximately equal to JobExecution startTime (epsilon time of 30 seconds) var epsilon = TimeSpan.FromSeconds(30); var timeDiff = Math.Abs((trigger.NextRunTime - existingExecution.StartTime).TotalSeconds); @@ -289,9 +289,9 @@ private async Task ScheduleJobAsync(JobTrigger trigger) } else { - // 2.1 Если задания одноразовые, то действие должно быть как при AllowConcurrentExecution=false - // не давать запуститься, но только если эта блокировка не устарела - // (другая реплика могла умереть и блокировка уже давно просрочена) + // 2.1 If jobs are one-time, behavior should be like with AllowConcurrentExecution=false + // Don't allow to start, but only if this lock is not expired + // (another replica could have died and the lock is long expired) if (existingExecution.LockExpiry > now) { _logger.LogDebug("One-time job '{JobName}' trigger {TriggerId} is already running and lock is not expired. Skipping.", @@ -300,14 +300,14 @@ private async Task ScheduleJobAsync(JobTrigger trigger) } } - // Если блокировка устарела (для одноразовых) или для нового периода (для регулярных), - // пытаемся захватить блокировку существующего JobExecution + // If lock is expired (for one-time) or for new period (for recurring), + // attempt to capture lock of existing JobExecution await TryCaptureExistingExecutionLock(trigger, jobDef, existingExecution); } private async Task CreateAndStartJobExecution(JobTrigger trigger, JobDefinition jobDef) { - // Создаем новую запись выполнения + // Create new execution record var execution = new JobExecution { TriggerId = trigger.TriggerId, @@ -325,7 +325,7 @@ private async Task CreateAndStartJobExecution(JobTrigger trigger, JobDefinition } catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey || ex.WriteError?.Code == 11000) { - // Дублирование ключа - другая реплика уже создала JobExecution для этого триггера + // Duplicate key - another replica already created JobExecution for this trigger _logger.LogDebug("Duplicate JobExecution for trigger {TriggerId} detected. Another replica already created it.", trigger.TriggerId); return; } @@ -342,7 +342,7 @@ private async Task TryCaptureExistingExecutionLock(JobTrigger trigger, JobDefini { var lockExpiry = DateTime.UtcNow.AddSeconds(_options.LockTimeoutSeconds); - // Пытаемся захватить блокировку существующего JobExecution + // Attempt to capture lock of existing JobExecution var filter = Builders.Filter.And( Builders.Filter.Eq(e => e.Id, existingExecution.Id), Builders.Filter.Eq(e => e.Status, expectedExecutionStatus), @@ -360,19 +360,19 @@ private async Task TryCaptureExistingExecutionLock(JobTrigger trigger, JobDefini var updated = await _executions.FindOneAndUpdateAsync(filter, update); if (updated == null) { - // Если блокировку не удалось захватить, то пропустить триггер + // If failed to acquire lock, skip the trigger _logger.LogDebug("Failed to acquire lock for existing execution of trigger {TriggerId}", trigger.TriggerId); return; } - // Если блокировка удалась, то запустить джобу + // If lock acquisition succeeded, start the job _logger.LogInformation("Starting job '{JobName}' with existing execution. TriggerId: {TriggerId}. ExecutionId: {ExecutionId}", jobDef.Name, trigger.TriggerId, existingExecution.Id); var cts = new CancellationTokenSource(); _runningJobs.TryAdd(existingExecution.Id, cts); - // Запускаем асинхронное выполнение + // Start asynchronous execution _ = Task.Run(() => ExecuteJobAsync(trigger, existingExecution, jobDef, cts.Token)); } @@ -380,14 +380,14 @@ private async Task ExecuteJobAsync(JobTrigger trigger, JobExecution execution, J { try { - // Получаем экземпляр задания из DI (transient или scoped) + // Get job instance from DI (transient or scoped) using var scope = _serviceProvider.CreateScope(); var jobType = Type.GetType(jobDef.JobType) ?? throw new InvalidOperationException($"Cannot load job type {jobDef.JobType}"); - // Проверяем, есть ли настройки + // Check if there are settings if (string.IsNullOrEmpty(jobDef.SettingsType)) { - // Задание без настроек + // Job without settings var job = scope.ServiceProvider.GetRequiredService(jobType) as IJob; if (job == null) throw new InvalidOperationException($"Job {jobDef.JobType} does not implement IJob"); @@ -395,7 +395,7 @@ private async Task ExecuteJobAsync(JobTrigger trigger, JobExecution execution, J } else { - // Задание с настройками + // Job with settings var settingsType = Type.GetType(jobDef.SettingsType); if (settingsType == null) throw new InvalidOperationException($"Cannot load settings type {jobDef.SettingsType}"); @@ -412,9 +412,9 @@ private async Task ExecuteJobAsync(JobTrigger trigger, JobExecution execution, J await (Task)method.Invoke(job, [settings, cancellationToken]); } - // Успешное завершение + // Successful completion await UpdateExecutionStatus(execution.Id, ExecutionStatus.Completed, result: "Success"); - // Обновляем время следующего запуска триггера + // Update next run time for trigger await UpdateTriggerNextRun(trigger); _logger.LogInformation("Job {JobName} (ExecutionId: {ExecutionId}) completed successfully", jobDef.Name, execution.Id); } @@ -440,7 +440,7 @@ private async Task UpdateExecutionStatus(string executionId, ExecutionStatus sta .Set(e => e.Status, status) .Set(e => e.EndTime, DateTime.UtcNow) .Set(e => e.Result, result) - .Unset(e => e.LockId) // снимаем блокировку + .Unset(e => e.LockId) // remove lock .Unset(e => e.LockExpiry); await _executions.UpdateOneAsync(e => e.Id == executionId, update); } @@ -449,16 +449,16 @@ private async Task UpdateTriggerNextRun(JobTrigger trigger) { if (!trigger.IsRecurring || string.IsNullOrEmpty(trigger.CronExpression)) { - // Одноразовый триггер — удаляем + // One-time trigger — delete it await _triggers.DeleteOneAsync(t => t.Id == trigger.Id); return; } - // Вычисляем следующее время запуска на основе Cron + // Calculate next run time based on Cron var nextRun = CronHelper.GetNextOccurrence(trigger.CronExpression, DateTime.UtcNow); if (!nextRun.HasValue) { - // Cron больше не сработает (например, истёк) — удаляем + // Cron will no longer fire (e.g., expired) — delete it await _triggers.DeleteOneAsync(t => t.Id == trigger.Id); return; } @@ -476,7 +476,7 @@ private async void Heartbeat(object? state) var now = DateTime.UtcNow; var newExpiry = now.AddSeconds(_options.LockTimeoutSeconds); - // Находим все выполнения, которые принадлежат этому экземпляру и находятся в статусе Running + // Find all executions that belong to this instance and are in Running status var filter = Builders.Filter.And( Builders.Filter.Eq(e => e.LockId, _instanceId), Builders.Filter.Eq(e => e.Status, ExecutionStatus.Running)); diff --git a/src/Influunt.Scheduler/JobSchedulerOptions.cs b/src/Influunt.Scheduler/JobSchedulerOptions.cs index 4e77dff..7fa9532 100644 --- a/src/Influunt.Scheduler/JobSchedulerOptions.cs +++ b/src/Influunt.Scheduler/JobSchedulerOptions.cs @@ -2,18 +2,18 @@ namespace Influunt.Scheduler; public class JobSchedulerOptions { - /// Строка подключения к MongoDB. + /// MongoDB connection string. public string ConnectionString { get; set; } = "mongodb://localhost:27017/JobScheduler"; - /// Интервал сканирования триггеров (сек). + /// Trigger scan interval (seconds). public int ScanIntervalSeconds { get; set; } = 60; - /// Время жизни блокировки (сек). + /// Lock timeout duration (seconds). public int LockTimeoutSeconds { get; set; } = 300; - /// Интервал обновления heartbeat (сек). + /// Heartbeat update interval (seconds). public int HeartbeatIntervalSeconds { get; set; } = 60; - /// Максимальное количество одновременно выполняемых заданий. + /// Maximum number of concurrently running jobs. public int MaxConcurrentJobs { get; set; } = 10; - } \ No newline at end of file + } diff --git a/src/Influunt.Scheduler/MongoContext.cs b/src/Influunt.Scheduler/MongoContext.cs index b356c42..cc8ac67 100644 --- a/src/Influunt.Scheduler/MongoContext.cs +++ b/src/Influunt.Scheduler/MongoContext.cs @@ -5,7 +5,7 @@ namespace Influunt.Scheduler; /// -/// Реализация контекста MongoDB +/// MongoDB context implementation /// public class MongoContext : IMongoContext, IDisposable { @@ -15,9 +15,9 @@ public class MongoContext : IMongoContext, IDisposable private bool _disposed; /// - /// Создает новый экземпляр MongoContext с указанной строкой подключения + /// Creates a new instance of MongoContext with the specified connection string /// - /// Настройки планировщика заданий + /// Job scheduler options public MongoContext(IOptions options) { if (options?.Value?.ConnectionString == null) @@ -25,24 +25,24 @@ public MongoContext(IOptions options) var connectionString = options.Value.ConnectionString; - // Парсинг строки подключения + // Connection string parsing _url = MongoUrl.Create(connectionString); - // Lazy создание MongoClient + // Lazy creation of MongoClient _mongoClient = new Lazy(() => new MongoClient(_url)); } /// - /// Получает базу данных MongoDB + /// Gets the MongoDB database /// public IMongoDatabase Database => _database ??= _mongoClient.Value.GetDatabase(_url.DatabaseName); /// - /// Получает коллекцию указанного типа + /// Gets the collection of the specified type /// - /// Тип документов коллекции - /// Имя коллекции (если null, будет использовано имя типа) - /// Коллекция MongoDB + /// Type of collection documents + /// Collection name (if null, the type name will be used) + /// MongoDB collection public IMongoCollection GetCollection(string? collectionName = null) { if (_disposed) @@ -53,7 +53,7 @@ public IMongoCollection GetCollection(string? collectionName = null) } /// - /// Освобождает ресурсы + /// Releases resources /// public void Dispose() { @@ -62,7 +62,7 @@ public void Dispose() } /// - /// Освобождает ресурсы + /// Releases resources /// protected virtual void Dispose(bool disposing) { @@ -70,10 +70,10 @@ protected virtual void Dispose(bool disposing) { if (disposing) { - // Очищаем управляемые ресурсы + // Cleaning up managed resources if (_mongoClient.IsValueCreated) { - // MongoClient не требует явного Dispose, но если нужно, можно добавить + // MongoClient does not require explicit Dispose, but it can be added if needed } } @@ -82,7 +82,7 @@ protected virtual void Dispose(bool disposing) } /// - /// Финализатор + /// Finalizer /// ~MongoContext() { From d4d4eb97d67f74a5ffc5708f0dfc47fe26203db0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BB=D0=B0=D0=B1=D1=83=D0=BA=D0=BE=D0=B2=20=D0=AD?= =?UTF-8?q?=D1=80=D0=B8=D0=BA=20=D0=9A=D0=BE=D0=BD=D1=81=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D1=82=D0=B8=D0=BD=D0=BE=D0=B2=D0=B8=D1=87?= Date: Wed, 1 Apr 2026 20:50:36 +0500 Subject: [PATCH 4/8] Add tests --- Influunt.sln | 17 ++ .../Models/JobTrigger.cs | 21 +- .../Services/IScheduler.cs | 73 +++++- .../IndexCreationBackgroundService.cs | 5 +- .../Influunt.Scheduler.csproj | 34 +-- .../JobExecutionClassMap.cs | 1 - src/Influunt.Scheduler/JobTriggerClassMap.cs | 2 - src/Influunt.Scheduler/MongoContext.cs | 1 - src/Influunt.Scheduler/SchedulerModule.cs | 14 +- .../Influunt.Scheduler.Tests.csproj | 32 +++ .../JobSchedulerIntegrationTests.cs | 225 ++++++++++++++++++ .../SchedulerIntegrationTestBase.cs | 77 ++++++ tests/Influunt.Scheduler.Tests/TestJobs.cs | 33 +++ 13 files changed, 496 insertions(+), 39 deletions(-) create mode 100644 tests/Influunt.Scheduler.Tests/Influunt.Scheduler.Tests.csproj create mode 100644 tests/Influunt.Scheduler.Tests/JobSchedulerIntegrationTests.cs create mode 100644 tests/Influunt.Scheduler.Tests/SchedulerIntegrationTestBase.cs create mode 100644 tests/Influunt.Scheduler.Tests/TestJobs.cs diff --git a/Influunt.sln b/Influunt.sln index 46a831a..5989ab1 100644 --- a/Influunt.sln +++ b/Influunt.sln @@ -17,6 +17,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Influunt.Scheduler", "src\I EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Influunt.Scheduler.Abstractions", "src\Influunt.Scheduler.Abstractions\Influunt.Scheduler.Abstractions.csproj", "{D065CA50-272A-4E29-8D75-3A373EBCB98B}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Influunt.Scheduler.Tests", "tests\Influunt.Scheduler.Tests\Influunt.Scheduler.Tests.csproj", "{E9EA202F-F5BF-446B-A7BB-886643AE20C6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -99,6 +103,18 @@ Global {D065CA50-272A-4E29-8D75-3A373EBCB98B}.Release|x64.Build.0 = Release|Any CPU {D065CA50-272A-4E29-8D75-3A373EBCB98B}.Release|x86.ActiveCfg = Release|Any CPU {D065CA50-272A-4E29-8D75-3A373EBCB98B}.Release|x86.Build.0 = Release|Any CPU + {E9EA202F-F5BF-446B-A7BB-886643AE20C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E9EA202F-F5BF-446B-A7BB-886643AE20C6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E9EA202F-F5BF-446B-A7BB-886643AE20C6}.Debug|x64.ActiveCfg = Debug|Any CPU + {E9EA202F-F5BF-446B-A7BB-886643AE20C6}.Debug|x64.Build.0 = Debug|Any CPU + {E9EA202F-F5BF-446B-A7BB-886643AE20C6}.Debug|x86.ActiveCfg = Debug|Any CPU + {E9EA202F-F5BF-446B-A7BB-886643AE20C6}.Debug|x86.Build.0 = Debug|Any CPU + {E9EA202F-F5BF-446B-A7BB-886643AE20C6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E9EA202F-F5BF-446B-A7BB-886643AE20C6}.Release|Any CPU.Build.0 = Release|Any CPU + {E9EA202F-F5BF-446B-A7BB-886643AE20C6}.Release|x64.ActiveCfg = Release|Any CPU + {E9EA202F-F5BF-446B-A7BB-886643AE20C6}.Release|x64.Build.0 = Release|Any CPU + {E9EA202F-F5BF-446B-A7BB-886643AE20C6}.Release|x86.ActiveCfg = Release|Any CPU + {E9EA202F-F5BF-446B-A7BB-886643AE20C6}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -106,6 +122,7 @@ Global GlobalSection(NestedProjects) = preSolution {44AC6B8E-C694-48E7-95B6-5481EB122559} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {D065CA50-272A-4E29-8D75-3A373EBCB98B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {E9EA202F-F5BF-446B-A7BB-886643AE20C6} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {FDF77C48-6495-4B15-9513-34DB5A584086} diff --git a/src/Influunt.Scheduler.Abstractions/Models/JobTrigger.cs b/src/Influunt.Scheduler.Abstractions/Models/JobTrigger.cs index baea87a..da4986d 100644 --- a/src/Influunt.Scheduler.Abstractions/Models/JobTrigger.cs +++ b/src/Influunt.Scheduler.Abstractions/Models/JobTrigger.cs @@ -1,13 +1,12 @@ -namespace Influunt.Scheduler +namespace Influunt.Scheduler; + +public class JobTrigger { - public class JobTrigger - { - public string Id { get; set; } - public string TriggerId { get; set; } - public string JobType { get; set; } - public DateTime NextRunTime { get; set; } - public bool IsRecurring => !string.IsNullOrEmpty(CronExpression); - public string? CronExpression { get; set; } - public string? SettingsJson { get; set; } - } + public string Id { get; set; } + public string TriggerId { get; set; } + public string JobType { get; set; } + public DateTime NextRunTime { get; set; } + public bool IsRecurring => !string.IsNullOrEmpty(CronExpression); + public string? CronExpression { get; set; } + public string? SettingsJson { get; set; } } \ No newline at end of file diff --git a/src/Influunt.Scheduler.Abstractions/Services/IScheduler.cs b/src/Influunt.Scheduler.Abstractions/Services/IScheduler.cs index 057e0f1..61dcd2c 100644 --- a/src/Influunt.Scheduler.Abstractions/Services/IScheduler.cs +++ b/src/Influunt.Scheduler.Abstractions/Services/IScheduler.cs @@ -1,15 +1,86 @@ using Influunt.Scheduler.Abstractions.Models; namespace Influunt.Scheduler.Abstractions.Services; + +/// +/// Scheduler service that manages job registration and scheduling. +/// public interface IScheduler { + /// + /// Registers a job with the specified name and optional description. + /// + /// The type of the job implementing . + /// The type of the job settings. + /// The name of the job. + /// An optional description of the job. + /// Whether the job allows concurrent execution. Task RegisterJobAsync(string name, string? description = null, bool allowConcurrentExecution = false) where TJob : IJob; + + /// + /// Registers a job with the specified name and optional description. + /// + /// The type of the job implementing . + /// The name of the job. + /// An optional description of the job. + /// Whether the job allows concurrent execution. Task RegisterJobAsync(string name, string? description = null, bool allowConcurrentExecution = false) where TJob : IJob; + + /// + /// Schedules a one-time job to run at the specified time. + /// + /// The type of the job implementing . + /// The time at which the job should run. If null, the job will run as soon as possible. + /// An optional identifier for the trigger. + /// The identifier of the scheduled trigger. Task ScheduleOneTimeJobAsync(DateTime? runAt = null, string? triggerId = null) where TJob : IJob; + + /// + /// Schedules a recurring job based on the specified cron expression. + /// + /// The type of the job implementing . + /// The cron expression defining the schedule. + /// An optional identifier for the trigger. + /// The identifier of the scheduled trigger. Task ScheduleRecurringJobAsync(string cronExpression, string? triggerId = null) where TJob : IJob; + + /// + /// Schedules a one-time job with settings to run at the specified time. + /// + /// The type of the job implementing . + /// The type of the job settings. + /// The settings for the job. + /// The time at which the job should run. If null, the job will run as soon as possible. + /// An optional identifier for the trigger. + /// The identifier of the scheduled trigger. Task ScheduleOneTimeJobAsync(TSettings settings, DateTime? runAt = null, string? triggerId = null) where TJob : IJob; + + /// + /// Schedules a recurring job with settings based on the specified cron expression. + /// + /// The type of the job implementing . + /// The type of the job settings. + /// The cron expression defining the schedule. + /// The settings for the job. + /// An optional identifier for the trigger. + /// The identifier of the scheduled trigger. Task ScheduleRecurringJobAsync(string cronExpression, TSettings settings, string? triggerId = null) where TJob : IJob; + + /// + /// Unschedules a job with the specified trigger identifier. + /// + /// The identifier of the trigger to unschedule. Task UnScheduleJobAsync(string triggerId); + + /// + /// Retrieves all registered job definitions. + /// + /// A collection of registered job definitions. Task> GetRegisteredJobsAsync(); + + /// + /// Retrieves all scheduled job triggers. + /// + /// A collection of scheduled job triggers. Task> GetScheduledTriggersAsync(); -} \ No newline at end of file +} diff --git a/src/Influunt.Scheduler/IndexCreationBackgroundService.cs b/src/Influunt.Scheduler/IndexCreationBackgroundService.cs index 80ac0af..8a51b1c 100644 --- a/src/Influunt.Scheduler/IndexCreationBackgroundService.cs +++ b/src/Influunt.Scheduler/IndexCreationBackgroundService.cs @@ -1,6 +1,3 @@ -using System; -using System.Threading; -using System.Threading.Tasks; using Influunt.Scheduler.Abstractions.Models; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -56,8 +53,8 @@ private async Task CreateIndexesAsync(CancellationToken cancellationToken) using var scope = _serviceProvider.CreateScope(); var context = scope.ServiceProvider.GetRequiredService(); - await CreateJobDefinitionIndexesAsync(context, cancellationToken); await CreateJobTriggerIndexesAsync(context, cancellationToken); + await CreateJobDefinitionIndexesAsync(context, cancellationToken); await CreateJobExecutionIndexesAsync(context, cancellationToken); _logger.LogInformation("MongoDB index creation for scheduler module completed."); diff --git a/src/Influunt.Scheduler/Influunt.Scheduler.csproj b/src/Influunt.Scheduler/Influunt.Scheduler.csproj index 409783a..75b4878 100644 --- a/src/Influunt.Scheduler/Influunt.Scheduler.csproj +++ b/src/Influunt.Scheduler/Influunt.Scheduler.csproj @@ -1,16 +1,20 @@ - - - net8.0 - enable - enable - - - - - - - - - - + + + net8.0 + enable + enable + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Influunt.Scheduler/JobExecutionClassMap.cs b/src/Influunt.Scheduler/JobExecutionClassMap.cs index 7dbd238..0f0b90f 100644 --- a/src/Influunt.Scheduler/JobExecutionClassMap.cs +++ b/src/Influunt.Scheduler/JobExecutionClassMap.cs @@ -1,7 +1,6 @@ using Influunt.Scheduler.Abstractions.Models; using MongoDB.Bson; using MongoDB.Bson.Serialization; -using MongoDB.Bson.Serialization.Conventions; using MongoDB.Bson.Serialization.IdGenerators; using MongoDB.Bson.Serialization.Serializers; diff --git a/src/Influunt.Scheduler/JobTriggerClassMap.cs b/src/Influunt.Scheduler/JobTriggerClassMap.cs index 2f6e913..fc3ffb2 100644 --- a/src/Influunt.Scheduler/JobTriggerClassMap.cs +++ b/src/Influunt.Scheduler/JobTriggerClassMap.cs @@ -1,7 +1,5 @@ -using Influunt.Scheduler.Abstractions.Models; using MongoDB.Bson; using MongoDB.Bson.Serialization; -using MongoDB.Bson.Serialization.Conventions; using MongoDB.Bson.Serialization.IdGenerators; using MongoDB.Bson.Serialization.Serializers; diff --git a/src/Influunt.Scheduler/MongoContext.cs b/src/Influunt.Scheduler/MongoContext.cs index cc8ac67..5ed4e9e 100644 --- a/src/Influunt.Scheduler/MongoContext.cs +++ b/src/Influunt.Scheduler/MongoContext.cs @@ -1,4 +1,3 @@ -using System; using Microsoft.Extensions.Options; using MongoDB.Driver; diff --git a/src/Influunt.Scheduler/SchedulerModule.cs b/src/Influunt.Scheduler/SchedulerModule.cs index bdabfad..e307e96 100644 --- a/src/Influunt.Scheduler/SchedulerModule.cs +++ b/src/Influunt.Scheduler/SchedulerModule.cs @@ -16,13 +16,19 @@ public static IServiceCollection AddJobScheduler(this IServiceCollection service // Register JobScheduler services.AddSingleton(); - + services.AddHostedService(); // Register BSON class maps - BsonClassMap.RegisterClassMap(new JobDefinitionClassMap()); - BsonClassMap.RegisterClassMap(new JobExecutionClassMap()); - BsonClassMap.RegisterClassMap(new JobTriggerClassMap()); + try{ + BsonClassMap.RegisterClassMap(new JobDefinitionClassMap()); + BsonClassMap.RegisterClassMap(new JobExecutionClassMap()); + BsonClassMap.RegisterClassMap(new JobTriggerClassMap()); + } + catch (ArgumentException) + { + // Do nothing. Needs for tests + } return services; } diff --git a/tests/Influunt.Scheduler.Tests/Influunt.Scheduler.Tests.csproj b/tests/Influunt.Scheduler.Tests/Influunt.Scheduler.Tests.csproj new file mode 100644 index 0000000..a7c2629 --- /dev/null +++ b/tests/Influunt.Scheduler.Tests/Influunt.Scheduler.Tests.csproj @@ -0,0 +1,32 @@ + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Influunt.Scheduler.Tests/JobSchedulerIntegrationTests.cs b/tests/Influunt.Scheduler.Tests/JobSchedulerIntegrationTests.cs new file mode 100644 index 0000000..e5132c0 --- /dev/null +++ b/tests/Influunt.Scheduler.Tests/JobSchedulerIntegrationTests.cs @@ -0,0 +1,225 @@ +using AwesomeAssertions; +using Influunt.Scheduler.Abstractions.Services; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; + +namespace Influunt.Scheduler.Tests; + +public class JobSchedulerIntegrationTests : SchedulerIntegrationTestBase +{ + protected override void ConfigureServices(IServiceCollection services, string connectionString) + { + // Register test jobs + services.AddTransient(); + services.AddTransient(); + + // Add scheduler module with hosted service registration + services.AddJobSchedulerHost(options => + { + options.ConnectionString = connectionString; + options.ScanIntervalSeconds = 1; // Faster scanning for tests + options.LockTimeoutSeconds = 5; + options.HeartbeatIntervalSeconds = 2; + options.MaxConcurrentJobs = 10; + }); + } + + protected override async Task InitializeTestDataAsync() + { + // Reset static fields + TestJobWithoutSettings.WasExecuted = false; + TestJobWithSettings.WasExecuted = false; + TestJobWithSettings.LastSettings = null; + + await base.InitializeTestDataAsync(); + } + + [Fact] + public async Task Should_Register_Job_Without_Settings() + { + // Arrange + var scheduler = GetRequiredService(); + + // Act + await scheduler.RegisterJobAsync("TestJobWithoutSettings", "Test job without settings"); + + // Assert + var jobs = await scheduler.GetRegisteredJobsAsync(); + jobs.Should().ContainSingle(); + var job = jobs.First(); + job.Name.Should().Be("TestJobWithoutSettings"); + job.Description.Should().Be("Test job without settings"); + job.JobType.Should().Be(typeof(TestJobWithoutSettings).AssemblyQualifiedName); + job.SettingsType.Should().BeNullOrEmpty(); + } + + [Fact] + public async Task Should_Register_Job_With_Settings() + { + // Arrange + var scheduler = GetRequiredService(); + + // Act + await scheduler.RegisterJobAsync("TestJobWithSettings", "Test job with settings"); + + // Assert + var jobs = await scheduler.GetRegisteredJobsAsync(); + jobs.Should().ContainSingle(); + var job = jobs.First(); + job.Name.Should().Be("TestJobWithSettings"); + job.Description.Should().Be("Test job with settings"); + job.JobType.Should().Be(typeof(TestJobWithSettings).AssemblyQualifiedName); + job.SettingsType.Should().Be(typeof(TestJobSettings).AssemblyQualifiedName); + } + + [Fact] + public async Task Should_Schedule_OneTime_Job_Without_Settings() + { + // Arrange + var scheduler = GetRequiredService(); + await scheduler.RegisterJobAsync("TestJobWithoutSettings"); + + // Act + var triggerId = await scheduler.ScheduleOneTimeJobAsync(DateTime.UtcNow.AddSeconds(1)); + + // Assert + triggerId.Should().NotBeNullOrEmpty(); + + var triggers = await scheduler.GetScheduledTriggersAsync(); + triggers.Should().ContainSingle(); + var trigger = triggers.First(); + trigger.TriggerId.Should().Be(triggerId); + trigger.JobType.Should().Be(typeof(TestJobWithoutSettings).AssemblyQualifiedName); + trigger.IsRecurring.Should().BeFalse(); + + // Wait for job to execute + await Task.Delay(3000); + + TestJobWithoutSettings.WasExecuted.Should().BeTrue(); + + // Trigger should be removed after execution + triggers = await scheduler.GetScheduledTriggersAsync(); + triggers.Should().BeEmpty(); + } + + [Fact] + public async Task Should_Schedule_Recurring_Job_Without_Settings() + { + // Arrange + var scheduler = GetRequiredService(); + await scheduler.RegisterJobAsync("TestJobWithoutSettings"); + + // Act + var triggerId = await scheduler.ScheduleRecurringJobAsync("* * * * *"); // Every minute + + // Assert + triggerId.Should().NotBeNullOrEmpty(); + + var triggers = await scheduler.GetScheduledTriggersAsync(); + triggers.Should().ContainSingle(); + var trigger = triggers.First(); + trigger.TriggerId.Should().Be(triggerId); + trigger.JobType.Should().Be(typeof(TestJobWithoutSettings).AssemblyQualifiedName); + trigger.IsRecurring.Should().BeTrue(); + trigger.CronExpression.Should().Be("* * * * *"); + + await Task.Delay(TimeSpan.FromSeconds(70)); // Wait 70 seconds to ensure job runs (around next minute) + + TestJobWithoutSettings.WasExecuted.Should().BeTrue(); + + // Trigger should still exist + triggers = await scheduler.GetScheduledTriggersAsync(); + triggers.Should().ContainSingle(); + } + + [Fact] + public async Task Should_Schedule_OneTime_Job_With_Settings() + { + // Arrange + var scheduler = GetRequiredService(); + await scheduler.RegisterJobAsync("TestJobWithSettings"); + + var settings = new TestJobSettings + { + Message = "Hello Test", + Count = 42 + }; + + // Act + var triggerId = await scheduler.ScheduleOneTimeJobAsync( + settings, DateTime.UtcNow.AddSeconds(1)); + + // Assert + triggerId.Should().NotBeNullOrEmpty(); + + // Wait for job to execute + await Task.Delay(TimeSpan.FromSeconds(3)); + + TestJobWithSettings.WasExecuted.Should().BeTrue(); + TestJobWithSettings.LastSettings.Should().NotBeNull(); + TestJobWithSettings.LastSettings!.Message.Should().Be("Hello Test"); + TestJobWithSettings.LastSettings!.Count.Should().Be(42); + + // Trigger should be removed after execution + var triggers = await scheduler.GetScheduledTriggersAsync(); + triggers.Should().BeEmpty(); + } + + [Fact] + public async Task Should_Unschedule_Job() + { + // Arrange + var scheduler = GetRequiredService(); + await scheduler.RegisterJobAsync("TestJobWithoutSettings"); + var triggerId = await scheduler.ScheduleOneTimeJobAsync(DateTime.UtcNow.AddMinutes(5)); + + // Act + await scheduler.UnScheduleJobAsync(triggerId); + + // Assert + var triggers = await scheduler.GetScheduledTriggersAsync(); + triggers.Should().BeEmpty(); + } + + [Fact] + public async Task Should_Not_Allow_Duplicate_Trigger_Execution() + { + // Arrange + var scheduler = GetRequiredService(); + await scheduler.RegisterJobAsync("TestJobWithoutSettings", allowConcurrentExecution: false); + + var runAt = DateTime.UtcNow.AddSeconds(1); + await scheduler.ScheduleOneTimeJobAsync(runAt, "test-trigger"); + var duplicateTriggerId = () => scheduler.ScheduleOneTimeJobAsync(runAt.AddSeconds(10), "test-trigger"); + + var exception = await Assert.ThrowsAsync(duplicateTriggerId); + exception.WriteError.Category.Should().Be(ServerErrorCategory.DuplicateKey); + exception.WriteError.Code.Should().Be(11000); + } + + [Fact] + public async Task Should_Handle_Multiple_Concurrent_Jobs() + { + // Arrange + var scheduler = GetRequiredService(); + await scheduler.RegisterJobAsync("TestJobWithoutSettings", allowConcurrentExecution: true); + + // Act + var triggerId1 = await scheduler.ScheduleOneTimeJobAsync(DateTime.UtcNow.AddSeconds(1), "trigger1"); + var triggerId2 = await scheduler.ScheduleOneTimeJobAsync(DateTime.UtcNow.AddSeconds(1), "trigger2"); + var triggerId3 = await scheduler.ScheduleOneTimeJobAsync(DateTime.UtcNow.AddSeconds(1), "trigger3"); + + // Assert + var triggers = await scheduler.GetScheduledTriggersAsync(); + triggers.Should().HaveCount(3); + + // Wait for execution + await Task.Delay(TimeSpan.FromSeconds(4)); + + TestJobWithoutSettings.WasExecuted.Should().BeTrue(); + + // All triggers should be removed after execution + triggers = await scheduler.GetScheduledTriggersAsync(); + triggers.Should().BeEmpty(); + } +} \ No newline at end of file diff --git a/tests/Influunt.Scheduler.Tests/SchedulerIntegrationTestBase.cs b/tests/Influunt.Scheduler.Tests/SchedulerIntegrationTestBase.cs new file mode 100644 index 0000000..d3c56dc --- /dev/null +++ b/tests/Influunt.Scheduler.Tests/SchedulerIntegrationTestBase.cs @@ -0,0 +1,77 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Testcontainers.MongoDb; + +namespace Influunt.Scheduler.Tests; + +public abstract class SchedulerIntegrationTestBase : IAsyncLifetime +{ + private readonly MongoDbContainer _mongoContainer; + private IHost _host; + private IServiceProvider _serviceProvider; + + protected SchedulerIntegrationTestBase() + { + _mongoContainer = new MongoDbBuilder() + .WithImage("mongo:latest") + .Build(); + } + + protected IServiceProvider ServiceProvider => _serviceProvider ?? throw new InvalidOperationException("Test not initialized"); + + protected T GetRequiredService() where T : notnull + { + return ServiceProvider.GetRequiredService(); + } + + public async Task InitializeAsync() + { + await _mongoContainer.StartAsync(); + + var connectionStringUriBuilder = new UriBuilder(_mongoContainer.GetConnectionString()); + connectionStringUriBuilder.Path = "test-db"; + connectionStringUriBuilder.Query += "&authMechanism=SCRAM-SHA-256&authSource=admin"; + + _host = Host.CreateDefaultBuilder() + .ConfigureServices(services => + { + ConfigureServices(services, connectionStringUriBuilder.ToString()); + }) + .ConfigureLogging(logging => + { + logging.ClearProviders(); + logging.AddConsole(); + }) + .Build(); + + _serviceProvider = _host.Services; + + await InitializeTestDataAsync(); + + _host.StartAsync(CancellationToken.None); + + await Task.Delay(TimeSpan.FromMinutes(1)); // Time for index creation + } + + protected virtual void ConfigureServices(IServiceCollection services, string connectionString) + { + // Override in derived tests to add additional services + } + + protected virtual Task InitializeTestDataAsync() + { + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + if (_host != null) + { + await _host.StopAsync(); + _host.Dispose(); + } + + await _mongoContainer.DisposeAsync(); + } +} \ No newline at end of file diff --git a/tests/Influunt.Scheduler.Tests/TestJobs.cs b/tests/Influunt.Scheduler.Tests/TestJobs.cs new file mode 100644 index 0000000..edf4ff8 --- /dev/null +++ b/tests/Influunt.Scheduler.Tests/TestJobs.cs @@ -0,0 +1,33 @@ +using Influunt.Scheduler.Abstractions.Models; + +namespace Influunt.Scheduler.Tests; + +public class TestJobWithoutSettings : IJob +{ + public static bool WasExecuted { get; set; } + + public Task ExecuteAsync(CancellationToken cancellationToken) + { + WasExecuted = true; + return Task.CompletedTask; + } +} + +public class TestJobWithSettings : IJob +{ + public static TestJobSettings? LastSettings { get; set; } + public static bool WasExecuted { get; set; } + + public Task ExecuteAsync(TestJobSettings settings, CancellationToken cancellationToken) + { + LastSettings = settings; + WasExecuted = true; + return Task.CompletedTask; + } +} + +public class TestJobSettings +{ + public string Message { get; set; } = string.Empty; + public int Count { get; set; } +} \ No newline at end of file From c650d2c20f2e4f33e0a22e5d8294e592de77bf34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BB=D0=B0=D0=B1=D1=83=D0=BA=D0=BE=D0=B2=20=D0=AD?= =?UTF-8?q?=D1=80=D0=B8=D0=BA=20=D0=9A=D0=BE=D0=BD=D1=81=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D1=82=D0=B8=D0=BD=D0=BE=D0=B2=D0=B8=D1=87?= Date: Fri, 22 May 2026 21:30:41 +0500 Subject: [PATCH 5/8] Add worker Migrate to jobs for feed crawling Update build and publish docker container workflow --- .github/workflows/main.yml | 120 ++++++++++++------ .vscode/launch.json | 14 ++ .vscode/tasks.json | 44 ++++--- AGENTS.md | 62 +++++++++ Dockerfile | 7 + Influunt.sln | 19 +++ README.md | 17 ++- .../Influunt.Feed.Rss.csproj | 2 +- src/Influunt.Feed/Crawler/CrawlerOptions.cs | 3 - .../Crawler/FeedCrawlerBackgroundWorker.cs | 95 -------------- src/Influunt.Feed/Crawler/FeedCrawlerJob.cs | 47 +++++++ .../Crawler/UserFeedChannelUpdateJob.cs | 65 ++++++++++ .../UserFeedChannelUpdateJobSettings.cs | 8 ++ .../Extensions/ServicesExtensions.cs | 15 +++ .../FeedJobsRegistrationService.cs | 29 +++++ src/Influunt.Feed/Influunt.Feed.csproj | 40 +++--- .../Controllers/ChannelController.cs | 25 +++- src/Influunt.Host/Influunt.Host.csproj | 14 +- src/Influunt.Host/Program.cs | 11 +- src/Influunt.Host/StartupModule.cs | 2 + src/Influunt.Host/WebModule.cs | 2 +- .../appsettings.Development.json | 17 +-- src/Influunt.Host/appsettings.json | 8 +- .../Influunt.Scheduler.csproj | 2 +- .../JobDefinitionClassMap.cs | 2 + .../JobExecutionClassMap.cs | 2 + src/Influunt.Scheduler/JobSchedulerOptions.cs | 2 +- src/Influunt.Worker/Influunt.Worker.csproj | 20 +++ .../NoOpHttpContextAccessor.cs | 7 + src/Influunt.Worker/Program.cs | 22 ++++ .../Properties/launchSettings.json | 12 ++ src/Influunt.Worker/StartupModule.cs | 33 +++++ .../appsettings.Development.json | 8 ++ src/Influunt.Worker/appsettings.json | 22 ++++ .../JobSchedulerIntegrationTests.cs | 5 + tests/Influunt.Scheduler.Tests/TestJobs.cs | 4 + 36 files changed, 609 insertions(+), 198 deletions(-) create mode 100644 AGENTS.md delete mode 100644 src/Influunt.Feed/Crawler/FeedCrawlerBackgroundWorker.cs create mode 100644 src/Influunt.Feed/Crawler/FeedCrawlerJob.cs create mode 100644 src/Influunt.Feed/Crawler/UserFeedChannelUpdateJob.cs create mode 100644 src/Influunt.Feed/Crawler/UserFeedChannelUpdateJobSettings.cs create mode 100644 src/Influunt.Feed/Extensions/ServicesExtensions.cs create mode 100644 src/Influunt.Feed/FeedJobsRegistrationService.cs create mode 100644 src/Influunt.Worker/Influunt.Worker.csproj create mode 100644 src/Influunt.Worker/NoOpHttpContextAccessor.cs create mode 100644 src/Influunt.Worker/Program.cs create mode 100644 src/Influunt.Worker/Properties/launchSettings.json create mode 100644 src/Influunt.Worker/StartupModule.cs create mode 100644 src/Influunt.Worker/appsettings.Development.json create mode 100644 src/Influunt.Worker/appsettings.json diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a39a413..f835655 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,36 +1,84 @@ -# This is a basic workflow to help you get started with Actions - -name: CI - -# Controls when the action will run. Triggers the workflow on push or pull request -# events but only for the master branch -on: - push: - branches: [ master ] - tags: - - v* - pull_request: - branches: [ master ] - -# A workflow run is made up of one or more jobs that can run sequentially or in parallel -jobs: - # This workflow contains a single job called "build" - publish: - # The type of runner that the job will run on - runs-on: ubuntu-latest - - # Steps represent a sequence of tasks that will be executed as part of the job - steps: - # Runs a single command using the runners shell - - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - name: Build And Push Influunt - uses: docker/build-push-action@v1.1.0 - with: - repository: eluki/influunt - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - target: influunt - tag_with_ref: true - push: ${{ github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/') }} +# This is a basic workflow to help you get started with Actions + +name: CI + +# Controls when the action will run. Triggers the workflow on push or pull request +# events but only for the master branch +on: + push: + branches: [ master ] + tags: + - v* + pull_request: + branches: [ master ] + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + publish-webhost: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + # list of Docker images to use as base name for tags + images: eluki/influunt + # generate Docker tags based on the following events/attributes + tags: | + type=semver,pattern={{version}} + type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=main,enable={{is_default_branch}} + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build and push Influunt Host + uses: docker/build-push-action@v6 + with: + context: . + target: influunt + push: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + publish-worker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + # list of Docker images to use as base name for tags + images: eluki/influunt-worker + # generate Docker tags based on the following events/attributes + tags: | + type=semver,pattern={{version}} + type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=main,enable={{is_default_branch}} + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build and push Influunt Worker + uses: docker/build-push-action@v6 + with: + context: . + target: influunt + push: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index d88866c..3ce1833 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -20,6 +20,20 @@ "sourceFileMap": { "/Views": "${workspaceFolder}/src/Influunt.Host/Views" } + }, + { + "name": "Influunt.Worker", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build-Influunt.Worker", + "program": "${workspaceFolder}/src/Influunt.Worker/bin/Debug/net8/Influunt.Worker.dll", + "args": [], + "env": { + "DOTNET_ENVIRONMENT": "Development" + }, + "cwd": "${workspaceFolder}/src/Influunt.Worker", + "stopAtEntry": false, + "console": "internalConsole" } ] } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 3b1c986..f107345 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,17 +1,29 @@ -{ - "version": "2.0.0", - "tasks": [ - { - "label": "build-Influunt.Host", - "command": "dotnet", - "type": "process", - "args": [ - "build", - "${workspaceFolder}/src/Influunt.Host/Influunt.Host.csproj", - "/property:GenerateFullPaths=true", - "/consoleloggerparameters:NoSummary" - ], - "problemMatcher": "$msCompile" - } - ] +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build-Influunt.Host", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/src/Influunt.Host/Influunt.Host.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "build-Influunt.Worker", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/src/Influunt.Worker/Influunt.Worker.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + } + ] } \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3181299 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,62 @@ +# Influunt Developer Guide + +## Project Overview +Influunt is a .NET 8 + Vue.js RSS aggregator with MongoDB storage and Redis caching. + +## Key Technologies +- Backend: .NET 8, ASP.NET Core +- Frontend: Vue.js 2.x with Vue CLI 3 +- Database: MongoDB +- Caching: Redis +- Authentication: Google OAuth2 and guest login + +## Project Structure +``` +src/ +├── Influunt.Host/ # Main .NET backend with Vue frontend +│ ├── ClientApp/ # Vue.js frontend +│ └── appsettings.json # Configuration +└── Influunt.Feed/ # Feed processing logic +└── Influunt.Storage/ # Data access layer +``` + +## Development Commands +### Backend (.NET) +- Build: `dotnet build` +- Run: `dotnet run` (from src/Influunt.Host/) +- Test: `dotnet test` (from tests/ directory) + +### Frontend (Vue.js) +- Install dependencies: `npm install` (in src/Influunt.Host/ClientApp/) +- Development server: `npm run serve` +- Production build: `npm run build` +- Lint: `npm run lint` + +## Important Notes +1. **Environment Setup**: + - Requires .NET 8 SDK + - Requires Node.js 16+ (due to legacy provider flag in package.json) + - Requires MongoDB and Redis instances + +2. **Configuration**: + - All settings in `appsettings.json` or environment variables + - Google Auth requires ClientId/ClientSecret + - Connection strings for MongoDB and Redis + +3. **Build Process**: + - .NET builds automatically restore npm packages for the frontend + - Vue build requires `NODE_OPTIONS=--openssl-legacy-provider` environment variable + +4. **Testing**: + - Tests are in the `tests/` directory + - Run with `dotnet test` from the test project directory + +5. **Docker Deployment**: + - Use provided docker-compose or docker run commands in README + - Requires MongoDB and Redis connection strings + - Google Auth credentials for authentication + +6. **Architecture**: + - Uses VueCliMiddleware for frontend integration + - Separated concerns: Host (web), Feed (processing), Storage (data) + - Background feed crawler enabled by default \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 515a154..a7235c9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,7 @@ RUN sed -i -e "s/0-develop<\/Version>/$(cat version | cut -c2- dotnet restore -s https://api.nuget.org/v3/index.json &&\ dotnet build --no-restore -c Release &&\ dotnet publish ./src/Influunt.Host/Influunt.Host.csproj -c Release -o /app --no-build &&\ + dotnet publish ./src/Influunt.Worker/Influunt.Worker.csproj -c Release -o /worker --no-build &&\ dotnet nuget locals http-cache --clear &&\ dotnet nuget locals temp --clear @@ -28,3 +29,9 @@ WORKDIR /influunt EXPOSE 80 ENV ASPNETCORE_URLS=http://*:80 ENTRYPOINT ["dotnet", "Influunt.Host.dll"] + +######## Influunt worker +FROM mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim as worker +COPY --from=build /worker /influunt +WORKDIR /influunt +ENTRYPOINT ["dotnet", "Influunt.Worker.dll"] diff --git a/Influunt.sln b/Influunt.sln index 5989ab1..5f8f337 100644 --- a/Influunt.sln +++ b/Influunt.sln @@ -21,6 +21,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Influunt.Scheduler.Tests", "tests\Influunt.Scheduler.Tests\Influunt.Scheduler.Tests.csproj", "{E9EA202F-F5BF-446B-A7BB-886643AE20C6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Influunt.Worker", "src\Influunt.Worker\Influunt.Worker.csproj", "{5DF597D9-BDF2-472A-917B-F82E0D42EC6C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -115,14 +117,31 @@ Global {E9EA202F-F5BF-446B-A7BB-886643AE20C6}.Release|x64.Build.0 = Release|Any CPU {E9EA202F-F5BF-446B-A7BB-886643AE20C6}.Release|x86.ActiveCfg = Release|Any CPU {E9EA202F-F5BF-446B-A7BB-886643AE20C6}.Release|x86.Build.0 = Release|Any CPU + {5DF597D9-BDF2-472A-917B-F82E0D42EC6C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5DF597D9-BDF2-472A-917B-F82E0D42EC6C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5DF597D9-BDF2-472A-917B-F82E0D42EC6C}.Debug|x64.ActiveCfg = Debug|Any CPU + {5DF597D9-BDF2-472A-917B-F82E0D42EC6C}.Debug|x64.Build.0 = Debug|Any CPU + {5DF597D9-BDF2-472A-917B-F82E0D42EC6C}.Debug|x86.ActiveCfg = Debug|Any CPU + {5DF597D9-BDF2-472A-917B-F82E0D42EC6C}.Debug|x86.Build.0 = Debug|Any CPU + {5DF597D9-BDF2-472A-917B-F82E0D42EC6C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5DF597D9-BDF2-472A-917B-F82E0D42EC6C}.Release|Any CPU.Build.0 = Release|Any CPU + {5DF597D9-BDF2-472A-917B-F82E0D42EC6C}.Release|x64.ActiveCfg = Release|Any CPU + {5DF597D9-BDF2-472A-917B-F82E0D42EC6C}.Release|x64.Build.0 = Release|Any CPU + {5DF597D9-BDF2-472A-917B-F82E0D42EC6C}.Release|x86.ActiveCfg = Release|Any CPU + {5DF597D9-BDF2-472A-917B-F82E0D42EC6C}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution + {D5232164-9233-4B32-8022-75799161F113} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {82E2087D-00F6-47A4-90DE-47BFAF40DF39} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {9F87FB47-0B6A-435A-BC52-5AE6AC25245C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {C8997ACE-506F-470F-8BD5-EC955BE522E0} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {44AC6B8E-C694-48E7-95B6-5481EB122559} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {D065CA50-272A-4E29-8D75-3A373EBCB98B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {E9EA202F-F5BF-446B-A7BB-886643AE20C6} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {5DF597D9-BDF2-472A-917B-F82E0D42EC6C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {FDF77C48-6495-4B15-9513-34DB5A584086} diff --git a/README.md b/README.md index 929287b..1307752 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,7 @@ Influunt use MongoDB as persistent data storage and Redis for faster distributed ### Feed -* `FeedCrawler:Enabled` - enable background channels fetch (default: `true`). _In the future, can be disabled in api, and enabled in external worker_ -* `FeedCrawler:FetchInterval` - interval between fetching news from channels (default: `00:30:00` (30min)) +* `FeedCrawler:Enabled` - enable job host inside web host (default: `true`). _It may be disabled if you run Worker service_ * `FeedCrawler:LastActivityDaysAgo` - Minimal user last activity date, for fetch news. If user don't use service more then n days, don't fetch news from user channel (default 93 days (~3 month)) ## Screenshots @@ -66,7 +65,7 @@ Influunt use MongoDB as persistent data storage and Redis for faster distributed ## Using in docker -**Docker compose file for swarm mode:** +**Docker compose file for swarm mode (with worker):** ```Dockerfile version: '3.8' @@ -78,6 +77,7 @@ services: - "ConnectionStrings:Redis:ConnectionString=[Redis address]" # optional redis-based distributed cache connection - "Authentication:Google:ClientSecret=[GOOGLE CLIENT SECRET]" - "Authentication:Google:ClientId=[GOOGLE CLIENT ID]" + - "FeedCrawler:Enabled=false" ports: - target: 80 published: 30002 @@ -90,6 +90,17 @@ services: options: max-size: "3m" max-file: "3" + host: + image: eluki/influunt-worker + environment: + - "ConnectionStrings:Mongo:ConnectionString=[MONGO DB CONNECTION STRING]" + deploy: + replicas: 1 + logging: + driver: "json-file" + options: + max-size: "3m" + max-file: "3" ``` Run stack: ```bash diff --git a/src/Influunt.Feed.Rss/Influunt.Feed.Rss.csproj b/src/Influunt.Feed.Rss/Influunt.Feed.Rss.csproj index f4c5ec1..daf69b0 100644 --- a/src/Influunt.Feed.Rss/Influunt.Feed.Rss.csproj +++ b/src/Influunt.Feed.Rss/Influunt.Feed.Rss.csproj @@ -14,7 +14,7 @@ - + diff --git a/src/Influunt.Feed/Crawler/CrawlerOptions.cs b/src/Influunt.Feed/Crawler/CrawlerOptions.cs index 3ca4f80..d2f5f57 100644 --- a/src/Influunt.Feed/Crawler/CrawlerOptions.cs +++ b/src/Influunt.Feed/Crawler/CrawlerOptions.cs @@ -1,9 +1,6 @@ -using System; - namespace Influunt.Feed.Crawler; public class CrawlerOptions { - public TimeSpan FetchInterval { get; set; } = TimeSpan.FromMinutes(30); public int LastActivityDaysAgo { get; set; } = 31*3; // 3 Month default } \ No newline at end of file diff --git a/src/Influunt.Feed/Crawler/FeedCrawlerBackgroundWorker.cs b/src/Influunt.Feed/Crawler/FeedCrawlerBackgroundWorker.cs deleted file mode 100644 index 68a4407..0000000 --- a/src/Influunt.Feed/Crawler/FeedCrawlerBackgroundWorker.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Influunt.Feed.Entity; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Skidbladnir.Utility.Common; - -namespace Influunt.Feed.Crawler; - -public class FeedCrawlerBackgroundWorker : BackgroundService -{ - private readonly IEnumerable _feedSourceProviders; - private readonly IFeedService _feedService; - private readonly IUserService _userService; - private readonly IChannelService _channelService; - private readonly CrawlerOptions _options; - private readonly ILogger _logger; - - public FeedCrawlerBackgroundWorker(IEnumerable feedSourceProviders, - IFeedService feedService, IUserService userService, - IOptions options, - IChannelService channelService, - ILogger logger) - { - _feedSourceProviders = feedSourceProviders; - _feedService = feedService; - _userService = userService; - _channelService = channelService; - _options = options.Value; - _logger = logger; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - _logger.LogInformation("Feed Crawler Background Worker running."); - - - while (!stoppingToken.IsCancellationRequested) - { - try - { - await InnerExecute(stoppingToken); - } - catch (Exception e) - { - if (e is OperationCanceledException) - break; - _logger.LogError(e, "Error in Feed Crawler Background Worker when fetch feeds."); - } - await Task.Delay(_options.FetchInterval, stoppingToken); - } - _logger.LogInformation("Feed Crawler Background Worker is stopping."); - - } - - private async Task InnerExecute(CancellationToken token) - { - var minimumLastActivityDate = DateTime.UtcNow - TimeSpan.FromDays(_options.LastActivityDaysAgo); - var users = await _userService.GetUsers(); - token.ThrowIfCancellationRequested(); - foreach (var user in users.Where(x => x.LastActivity > minimumLastActivityDate)) - { - token.ThrowIfCancellationRequested(); - var channels = await Try.DoAsync(() => _channelService.GetUserChannels(user)); - if (channels is null) - continue; - try - { - var fetchTasks = channels.Select(x => FetchFeedFromChannel(x, user, token)); - await Task.WhenAll(fetchTasks); - } - catch (Exception e) - { - if (e is OperationCanceledException) - throw; - _logger.LogError(e, $"Can't fetch feed for user {user.Id}"); - } - } - } - - private async Task FetchFeedFromChannel(FeedChannel channel, User user, CancellationToken token) - { - token.ThrowIfCancellationRequested(); - var remoteFeedProvider = _feedSourceProviders.FirstOrDefault(x => x.CanProcessChannel(channel)); - if (remoteFeedProvider is null) - return; - var remoteFeed = await remoteFeedProvider.GetRemoteFeed(channel); - var count = await _feedService.TryAddToFeed(user, remoteFeed, channel); - _logger.LogInformation($"For user {user.Id} added {count} posts from channel {channel.Id}"); - } -} \ No newline at end of file diff --git a/src/Influunt.Feed/Crawler/FeedCrawlerJob.cs b/src/Influunt.Feed/Crawler/FeedCrawlerJob.cs new file mode 100644 index 0000000..4aa2a42 --- /dev/null +++ b/src/Influunt.Feed/Crawler/FeedCrawlerJob.cs @@ -0,0 +1,47 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Influunt.Scheduler.Abstractions.Models; +using Influunt.Scheduler.Abstractions.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Influunt.Feed.Crawler; + +public class FeedCrawlerJob : IJob +{ + private readonly IUserService _userService; + private readonly IScheduler _scheduler; + private readonly ILogger _logger; + private readonly CrawlerOptions _options; + + public FeedCrawlerJob(IOptions options, + IUserService userService, + IScheduler scheduler, + ILogger logger) + { + _options = options.Value; + _userService = userService; + _scheduler = scheduler; + _logger = logger; + } + + public async Task ExecuteAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Feed Crawler job started"); + var minimumLastActivityDate = DateTime.UtcNow - TimeSpan.FromDays(_options.LastActivityDaysAgo); + var users = await _userService.GetUsers(); + + foreach (var user in users.Where(x => x.LastActivity > minimumLastActivityDate)) + { + var triggerId = await _scheduler.ScheduleOneTimeJobAsync(new UserFeedChannelUpdateJobSettings() + { + User = user + }); + _logger.LogInformation("Feed channel update job enqueued for user {email} with trigger id {triggerId}", user.Email, triggerId); + } + _logger.LogInformation("Feed Crawler job ended"); + } + +} diff --git a/src/Influunt.Feed/Crawler/UserFeedChannelUpdateJob.cs b/src/Influunt.Feed/Crawler/UserFeedChannelUpdateJob.cs new file mode 100644 index 0000000..8a41662 --- /dev/null +++ b/src/Influunt.Feed/Crawler/UserFeedChannelUpdateJob.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Influunt.Feed.Entity; +using Influunt.Scheduler.Abstractions.Models; +using Microsoft.Extensions.Logging; +using Skidbladnir.Utility.Common; + +namespace Influunt.Feed.Crawler; + +public class UserFeedChannelUpdateJob : IJob +{ + private readonly IEnumerable _feedSourceProviders; + private readonly IFeedService _feedService; + private readonly IChannelService _channelService; + private readonly ILogger _logger; + + public UserFeedChannelUpdateJob(IEnumerable feedSourceProviders, + IFeedService feedService, + IChannelService channelService, + ILogger logger) + { + _feedSourceProviders = feedSourceProviders; + _feedService = feedService; + _channelService = channelService; + _logger = logger; + } + + public async Task ExecuteAsync(UserFeedChannelUpdateJobSettings settings, CancellationToken cancellationToken) + { + if (settings?.User is null) + { + _logger.LogError("User is null"); + return; + } + _logger.BeginScope("Update feed channels job for user {email}", settings.User.Email); + var channels = await Try.DoAsync(() => _channelService.GetUserChannels(settings.User)); + if (channels is null) + return; + + try + { + var fetchTasks = channels.Select(x => FetchFeedFromChannel(x, settings.User)); + await Task.WhenAll(fetchTasks); + } + catch (Exception e) + { + _logger.LogError(e, $"Can't fetch feed"); + } + } + + + private async Task FetchFeedFromChannel(FeedChannel channel, User user) + { + var remoteFeedProvider = _feedSourceProviders.FirstOrDefault(x => x.CanProcessChannel(channel)); + if (remoteFeedProvider is null) + return; + var remoteFeed = await remoteFeedProvider.GetRemoteFeed(channel); + var count = await _feedService.TryAddToFeed(user, remoteFeed, channel); + _logger.LogInformation("Added {count} posts from channel {channelId}", user.Email, count, channel.Id); + } + +} diff --git a/src/Influunt.Feed/Crawler/UserFeedChannelUpdateJobSettings.cs b/src/Influunt.Feed/Crawler/UserFeedChannelUpdateJobSettings.cs new file mode 100644 index 0000000..c7e60ea --- /dev/null +++ b/src/Influunt.Feed/Crawler/UserFeedChannelUpdateJobSettings.cs @@ -0,0 +1,8 @@ +using Influunt.Feed.Entity; + +namespace Influunt.Feed.Crawler; + +public class UserFeedChannelUpdateJobSettings +{ + public User User { get; set; } +} diff --git a/src/Influunt.Feed/Extensions/ServicesExtensions.cs b/src/Influunt.Feed/Extensions/ServicesExtensions.cs new file mode 100644 index 0000000..ef9549c --- /dev/null +++ b/src/Influunt.Feed/Extensions/ServicesExtensions.cs @@ -0,0 +1,15 @@ +using System.Threading.Tasks; +using Influunt.Feed.Crawler; +using Microsoft.Extensions.DependencyInjection; + +namespace Influunt.Feed.Extensions; + +public static class ServicesExtensions +{ + public static IServiceCollection AddFeedJobs(this IServiceCollection services) + { + return services.AddScoped() + .AddScoped() + .AddHostedService(); + } +} diff --git a/src/Influunt.Feed/FeedJobsRegistrationService.cs b/src/Influunt.Feed/FeedJobsRegistrationService.cs new file mode 100644 index 0000000..359d855 --- /dev/null +++ b/src/Influunt.Feed/FeedJobsRegistrationService.cs @@ -0,0 +1,29 @@ +using System.Threading; +using System.Threading.Tasks; +using Influunt.Feed.Crawler; +using Influunt.Scheduler.Abstractions.Services; +using Microsoft.Extensions.Hosting; + +namespace Influunt.Feed; + +public class FeedJobsRegistrationService : BackgroundService +{ + private const string CrawlerJobTriggerId = "RegularFeedCrawlerJob"; + private readonly IScheduler _scheduler; + + public FeedJobsRegistrationService(IScheduler scheduler) + { + _scheduler = scheduler; + + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await _scheduler.RegisterJobAsync("Users feeds updating job", "The task starts updating RSS feeds for each active user"); + await _scheduler.RegisterJobAsync("User feed updating job", "The task receives new posts from the user's RSS feeds and adds them to the news feed"); + + await _scheduler.UnScheduleJobAsync(CrawlerJobTriggerId); + await _scheduler.ScheduleRecurringJobAsync("*/30 * * * *", CrawlerJobTriggerId); + } + +} diff --git a/src/Influunt.Feed/Influunt.Feed.csproj b/src/Influunt.Feed/Influunt.Feed.csproj index 9d4e64d..385e87d 100644 --- a/src/Influunt.Feed/Influunt.Feed.csproj +++ b/src/Influunt.Feed/Influunt.Feed.csproj @@ -1,19 +1,21 @@ - - - - net8 - Klabukov Erik - Influunt - Influunt - Simple Rss Agregator - Klabukov Erik - false - - - - - - - - - - + + + net8 + Klabukov Erik + Influunt + Influunt - Simple Rss Agregator + Klabukov Erik + false + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Influunt.Host/Controllers/ChannelController.cs b/src/Influunt.Host/Controllers/ChannelController.cs index 3a9cdfc..afbf504 100644 --- a/src/Influunt.Host/Controllers/ChannelController.cs +++ b/src/Influunt.Host/Controllers/ChannelController.cs @@ -6,6 +6,9 @@ using System.Net; using System.Threading.Tasks; using Influunt.Host.ViewModels; +using Influunt.Scheduler.Abstractions.Services; +using Influunt.Feed.Entity; +using Influunt.Feed.Crawler; namespace Influunt.Host.Controllers; @@ -20,15 +23,18 @@ public class ChannelController : ControllerBase private readonly IUserService _userService; private readonly IChannelService _channelService; private readonly IFeedService _feedService; + private readonly IScheduler _scheduler; /// public ChannelController(IUserService userService, IChannelService channelService, - IFeedService feedService) + IFeedService feedService, + IScheduler scheduler) { _userService = userService; _channelService = channelService; _feedService = feedService; + _scheduler = scheduler; } /// @@ -58,7 +64,7 @@ public async Task Get(string id) if (channel.UserId.Equals(user.Id, StringComparison.OrdinalIgnoreCase)) return channel.ToModel(); - Response.StatusCode = (int) HttpStatusCode.Forbidden; + Response.StatusCode = (int)HttpStatusCode.Forbidden; return null; } @@ -113,7 +119,7 @@ public async Task Delete(string id) { var user = await _userService.GetCurrentUser(); var channel = await _channelService.Get(id); - if (!channel.UserId.Equals(user.Id, StringComparison.OrdinalIgnoreCase)) + if (!channel.UserId.Equals(user.Id, StringComparison.OrdinalIgnoreCase)) return Forbid(); await _channelService.Remove(user, channel); #pragma warning disable 4014 @@ -121,4 +127,17 @@ public async Task Delete(string id) #pragma warning restore 4014 return Ok(); } + + private async Task ScheduleFeedUpdateJob(User user) + { + var triggerId = $"user_{user.Id}_manual_feed_update"; + await _scheduler.UnScheduleJobAsync(triggerId); + await _scheduler.ScheduleOneTimeJobAsync( + new UserFeedChannelUpdateJobSettings() + { + User = user + }, + DateTime.UtcNow + TimeSpan.FromMinutes(10), + triggerId); + } } \ No newline at end of file diff --git a/src/Influunt.Host/Influunt.Host.csproj b/src/Influunt.Host/Influunt.Host.csproj index c04b05d..9acdf12 100644 --- a/src/Influunt.Host/Influunt.Host.csproj +++ b/src/Influunt.Host/Influunt.Host.csproj @@ -24,8 +24,7 @@ - - + @@ -34,16 +33,17 @@ - - - - - + + + + + + diff --git a/src/Influunt.Host/Program.cs b/src/Influunt.Host/Program.cs index a5a52d7..5c1f85d 100644 --- a/src/Influunt.Host/Program.cs +++ b/src/Influunt.Host/Program.cs @@ -1,6 +1,8 @@ +using System; using Influunt.Feed.Crawler; using Influunt.Host; using Influunt.Host.Configurations; +using Influunt.Scheduler; using Influunt.Storage; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.HttpOverrides; @@ -13,10 +15,17 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddOptions(); +Action schedulerConfigureAction = (c) => +{ + builder.Configuration.GetSection("Scheduler").Bind(c); + c.ConnectionString = builder.Configuration.GetSection("ConnectionStrings:Mongo").GetValue("ConnectionString"); +}; builder.Services.Configure(builder.Configuration.GetSection("FeedCrawler")); var crawlerEnabled = builder.Configuration.GetSection("FeedCrawler:Enabled").Get(); if (crawlerEnabled) - builder.Services.AddHostedService(); + builder.Services.AddJobSchedulerHost(schedulerConfigureAction); +else + builder.Services.AddJobScheduler(schedulerConfigureAction); builder.Services.AddSkidbladnirModules(configuration => { diff --git a/src/Influunt.Host/StartupModule.cs b/src/Influunt.Host/StartupModule.cs index 95d56c1..96d3931 100644 --- a/src/Influunt.Host/StartupModule.cs +++ b/src/Influunt.Host/StartupModule.cs @@ -1,4 +1,5 @@ using System; +using Influunt.Feed.Extensions; using Influunt.Feed.Rss; using Influunt.Host.Configurations; using Influunt.Host.Services; @@ -23,6 +24,7 @@ public override void Configure(IServiceCollection services) .PersistKeysToMongoDb(Configuration.AppConfiguration["ConnectionStrings:Mongo:ConnectionString"]); ConfigureDistributedCache(services); services.TryAddSingleton(); + services.AddFeedJobs(); } private void ConfigureDistributedCache(IServiceCollection services) diff --git a/src/Influunt.Host/WebModule.cs b/src/Influunt.Host/WebModule.cs index 1db7180..26edf98 100644 --- a/src/Influunt.Host/WebModule.cs +++ b/src/Influunt.Host/WebModule.cs @@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.DependencyInjection; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using Skidbladnir.Modules; namespace Influunt.Host; diff --git a/src/Influunt.Host/appsettings.Development.json b/src/Influunt.Host/appsettings.Development.json index 3f90f31..827a2f8 100644 --- a/src/Influunt.Host/appsettings.Development.json +++ b/src/Influunt.Host/appsettings.Development.json @@ -1,10 +1,7 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Debug" - } - }, - "FeedService": { - "FeedUpdateCron": "* * * * *" - } -} +{ + "Logging": { + "LogLevel": { + "Default": "Debug" + } + } +} diff --git a/src/Influunt.Host/appsettings.json b/src/Influunt.Host/appsettings.json index ea6dbdd..fe4bd46 100644 --- a/src/Influunt.Host/appsettings.json +++ b/src/Influunt.Host/appsettings.json @@ -23,5 +23,11 @@ }, "FeedCrawler": { "Enabled": true - } + }, + "Scheduler":{ + "ScanIntervalSeconds": 30, + "LockTimeoutSeconds": 600, + "HeartbeatIntervalSeconds": 60, + "MaxConcurrentJobs": 10 + } } diff --git a/src/Influunt.Scheduler/Influunt.Scheduler.csproj b/src/Influunt.Scheduler/Influunt.Scheduler.csproj index 75b4878..f1d7e75 100644 --- a/src/Influunt.Scheduler/Influunt.Scheduler.csproj +++ b/src/Influunt.Scheduler/Influunt.Scheduler.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/Influunt.Scheduler/JobDefinitionClassMap.cs b/src/Influunt.Scheduler/JobDefinitionClassMap.cs index fe5f9cf..d81a0d2 100644 --- a/src/Influunt.Scheduler/JobDefinitionClassMap.cs +++ b/src/Influunt.Scheduler/JobDefinitionClassMap.cs @@ -15,5 +15,7 @@ public JobDefinitionClassMap() .SetIdGenerator(StringObjectIdGenerator.Instance) .SetSerializer(new StringSerializer(BsonType.ObjectId)); SetIgnoreExtraElements(true); + MapProperty(x => x.Id) + .SetIgnoreIfNull(true); } } diff --git a/src/Influunt.Scheduler/JobExecutionClassMap.cs b/src/Influunt.Scheduler/JobExecutionClassMap.cs index 0f0b90f..208a046 100644 --- a/src/Influunt.Scheduler/JobExecutionClassMap.cs +++ b/src/Influunt.Scheduler/JobExecutionClassMap.cs @@ -15,5 +15,7 @@ public JobExecutionClassMap() .SetIdGenerator(StringObjectIdGenerator.Instance) .SetSerializer(new StringSerializer(BsonType.ObjectId)); SetIgnoreExtraElements(true); + MapProperty(x => x.Id) + .SetIgnoreIfNull(true); } } diff --git a/src/Influunt.Scheduler/JobSchedulerOptions.cs b/src/Influunt.Scheduler/JobSchedulerOptions.cs index 7fa9532..b5a7a9e 100644 --- a/src/Influunt.Scheduler/JobSchedulerOptions.cs +++ b/src/Influunt.Scheduler/JobSchedulerOptions.cs @@ -3,7 +3,7 @@ namespace Influunt.Scheduler; public class JobSchedulerOptions { /// MongoDB connection string. - public string ConnectionString { get; set; } = "mongodb://localhost:27017/JobScheduler"; + public string ConnectionString { get; set; } = ""; /// Trigger scan interval (seconds). public int ScanIntervalSeconds { get; set; } = 60; diff --git a/src/Influunt.Worker/Influunt.Worker.csproj b/src/Influunt.Worker/Influunt.Worker.csproj new file mode 100644 index 0000000..91fca51 --- /dev/null +++ b/src/Influunt.Worker/Influunt.Worker.csproj @@ -0,0 +1,20 @@ + + + net8 + enable + enable + f7b6a3ab-5dd3-487c-8763-0c114addb57b + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Influunt.Worker/NoOpHttpContextAccessor.cs b/src/Influunt.Worker/NoOpHttpContextAccessor.cs new file mode 100644 index 0000000..c0bea91 --- /dev/null +++ b/src/Influunt.Worker/NoOpHttpContextAccessor.cs @@ -0,0 +1,7 @@ +using Microsoft.AspNetCore.Http; + +public class NoOpHttpContextAccessor : IHttpContextAccessor +{ + public HttpContext? HttpContext { get => null; set => throw new NotImplementedException(); } + +} \ No newline at end of file diff --git a/src/Influunt.Worker/Program.cs b/src/Influunt.Worker/Program.cs new file mode 100644 index 0000000..d6c5ffe --- /dev/null +++ b/src/Influunt.Worker/Program.cs @@ -0,0 +1,22 @@ +using Influunt.Scheduler; +using Influunt.Storage; +using Influunt.Worker; +using Skidbladnir.Modules; + +var builder = Host.CreateApplicationBuilder(args); +if (builder.Environment.IsDevelopment()) + builder.Configuration.AddUserSecrets(); +builder.Services.AddOptions(); +builder.Services.AddJobSchedulerHost(c => +{ + builder.Configuration.GetSection("Scheduler").Bind(c); + c.ConnectionString = builder.Configuration.GetSection("ConnectionStrings:Mongo").GetValue("ConnectionString")!; +}); +builder.Services.AddSkidbladnirModules(configuration => +{ + var storageConfiguration = builder.Configuration.GetSection("ConnectionStrings:Mongo").Get(); + configuration.Add(storageConfiguration); +}, builder.Configuration); + +var host = builder.Build(); +host.Run(); diff --git a/src/Influunt.Worker/Properties/launchSettings.json b/src/Influunt.Worker/Properties/launchSettings.json new file mode 100644 index 0000000..c865ceb --- /dev/null +++ b/src/Influunt.Worker/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "Influunt.Worker": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Influunt.Worker/StartupModule.cs b/src/Influunt.Worker/StartupModule.cs new file mode 100644 index 0000000..965a0b8 --- /dev/null +++ b/src/Influunt.Worker/StartupModule.cs @@ -0,0 +1,33 @@ +using Influunt.Feed.Extensions; +using Influunt.Feed.Rss; +using Influunt.Storage; +using Microsoft.AspNetCore.Http; +using Skidbladnir.Caching.Distributed.MongoDB; +using Skidbladnir.Modules; + +namespace Influunt.Worker; + +public class StartupModule : Module +{ + public override Type[] DependsModules => [typeof(StorageModule), typeof(RssModule)]; + + public override void Configure(IServiceCollection services) + { + services.AddSingleton(); + ConfigureDistributedCache(services); + services.AddFeedJobs(); + } + + private void ConfigureDistributedCache(IServiceCollection services) + { + var redisConfiguration = Configuration.AppConfiguration["ConnectionStrings:Redis:ConnectionString"]; + if(string.IsNullOrWhiteSpace(redisConfiguration)) + { + services.AddMongoDistributedCache(Configuration.AppConfiguration["ConnectionStrings:Mongo:ConnectionString"]); + } + else + { + services.AddStackExchangeRedisCache(c => c.Configuration = redisConfiguration); + } + } +} \ No newline at end of file diff --git a/src/Influunt.Worker/appsettings.Development.json b/src/Influunt.Worker/appsettings.Development.json new file mode 100644 index 0000000..b2dcdb6 --- /dev/null +++ b/src/Influunt.Worker/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/src/Influunt.Worker/appsettings.json b/src/Influunt.Worker/appsettings.json new file mode 100644 index 0000000..43cc794 --- /dev/null +++ b/src/Influunt.Worker/appsettings.json @@ -0,0 +1,22 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "ConnectionStrings": { + "Mongo": { + "ConnectionString": "" + }, + "Redis":{ + "ConnectionString": "" + } + }, + "Scheduler":{ + "ScanIntervalSeconds": 30, + "LockTimeoutSeconds": 600, + "HeartbeatIntervalSeconds": 60, + "MaxConcurrentJobs": 10 + } +} diff --git a/tests/Influunt.Scheduler.Tests/JobSchedulerIntegrationTests.cs b/tests/Influunt.Scheduler.Tests/JobSchedulerIntegrationTests.cs index e5132c0..a15f9c6 100644 --- a/tests/Influunt.Scheduler.Tests/JobSchedulerIntegrationTests.cs +++ b/tests/Influunt.Scheduler.Tests/JobSchedulerIntegrationTests.cs @@ -47,6 +47,7 @@ public async Task Should_Register_Job_Without_Settings() var jobs = await scheduler.GetRegisteredJobsAsync(); jobs.Should().ContainSingle(); var job = jobs.First(); + job.Id.Should().NotBeNullOrEmpty(); job.Name.Should().Be("TestJobWithoutSettings"); job.Description.Should().Be("Test job without settings"); job.JobType.Should().Be(typeof(TestJobWithoutSettings).AssemblyQualifiedName); @@ -66,6 +67,7 @@ public async Task Should_Register_Job_With_Settings() var jobs = await scheduler.GetRegisteredJobsAsync(); jobs.Should().ContainSingle(); var job = jobs.First(); + job.Id.Should().NotBeNullOrEmpty(); job.Name.Should().Be("TestJobWithSettings"); job.Description.Should().Be("Test job with settings"); job.JobType.Should().Be(typeof(TestJobWithSettings).AssemblyQualifiedName); @@ -88,6 +90,7 @@ public async Task Should_Schedule_OneTime_Job_Without_Settings() var triggers = await scheduler.GetScheduledTriggersAsync(); triggers.Should().ContainSingle(); var trigger = triggers.First(); + trigger.Id.Should().NotBeNullOrEmpty(); trigger.TriggerId.Should().Be(triggerId); trigger.JobType.Should().Be(typeof(TestJobWithoutSettings).AssemblyQualifiedName); trigger.IsRecurring.Should().BeFalse(); @@ -118,6 +121,7 @@ public async Task Should_Schedule_Recurring_Job_Without_Settings() var triggers = await scheduler.GetScheduledTriggersAsync(); triggers.Should().ContainSingle(); var trigger = triggers.First(); + trigger.Id.Should().NotBeNullOrEmpty(); trigger.TriggerId.Should().Be(triggerId); trigger.JobType.Should().Be(typeof(TestJobWithoutSettings).AssemblyQualifiedName); trigger.IsRecurring.Should().BeTrue(); @@ -217,6 +221,7 @@ public async Task Should_Handle_Multiple_Concurrent_Jobs() await Task.Delay(TimeSpan.FromSeconds(4)); TestJobWithoutSettings.WasExecuted.Should().BeTrue(); + TestJobWithoutSettings.ExecutionCount.Should().Be(3); // All triggers should be removed after execution triggers = await scheduler.GetScheduledTriggersAsync(); diff --git a/tests/Influunt.Scheduler.Tests/TestJobs.cs b/tests/Influunt.Scheduler.Tests/TestJobs.cs index edf4ff8..6320d4c 100644 --- a/tests/Influunt.Scheduler.Tests/TestJobs.cs +++ b/tests/Influunt.Scheduler.Tests/TestJobs.cs @@ -4,17 +4,20 @@ namespace Influunt.Scheduler.Tests; public class TestJobWithoutSettings : IJob { + public static int ExecutionCount = 0; public static bool WasExecuted { get; set; } public Task ExecuteAsync(CancellationToken cancellationToken) { WasExecuted = true; + Interlocked.Increment(ref ExecutionCount); return Task.CompletedTask; } } public class TestJobWithSettings : IJob { + public static int ExecutionCount = 0; public static TestJobSettings? LastSettings { get; set; } public static bool WasExecuted { get; set; } @@ -22,6 +25,7 @@ public Task ExecuteAsync(TestJobSettings settings, CancellationToken cancellatio { LastSettings = settings; WasExecuted = true; + Interlocked.Increment(ref ExecutionCount); return Task.CompletedTask; } } From d8c4a15e5bfb732d6a3806893602ff1613798ea6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BB=D0=B0=D0=B1=D1=83=D0=BA=D0=BE=D0=B2=20=D0=AD?= =?UTF-8?q?=D1=80=D0=B8=D0=BA=20=D0=9A=D0=BE=D0=BD=D1=81=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D1=82=D0=B8=D0=BD=D0=BE=D0=B2=D0=B8=D1=87?= Date: Fri, 22 May 2026 22:34:12 +0500 Subject: [PATCH 6/8] Cleanup previous recurring run if it completed or failed state. Needs to run. + test on this case --- src/Influunt.Scheduler/JobScheduler.cs | 14 +++++- .../JobSchedulerIntegrationTests.cs | 45 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/Influunt.Scheduler/JobScheduler.cs b/src/Influunt.Scheduler/JobScheduler.cs index f26214a..4bf4b28 100644 --- a/src/Influunt.Scheduler/JobScheduler.cs +++ b/src/Influunt.Scheduler/JobScheduler.cs @@ -265,7 +265,19 @@ private async Task ScheduleJobAsync(JobTrigger trigger) if (existingExecution == null) { - // 3. If JobExecution doesn't exist, create it, attempt to acquire lock, start the job + // 3. Clean up any previous terminal executions for this trigger + // (Completed or Failed from a previous recurring run) + await _executions.DeleteManyAsync( + Builders.Filter.And( + Builders.Filter.Eq(e => e.TriggerId, trigger.TriggerId), + Builders.Filter.Or( + Builders.Filter.Eq(e => e.Status, ExecutionStatus.Completed), + Builders.Filter.Eq(e => e.Status, ExecutionStatus.Failed) + ) + ) + ); + + // 4. If JobExecution doesn't exist, create it, attempt to acquire lock, start the job await CreateAndStartJobExecution(trigger, jobDef); return; } diff --git a/tests/Influunt.Scheduler.Tests/JobSchedulerIntegrationTests.cs b/tests/Influunt.Scheduler.Tests/JobSchedulerIntegrationTests.cs index a15f9c6..35b7654 100644 --- a/tests/Influunt.Scheduler.Tests/JobSchedulerIntegrationTests.cs +++ b/tests/Influunt.Scheduler.Tests/JobSchedulerIntegrationTests.cs @@ -1,4 +1,6 @@ using AwesomeAssertions; +using Influunt.Scheduler; +using Influunt.Scheduler.Abstractions.Models; using Influunt.Scheduler.Abstractions.Services; using Microsoft.Extensions.DependencyInjection; using MongoDB.Driver; @@ -28,7 +30,9 @@ protected override async Task InitializeTestDataAsync() { // Reset static fields TestJobWithoutSettings.WasExecuted = false; + TestJobWithoutSettings.ExecutionCount = 0; TestJobWithSettings.WasExecuted = false; + TestJobWithSettings.ExecutionCount = 0; TestJobWithSettings.LastSettings = null; await base.InitializeTestDataAsync(); @@ -136,6 +140,39 @@ public async Task Should_Schedule_Recurring_Job_Without_Settings() triggers.Should().ContainSingle(); } + [Fact] + public async Task Should_Execute_Recurring_Job_Multiple_Times() + { + // Arrange + var scheduler = GetRequiredService(); + await scheduler.RegisterJobAsync("TestJobWithoutSettings"); + + var triggerId = await scheduler.ScheduleRecurringJobAsync("* * * * *"); + + // Force the trigger to be due immediately + var context = GetRequiredService(); + var triggersCollection = context.GetCollection(Constants.JobTriggersCollectionName); + await ForceRunTrigger(triggerId, triggersCollection); + + // Wait for first execution to complete + await Task.Delay(TimeSpan.FromSeconds(3)); + + TestJobWithoutSettings.ExecutionCount.Should().BeGreaterThanOrEqualTo(1); + + // Force trigger to be due again for second execution + await ForceRunTrigger(triggerId, triggersCollection); + + // Wait for second scan cycle + await Task.Delay(TimeSpan.FromSeconds(3)); + + // Verify job ran at least twice + TestJobWithoutSettings.ExecutionCount.Should().BeGreaterThanOrEqualTo(2); + + // Trigger should still exist for recurring job + var remainingTriggers = await scheduler.GetScheduledTriggersAsync(); + remainingTriggers.Should().ContainSingle(); + } + [Fact] public async Task Should_Schedule_OneTime_Job_With_Settings() { @@ -227,4 +264,12 @@ public async Task Should_Handle_Multiple_Concurrent_Jobs() triggers = await scheduler.GetScheduledTriggersAsync(); triggers.Should().BeEmpty(); } + + + private static async Task ForceRunTrigger(string triggerId, IMongoCollection triggersCollection) + { + await triggersCollection.UpdateOneAsync( + t => t.TriggerId == triggerId, + Builders.Update.Set(t => t.NextRunTime, DateTime.UtcNow.AddSeconds(-5))); + } } \ No newline at end of file From 4bf001168bfe6a462191568050935575d8c15785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BB=D0=B0=D0=B1=D1=83=D0=BA=D0=BE=D0=B2=20=D0=AD?= =?UTF-8?q?=D1=80=D0=B8=D0=BA=20=D0=9A=D0=BE=D0=BD=D1=81=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D1=82=D0=B8=D0=BD=D0=BE=D0=B2=D0=B8=D1=87?= Date: Sun, 24 May 2026 12:20:40 +0500 Subject: [PATCH 7/8] fix log --- src/Influunt.Feed/Crawler/UserFeedChannelUpdateJob.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Influunt.Feed/Crawler/UserFeedChannelUpdateJob.cs b/src/Influunt.Feed/Crawler/UserFeedChannelUpdateJob.cs index 8a41662..b42b1b7 100644 --- a/src/Influunt.Feed/Crawler/UserFeedChannelUpdateJob.cs +++ b/src/Influunt.Feed/Crawler/UserFeedChannelUpdateJob.cs @@ -59,7 +59,7 @@ private async Task FetchFeedFromChannel(FeedChannel channel, User user) return; var remoteFeed = await remoteFeedProvider.GetRemoteFeed(channel); var count = await _feedService.TryAddToFeed(user, remoteFeed, channel); - _logger.LogInformation("Added {count} posts from channel {channelId}", user.Email, count, channel.Id); + _logger.LogInformation("Added {count} posts from channel {channelId} to {email} feed", count, channel.Id, user.Email); } } From df451ccb1fa585b2587daff6182bd00857bb70de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BB=D0=B0=D0=B1=D1=83=D0=BA=D0=BE=D0=B2=20=D0=AD?= =?UTF-8?q?=D1=80=D0=B8=D0=BA=20=D0=9A=D0=BE=D0=BD=D1=81=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D1=82=D0=B8=D0=BD=D0=BE=D0=B2=D0=B8=D1=87?= Date: Tue, 3 Nov 2026 22:31:24 +0500 Subject: [PATCH 8/8] Scheduler fixes --- .vscode/settings.json | 5 +- Influunt.sln | 15 + README.md | 18 +- src/Influunt.Feed.Rss/Extensions.cs | 11 +- .../Influunt.Feed.Rss.csproj | 5 +- src/Influunt.Feed.Rss/RssClient.cs | 19 +- .../RssFeedSourceProvider.cs | 16 +- src/Influunt.Feed/Crawler/FeedCrawlerJob.cs | 35 +- .../Crawler/UserFeedChannelUpdateJob.cs | 44 +- src/Influunt.Feed/IChannelService.cs | 5 +- src/Influunt.Feed/IFeedService.cs | 5 +- src/Influunt.Feed/IFeedSourceProvider.cs | 5 +- src/Influunt.Feed/IUserService.cs | 5 +- .../Controllers/ChannelController.cs | 10 +- .../Models/JobExecution.cs | 8 +- .../Models/JobTrigger.cs | 5 +- .../Services/IScheduler.cs | 8 +- src/Influunt.Scheduler/Constants.cs | 6 +- .../IndexCreationBackgroundService.cs | 42 +- .../Influunt.Scheduler.csproj | 3 +- src/Influunt.Scheduler/JobScheduler.cs | 1187 ++++++++++------- src/Influunt.Scheduler/JobSchedulerOptions.cs | 3 + src/Influunt.Scheduler/JobTriggerClassMap.cs | 1 + src/Influunt.Scheduler/JobTypeLock.cs | 9 + src/Influunt.Scheduler/MongoContext.cs | 7 +- src/Influunt.Scheduler/SchedulerModule.cs | 17 +- src/Influunt.Storage/Influunt.Storage.csproj | 5 +- .../Services/ChannelService.cs | 8 +- src/Influunt.Storage/Services/FeedService.cs | 11 +- src/Influunt.Storage/Services/UserService.cs | 8 +- tests/Influunt.Feed.Tests/FeedJobsTests.cs | 226 ++++ .../Influunt.Feed.Tests.csproj | 20 + .../RssCancellationTests.cs | 96 ++ .../StorageCancellationTests.cs | 42 + .../CronHelperTests.cs | 37 + .../JobSchedulerIntegrationTests.cs | 69 +- .../JobSchedulerUnitTests.cs | 199 +++ .../ManualTimeProvider.cs | 8 + .../SchedulerBoundaryTests.cs | 479 +++++++ .../SchedulerIntegrationTestBase.cs | 4 +- .../SchedulerReliabilityTests.cs | 505 +++++++ tests/Influunt.Scheduler.Tests/TestJobs.cs | 17 + 42 files changed, 2616 insertions(+), 612 deletions(-) create mode 100644 src/Influunt.Scheduler/JobTypeLock.cs create mode 100644 tests/Influunt.Feed.Tests/FeedJobsTests.cs create mode 100644 tests/Influunt.Feed.Tests/Influunt.Feed.Tests.csproj create mode 100644 tests/Influunt.Feed.Tests/RssCancellationTests.cs create mode 100644 tests/Influunt.Feed.Tests/StorageCancellationTests.cs create mode 100644 tests/Influunt.Scheduler.Tests/CronHelperTests.cs create mode 100644 tests/Influunt.Scheduler.Tests/JobSchedulerUnitTests.cs create mode 100644 tests/Influunt.Scheduler.Tests/ManualTimeProvider.cs create mode 100644 tests/Influunt.Scheduler.Tests/SchedulerBoundaryTests.cs create mode 100644 tests/Influunt.Scheduler.Tests/SchedulerReliabilityTests.cs diff --git a/.vscode/settings.json b/.vscode/settings.json index 7e4bf2c..221c5f7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,5 +4,8 @@ "Catched", "Influunt", "Skidbladnir" - ] + ], + "dotrush.roslyn.projectOrSolutionFiles": [ + "Influunt.sln" + ], } \ No newline at end of file diff --git a/Influunt.sln b/Influunt.sln index 5f8f337..d5745fd 100644 --- a/Influunt.sln +++ b/Influunt.sln @@ -23,6 +23,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Influunt.Scheduler.Tests", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Influunt.Worker", "src\Influunt.Worker\Influunt.Worker.csproj", "{5DF597D9-BDF2-472A-917B-F82E0D42EC6C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Influunt.Feed.Tests", "tests\Influunt.Feed.Tests\Influunt.Feed.Tests.csproj", "{513FAC6E-AD73-4329-AB6E-272311D9FF6B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -129,6 +131,18 @@ Global {5DF597D9-BDF2-472A-917B-F82E0D42EC6C}.Release|x64.Build.0 = Release|Any CPU {5DF597D9-BDF2-472A-917B-F82E0D42EC6C}.Release|x86.ActiveCfg = Release|Any CPU {5DF597D9-BDF2-472A-917B-F82E0D42EC6C}.Release|x86.Build.0 = Release|Any CPU + {513FAC6E-AD73-4329-AB6E-272311D9FF6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {513FAC6E-AD73-4329-AB6E-272311D9FF6B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {513FAC6E-AD73-4329-AB6E-272311D9FF6B}.Debug|x64.ActiveCfg = Debug|Any CPU + {513FAC6E-AD73-4329-AB6E-272311D9FF6B}.Debug|x64.Build.0 = Debug|Any CPU + {513FAC6E-AD73-4329-AB6E-272311D9FF6B}.Debug|x86.ActiveCfg = Debug|Any CPU + {513FAC6E-AD73-4329-AB6E-272311D9FF6B}.Debug|x86.Build.0 = Debug|Any CPU + {513FAC6E-AD73-4329-AB6E-272311D9FF6B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {513FAC6E-AD73-4329-AB6E-272311D9FF6B}.Release|Any CPU.Build.0 = Release|Any CPU + {513FAC6E-AD73-4329-AB6E-272311D9FF6B}.Release|x64.ActiveCfg = Release|Any CPU + {513FAC6E-AD73-4329-AB6E-272311D9FF6B}.Release|x64.Build.0 = Release|Any CPU + {513FAC6E-AD73-4329-AB6E-272311D9FF6B}.Release|x86.ActiveCfg = Release|Any CPU + {513FAC6E-AD73-4329-AB6E-272311D9FF6B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -142,6 +156,7 @@ Global {D065CA50-272A-4E29-8D75-3A373EBCB98B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {E9EA202F-F5BF-446B-A7BB-886643AE20C6} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {5DF597D9-BDF2-472A-917B-F82E0D42EC6C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {513FAC6E-AD73-4329-AB6E-272311D9FF6B} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {FDF77C48-6495-4B15-9513-34DB5A584086} diff --git a/README.md b/README.md index 1307752..4a07ca3 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,23 @@ Influunt use MongoDB as persistent data storage and Redis for faster distributed * `FeedCrawler:Enabled` - enable job host inside web host (default: `true`). _It may be disabled if you run Worker service_ * `FeedCrawler:LastActivityDaysAgo` - Minimal user last activity date, for fetch news. If user don't use service more then n days, don't fetch news from user channel (default 93 days (~3 month)) -## Screenshots +### Scheduler + +Scheduler instances coordinate through MongoDB. `AllowConcurrentExecution = false` serializes a job type across all instances; `MaxConcurrentJobs` limits each instance. `ScanBatchSize` controls cursor batches without limiting how far a scan searches past busy triggers. The lock timeout must exceed the heartbeat interval. + +Each trigger generation and scheduled time has a persistent execution record. Expired `Pending` and `Running` executions are recovered with a new ownership token. Completion and heartbeat updates check that token so a previous owner cannot finish a replacement attempt. + +Successful and failed occurrences both consume their trigger: one-time triggers are removed, and recurring triggers advance to the next cron occurrence after completion. Missed cron occurrences are skipped. Exceptions do not request automatic retries; interrupted executions remain recoverable. Jobs should tolerate repeated side effects after a process crash or lease loss. + +On upgrade, stop older scheduler instances before starting this version. Startup creates the unique `TriggerOccurrence` index and removes the former unique index on `JobExecution.TriggerId`. Existing execution documents are preserved and adopted when recovered; subsequent occurrences retain their own records. MongoDB transactions or a replica set are not required. + +Shutdown cancels scanning, waits for in-flight scans and heartbeat writes, and cancels running jobs within the host's shutdown deadline. RSS jobs propagate cancellation through scheduling, channel loading, the concurrency limiter, HTTP, cache and storage operations. Feed download and storage errors reach the scheduler; the crawler continues scheduling other users and reports any enqueue failures together. + +### Tests + +Run fast tests with `dotnet test tests/Influunt.Scheduler.Tests --filter Category=Unit` and `dotnet test tests/Influunt.Feed.Tests`. Run MongoDB tests with `dotnet test tests/Influunt.Scheduler.Tests --filter Category=Integration` (Docker required). Add `--collect:"XPlat Code Coverage"` to collect coverage. Scheduler time boundaries use an injected `TimeProvider`; MongoDB failures and concurrent scheduler instances are covered separately. + +## Screenshots | | | | ------------------------------------------------------------------------------------------ | :---------------------------------------------------------------------------------------: | | ![Welcome](https://i.postimg.cc/TPkkZRgs/2020-11-28-12-37-08-localhost-06e95107c6d2.png) | ![Feed](https://i.postimg.cc/nzg3h0dY/2020-11-28-12-37-48-localhost-2ef2932fe865.png) | diff --git a/src/Influunt.Feed.Rss/Extensions.cs b/src/Influunt.Feed.Rss/Extensions.cs index 6f42de7..fff8ecd 100644 --- a/src/Influunt.Feed.Rss/Extensions.cs +++ b/src/Influunt.Feed.Rss/Extensions.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Text; using System.Threading.Tasks; +using System.Threading; using System.Xml.Serialization; using Influunt.Feed.Entity; using Microsoft.Extensions.Caching.Distributed; @@ -13,19 +14,19 @@ namespace Influunt.Feed.Rss; public static class Extensions { - public static async Task GetAsync(this IDistributedCache cache, string key) + public static async Task GetAsync(this IDistributedCache cache, string key, CancellationToken cancellationToken = default) { - var cacheValue = await cache.GetStringAsync(key); + var cacheValue = await cache.GetStringAsync(key, cancellationToken); if (string.IsNullOrWhiteSpace(cacheValue)) return default; return JsonConvert.DeserializeObject(cacheValue); } public static Task SetAsync(this IDistributedCache cache, string key, object entry, - DistributedCacheEntryOptions options) + DistributedCacheEntryOptions options, CancellationToken cancellationToken = default) { var serializedEntry = JsonConvert.SerializeObject(entry); - return cache.SetAsync(key, Encoding.UTF8.GetBytes(serializedEntry), options); + return cache.SetAsync(key, Encoding.UTF8.GetBytes(serializedEntry), options, cancellationToken); } public static List AsList(this IEnumerable source) @@ -83,4 +84,4 @@ public static List FeedFromAtomRss(this string xml) return item.NormalizeDescription(); }).ToList(); } -} \ No newline at end of file +} diff --git a/src/Influunt.Feed.Rss/Influunt.Feed.Rss.csproj b/src/Influunt.Feed.Rss/Influunt.Feed.Rss.csproj index daf69b0..366126c 100644 --- a/src/Influunt.Feed.Rss/Influunt.Feed.Rss.csproj +++ b/src/Influunt.Feed.Rss/Influunt.Feed.Rss.csproj @@ -22,5 +22,8 @@ + + + - \ No newline at end of file + diff --git a/src/Influunt.Feed.Rss/RssClient.cs b/src/Influunt.Feed.Rss/RssClient.cs index 520ca0a..08ebf5a 100644 --- a/src/Influunt.Feed.Rss/RssClient.cs +++ b/src/Influunt.Feed.Rss/RssClient.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Net.Http; using System.Threading.Tasks; +using System.Threading; using Influunt.Feed.Entity; using Microsoft.Extensions.Logging; @@ -18,26 +19,32 @@ public RssClient(HttpClient httpClient, ILogger logger) _logger = logger; } - public async Task> GetFeed(FeedChannel channel) + public async Task> GetFeed(FeedChannel channel, CancellationToken cancellationToken = default) { try { - var request = new HttpRequestMessage(HttpMethod.Get, channel.Url); + cancellationToken.ThrowIfCancellationRequested(); + using var request = new HttpRequestMessage(HttpMethod.Get, channel.Url); request.Headers.Add("user-agent", UserAgents.Generate()); - using (var result = await _httpClient.SendAsync(request)) + using (var result = await _httpClient.SendAsync(request, cancellationToken)) { - var xmlRss = await result.Content.ReadAsStringAsync(); + result.EnsureSuccessStatusCode(); + var xmlRss = await result.Content.ReadAsStringAsync(cancellationToken); return xmlRss.IsAtomRss() ? xmlRss.FeedFromAtomRss() : xmlRss.FeedFromRss(); } } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } catch (Exception e) { _logger.LogError( "Can not get rss feed from \nchannel: {channelName}\nurl: {channelUrl}\n with error: {message} ", channel.Name, channel.Url, e.Message); - return Array.Empty(); + throw; } } -} \ No newline at end of file +} diff --git a/src/Influunt.Feed.Rss/RssFeedSourceProvider.cs b/src/Influunt.Feed.Rss/RssFeedSourceProvider.cs index 32f6c13..4e9a790 100644 --- a/src/Influunt.Feed.Rss/RssFeedSourceProvider.cs +++ b/src/Influunt.Feed.Rss/RssFeedSourceProvider.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Threading.Tasks; +using System.Threading; using Influunt.Feed.Entity; namespace Influunt.Feed.Rss; @@ -22,10 +23,11 @@ public RssFeedSourceProvider(RssClient rssClient, IDistributedCache distributedC _logger = logger; } - public async Task> GetRemoteFeed(FeedChannel channel) + public async Task> GetRemoteFeed(FeedChannel channel, CancellationToken cancellationToken = default) { var sw = Stopwatch.StartNew(); - var remoteFeed = await GetFeedFromChannelCached(channel); + cancellationToken.ThrowIfCancellationRequested(); + var remoteFeed = await GetFeedFromChannelCached(channel, cancellationToken); _logger.LogDebug($"Elapsed time for getting channel ({channel.Id}) feed: {sw.Elapsed.TotalMilliseconds}ms"); return remoteFeed; } @@ -38,18 +40,18 @@ public void Dispose() GC.SuppressFinalize(this); } - private async Task> GetFeedFromChannelCached(FeedChannel channel) + private async Task> GetFeedFromChannelCached(FeedChannel channel, CancellationToken cancellationToken) { - var channelFeed = await _distributedCache.GetAsync>($"channel_url_{channel.Url}"); + var channelFeed = await _distributedCache.GetAsync>($"channel_url_{channel.Url}", cancellationToken); if (channelFeed is not null && channelFeed.Count != 0) return channelFeed; - channelFeed = (await _rssClient.GetFeed(channel)).AsList(); + channelFeed = (await _rssClient.GetFeed(channel, cancellationToken)).AsList(); if (channelFeed.Count != 0) await _distributedCache.SetAsync($"channel_url_{channel.Url}", channelFeed, new DistributedCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) - }); + }, cancellationToken); return channelFeed; } -} \ No newline at end of file +} diff --git a/src/Influunt.Feed/Crawler/FeedCrawlerJob.cs b/src/Influunt.Feed/Crawler/FeedCrawlerJob.cs index 4aa2a42..70cbdd3 100644 --- a/src/Influunt.Feed/Crawler/FeedCrawlerJob.cs +++ b/src/Influunt.Feed/Crawler/FeedCrawlerJob.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Influunt.Scheduler.Abstractions.Models; @@ -15,32 +16,52 @@ public class FeedCrawlerJob : IJob private readonly IScheduler _scheduler; private readonly ILogger _logger; private readonly CrawlerOptions _options; + private readonly TimeProvider _timeProvider; public FeedCrawlerJob(IOptions options, IUserService userService, IScheduler scheduler, - ILogger logger) + ILogger logger, TimeProvider timeProvider = null) { _options = options.Value; _userService = userService; _scheduler = scheduler; _logger = logger; + _timeProvider = timeProvider ?? TimeProvider.System; } public async Task ExecuteAsync(CancellationToken cancellationToken) { _logger.LogInformation("Feed Crawler job started"); - var minimumLastActivityDate = DateTime.UtcNow - TimeSpan.FromDays(_options.LastActivityDaysAgo); - var users = await _userService.GetUsers(); + cancellationToken.ThrowIfCancellationRequested(); + var minimumLastActivityDate = _timeProvider.GetUtcNow().UtcDateTime - TimeSpan.FromDays(_options.LastActivityDaysAgo); + var users = await _userService.GetUsers(cancellationToken); + var errors = new List(); foreach (var user in users.Where(x => x.LastActivity > minimumLastActivityDate)) { - var triggerId = await _scheduler.ScheduleOneTimeJobAsync(new UserFeedChannelUpdateJobSettings() + cancellationToken.ThrowIfCancellationRequested(); + try { - User = user - }); - _logger.LogInformation("Feed channel update job enqueued for user {email} with trigger id {triggerId}", user.Email, triggerId); + var triggerId = await _scheduler.ScheduleOneTimeJobAsync(new UserFeedChannelUpdateJobSettings() + { + User = user + }, cancellationToken: cancellationToken); + _logger.LogInformation("Feed channel update job enqueued for user {email} with trigger id {triggerId}", user.Email, triggerId); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + errors.Add(ex); + _logger.LogError(ex, "Failed to schedule feed update for user {email}", user.Email); + } } + cancellationToken.ThrowIfCancellationRequested(); + if (errors.Count > 0) + throw new AggregateException("Failed to schedule one or more feed updates.", errors); _logger.LogInformation("Feed Crawler job ended"); } diff --git a/src/Influunt.Feed/Crawler/UserFeedChannelUpdateJob.cs b/src/Influunt.Feed/Crawler/UserFeedChannelUpdateJob.cs index b42b1b7..2a93d06 100644 --- a/src/Influunt.Feed/Crawler/UserFeedChannelUpdateJob.cs +++ b/src/Influunt.Feed/Crawler/UserFeedChannelUpdateJob.cs @@ -6,7 +6,6 @@ using Influunt.Feed.Entity; using Influunt.Scheduler.Abstractions.Models; using Microsoft.Extensions.Logging; -using Skidbladnir.Utility.Common; namespace Influunt.Feed.Crawler; @@ -30,36 +29,59 @@ public UserFeedChannelUpdateJob(IEnumerable feedSourceProvi public async Task ExecuteAsync(UserFeedChannelUpdateJobSettings settings, CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); if (settings?.User is null) { _logger.LogError("User is null"); return; } - _logger.BeginScope("Update feed channels job for user {email}", settings.User.Email); - var channels = await Try.DoAsync(() => _channelService.GetUserChannels(settings.User)); + using var scope = _logger.BeginScope("Update feed channels job for user {email}", settings.User.Email); + var channels = await _channelService.GetUserChannels(settings.User, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); if (channels is null) return; + var throttler = new SemaphoreSlim(5, 5); try { - var fetchTasks = channels.Select(x => FetchFeedFromChannel(x, settings.User)); + var fetchTasks = channels.Select(x => FetchFeedFromChannel(x, settings.User, throttler, cancellationToken)); await Task.WhenAll(fetchTasks); + cancellationToken.ThrowIfCancellationRequested(); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; } catch (Exception e) { _logger.LogError(e, $"Can't fetch feed"); + throw; + } + finally + { + throttler.Dispose(); } } - private async Task FetchFeedFromChannel(FeedChannel channel, User user) + private async Task FetchFeedFromChannel(FeedChannel channel, User user, SemaphoreSlim throttler, CancellationToken cancellationToken) { - var remoteFeedProvider = _feedSourceProviders.FirstOrDefault(x => x.CanProcessChannel(channel)); - if (remoteFeedProvider is null) - return; - var remoteFeed = await remoteFeedProvider.GetRemoteFeed(channel); - var count = await _feedService.TryAddToFeed(user, remoteFeed, channel); - _logger.LogInformation("Added {count} posts from channel {channelId} to {email} feed", count, channel.Id, user.Email); + await throttler.WaitAsync(cancellationToken); + try + { + cancellationToken.ThrowIfCancellationRequested(); + var remoteFeedProvider = _feedSourceProviders.FirstOrDefault(x => x.CanProcessChannel(channel)); + if (remoteFeedProvider is null) + return; + var remoteFeed = await remoteFeedProvider.GetRemoteFeed(channel, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + var count = await _feedService.TryAddToFeed(user, remoteFeed, channel, cancellationToken); + _logger.LogInformation("Added {count} posts from channel {channelId} to {email} feed", count, channel.Id, user.Email); + } + finally + { + throttler.Release(); + } } } diff --git a/src/Influunt.Feed/IChannelService.cs b/src/Influunt.Feed/IChannelService.cs index fa97684..6678bf9 100644 --- a/src/Influunt.Feed/IChannelService.cs +++ b/src/Influunt.Feed/IChannelService.cs @@ -1,6 +1,7 @@ using Influunt.Feed.Entity; using System.Collections.Generic; using System.Threading.Tasks; +using System.Threading; namespace Influunt.Feed; @@ -44,5 +45,5 @@ public interface IChannelService /// /// /// - Task> GetUserChannels(User user); -} \ No newline at end of file + Task> GetUserChannels(User user, CancellationToken cancellationToken = default); +} diff --git a/src/Influunt.Feed/IFeedService.cs b/src/Influunt.Feed/IFeedService.cs index 1229f71..1aeeaed 100644 --- a/src/Influunt.Feed/IFeedService.cs +++ b/src/Influunt.Feed/IFeedService.cs @@ -1,6 +1,7 @@ using Influunt.Feed.Entity; using System.Collections.Generic; using System.Threading.Tasks; +using System.Threading; namespace Influunt.Feed; @@ -42,5 +43,5 @@ public interface IFeedService /// /// /// - Task TryAddToFeed(User user, IEnumerable newFeedItems, FeedChannel channel = null); -} \ No newline at end of file + Task TryAddToFeed(User user, IEnumerable newFeedItems, FeedChannel channel = null, CancellationToken cancellationToken = default); +} diff --git a/src/Influunt.Feed/IFeedSourceProvider.cs b/src/Influunt.Feed/IFeedSourceProvider.cs index de30b38..d932eb7 100644 --- a/src/Influunt.Feed/IFeedSourceProvider.cs +++ b/src/Influunt.Feed/IFeedSourceProvider.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Threading.Tasks; +using System.Threading; using Influunt.Feed.Entity; namespace Influunt.Feed; @@ -12,7 +13,7 @@ public interface IFeedSourceProvider /// /// /// - Task> GetRemoteFeed(FeedChannel channel); + Task> GetRemoteFeed(FeedChannel channel, CancellationToken cancellationToken = default); /// /// Can this provider process this channel @@ -20,4 +21,4 @@ public interface IFeedSourceProvider /// /// bool CanProcessChannel(FeedChannel channel); -} \ No newline at end of file +} diff --git a/src/Influunt.Feed/IUserService.cs b/src/Influunt.Feed/IUserService.cs index 446fc76..de22915 100644 --- a/src/Influunt.Feed/IUserService.cs +++ b/src/Influunt.Feed/IUserService.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Threading.Tasks; +using System.Threading; using Influunt.Feed.Entity; namespace Influunt.Feed; @@ -13,7 +14,7 @@ public interface IUserService /// Получение списка пользователей /// /// - Task> GetUsers(); + Task> GetUsers(CancellationToken cancellationToken = default); /// /// Получение текущего пользователя @@ -55,4 +56,4 @@ public interface IUserService /// /// Task Remove(User user); -} \ No newline at end of file +} diff --git a/src/Influunt.Host/Controllers/ChannelController.cs b/src/Influunt.Host/Controllers/ChannelController.cs index afbf504..418c7af 100644 --- a/src/Influunt.Host/Controllers/ChannelController.cs +++ b/src/Influunt.Host/Controllers/ChannelController.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; using Influunt.Feed; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -122,9 +123,12 @@ public async Task Delete(string id) if (!channel.UserId.Equals(user.Id, StringComparison.OrdinalIgnoreCase)) return Forbid(); await _channelService.Remove(user, channel); -#pragma warning disable 4014 - _feedService.RemoveFeedByChannel(user, channel); -#pragma warning restore 4014 + _ = Task.Factory.StartNew( + () => _feedService.RemoveFeedByChannel(user, channel), + CancellationToken.None, + TaskCreationOptions.DenyChildAttach, + TaskScheduler.Default + ).Unwrap(); return Ok(); } diff --git a/src/Influunt.Scheduler.Abstractions/Models/JobExecution.cs b/src/Influunt.Scheduler.Abstractions/Models/JobExecution.cs index 1e435b1..42d7499 100644 --- a/src/Influunt.Scheduler.Abstractions/Models/JobExecution.cs +++ b/src/Influunt.Scheduler.Abstractions/Models/JobExecution.cs @@ -4,6 +4,12 @@ public class JobExecution { public string Id { get; set; } public string TriggerId { get; set; } + /// The trigger document identity, distinguishing reused trigger names. + public string? TriggerDocumentId { get; set; } + /// The scheduled occurrence consumed by this execution. + public DateTime? ScheduledRunTime { get; set; } + /// The next occurrence calculated when this execution completes. + public DateTime? NextRunTime { get; set; } public string JobType { get; set; } public ExecutionStatus Status { get; set; } public DateTime StartTime { get; set; } @@ -12,4 +18,4 @@ public class JobExecution public string LockId { get; set; } public DateTime LockExpiry { get; set; } public DateTime LastHeartbeat { get; set; } -} \ No newline at end of file +} diff --git a/src/Influunt.Scheduler.Abstractions/Models/JobTrigger.cs b/src/Influunt.Scheduler.Abstractions/Models/JobTrigger.cs index da4986d..3005c80 100644 --- a/src/Influunt.Scheduler.Abstractions/Models/JobTrigger.cs +++ b/src/Influunt.Scheduler.Abstractions/Models/JobTrigger.cs @@ -1,4 +1,4 @@ -namespace Influunt.Scheduler; +namespace Influunt.Scheduler.Abstractions.Models; public class JobTrigger { @@ -6,7 +6,8 @@ public class JobTrigger public string TriggerId { get; set; } public string JobType { get; set; } public DateTime NextRunTime { get; set; } + public DateTime? CreatedAt { get; set; } public bool IsRecurring => !string.IsNullOrEmpty(CronExpression); public string? CronExpression { get; set; } public string? SettingsJson { get; set; } -} \ No newline at end of file +} diff --git a/src/Influunt.Scheduler.Abstractions/Services/IScheduler.cs b/src/Influunt.Scheduler.Abstractions/Services/IScheduler.cs index 61dcd2c..2a8e202 100644 --- a/src/Influunt.Scheduler.Abstractions/Services/IScheduler.cs +++ b/src/Influunt.Scheduler.Abstractions/Services/IScheduler.cs @@ -33,7 +33,7 @@ public interface IScheduler /// The time at which the job should run. If null, the job will run as soon as possible. /// An optional identifier for the trigger. /// The identifier of the scheduled trigger. - Task ScheduleOneTimeJobAsync(DateTime? runAt = null, string? triggerId = null) where TJob : IJob; + Task ScheduleOneTimeJobAsync(DateTime? runAt = null, string? triggerId = null, CancellationToken cancellationToken = default) where TJob : IJob; /// /// Schedules a recurring job based on the specified cron expression. @@ -42,7 +42,7 @@ public interface IScheduler /// The cron expression defining the schedule. /// An optional identifier for the trigger. /// The identifier of the scheduled trigger. - Task ScheduleRecurringJobAsync(string cronExpression, string? triggerId = null) where TJob : IJob; + Task ScheduleRecurringJobAsync(string cronExpression, string? triggerId = null, CancellationToken cancellationToken = default) where TJob : IJob; /// /// Schedules a one-time job with settings to run at the specified time. @@ -53,7 +53,7 @@ public interface IScheduler /// The time at which the job should run. If null, the job will run as soon as possible. /// An optional identifier for the trigger. /// The identifier of the scheduled trigger. - Task ScheduleOneTimeJobAsync(TSettings settings, DateTime? runAt = null, string? triggerId = null) where TJob : IJob; + Task ScheduleOneTimeJobAsync(TSettings settings, DateTime? runAt = null, string? triggerId = null, CancellationToken cancellationToken = default) where TJob : IJob; /// /// Schedules a recurring job with settings based on the specified cron expression. @@ -64,7 +64,7 @@ public interface IScheduler /// The settings for the job. /// An optional identifier for the trigger. /// The identifier of the scheduled trigger. - Task ScheduleRecurringJobAsync(string cronExpression, TSettings settings, string? triggerId = null) where TJob : IJob; + Task ScheduleRecurringJobAsync(string cronExpression, TSettings settings, string? triggerId = null, CancellationToken cancellationToken = default) where TJob : IJob; /// /// Unschedules a job with the specified trigger identifier. diff --git a/src/Influunt.Scheduler/Constants.cs b/src/Influunt.Scheduler/Constants.cs index fb57fb5..eeef404 100644 --- a/src/Influunt.Scheduler/Constants.cs +++ b/src/Influunt.Scheduler/Constants.cs @@ -18,5 +18,7 @@ public static class Constants /// /// Job executions collection name. /// - public const string JobExecutionsCollectionName = "job_executions"; -} \ No newline at end of file + public const string JobExecutionsCollectionName = "job_executions"; + + internal const string JobLocksCollectionName = "job_locks"; +} diff --git a/src/Influunt.Scheduler/IndexCreationBackgroundService.cs b/src/Influunt.Scheduler/IndexCreationBackgroundService.cs index 8a51b1c..2d78644 100644 --- a/src/Influunt.Scheduler/IndexCreationBackgroundService.cs +++ b/src/Influunt.Scheduler/IndexCreationBackgroundService.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using MongoDB.Bson; using MongoDB.Driver; namespace Influunt.Scheduler; @@ -23,6 +24,13 @@ public IndexCreationBackgroundService( _logger = logger; } + public override async Task StartAsync(CancellationToken cancellationToken) + { + await base.StartAsync(cancellationToken); + // Acquisition relies on unique indexes; do not start the scheduler before they exist. + await ExecuteTask!.WaitAsync(cancellationToken); + } + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation("Index Creation Background Service started."); @@ -110,14 +118,36 @@ private async Task CreateJobExecutionIndexesAsync(IMongoContext context, Cancell { var collection = context.GetCollection(Constants.JobExecutionsCollectionName); - // Unique index by trigger identifier (to ensure only one execution per trigger) + // One durable receipt per trigger generation and scheduled occurrence. + // Exclude legacy documents, which do not yet have these fields. await collection.Indexes.CreateOneAsync( new CreateIndexModel( - Builders.IndexKeys.Ascending(x => x.TriggerId), - new CreateIndexOptions { Unique = true, Background = true }), + Builders.IndexKeys.Ascending(x => x.TriggerDocumentId).Ascending(x => x.ScheduledRunTime), + new CreateIndexOptions + { + Name = "TriggerOccurrence", + Unique = true, + PartialFilterExpression = Builders.Filter.And( + Builders.Filter.Type(x => x.TriggerDocumentId, BsonType.String), + Builders.Filter.Type(x => x.ScheduledRunTime, BsonType.DateTime)) + }), cancellationToken: cancellationToken); - - _logger.LogDebug("Created unique index JobExecution.TriggerId"); + + using var indexesCursor = await collection.Indexes.ListAsync(cancellationToken); + foreach (var index in await indexesCursor.ToListAsync(cancellationToken)) + { + var keys = index["key"].AsBsonDocument; + if (keys.ElementCount != 1 || !keys.Contains("TriggerId") || !index.GetValue("unique", false).AsBoolean) + continue; + try + { + await collection.Indexes.DropOneAsync(index["name"].AsString, cancellationToken); + } + catch (MongoCommandException ex) when (ex.Code == 27) + { + // Another scheduler instance already migrated the index. + } + } // Index for searching running jobs by lock await collection.Indexes.CreateOneAsync( @@ -155,4 +185,4 @@ await collection.Indexes.CreateOneAsync( _logger.LogDebug("Created index JobExecution.LockExpiry"); } -} \ No newline at end of file +} diff --git a/src/Influunt.Scheduler/Influunt.Scheduler.csproj b/src/Influunt.Scheduler/Influunt.Scheduler.csproj index f1d7e75..438e796 100644 --- a/src/Influunt.Scheduler/Influunt.Scheduler.csproj +++ b/src/Influunt.Scheduler/Influunt.Scheduler.csproj @@ -7,6 +7,7 @@ + @@ -17,4 +18,4 @@ - \ No newline at end of file + diff --git a/src/Influunt.Scheduler/JobScheduler.cs b/src/Influunt.Scheduler/JobScheduler.cs index 4bf4b28..54d8c87 100644 --- a/src/Influunt.Scheduler/JobScheduler.cs +++ b/src/Influunt.Scheduler/JobScheduler.cs @@ -1,516 +1,671 @@ -using System.Collections.Concurrent; -using System.Text.Json; -using Influunt.Scheduler.Abstractions.Models; -using Influunt.Scheduler.Abstractions.Services; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using MongoDB.Driver; - -namespace Influunt.Scheduler; - - internal class JobScheduler : IScheduler, IHostedService, IDisposable -{ - private readonly IServiceProvider _serviceProvider; - private readonly ILogger _logger; - private readonly JobSchedulerOptions _options; - private readonly IMongoContext _context; - private readonly string _instanceId; // unique scheduler instance identifier - private readonly ConcurrentDictionary _runningJobs = new(); - private Timer _scanTimer; - private Timer _heartbeatTimer; - private bool _disposed; - - public JobScheduler(IServiceProvider serviceProvider, IOptions options, ILogger logger, IMongoContext context) - { - _serviceProvider = serviceProvider; - _logger = logger; - _options = options.Value; - _context = context; - - _instanceId = $"{Environment.MachineName}_{Guid.NewGuid():N}"; - } - - private IMongoCollection _jobDefinitions => _context.GetCollection(Constants.JobDefinitionsCollectionName); - private IMongoCollection _triggers => _context.GetCollection(Constants.JobTriggersCollectionName); - private IMongoCollection _executions => _context.GetCollection(Constants.JobExecutionsCollectionName); - - public Task StartAsync(CancellationToken cancellationToken) - { - _logger.LogInformation("JobScheduler starting. InstanceId: {InstanceId}", _instanceId); - - // Start timers - _scanTimer = new Timer(ScanTriggers, null, TimeSpan.Zero, TimeSpan.FromSeconds(_options.ScanIntervalSeconds)); - _heartbeatTimer = new Timer(Heartbeat, null, TimeSpan.FromSeconds(_options.HeartbeatIntervalSeconds), TimeSpan.FromSeconds(_options.HeartbeatIntervalSeconds)); - - return Task.CompletedTask; - } - - public Task StopAsync(CancellationToken cancellationToken) - { - _logger.LogInformation("JobScheduler stopping..."); - - _scanTimer?.Change(Timeout.Infinite, 0); - _heartbeatTimer?.Change(Timeout.Infinite, 0); - - // Cancel all running jobs - foreach (var cts in _runningJobs.Values) - cts.Cancel(); - - // Wait for all jobs to complete (simplified) - // In a real project, more careful waiting for completion is needed - return Task.CompletedTask; - } - - public async Task RegisterJobAsync(string name, string? description = null, bool allowConcurrentExecution = false) - where TJob : IJob - { - var jobDef = new JobDefinition - { - Name = name, - Description = description, - JobType = typeof(TJob)!.AssemblyQualifiedName!, - SettingsType = typeof(TSettings)!.AssemblyQualifiedName!, - AllowConcurrentExecution = allowConcurrentExecution - }; - await _jobDefinitions.ReplaceOneAsync(d => d.Name == name, jobDef, new ReplaceOptions { IsUpsert = true }); - } - - public async Task RegisterJobAsync(string name, string? description = null, bool allowConcurrentExecution = false) - where TJob : IJob - { - var jobDef = new JobDefinition - { - Name = name, - Description = description, - JobType = typeof(TJob)!.AssemblyQualifiedName!, - AllowConcurrentExecution = allowConcurrentExecution - }; - await _jobDefinitions.ReplaceOneAsync(d => d.Name == name, jobDef, new ReplaceOptions { IsUpsert = true }); - } - - public async Task ScheduleOneTimeJobAsync(DateTime? runAt = null, string? triggerId = null) where TJob : IJob - { - var cursor = await _jobDefinitions.FindAsync(x => x.JobType == typeof(TJob).AssemblyQualifiedName); - var registeredJob = await cursor.FirstOrDefaultAsync(); - if (registeredJob is null) - throw new InvalidOperationException("Job not registered"); - - var trigger = new JobTrigger - { - TriggerId = string.IsNullOrEmpty(triggerId) ? Guid.NewGuid().ToString() : triggerId, - JobType = registeredJob.JobType, - NextRunTime = runAt.HasValue ? runAt.Value.ToUniversalTime() : DateTime.UtcNow - }; - await _triggers.InsertOneAsync(trigger); - return trigger.TriggerId; - } - - public async Task ScheduleRecurringJobAsync(string cronExpression, string? triggerId = null) where TJob : IJob - { - var cursor = await _jobDefinitions.FindAsync(x => x.JobType == typeof(TJob).AssemblyQualifiedName); - var registeredJob = await cursor.FirstOrDefaultAsync(); - if (registeredJob is null) - throw new InvalidOperationException("Job not registered"); - - var nextRun = CronHelper.GetNextOccurrence(cronExpression, DateTime.UtcNow); - if (!nextRun.HasValue) - throw new ArgumentException("Cron expression does not produce any future occurrence"); - - var trigger = new JobTrigger - { - TriggerId = string.IsNullOrEmpty(triggerId) ? Guid.NewGuid().ToString() : triggerId, - JobType = registeredJob.JobType, - NextRunTime = nextRun.Value, - CronExpression = cronExpression - }; - await _triggers.InsertOneAsync(trigger); - return trigger.TriggerId; - } - - public async Task ScheduleOneTimeJobAsync(TSettings settings, DateTime? runAt = null, string? triggerId = null) where TJob : IJob - { - var cursor = await _jobDefinitions.FindAsync(x => x.JobType == typeof(TJob).AssemblyQualifiedName); - var registeredJob = await cursor.FirstOrDefaultAsync(); - if (registeredJob is null) - throw new InvalidOperationException("Job not registered"); - if (registeredJob.SettingsType != typeof(TSettings).AssemblyQualifiedName) - throw new InvalidOperationException("The task settings type does not match the registered type."); - - var trigger = new JobTrigger - { - TriggerId = string.IsNullOrEmpty(triggerId) ? Guid.NewGuid().ToString() : triggerId, - JobType = registeredJob.JobType, - NextRunTime = runAt.HasValue ? runAt.Value.ToUniversalTime() : DateTime.UtcNow, - SettingsJson = JsonSerializer.Serialize(settings) - }; - await _triggers.InsertOneAsync(trigger); - return trigger.TriggerId; - } - - public async Task ScheduleRecurringJobAsync(string cronExpression, TSettings settings, string? triggerId = null) where TJob : IJob - { - var cursor = await _jobDefinitions.FindAsync(x => x.JobType == typeof(TJob).AssemblyQualifiedName); - var registeredJob = await cursor.FirstOrDefaultAsync(); - if (registeredJob is null) - throw new InvalidOperationException("Job not registered"); - if (registeredJob.SettingsType != typeof(TSettings).AssemblyQualifiedName) - throw new InvalidOperationException("The task settings type does not match the registered type."); - - var nextRun = CronHelper.GetNextOccurrence(cronExpression, DateTime.UtcNow); - if (!nextRun.HasValue) - throw new ArgumentException("Cron expression does not produce any future occurrence"); - - var trigger = new JobTrigger - { - TriggerId = string.IsNullOrEmpty(triggerId) ? Guid.NewGuid().ToString() : triggerId, - JobType = registeredJob.JobType, - NextRunTime = nextRun.Value, - CronExpression = cronExpression, - SettingsJson = JsonSerializer.Serialize(settings) - }; - await _triggers.InsertOneAsync(trigger); - return trigger.TriggerId; - } - - public async Task UnScheduleJobAsync(string triggerId) - { - await _triggers.DeleteOneAsync(x => x.TriggerId == triggerId); - } - - public async Task> GetRegisteredJobsAsync() - { - var cursor = await _jobDefinitions.FindAsync(x => true); - return await cursor.ToListAsync(); - } - - public async Task> GetScheduledTriggersAsync() - { - var cursor = await _triggers.FindAsync(x => true); - return await cursor.ToListAsync(); - } - - // ============================== Trigger Scanning ============================== - - private async void ScanTriggers(object? state) - { - try - { - var now = DateTime.UtcNow; - // Find triggers where NextRunTime <= now and are active - var filter = Builders.Filter.And( - Builders.Filter.Lte(t => t.NextRunTime, now)); - var triggers = await _triggers.Find(filter).ToListAsync(); - - // Check current number of running jobs - var runningCount = _runningJobs.Count; - - // If limit is already reached, skip all triggers - if (runningCount >= _options.MaxConcurrentJobs) - { - _logger.LogDebug("Concurrency limit reached ({Running}/{Max}). Skipping all triggers in this scan cycle.", - runningCount, _options.MaxConcurrentJobs); - return; - } - - // Calculate how many more jobs can be started - var availableSlots = _options.MaxConcurrentJobs - runningCount; - var processedTriggers = 0; - - foreach (var trigger in triggers) - { - // If maximum possible number of triggers have been processed, exit - if (processedTriggers >= availableSlots) - { - _logger.LogDebug("Available slots exhausted. Processed {Processed}/{Available} triggers in this cycle.", - processedTriggers, availableSlots); - break; - } - - try - { - // For each trigger, attempt to create execution - await ScheduleJobAsync(trigger); - processedTriggers++; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error scheduling job for trigger {TriggerId}", trigger.TriggerId); - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error scanning triggers"); - } - } - - private async Task ScheduleJobAsync(JobTrigger trigger) - { - // 1. Get JobDefinition to determine if the same trigger can be executed concurrently for a job - var jobDef = await _jobDefinitions.Find(d => d.JobType == trigger.JobType).FirstOrDefaultAsync(); - if (jobDef == null) - { - _logger.LogWarning("Job definition not found for trigger {TriggerId}", trigger.Id); - return; - } - - // 2. Check if there is a lock for the current TRIGGER - var existingExecutionFilter = Builders.Filter.And( - Builders.Filter.Eq(e => e.TriggerId, trigger.TriggerId), - Builders.Filter.Eq(e => e.Status, ExecutionStatus.Running) - ); - var existingExecution = await _executions.Find(existingExecutionFilter).FirstOrDefaultAsync(); - - if (existingExecution == null) - { - // 3. Clean up any previous terminal executions for this trigger - // (Completed or Failed from a previous recurring run) - await _executions.DeleteManyAsync( - Builders.Filter.And( - Builders.Filter.Eq(e => e.TriggerId, trigger.TriggerId), - Builders.Filter.Or( - Builders.Filter.Eq(e => e.Status, ExecutionStatus.Completed), - Builders.Filter.Eq(e => e.Status, ExecutionStatus.Failed) - ) - ) - ); - - // 4. If JobExecution doesn't exist, create it, attempt to acquire lock, start the job - await CreateAndStartJobExecution(trigger, jobDef); - return; - } - - // Trigger is already running, check the time - var now = DateTime.UtcNow; - - if (trigger.IsRecurring) - { - // 2.2 If job is recurring, check if jobExecution belongs to the current run period - // nextRunTime is approximately equal to JobExecution startTime (epsilon time of 30 seconds) - var epsilon = TimeSpan.FromSeconds(30); - var timeDiff = Math.Abs((trigger.NextRunTime - existingExecution.StartTime).TotalSeconds); - - if (timeDiff <= epsilon.TotalSeconds) - { - _logger.LogDebug("Recurring job '{JobName}' trigger {TriggerId} is already running for current period. Skipping.", - jobDef.Name, trigger.TriggerId); - return; - } - } - else - { - // 2.1 If jobs are one-time, behavior should be like with AllowConcurrentExecution=false - // Don't allow to start, but only if this lock is not expired - // (another replica could have died and the lock is long expired) - if (existingExecution.LockExpiry > now) - { - _logger.LogDebug("One-time job '{JobName}' trigger {TriggerId} is already running and lock is not expired. Skipping.", - jobDef.Name, trigger.TriggerId); - return; - } - } - - // If lock is expired (for one-time) or for new period (for recurring), - // attempt to capture lock of existing JobExecution - await TryCaptureExistingExecutionLock(trigger, jobDef, existingExecution); - } - - private async Task CreateAndStartJobExecution(JobTrigger trigger, JobDefinition jobDef) - { - // Create new execution record - var execution = new JobExecution - { - TriggerId = trigger.TriggerId, - JobType = trigger.JobType, - Status = ExecutionStatus.Pending, - StartTime = DateTime.UtcNow, - LockId = null, - LockExpiry = DateTime.UtcNow.AddSeconds(_options.LockTimeoutSeconds), - LastHeartbeat = DateTime.UtcNow - }; - - try - { - await _executions.InsertOneAsync(execution); - } - catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey || ex.WriteError?.Code == 11000) - { - // Duplicate key - another replica already created JobExecution for this trigger - _logger.LogDebug("Duplicate JobExecution for trigger {TriggerId} detected. Another replica already created it.", trigger.TriggerId); - return; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating JobExecution for trigger {TriggerId}", trigger.TriggerId); - return; - } - - await TryCaptureExistingExecutionLock(trigger, jobDef, execution, ExecutionStatus.Pending); - } - - private async Task TryCaptureExistingExecutionLock(JobTrigger trigger, JobDefinition jobDef, JobExecution existingExecution, ExecutionStatus expectedExecutionStatus = ExecutionStatus.Running ) - { - var lockExpiry = DateTime.UtcNow.AddSeconds(_options.LockTimeoutSeconds); - - // Attempt to capture lock of existing JobExecution - var filter = Builders.Filter.And( - Builders.Filter.Eq(e => e.Id, existingExecution.Id), - Builders.Filter.Eq(e => e.Status, expectedExecutionStatus), - Builders.Filter.Or( - Builders.Filter.Eq(e => e.LockId, null), - Builders.Filter.Lt(e => e.LockExpiry, DateTime.UtcNow) - ) - ); - - var update = Builders.Update - .Set(e => e.LockId, _instanceId) - .Set(e => e.LockExpiry, lockExpiry) - .Set(e => e.LastHeartbeat, DateTime.UtcNow); - - var updated = await _executions.FindOneAndUpdateAsync(filter, update); - if (updated == null) - { - // If failed to acquire lock, skip the trigger - _logger.LogDebug("Failed to acquire lock for existing execution of trigger {TriggerId}", trigger.TriggerId); - return; - } - - // If lock acquisition succeeded, start the job - _logger.LogInformation("Starting job '{JobName}' with existing execution. TriggerId: {TriggerId}. ExecutionId: {ExecutionId}", - jobDef.Name, trigger.TriggerId, existingExecution.Id); - - var cts = new CancellationTokenSource(); - _runningJobs.TryAdd(existingExecution.Id, cts); - - // Start asynchronous execution - _ = Task.Run(() => ExecuteJobAsync(trigger, existingExecution, jobDef, cts.Token)); - } - - private async Task ExecuteJobAsync(JobTrigger trigger, JobExecution execution, JobDefinition jobDef, CancellationToken cancellationToken) - { - try - { - // Get job instance from DI (transient or scoped) - using var scope = _serviceProvider.CreateScope(); - var jobType = Type.GetType(jobDef.JobType) ?? throw new InvalidOperationException($"Cannot load job type {jobDef.JobType}"); - - // Check if there are settings - if (string.IsNullOrEmpty(jobDef.SettingsType)) - { - // Job without settings - var job = scope.ServiceProvider.GetRequiredService(jobType) as IJob; - if (job == null) - throw new InvalidOperationException($"Job {jobDef.JobType} does not implement IJob"); - await job.ExecuteAsync(cancellationToken); - } - else - { - // Job with settings - var settingsType = Type.GetType(jobDef.SettingsType); - if (settingsType == null) - throw new InvalidOperationException($"Cannot load settings type {jobDef.SettingsType}"); - - var settings = string.IsNullOrEmpty(trigger.SettingsJson) - ? Activator.CreateInstance(settingsType) - : JsonSerializer.Deserialize(trigger.SettingsJson, settingsType); - - var jobTypeGeneric = typeof(IJob<>).MakeGenericType(settingsType); - var job = scope.ServiceProvider.GetRequiredService(jobType); - var method = jobType.GetMethod("ExecuteAsync"); - if (method == null) - throw new InvalidOperationException($"ExecuteAsync method not found on {jobDef.JobType}"); - await (Task)method.Invoke(job, [settings, cancellationToken]); - } - - // Successful completion - await UpdateExecutionStatus(execution.Id, ExecutionStatus.Completed, result: "Success"); - // Update next run time for trigger - await UpdateTriggerNextRun(trigger); - _logger.LogInformation("Job {JobName} (ExecutionId: {ExecutionId}) completed successfully", jobDef.Name, execution.Id); - } - catch (OperationCanceledException) - { - await UpdateExecutionStatus(execution.Id, ExecutionStatus.Failed, result: "Cancelled"); - _logger.LogWarning("Job {JobName} (TriggerId: {TriggerId}; ExecutionId: {ExecutionId}) was cancelled", jobDef.Name, trigger.TriggerId, execution.Id); - } - catch (Exception ex) - { - await UpdateExecutionStatus(execution.Id, ExecutionStatus.Failed, result: ex.ToString()); - _logger.LogError(ex, "Job {JobName} (TriggerId: {TriggerId}; ExecutionId: {ExecutionId}) failed", jobDef.Name, trigger.TriggerId, execution.Id); - } - finally - { - _runningJobs.TryRemove(execution.Id, out _); - } - } - - private async Task UpdateExecutionStatus(string executionId, ExecutionStatus status, string result) - { - var update = Builders.Update - .Set(e => e.Status, status) - .Set(e => e.EndTime, DateTime.UtcNow) - .Set(e => e.Result, result) - .Unset(e => e.LockId) // remove lock - .Unset(e => e.LockExpiry); - await _executions.UpdateOneAsync(e => e.Id == executionId, update); - } - - private async Task UpdateTriggerNextRun(JobTrigger trigger) - { - if (!trigger.IsRecurring || string.IsNullOrEmpty(trigger.CronExpression)) - { - // One-time trigger — delete it - await _triggers.DeleteOneAsync(t => t.Id == trigger.Id); - return; - } - - // Calculate next run time based on Cron - var nextRun = CronHelper.GetNextOccurrence(trigger.CronExpression, DateTime.UtcNow); - if (!nextRun.HasValue) - { - // Cron will no longer fire (e.g., expired) — delete it - await _triggers.DeleteOneAsync(t => t.Id == trigger.Id); - return; - } - - await _triggers.UpdateOneAsync(t => t.Id == trigger.Id, - Builders.Update.Set(t => t.NextRunTime, nextRun.Value)); - } - - // ============================== Heartbeat ============================== - - private async void Heartbeat(object? state) - { - try - { - var now = DateTime.UtcNow; - var newExpiry = now.AddSeconds(_options.LockTimeoutSeconds); - - // Find all executions that belong to this instance and are in Running status - var filter = Builders.Filter.And( - Builders.Filter.Eq(e => e.LockId, _instanceId), - Builders.Filter.Eq(e => e.Status, ExecutionStatus.Running)); - - var update = Builders.Update - .Set(e => e.LockExpiry, newExpiry) - .Set(e => e.LastHeartbeat, now); - - var result = await _executions.UpdateManyAsync(filter, update); - _logger.LogDebug("Heartbeat: updated {Count} executions", result.ModifiedCount); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error during heartbeat"); - } - } - - public void Dispose() - { - if (_disposed) return; - _scanTimer?.Dispose(); - _heartbeatTimer?.Dispose(); - _disposed = true; - } -} \ No newline at end of file +using System.Collections.Concurrent; +using System.Threading; +using System.Text.Json; +using System.Reflection; +using System.Runtime.ExceptionServices; +using Influunt.Scheduler.Abstractions.Models; +using Influunt.Scheduler.Abstractions.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using MongoDB.Driver; + +namespace Influunt.Scheduler; + +internal class JobScheduler : IScheduler, IHostedService, IDisposable +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly JobSchedulerOptions _options; + private readonly IMongoContext _context; + private readonly TimeProvider _timeProvider; + private readonly string _instanceId; + private readonly ConcurrentDictionary _runningJobs = new(); + private readonly SemaphoreSlim _scanLock = new(1, 1); + private readonly SemaphoreSlim _heartbeatLock = new(1, 1); + private readonly object _lifecycleLock = new(); + private readonly CancellationTokenSource _scanCts = new(); + private Timer? _scanTimer; + private Timer? _heartbeatTimer; + private volatile bool _stopping; + private volatile bool _disposed; + + public JobScheduler(IServiceProvider serviceProvider, IOptions options, ILogger logger, IMongoContext context, TimeProvider? timeProvider = null) + { + _serviceProvider = serviceProvider; + _logger = logger; + _options = options.Value; + _context = context; + _timeProvider = timeProvider ?? TimeProvider.System; + + if (_options.ScanIntervalSeconds <= 0 || _options.MaxConcurrentJobs <= 0 || _options.ScanBatchSize <= 0) + throw new ArgumentException("Scan interval, concurrency and batch size must be positive.", nameof(options)); + if (_options.HeartbeatIntervalSeconds <= 0 || _options.LockTimeoutSeconds <= _options.HeartbeatIntervalSeconds) + throw new ArgumentException("Lock timeout must exceed the positive heartbeat interval.", nameof(options)); + + _instanceId = $"{Environment.MachineName}_{Guid.NewGuid():N}"; + } + + private IMongoCollection _jobDefinitions => _context.GetCollection(Constants.JobDefinitionsCollectionName); + private IMongoCollection _triggers => _context.GetCollection(Constants.JobTriggersCollectionName); + private IMongoCollection _executions => _context.GetCollection(Constants.JobExecutionsCollectionName); + private IMongoCollection _jobLocks => _context.GetCollection(Constants.JobLocksCollectionName); + private DateTime UtcNow => _timeProvider.GetUtcNow().UtcDateTime; + + public Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("JobScheduler starting. InstanceId: {InstanceId}", _instanceId); + + _scanTimer = new Timer(ScanTriggers, null, TimeSpan.Zero, TimeSpan.FromSeconds(_options.ScanIntervalSeconds)); + _heartbeatTimer = new Timer(Heartbeat, null, TimeSpan.FromSeconds(_options.HeartbeatIntervalSeconds), TimeSpan.FromSeconds(_options.HeartbeatIntervalSeconds)); + + return Task.CompletedTask; + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("JobScheduler stopping..."); + + lock (_lifecycleLock) + _stopping = true; + _scanTimer?.Change(Timeout.Infinite, 0); + _scanCts.Cancel(); + foreach (var job in _runningJobs.Values) + job.Cancel(); + + try + { + await _scanLock.WaitAsync(cancellationToken); + _scanLock.Release(); + foreach (var job in _runningJobs.Values) + job.Cancel(); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(30)); + await Task.WhenAll(_runningJobs.Values.Select(j => j.Completion.Task)).WaitAsync(timeoutCts.Token); + } + catch (OperationCanceledException) + { + _logger.LogWarning("Shutdown timeout reached. Some jobs may still be running."); + } + finally + { + // Keep renewing leases while cancelled jobs are still winding down. + _heartbeatTimer?.Change(Timeout.Infinite, 0); + try + { + await _heartbeatLock.WaitAsync(cancellationToken); + _heartbeatLock.Release(); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + } + } + + public async Task RegisterJobAsync(string name, string? description = null, bool allowConcurrentExecution = false) + where TJob : IJob + { + var jobDef = new JobDefinition + { + Name = name, + Description = description, + JobType = typeof(TJob)!.AssemblyQualifiedName!, + SettingsType = typeof(TSettings)!.AssemblyQualifiedName!, + AllowConcurrentExecution = allowConcurrentExecution + }; + await _jobDefinitions.ReplaceOneAsync(d => d.Name == name, jobDef, new ReplaceOptions { IsUpsert = true }); + } + + public async Task RegisterJobAsync(string name, string? description = null, bool allowConcurrentExecution = false) + where TJob : IJob + { + var jobDef = new JobDefinition + { + Name = name, + Description = description, + JobType = typeof(TJob)!.AssemblyQualifiedName!, + AllowConcurrentExecution = allowConcurrentExecution + }; + await _jobDefinitions.ReplaceOneAsync(d => d.Name == name, jobDef, new ReplaceOptions { IsUpsert = true }); + } + + public async Task ScheduleOneTimeJobAsync(DateTime? runAt = null, string? triggerId = null, CancellationToken cancellationToken = default) where TJob : IJob + { + var cursor = await _jobDefinitions.FindAsync(x => x.JobType == typeof(TJob).AssemblyQualifiedName, cancellationToken: cancellationToken); + var registeredJob = await cursor.FirstOrDefaultAsync(cancellationToken); + if (registeredJob is null) + throw new InvalidOperationException("Job not registered"); + + var trigger = new JobTrigger + { + TriggerId = string.IsNullOrEmpty(triggerId) ? Guid.NewGuid().ToString() : triggerId, + JobType = registeredJob.JobType, + NextRunTime = runAt.HasValue ? runAt.Value.ToUniversalTime() : UtcNow, + CreatedAt = UtcNow + }; + await _triggers.InsertOneAsync(trigger, cancellationToken: cancellationToken); + return trigger.TriggerId; + } + + public async Task ScheduleRecurringJobAsync(string cronExpression, string? triggerId = null, CancellationToken cancellationToken = default) where TJob : IJob + { + var cursor = await _jobDefinitions.FindAsync(x => x.JobType == typeof(TJob).AssemblyQualifiedName, cancellationToken: cancellationToken); + var registeredJob = await cursor.FirstOrDefaultAsync(cancellationToken); + if (registeredJob is null) + throw new InvalidOperationException("Job not registered"); + + var nextRun = CronHelper.GetNextOccurrence(cronExpression, UtcNow); + if (!nextRun.HasValue) + throw new ArgumentException("Cron expression does not produce any future occurrence"); + + var trigger = new JobTrigger + { + TriggerId = string.IsNullOrEmpty(triggerId) ? Guid.NewGuid().ToString() : triggerId, + JobType = registeredJob.JobType, + NextRunTime = nextRun.Value, + CronExpression = cronExpression, + CreatedAt = UtcNow + }; + await _triggers.InsertOneAsync(trigger, cancellationToken: cancellationToken); + return trigger.TriggerId; + } + + public async Task ScheduleOneTimeJobAsync(TSettings settings, DateTime? runAt = null, string? triggerId = null, CancellationToken cancellationToken = default) where TJob : IJob + { + var cursor = await _jobDefinitions.FindAsync(x => x.JobType == typeof(TJob).AssemblyQualifiedName, cancellationToken: cancellationToken); + var registeredJob = await cursor.FirstOrDefaultAsync(cancellationToken); + if (registeredJob is null) + throw new InvalidOperationException("Job not registered"); + if (registeredJob.SettingsType != typeof(TSettings).AssemblyQualifiedName) + throw new InvalidOperationException("The task settings type does not match the registered type."); + + var trigger = new JobTrigger + { + TriggerId = string.IsNullOrEmpty(triggerId) ? Guid.NewGuid().ToString() : triggerId, + JobType = registeredJob.JobType, + NextRunTime = runAt.HasValue ? runAt.Value.ToUniversalTime() : UtcNow, + SettingsJson = JsonSerializer.Serialize(settings), + CreatedAt = UtcNow + }; + await _triggers.InsertOneAsync(trigger, cancellationToken: cancellationToken); + return trigger.TriggerId; + } + + public async Task ScheduleRecurringJobAsync(string cronExpression, TSettings settings, string? triggerId = null, CancellationToken cancellationToken = default) where TJob : IJob + { + var cursor = await _jobDefinitions.FindAsync(x => x.JobType == typeof(TJob).AssemblyQualifiedName, cancellationToken: cancellationToken); + var registeredJob = await cursor.FirstOrDefaultAsync(cancellationToken); + if (registeredJob is null) + throw new InvalidOperationException("Job not registered"); + if (registeredJob.SettingsType != typeof(TSettings).AssemblyQualifiedName) + throw new InvalidOperationException("The task settings type does not match the registered type."); + + var nextRun = CronHelper.GetNextOccurrence(cronExpression, UtcNow); + if (!nextRun.HasValue) + throw new ArgumentException("Cron expression does not produce any future occurrence"); + + var trigger = new JobTrigger + { + TriggerId = string.IsNullOrEmpty(triggerId) ? Guid.NewGuid().ToString() : triggerId, + JobType = registeredJob.JobType, + NextRunTime = nextRun.Value, + CronExpression = cronExpression, + SettingsJson = JsonSerializer.Serialize(settings), + CreatedAt = UtcNow + }; + await _triggers.InsertOneAsync(trigger, cancellationToken: cancellationToken); + return trigger.TriggerId; + } + + public async Task UnScheduleJobAsync(string triggerId) + { + await _triggers.DeleteOneAsync(x => x.TriggerId == triggerId); + } + + public async Task> GetRegisteredJobsAsync() + { + var cursor = await _jobDefinitions.FindAsync(x => true); + return await cursor.ToListAsync(); + } + + public async Task> GetScheduledTriggersAsync() + { + var cursor = await _triggers.FindAsync(x => true); + return await cursor.ToListAsync(); + } + + private void ScanTriggers(object? state) => _ = ScanTriggersAsync(); + + internal async Task ScanTriggersAsync() + { + if (_stopping || _disposed || !_scanLock.Wait(0)) + return; + + try + { + var cancellationToken = _scanCts.Token; + var filter = Builders.Filter.Lte(t => t.NextRunTime, UtcNow); + // Batch the cursor, but keep looking past busy triggers until actual worker slots are full. + using var cursor = await _triggers.Find(filter, new FindOptions { BatchSize = _options.ScanBatchSize }) + .SortBy(t => t.NextRunTime).ThenBy(t => t.Id) + .ToCursorAsync(cancellationToken); + + while (!_stopping && _runningJobs.Count < _options.MaxConcurrentJobs && await cursor.MoveNextAsync(cancellationToken)) + { + foreach (var trigger in cursor.Current) + { + if (_stopping || _runningJobs.Count >= _options.MaxConcurrentJobs) + return; + try + { + await ScheduleJobAsync(trigger, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error scheduling job for trigger {TriggerId}", trigger.TriggerId); + } + } + } + } + catch (OperationCanceledException) when (_scanCts.IsCancellationRequested) + { + } + catch (Exception ex) + { + _logger.LogError(ex, "Error scanning triggers"); + } + finally + { + _scanLock.Release(); + } + } + + internal async Task ScheduleJobAsync(JobTrigger trigger, CancellationToken cancellationToken = default) + { + if (_stopping) + return false; + + var jobDef = await _jobDefinitions.Find(d => d.JobType == trigger.JobType).FirstOrDefaultAsync(cancellationToken); + if (jobDef == null) + return false; + + var lockId = $"{_instanceId}_{Guid.NewGuid():N}"; + var ownsJobLock = false; + var started = false; + try + { + if (!jobDef.AllowConcurrentExecution) + { + ownsJobLock = await TryAcquireJobLockAsync(trigger.JobType, lockId, cancellationToken); + if (!ownsJobLock) + return false; + + // Also respect active executions persisted before job-type leases were introduced. + var active = Builders.Filter.And( + Builders.Filter.Eq(e => e.JobType, trigger.JobType), + Builders.Filter.In(e => e.Status, new[] { ExecutionStatus.Pending, ExecutionStatus.Running }), + Builders.Filter.Ne(e => e.LockId, null), + Builders.Filter.Gt(e => e.LockExpiry, UtcNow)); + if (await _executions.Find(active).AnyAsync(cancellationToken)) + return false; + } + + // A different scan may already have removed or advanced this occurrence. + if (!await _triggers.Find(CurrentOccurrence(trigger)).AnyAsync(cancellationToken)) + return false; + + var execution = await GetOrCreateExecutionAsync(trigger, cancellationToken); + var terminal = execution.Status is ExecutionStatus.Completed or ExecutionStatus.Failed; + if (terminal) + { + // Repair a crash between persisting completion and advancing the trigger. + await UpdateTriggerNextRunAsync(trigger, execution.NextRunTime ?? GetNextRunTime(trigger, execution.EndTime ?? execution.StartTime)); + return false; + } + + var now = UtcNow; + if (execution.LockId != null && execution.LockExpiry > now) + return false; + + var filter = Builders.Filter.And( + Builders.Filter.Eq(e => e.Id, execution.Id), + Builders.Filter.Eq(e => e.LockId, execution.LockId), + execution.Status == ExecutionStatus.Pending + ? Builders.Filter.Eq(e => e.Status, ExecutionStatus.Pending) | Builders.Filter.Exists(e => e.Status, false) + : Builders.Filter.Eq(e => e.Status, execution.Status)); + filter &= Builders.Filter.Or( + Builders.Filter.Eq(e => e.LockId, null), + Builders.Filter.Lte(e => e.LockExpiry, now)); + + var update = Builders.Update + .Set(e => e.TriggerDocumentId, trigger.Id) + .Set(e => e.ScheduledRunTime, trigger.NextRunTime) + .Set(e => e.JobType, trigger.JobType) + .Set(e => e.Status, ExecutionStatus.Running) + .Set(e => e.StartTime, now) + .Set(e => e.LockId, lockId) + .Set(e => e.LockExpiry, now.AddSeconds(_options.LockTimeoutSeconds)) + .Set(e => e.LastHeartbeat, now) + .Unset(e => e.EndTime) + .Unset(e => e.NextRunTime) + .Unset(e => e.Result); + var acquired = await _executions.FindOneAndUpdateAsync(filter, update, + new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }, cancellationToken); + if (acquired == null) + return false; + + if (_stopping || acquired.LockExpiry <= UtcNow || + !await _triggers.Find(CurrentOccurrence(trigger)).AnyAsync(cancellationToken) || + (ownsJobLock && !await RenewJobLockAsync(trigger.JobType, lockId))) + { + await AbandonExecutionAsync(acquired); + return false; + } + + var running = new RunningJob(acquired, jobDef, ownsJobLock); + lock (_lifecycleLock) + { + if (!_stopping) + { + _runningJobs[lockId] = running; + started = true; + } + } + if (!started) + { + running.Cts.Dispose(); + await AbandonExecutionAsync(acquired); + return false; + } + _ = Task.Run(() => ExecuteJobAsync(trigger, running)); + return true; + } + finally + { + if (ownsJobLock && !started) + await ReleaseJobLockAsync(trigger.JobType, lockId); + } + } + + private async Task GetOrCreateExecutionAsync(JobTrigger trigger, CancellationToken cancellationToken) + { + var occurrence = Builders.Filter.And( + Builders.Filter.Eq(e => e.TriggerDocumentId, trigger.Id), + Builders.Filter.Eq(e => e.ScheduledRunTime, trigger.NextRunTime)); + var existing = await _executions.Find(occurrence).SingleOrDefaultAsync(cancellationToken); + if (existing != null) + return existing; + + // Only old trigger documents can inherit a receipt without a recorded generation. + // A newly scheduled trigger may reuse an old name and deliberately run in the past. + var legacy = trigger.CreatedAt == null + ? await _executions.Find(e => e.TriggerId == trigger.TriggerId && e.TriggerDocumentId == null).FirstOrDefaultAsync(cancellationToken) + : null; + if (legacy != null && (legacy.Status is ExecutionStatus.Pending or ExecutionStatus.Running || HasConsumedOccurrence(legacy, trigger))) + { + try + { + var migrated = await _executions.FindOneAndUpdateAsync( + Builders.Filter.Eq(e => e.Id, legacy.Id) & Builders.Filter.Eq(e => e.TriggerDocumentId, null), + Builders.Update.Set(e => e.TriggerDocumentId, trigger.Id).Set(e => e.ScheduledRunTime, trigger.NextRunTime), + new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }, cancellationToken); + if (migrated != null) + return migrated; + } + catch (MongoException ex) when (IsDuplicateKey(ex)) + { + return await _executions.Find(occurrence).SingleAsync(cancellationToken); + } + } + + // Never delete terminal receipts, even when another occurrence or trigger generation starts. + var update = Builders.Update + .SetOnInsert(e => e.TriggerId, trigger.TriggerId) + .SetOnInsert(e => e.JobType, trigger.JobType) + .SetOnInsert(e => e.Status, ExecutionStatus.Pending); + try + { + return await _executions.FindOneAndUpdateAsync(occurrence, update, + new FindOneAndUpdateOptions { IsUpsert = true, ReturnDocument = ReturnDocument.After }, cancellationToken); + } + catch (MongoException ex) when (IsDuplicateKey(ex)) + { + return await _executions.Find(occurrence).SingleAsync(cancellationToken); + } + } + + private static bool HasConsumedOccurrence(JobExecution execution, JobTrigger trigger) + { + if (execution.TriggerDocumentId != null && execution.ScheduledRunTime.HasValue) + return execution.TriggerDocumentId == trigger.Id && execution.ScheduledRunTime.Value >= trigger.NextRunTime; + + // Old scheduler documents have neither trigger generation nor scheduled occurrence. + return execution.EndTime.HasValue && execution.EndTime.Value >= trigger.NextRunTime; + } + + private static FilterDefinition CurrentOccurrence(JobTrigger trigger) => + Builders.Filter.And( + Builders.Filter.Eq(t => t.Id, trigger.Id), + Builders.Filter.Eq(t => t.NextRunTime, trigger.NextRunTime)); + + private async Task TryAcquireJobLockAsync(string jobType, string lockId, CancellationToken cancellationToken) + { + var now = UtcNow; + var filter = Builders.Filter.And( + Builders.Filter.Eq(l => l.Id, jobType), + Builders.Filter.Or( + Builders.Filter.Eq(l => l.LockId, null), + Builders.Filter.Lte(l => l.LockExpiry, now))); + try + { + await _jobLocks.FindOneAndUpdateAsync(filter, + Builders.Update.Set(l => l.LockId, lockId).Set(l => l.LockExpiry, now.AddSeconds(_options.LockTimeoutSeconds)), + new FindOneAndUpdateOptions { IsUpsert = true }, cancellationToken); + return true; + } + catch (MongoException ex) when (IsDuplicateKey(ex)) + { + return false; + } + } + + private static bool IsDuplicateKey(MongoException exception) => exception switch + { + MongoWriteException write => write.WriteError?.Category == ServerErrorCategory.DuplicateKey || write.WriteError?.Code == 11000, + MongoCommandException command => command.Code == 11000, + _ => false + }; + + private async Task RenewJobLockAsync(string jobType, string lockId) + { + var now = UtcNow; + var result = await _jobLocks.UpdateOneAsync(l => l.Id == jobType && l.LockId == lockId && l.LockExpiry > now, + Builders.Update.Set(l => l.LockExpiry, now.AddSeconds(_options.LockTimeoutSeconds))); + return result.MatchedCount == 1; + } + + private Task ReleaseJobLockAsync(string jobType, string lockId) => + _jobLocks.UpdateOneAsync(l => l.Id == jobType && l.LockId == lockId, + Builders.Update.Unset(l => l.LockId).Unset(l => l.LockExpiry)); + + private async Task ExecuteJobAsync(JobTrigger trigger, RunningJob running) + { + var execution = running.Execution; + var jobDef = running.Definition; + try + { + var status = ExecutionStatus.Completed; + var result = "Success"; + try + { + running.Cts.Token.ThrowIfCancellationRequested(); + using var scope = _serviceProvider.CreateScope(); + var jobType = Type.GetType(jobDef.JobType) ?? throw new InvalidOperationException($"Cannot load job type {jobDef.JobType}"); + var job = scope.ServiceProvider.GetRequiredService(jobType); + if (string.IsNullOrEmpty(jobDef.SettingsType)) + { + await ((IJob)job).ExecuteAsync(running.Cts.Token); + } + else + { + var settingsType = Type.GetType(jobDef.SettingsType) ?? throw new InvalidOperationException($"Cannot load settings type {jobDef.SettingsType}"); + var settings = string.IsNullOrEmpty(trigger.SettingsJson) + ? Activator.CreateInstance(settingsType) + : JsonSerializer.Deserialize(trigger.SettingsJson, settingsType); + var method = typeof(IJob<>).MakeGenericType(settingsType).GetMethod("ExecuteAsync")!; + Task task; + try + { + task = (Task)method.Invoke(job, new[] { settings, (object)running.Cts.Token })!; + } + catch (TargetInvocationException ex) when (ex.InnerException != null) + { + ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); + throw; + } + await task; + } + } + catch (OperationCanceledException) when (running.Cts.IsCancellationRequested) + { + // Interrupted work remains recoverable instead of consuming the occurrence. + await AbandonExecutionAsync(execution); + return; + } + catch (Exception ex) + { + status = ExecutionStatus.Failed; + result = ex.ToString(); + _logger.LogError(ex, "Job {JobName} (TriggerId: {TriggerId}) failed", jobDef.Name, trigger.TriggerId); + } + + var endTime = UtcNow; + var nextRun = GetNextRunTime(trigger, endTime); + var persisted = await _executions.UpdateOneAsync(OwnedExecution(execution, endTime), + Builders.Update + .Set(e => e.Status, status) + .Set(e => e.EndTime, endTime) + .Set(e => e.Result, result) + .Set(e => e.NextRunTime, nextRun) + .Unset(e => e.LockExpiry)); + if (persisted.MatchedCount == 0) + { + _logger.LogWarning("Lease lost for execution {ExecutionId}; ignoring its result", execution.Id); + return; + } + + // Terminal status is durable before advancing the trigger. Any node can finish this step. + await UpdateTriggerNextRunAsync(trigger, nextRun); + _logger.LogInformation("Job {JobName} (ExecutionId: {ExecutionId}) finished with status {Status}", jobDef.Name, execution.Id, status); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error persisting execution {ExecutionId}", execution.Id); + } + finally + { + try + { + if (running.OwnsJobLock) + await ReleaseJobLockAsync(execution.JobType, execution.LockId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error releasing job lock for execution {ExecutionId}", execution.Id); + } + _runningJobs.TryRemove(execution.LockId, out _); + running.Completion.TrySetResult(); + running.Cts.Dispose(); + } + } + + private static FilterDefinition OwnedExecution(JobExecution execution, DateTime now) => + Builders.Filter.And( + Builders.Filter.Eq(e => e.Id, execution.Id), + Builders.Filter.Eq(e => e.LockId, execution.LockId), + Builders.Filter.Eq(e => e.Status, ExecutionStatus.Running), + Builders.Filter.Gt(e => e.LockExpiry, now)); + + private Task AbandonExecutionAsync(JobExecution execution) => + _executions.UpdateOneAsync(e => e.Id == execution.Id && e.LockId == execution.LockId && e.Status == ExecutionStatus.Running, + Builders.Update.Set(e => e.LockExpiry, UtcNow)); + + private static DateTime? GetNextRunTime(JobTrigger trigger, DateTime completedAt) => + trigger.IsRecurring ? CronHelper.GetNextOccurrence(trigger.CronExpression!, completedAt) : null; + + private Task UpdateTriggerNextRunAsync(JobTrigger trigger, DateTime? nextRun) => + nextRun.HasValue + ? _triggers.UpdateOneAsync(CurrentOccurrence(trigger), Builders.Update.Set(t => t.NextRunTime, nextRun.Value)) + : _triggers.DeleteOneAsync(CurrentOccurrence(trigger)); + + private void Heartbeat(object? state) => _ = HeartbeatAsync(); + + internal async Task HeartbeatAsync() + { + if (_disposed || !_heartbeatLock.Wait(0)) + return; + try + { + foreach (var running in _runningJobs.Values) + { + try + { + var execution = running.Execution; + if (running.OwnsJobLock && !await RenewJobLockAsync(execution.JobType, execution.LockId)) + { + running.Cancel(); + continue; + } + var now = UtcNow; + var renewed = await _executions.UpdateOneAsync(OwnedExecution(execution, now), + Builders.Update + .Set(e => e.LockExpiry, now.AddSeconds(_options.LockTimeoutSeconds)) + .Set(e => e.LastHeartbeat, now)); + if (renewed.MatchedCount == 0) + running.Cancel(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error renewing execution lease"); + } + } + } + finally + { + _heartbeatLock.Release(); + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + lock (_lifecycleLock) + _stopping = true; + _scanTimer?.Dispose(); + _heartbeatTimer?.Dispose(); + _scanCts.Cancel(); + foreach (var running in _runningJobs.Values) + running.Cancel(); + } + + private sealed class RunningJob(JobExecution execution, JobDefinition definition, bool ownsJobLock) + { + public JobExecution Execution { get; } = execution; + public JobDefinition Definition { get; } = definition; + public bool OwnsJobLock { get; } = ownsJobLock; + public CancellationTokenSource Cts { get; } = new(); + public TaskCompletionSource Completion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public void Cancel() + { + try { Cts.Cancel(); } + catch (ObjectDisposedException) { } + } + } +} diff --git a/src/Influunt.Scheduler/JobSchedulerOptions.cs b/src/Influunt.Scheduler/JobSchedulerOptions.cs index b5a7a9e..f33a55d 100644 --- a/src/Influunt.Scheduler/JobSchedulerOptions.cs +++ b/src/Influunt.Scheduler/JobSchedulerOptions.cs @@ -16,4 +16,7 @@ public class JobSchedulerOptions /// Maximum number of concurrently running jobs. public int MaxConcurrentJobs { get; set; } = 10; + + /// Batch size for scan queries. + public int ScanBatchSize { get; set; } = 50; } diff --git a/src/Influunt.Scheduler/JobTriggerClassMap.cs b/src/Influunt.Scheduler/JobTriggerClassMap.cs index fc3ffb2..d80db24 100644 --- a/src/Influunt.Scheduler/JobTriggerClassMap.cs +++ b/src/Influunt.Scheduler/JobTriggerClassMap.cs @@ -1,3 +1,4 @@ +using Influunt.Scheduler.Abstractions.Models; using MongoDB.Bson; using MongoDB.Bson.Serialization; using MongoDB.Bson.Serialization.IdGenerators; diff --git a/src/Influunt.Scheduler/JobTypeLock.cs b/src/Influunt.Scheduler/JobTypeLock.cs new file mode 100644 index 0000000..429bf36 --- /dev/null +++ b/src/Influunt.Scheduler/JobTypeLock.cs @@ -0,0 +1,9 @@ +namespace Influunt.Scheduler; + +internal class JobTypeLock +{ + // MongoDB's unique _id index makes acquisition atomic across scheduler instances. + public string Id { get; set; } = null!; + public string? LockId { get; set; } + public DateTime LockExpiry { get; set; } +} diff --git a/src/Influunt.Scheduler/MongoContext.cs b/src/Influunt.Scheduler/MongoContext.cs index 5ed4e9e..a1a182f 100644 --- a/src/Influunt.Scheduler/MongoContext.cs +++ b/src/Influunt.Scheduler/MongoContext.cs @@ -69,11 +69,8 @@ protected virtual void Dispose(bool disposing) { if (disposing) { - // Cleaning up managed resources - if (_mongoClient.IsValueCreated) - { - // MongoClient does not require explicit Dispose, but it can be added if needed - } + if (_mongoClient.IsValueCreated && _mongoClient.Value is IDisposable disposable) + disposable.Dispose(); } _disposed = true; diff --git a/src/Influunt.Scheduler/SchedulerModule.cs b/src/Influunt.Scheduler/SchedulerModule.cs index e307e96..d711527 100644 --- a/src/Influunt.Scheduler/SchedulerModule.cs +++ b/src/Influunt.Scheduler/SchedulerModule.cs @@ -1,3 +1,4 @@ +using Influunt.Scheduler.Abstractions.Models; using Influunt.Scheduler.Abstractions.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -7,6 +8,8 @@ namespace Influunt.Scheduler; public static class SchedulerModule { + private static readonly object ClassMapLock = new(); + public static IServiceCollection AddJobScheduler(this IServiceCollection services, Action configureOptions) { services.Configure(configureOptions); @@ -20,14 +23,14 @@ public static IServiceCollection AddJobScheduler(this IServiceCollection service services.AddHostedService(); // Register BSON class maps - try{ - BsonClassMap.RegisterClassMap(new JobDefinitionClassMap()); - BsonClassMap.RegisterClassMap(new JobExecutionClassMap()); - BsonClassMap.RegisterClassMap(new JobTriggerClassMap()); - } - catch (ArgumentException) + lock (ClassMapLock) { - // Do nothing. Needs for tests + if (!BsonClassMap.IsClassMapRegistered(typeof(JobDefinition))) + BsonClassMap.RegisterClassMap(new JobDefinitionClassMap()); + if (!BsonClassMap.IsClassMapRegistered(typeof(JobExecution))) + BsonClassMap.RegisterClassMap(new JobExecutionClassMap()); + if (!BsonClassMap.IsClassMapRegistered(typeof(JobTrigger))) + BsonClassMap.RegisterClassMap(new JobTriggerClassMap()); } return services; diff --git a/src/Influunt.Storage/Influunt.Storage.csproj b/src/Influunt.Storage/Influunt.Storage.csproj index 848bbed..85fff78 100644 --- a/src/Influunt.Storage/Influunt.Storage.csproj +++ b/src/Influunt.Storage/Influunt.Storage.csproj @@ -18,5 +18,8 @@ + + + - \ No newline at end of file + diff --git a/src/Influunt.Storage/Services/ChannelService.cs b/src/Influunt.Storage/Services/ChannelService.cs index 5a4eb46..3715fe7 100644 --- a/src/Influunt.Storage/Services/ChannelService.cs +++ b/src/Influunt.Storage/Services/ChannelService.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using System.Threading; using Skidbladnir.Repository.Abstractions; namespace Influunt.Storage.Services; @@ -42,8 +43,9 @@ public Task Update(User user, FeedChannel channel) return _channelRepository.Update(channel); } - public async Task> GetUserChannels(User user) + public async Task> GetUserChannels(User user, CancellationToken cancellationToken = default) { - return await _channelRepository.GetAll().Where(c => c.UserId == user.Id).ToArrayAsync(); + cancellationToken.ThrowIfCancellationRequested(); + return await _channelRepository.GetAll().Where(c => c.UserId == user.Id).ToArrayAsync(cancellationToken); } -} \ No newline at end of file +} diff --git a/src/Influunt.Storage/Services/FeedService.cs b/src/Influunt.Storage/Services/FeedService.cs index d56c2c9..7040279 100644 --- a/src/Influunt.Storage/Services/FeedService.cs +++ b/src/Influunt.Storage/Services/FeedService.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using System.Threading; using Influunt.Feed; using Influunt.Feed.Entity; using Influunt.Feed.Services; @@ -71,20 +72,22 @@ public async Task RemoveFeedByChannel(User user, FeedChannel channel) _logger.LogInformation($"Removing channel with id = {channel.Id} and all channel feed completed"); } - public async Task TryAddToFeed(User user, IEnumerable newFeedItems, FeedChannel channel = null) + public async Task TryAddToFeed(User user, IEnumerable newFeedItems, FeedChannel channel = null, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); var addedCount = 0; foreach (var item in newFeedItems) { + cancellationToken.ThrowIfCancellationRequested(); item.UserId = user.Id; if (channel is not null) item.ChannelId = channel.Id; item.Hash = item.ComputeHash(); - if (await _feedRepository.GetAll().AnyAsync(x => x.UserId == user.Id && x.Hash == item.Hash)) + if (await _feedRepository.GetAll().AnyAsync(x => x.UserId == user.Id && x.Hash == item.Hash, cancellationToken)) continue; - await _feedRepository.Create(item); + await _feedRepository.Create(item, cancellationToken); addedCount++; } return addedCount; } -} \ No newline at end of file +} diff --git a/src/Influunt.Storage/Services/UserService.cs b/src/Influunt.Storage/Services/UserService.cs index 3b04898..490146e 100644 --- a/src/Influunt.Storage/Services/UserService.cs +++ b/src/Influunt.Storage/Services/UserService.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using System.Threading; namespace Influunt.Storage.Services; @@ -19,9 +20,10 @@ public UserService(IRepository userRepository, IHttpContextAccessor httpCo _httpContextAccessor = httpContextAccessor; } - public async Task> GetUsers() + public async Task> GetUsers(CancellationToken cancellationToken = default) { - return await _userRepository.GetAll().ToArrayAsync(); + cancellationToken.ThrowIfCancellationRequested(); + return await _userRepository.GetAll().ToArrayAsync(cancellationToken); } public async Task GetCurrentUser() @@ -68,4 +70,4 @@ public Task Remove(User user) { return _userRepository.Delete(user); } -} \ No newline at end of file +} diff --git a/tests/Influunt.Feed.Tests/FeedJobsTests.cs b/tests/Influunt.Feed.Tests/FeedJobsTests.cs new file mode 100644 index 0000000..f0dcdbb --- /dev/null +++ b/tests/Influunt.Feed.Tests/FeedJobsTests.cs @@ -0,0 +1,226 @@ +using FakeItEasy; +using Influunt.Feed.Crawler; +using Influunt.Feed.Entity; +using Influunt.Scheduler.Abstractions.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace Influunt.Feed.Tests; + +[Trait("Category", "Unit")] +public sealed class FeedJobsTests +{ + [Fact] + public async Task Crawler_Only_Schedules_Users_Strictly_Inside_Activity_Window() + { + var h = new CrawlerHarness(); + var cutoff = h.Now.AddDays(-93); + h.Users.AddRange(new[] { new User { Id = "old", LastActivity = cutoff.AddTicks(-1) }, + new User { Id = "boundary", LastActivity = cutoff }, new User { Id = "active", LastActivity = cutoff.AddTicks(1) } }); + await h.Job.ExecuteAsync(default); + Assert.Equal("active", Assert.Single(h.Enqueued).Id); + } + + [Fact] + public async Task Crawler_With_No_Users_Does_Not_Enqueue_Work() + { + var h = new CrawlerHarness(); + await h.Job.ExecuteAsync(default); + Assert.Empty(h.Enqueued); + } + + [Fact] + public async Task Crawler_Continues_After_Enqueue_Failure_But_Reports_An_Aggregated_Error() + { + var h = new CrawlerHarness(); + h.Users.AddRange(new[] { new User { Id = "a", LastActivity = h.Now }, new User { Id = "b", LastActivity = h.Now } }); + A.CallTo(() => h.Scheduler.ScheduleOneTimeJobAsync( + A.That.Matches(x => x.User.Id == "a"), null, null, A._)) + .ThrowsAsync(new InvalidOperationException("enqueue failed")); + var error = await Assert.ThrowsAsync(() => h.Job.ExecuteAsync(default)); + Assert.Contains("enqueue failed", Assert.Single(error.InnerExceptions).Message); + Assert.Equal("b", Assert.Single(h.Enqueued).Id); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Crawler_Cancellation_Stops_Enqueueing(bool cancelAfterFirst) + { + var h = new CrawlerHarness(); + h.Users.AddRange(new[] { new User { Id = "a", LastActivity = h.Now }, new User { Id = "b", LastActivity = h.Now } }); + using var cts = new CancellationTokenSource(); + if (cancelAfterFirst) + A.CallTo(() => h.Scheduler.ScheduleOneTimeJobAsync( + A._, null, null, cts.Token)) + .ReturnsLazily(() => { cts.Cancel(); return Task.FromResult("first"); }); + else + cts.Cancel(); + await Assert.ThrowsAnyAsync(() => h.Job.ExecuteAsync(cts.Token)); + A.CallTo(() => h.Scheduler.ScheduleOneTimeJobAsync( + A._, null, null, cts.Token)) + .MustHaveHappened(cancelAfterFirst ? 1 : 0, Times.Exactly); + } + + [Fact] + public async Task Cancellation_During_Enqueue_Is_Not_Converted_To_A_Partial_Failure() + { + var h = new CrawlerHarness(); + h.Users.Add(new User { Id = "a", LastActivity = h.Now }); + using var cts = new CancellationTokenSource(); + A.CallTo(() => h.Scheduler.ScheduleOneTimeJobAsync( + A._, null, null, cts.Token)) + .ReturnsLazily(() => { cts.Cancel(); return Task.FromCanceled(cts.Token); }); + await Assert.ThrowsAnyAsync(() => h.Job.ExecuteAsync(cts.Token)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Missing_User_Does_Not_Load_Channels(bool nullSettings) + { + var h = new ChannelHarness(); + await h.Job.ExecuteAsync(nullSettings ? null! : new UserFeedChannelUpdateJobSettings(), default); + A.CallTo(() => h.Channels.GetUserChannels(A._, A._)).MustNotHaveHappened(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Empty_Or_Null_Channels_Do_Not_Fetch_Feeds(bool nullChannels) + { + var h = new ChannelHarness(0); + if (nullChannels) + A.CallTo(() => h.Channels.GetUserChannels(h.User, A._)).Returns(Task.FromResult>(null!)); + await h.Execute(); + A.CallTo(() => h.Source.GetRemoteFeed(A._, A._)).MustNotHaveHappened(); + } + + [Fact] + public async Task Unsupported_Channels_Are_Skipped() + { + var h = new ChannelHarness(); + A.CallTo(() => h.Source.CanProcessChannel(A._)).Returns(false); + await h.Execute(); + A.CallTo(() => h.Source.GetRemoteFeed(A._, A._)).MustNotHaveHappened(); + A.CallTo(() => h.Feed.TryAddToFeed(A._, A>._, A._, A._)).MustNotHaveHappened(); + } + + [Fact] + public async Task At_Most_Five_Channels_Are_Fetched_Concurrently() + { + var h = new ChannelHarness(12); + var reachedFive = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var active = 0; + var started = 0; + var peak = 0; + A.CallTo(() => h.Source.GetRemoteFeed(A._, A._)) + .ReturnsLazily(async (FeedChannel _, CancellationToken token) => + { + var count = Interlocked.Increment(ref active); + peak = Math.Max(peak, count); + if (Interlocked.Increment(ref started) == 5) reachedFive.TrySetResult(); + try { await release.Task.WaitAsync(token); return (IEnumerable)Array.Empty(); } + finally { Interlocked.Decrement(ref active); } + }); + var execution = h.Execute(); + try + { + await reachedFive.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(5, started); + } + finally { release.TrySetResult(); } + await execution; + Assert.Equal(12, started); + Assert.Equal(5, peak); + } + + [Fact] + public async Task Cancellation_Stops_Waiting_Channels_And_Propagates_To_Active_Fetches() + { + var h = new ChannelHarness(12); + using var cts = new CancellationTokenSource(); + var reached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var started = 0; + A.CallTo(() => h.Source.GetRemoteFeed(A._, cts.Token)) + .ReturnsLazily(async (FeedChannel _, CancellationToken token) => + { + if (Interlocked.Increment(ref started) == 5) reached.TrySetResult(); + await Task.Delay(Timeout.Infinite, token); + return (IEnumerable)Array.Empty(); + }); + var execution = h.Execute(cts.Token); + try { await reached.Task.WaitAsync(TimeSpan.FromSeconds(5)); } + finally { cts.Cancel(); } + await Assert.ThrowsAnyAsync(() => execution); + Assert.Equal(5, started); + A.CallTo(() => h.Feed.TryAddToFeed(A._, A>._, A._, A._)).MustNotHaveHappened(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task A_Channel_Failure_Does_Not_Hide_The_Error_Or_Prevent_Other_Channels(bool storageFailure) + { + var h = new ChannelHarness(12); + if (storageFailure) + A.CallTo(() => h.Feed.TryAddToFeed(h.User, A>._, + A.That.Matches(x => x.Id == "0"), A._)) + .ThrowsAsync(new InvalidOperationException("storage failure")); + else + A.CallTo(() => h.Source.GetRemoteFeed(A.That.Matches(x => x.Id == "0"), A._)) + .ThrowsAsync(new InvalidOperationException("source failure")); + await Assert.ThrowsAsync(() => h.Execute()); + A.CallTo(() => h.Source.GetRemoteFeed(A._, A._)).MustHaveHappened(12, Times.Exactly); + A.CallTo(() => h.Feed.TryAddToFeed(h.User, A>._, A._, A._)) + .MustHaveHappened(storageFailure ? 12 : 11, Times.Exactly); + } + + [Fact] + public async Task Channel_Lookup_Error_Reaches_The_Scheduler() + { + var h = new ChannelHarness(); + A.CallTo(() => h.Channels.GetUserChannels(h.User, A._)).ThrowsAsync(new InvalidOperationException("lookup failure")); + await Assert.ThrowsAsync(() => h.Execute()); + } + + private sealed class CrawlerHarness + { + public DateTime Now { get; } = new(2030, 1, 1, 12, 0, 0, DateTimeKind.Utc); + public List Users { get; } = new(); + public List Enqueued { get; } = new(); + public IScheduler Scheduler { get; } = A.Fake(); + public FeedCrawlerJob Job { get; } + public CrawlerHarness() + { + var users = A.Fake(); + A.CallTo(() => users.GetUsers(A._)).ReturnsLazily(() => Task.FromResult>(Users)); + A.CallTo(() => Scheduler.ScheduleOneTimeJobAsync( + A._, null, null, A._)) + .ReturnsLazily((UserFeedChannelUpdateJobSettings settings, DateTime? _, string? _, CancellationToken _) => + { Enqueued.Add(settings.User); return Task.FromResult(Guid.NewGuid().ToString()); }); + Job = new FeedCrawlerJob(Options.Create(new CrawlerOptions()), users, Scheduler, NullLogger.Instance, new FixedClock(Now)); + } + } + + private sealed class FixedClock(DateTime now) : TimeProvider { public override DateTimeOffset GetUtcNow() => new(now); } + + private sealed class ChannelHarness + { + public User User { get; } = new() { Id = "user", Email = "user@example.test" }; + public IChannelService Channels { get; } = A.Fake(); + public IFeedSourceProvider Source { get; } = A.Fake(); + public IFeedService Feed { get; } = A.Fake(); + public UserFeedChannelUpdateJob Job { get; } + public ChannelHarness(int count = 1) + { + A.CallTo(() => Channels.GetUserChannels(User, A._)) + .Returns(Task.FromResult>(Enumerable.Range(0, count).Select(i => new FeedChannel { Id = i.ToString() }).ToArray())); + A.CallTo(() => Source.CanProcessChannel(A._)).Returns(true); + A.CallTo(() => Source.GetRemoteFeed(A._, A._)).Returns(Task.FromResult>(Array.Empty())); + Job = new UserFeedChannelUpdateJob(new[] { Source }, Feed, Channels, NullLogger.Instance); + } + public Task Execute(CancellationToken token = default) => Job.ExecuteAsync(new() { User = User }, token); + } +} diff --git a/tests/Influunt.Feed.Tests/Influunt.Feed.Tests.csproj b/tests/Influunt.Feed.Tests/Influunt.Feed.Tests.csproj new file mode 100644 index 0000000..47e7e13 --- /dev/null +++ b/tests/Influunt.Feed.Tests/Influunt.Feed.Tests.csproj @@ -0,0 +1,20 @@ + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + diff --git a/tests/Influunt.Feed.Tests/RssCancellationTests.cs b/tests/Influunt.Feed.Tests/RssCancellationTests.cs new file mode 100644 index 0000000..ccb09fb --- /dev/null +++ b/tests/Influunt.Feed.Tests/RssCancellationTests.cs @@ -0,0 +1,96 @@ +using System.Net; +using FakeItEasy; +using Influunt.Feed.Entity; +using Influunt.Feed.Rss; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Influunt.Feed.Tests; + +[Trait("Category", "Unit")] +public sealed class RssCancellationTests +{ + private static readonly FeedChannel Channel = new() { Id = "channel", Url = "https://example.test/rss" }; + + [Fact] + public async Task Cancelling_An_Active_Http_Request_Is_Not_Reported_As_An_Empty_Feed() + { + var reached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var http = new HttpClient(new Handler(async (_, token) => + { + reached.TrySetResult(); + await Task.Delay(Timeout.Infinite, token); + return new HttpResponseMessage(HttpStatusCode.OK); + })); + using var cts = new CancellationTokenSource(); + var client = new RssClient(http, NullLogger.Instance); + var fetching = client.GetFeed(Channel, cts.Token); + try { await reached.Task.WaitAsync(TimeSpan.FromSeconds(5)); } + finally { cts.Cancel(); } + await Assert.ThrowsAnyAsync(() => fetching); + } + + [Theory] + [InlineData(HttpStatusCode.Forbidden)] + [InlineData(HttpStatusCode.InternalServerError)] + public async Task Http_Failure_Is_Propagated(HttpStatusCode status) + { + using var http = new HttpClient(new Handler((_, _) => Task.FromResult(new HttpResponseMessage(status)))); + var client = new RssClient(http, NullLogger.Instance); + var error = await Assert.ThrowsAsync(() => client.GetFeed(Channel)); + Assert.Equal(status, error.StatusCode); + } + + [Fact] + public async Task Invalid_Feed_Is_A_Failure_Not_A_Successful_Empty_Result() + { + using var http = new HttpClient(new Handler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { Content = new StringContent("invalid xml") }))); + var client = new RssClient(http, NullLogger.Instance); + await Assert.ThrowsAsync(() => client.GetFeed(Channel)); + } + + [Fact] + public async Task Valid_Rss_Is_Still_Parsed() + { + const string xml = "testposttexthttps://example.test/post"; + using var http = new HttpClient(new Handler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { Content = new StringContent(xml) }))); + var client = new RssClient(http, NullLogger.Instance); + Assert.Equal("post", Assert.Single(await client.GetFeed(Channel)).Title); + } + + [Theory] + [InlineData("empty")] + [InlineData("empty")] + public async Task Valid_Empty_Rss_And_Atom_Are_Not_Reported_As_Errors(string xml) + { + using var http = new HttpClient(new Handler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { Content = new StringContent(xml) }))); + var client = new RssClient(http, NullLogger.Instance); + Assert.Empty(await client.GetFeed(Channel)); + } + + [Fact] + public async Task Cache_Read_Receives_The_Cancellation_Token() + { + var cache = A.Fake(); + using var cts = new CancellationTokenSource(); + var reached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + A.CallTo(() => cache.GetAsync(A._, cts.Token)).ReturnsLazily(async () => + { + reached.TrySetResult(); + await Task.Delay(Timeout.Infinite, cts.Token); + return (byte[]?)Array.Empty(); + }); + using var http = new HttpClient(new Handler((_, _) => throw new InvalidOperationException("HTTP should not start"))); + using var provider = new RssFeedSourceProvider(new RssClient(http, NullLogger.Instance), cache, NullLogger.Instance); + var fetching = provider.GetRemoteFeed(Channel, cts.Token); + try { await reached.Task.WaitAsync(TimeSpan.FromSeconds(5)); } + finally { cts.Cancel(); } + await Assert.ThrowsAnyAsync(() => fetching); + } + + private sealed class Handler(Func> send) : HttpMessageHandler + { protected override Task SendAsync(HttpRequestMessage request, CancellationToken token) => send(request, token); } +} diff --git a/tests/Influunt.Feed.Tests/StorageCancellationTests.cs b/tests/Influunt.Feed.Tests/StorageCancellationTests.cs new file mode 100644 index 0000000..928d24f --- /dev/null +++ b/tests/Influunt.Feed.Tests/StorageCancellationTests.cs @@ -0,0 +1,42 @@ +using FakeItEasy; +using Influunt.Feed.Entity; +using Influunt.Storage.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Skidbladnir.Repository.Abstractions; + +namespace Influunt.Feed.Tests; + +[Trait("Category", "Unit")] +public sealed class StorageCancellationTests +{ + [Fact] + public async Task Cancelled_User_Lookup_Does_Not_Start_A_Query() + { + var repository = A.Fake>(); + var service = new UserService(repository, A.Fake()); + await Assert.ThrowsAnyAsync(() => service.GetUsers(new CancellationToken(true))); + A.CallTo(() => repository.GetAll()).MustNotHaveHappened(); + } + + [Fact] + public async Task Cancelled_Channel_Lookup_Does_Not_Start_A_Query() + { + var repository = A.Fake>(); + var service = new ChannelService(repository); + await Assert.ThrowsAnyAsync(() => service.GetUserChannels(new User(), new CancellationToken(true))); + A.CallTo(() => repository.GetAll()).MustNotHaveHappened(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Cancelled_Feed_Write_Does_Not_Query_Or_Create_Items(bool emptyFeed) + { + var repository = A.Fake>(); + var service = new FeedService(A.Fake(), repository, NullLogger.Instance); + await Assert.ThrowsAnyAsync(() => service.TryAddToFeed(new User(), + emptyFeed ? Array.Empty() : new[] { new FeedItem() }, cancellationToken: new CancellationToken(true))); + A.CallTo(() => repository.GetAll()).MustNotHaveHappened(); + } +} diff --git a/tests/Influunt.Scheduler.Tests/CronHelperTests.cs b/tests/Influunt.Scheduler.Tests/CronHelperTests.cs new file mode 100644 index 0000000..7772c5b --- /dev/null +++ b/tests/Influunt.Scheduler.Tests/CronHelperTests.cs @@ -0,0 +1,37 @@ +using Cronos; + +namespace Influunt.Scheduler.Tests; + +[Trait("Category", "Unit")] +public sealed class CronHelperTests +{ + [Theory] + [InlineData("* * * * *", "2030-01-01T12:00:00Z", "2030-01-01T12:01:00Z")] + [InlineData("* * * * *", "2030-01-01T12:00:59Z", "2030-01-01T12:01:00Z")] + [InlineData("0 0 * * *", "2030-12-31T23:59:59Z", "2031-01-01T00:00:00Z")] + [InlineData("0 0 29 2 *", "2023-03-01T00:00:00Z", "2024-02-29T00:00:00Z")] + [InlineData("*/30 * * * *", "2030-01-01T12:30:00Z", "2030-01-01T13:00:00Z")] + public void Next_Occurrence_Is_Strictly_After_Input_And_In_Utc(string cron, string from, string expected) + { + var next = CronHelper.GetNextOccurrence(cron, DateTimeOffset.Parse(from).UtcDateTime); + Assert.Equal(DateTimeOffset.Parse(expected).UtcDateTime, next); + Assert.Equal(DateTimeKind.Utc, next!.Value.Kind); + } + + [Theory] + [InlineData("")] + [InlineData("invalid")] + [InlineData("* * * * * *")] + [InlineData("60 * * * *")] + [InlineData("*/0 * * * *")] + public void Invalid_And_Unsupported_Cron_Is_Rejected(string cron) => + Assert.Throws(() => CronHelper.GetNextOccurrence(cron, DateTime.UtcNow)); + + [Fact] + public void Impossible_Calendar_Date_Has_No_Next_Occurrence() => + Assert.Null(CronHelper.GetNextOccurrence("0 0 30 2 *", new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc))); + + [Fact] + public void Non_Utc_Input_Is_Rejected() => + Assert.Throws(() => CronHelper.GetNextOccurrence("* * * * *", new DateTime(2030, 1, 1))); +} diff --git a/tests/Influunt.Scheduler.Tests/JobSchedulerIntegrationTests.cs b/tests/Influunt.Scheduler.Tests/JobSchedulerIntegrationTests.cs index 35b7654..02d7fb7 100644 --- a/tests/Influunt.Scheduler.Tests/JobSchedulerIntegrationTests.cs +++ b/tests/Influunt.Scheduler.Tests/JobSchedulerIntegrationTests.cs @@ -7,13 +7,15 @@ namespace Influunt.Scheduler.Tests; -public class JobSchedulerIntegrationTests : SchedulerIntegrationTestBase +[Trait("Category", "Integration")] +public class JobSchedulerIntegrationTests : SchedulerIntegrationTestBase { protected override void ConfigureServices(IServiceCollection services, string connectionString) { // Register test jobs services.AddTransient(); services.AddTransient(); + services.AddTransient(); // Add scheduler module with hosted service registration services.AddJobSchedulerHost(options => @@ -34,8 +36,10 @@ protected override async Task InitializeTestDataAsync() TestJobWithSettings.WasExecuted = false; TestJobWithSettings.ExecutionCount = 0; TestJobWithSettings.LastSettings = null; - - await base.InitializeTestDataAsync(); + TestBlockingJob.ExecutionCount = 0; + TestBlockingJob.ContinueExecution = null; + + await base.InitializeTestDataAsync(); } [Fact] @@ -265,11 +269,66 @@ public async Task Should_Handle_Multiple_Concurrent_Jobs() triggers.Should().BeEmpty(); } - + [Fact] + public async Task Should_Respect_AllowConcurrentExecution_False() + { + // Arrange + var scheduler = GetRequiredService(); + + TestBlockingJob.ExecutionCount = 0; + var blockTcs = new TaskCompletionSource(); + TestBlockingJob.ContinueExecution = blockTcs; + + await scheduler.RegisterJobAsync("TestBlocking", allowConcurrentExecution: false); + + var triggerId1 = await scheduler.ScheduleOneTimeJobAsync(DateTime.UtcNow.AddSeconds(1), "concurrent-block-a"); + var triggerId2 = await scheduler.ScheduleOneTimeJobAsync(DateTime.UtcNow.AddSeconds(1), "concurrent-block-b"); + + // Act — wait for scan cycle + await Task.Delay(3000); + + // Assert — only one trigger should execute (second is blocked by AllowConcurrentExecution) + TestBlockingJob.ExecutionCount.Should().Be(1); + + // Release the first job + blockTcs.SetResult(); + TestBlockingJob.ContinueExecution = null; + + await Task.Delay(3000); + + // Second trigger should now execute + TestBlockingJob.ExecutionCount.Should().Be(2); + } + + [Fact] + public async Task Should_Execute_Concurrent_Jobs_With_AllowConcurrentExecution_True() + { + // Arrange + var scheduler = GetRequiredService(); + + TestBlockingJob.ExecutionCount = 0; + var blockTcs = new TaskCompletionSource(); + TestBlockingJob.ContinueExecution = blockTcs; + + await scheduler.RegisterJobAsync("TestBlockingConcurrent", allowConcurrentExecution: true); + + var triggerId1 = await scheduler.ScheduleOneTimeJobAsync(DateTime.UtcNow.AddSeconds(1), "concurrent-allow-a"); + var triggerId2 = await scheduler.ScheduleOneTimeJobAsync(DateTime.UtcNow.AddSeconds(1), "concurrent-allow-b"); + + // Act — wait for scan cycle + await Task.Delay(3000); + + // Assert — both triggers should execute (AllowConcurrentExecution = true) + TestBlockingJob.ExecutionCount.Should().Be(2); + + blockTcs.SetResult(); + TestBlockingJob.ContinueExecution = null; + } + private static async Task ForceRunTrigger(string triggerId, IMongoCollection triggersCollection) { await triggersCollection.UpdateOneAsync( t => t.TriggerId == triggerId, Builders.Update.Set(t => t.NextRunTime, DateTime.UtcNow.AddSeconds(-5))); } -} \ No newline at end of file +} diff --git a/tests/Influunt.Scheduler.Tests/JobSchedulerUnitTests.cs b/tests/Influunt.Scheduler.Tests/JobSchedulerUnitTests.cs new file mode 100644 index 0000000..1cad8c2 --- /dev/null +++ b/tests/Influunt.Scheduler.Tests/JobSchedulerUnitTests.cs @@ -0,0 +1,199 @@ +using System.Text.Json; +using Cronos; +using FakeItEasy; +using Influunt.Scheduler.Abstractions.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using MongoDB.Driver; + +namespace Influunt.Scheduler.Tests; + +[Trait("Category", "Unit")] +public sealed class JobSchedulerUnitTests +{ + [Theory] + [InlineData("scan", 0)] + [InlineData("scan", -1)] + [InlineData("batch", 0)] + [InlineData("batch", -1)] + [InlineData("workers", 0)] + [InlineData("workers", -1)] + [InlineData("heartbeat", 0)] + [InlineData("heartbeat", -1)] + [InlineData("lease", 0)] + [InlineData("lease", -1)] + [InlineData("lease", 60)] + public void Invalid_Options_Are_Rejected(string field, int value) + { + var options = new JobSchedulerOptions(); + switch (field) + { + case "scan": options.ScanIntervalSeconds = value; break; + case "batch": options.ScanBatchSize = value; break; + case "workers": options.MaxConcurrentJobs = value; break; + case "heartbeat": options.HeartbeatIntervalSeconds = value; break; + case "lease": options.LockTimeoutSeconds = value; break; + } + using var provider = new ServiceCollection().BuildServiceProvider(); + Assert.Throws(() => new JobScheduler(provider, Options.Create(options), + NullLogger.Instance, A.Fake())); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public async Task Every_Scheduling_Overload_Requires_A_Registered_Job(int overload) + { + using var h = new UnitHarness(); + await Assert.ThrowsAsync(() => overload switch + { + 0 => h.Node.ScheduleOneTimeJobAsync(), + 1 => h.Node.ScheduleRecurringJobAsync("* * * * *"), + 2 => h.Node.ScheduleOneTimeJobAsync(new()), + _ => h.Node.ScheduleRecurringJobAsync("* * * * *", new()) + }); + Assert.Empty(h.Inserted); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Settings_Type_Mismatch_Is_Rejected_Before_Inserting_Trigger(bool recurring) + { + using var h = new UnitHarness(new JobDefinition + { + JobType = typeof(SettingsJob).AssemblyQualifiedName!, SettingsType = typeof(string).AssemblyQualifiedName! + }); + await Assert.ThrowsAsync(() => recurring + ? h.Node.ScheduleRecurringJobAsync("* * * * *", new()) + : h.Node.ScheduleOneTimeJobAsync(new())); + Assert.Empty(h.Inserted); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Impossible_Cron_Is_Rejected_Without_Inserting_Trigger(bool withSettings) + { + using var h = new UnitHarness(Definition(withSettings)); + await Assert.ThrowsAsync(() => withSettings + ? h.Node.ScheduleRecurringJobAsync("0 0 30 2 *", new()) + : h.Node.ScheduleRecurringJobAsync("0 0 30 2 *")); + Assert.Empty(h.Inserted); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Invalid_Cron_Is_Rejected_Without_Inserting_Trigger(bool withSettings) + { + using var h = new UnitHarness(Definition(withSettings)); + await Assert.ThrowsAsync(() => withSettings + ? h.Node.ScheduleRecurringJobAsync("invalid", new()) + : h.Node.ScheduleRecurringJobAsync("invalid")); + Assert.Empty(h.Inserted); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task Generated_Trigger_Ids_Are_Unique_And_Immediate_Run_Uses_Clock(string? id) + { + using var h = new UnitHarness(Definition(false)); + var a = await h.Node.ScheduleOneTimeJobAsync(triggerId: id); + var b = await h.Node.ScheduleOneTimeJobAsync(triggerId: id); + Assert.True(Guid.TryParse(a, out _)); + Assert.NotEqual(a, b); + Assert.All(h.Inserted, t => Assert.Equal(h.Clock.GetUtcNow().UtcDateTime, t.NextRunTime)); + Assert.All(h.Inserted, t => Assert.Equal(h.Clock.GetUtcNow().UtcDateTime, t.CreatedAt)); + } + + [Fact] + public async Task Local_Run_Time_Is_Converted_To_Utc_And_Explicit_Id_Is_Preserved() + { + using var h = new UnitHarness(Definition(false)); + var local = new DateTime(2030, 1, 1, 12, 0, 0, DateTimeKind.Local); + await h.Node.ScheduleOneTimeJobAsync(local, "explicit"); + var trigger = Assert.Single(h.Inserted); + Assert.Equal(local.ToUniversalTime(), trigger.NextRunTime); + Assert.Equal(DateTimeKind.Utc, trigger.NextRunTime.Kind); + Assert.Equal("explicit", trigger.TriggerId); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Settings_Round_Trip_And_Recurring_Schedule_Use_The_Same_Clock(bool recurring) + { + using var h = new UnitHarness(Definition(true)); + var settings = new Payload { Text = "RSS: новости <>&", Values = new[] { 0, -1, int.MaxValue } }; + if (recurring) + await h.Node.ScheduleRecurringJobAsync("*/30 * * * *", settings, "settings"); + else + await h.Node.ScheduleOneTimeJobAsync(settings, triggerId: "settings"); + var trigger = Assert.Single(h.Inserted); + var decoded = JsonSerializer.Deserialize(trigger.SettingsJson!)!; + Assert.Equal(settings.Text, decoded.Text); + Assert.Equal(settings.Values, decoded.Values); + Assert.Equal(h.Clock.GetUtcNow().UtcDateTime.AddMinutes(recurring ? 30 : 0), trigger.NextRunTime); + } + + [Fact] + public async Task Stop_And_Dispose_Are_Idempotent_And_Prevent_New_Scans() + { + using var h = new UnitHarness(); + await h.Node.StopAsync(new CancellationToken(true)); + await h.Node.StopAsync(default); + h.Node.Dispose(); + h.Node.Dispose(); + await h.Node.ScanTriggersAsync(); + await h.Node.HeartbeatAsync(); + Assert.False(await h.Node.ScheduleJobAsync(new JobTrigger())); + Assert.Empty(h.Inserted); + } + + private static JobDefinition Definition(bool settings) => new() + { + JobType = (settings ? typeof(SettingsJob) : typeof(PlainJob)).AssemblyQualifiedName!, + SettingsType = settings ? typeof(Payload).AssemblyQualifiedName! : null! + }; + + private sealed class UnitHarness : IDisposable + { + private readonly ServiceProvider _provider = new ServiceCollection().BuildServiceProvider(); + public ManualTimeProvider Clock { get; } = new(); + public List Inserted { get; } = new(); + public JobScheduler Node { get; } + public UnitHarness(JobDefinition? definition = null) + { + var context = A.Fake(); + var definitions = A.Fake>(); + var triggers = A.Fake>(); + A.CallTo(() => context.GetCollection(Constants.JobDefinitionsCollectionName)).Returns(definitions); + A.CallTo(() => context.GetCollection(Constants.JobTriggersCollectionName)).Returns(triggers); + A.CallTo(() => definitions.FindAsync(A>._, A>._, A._)) + .ReturnsLazily(() => Task.FromResult>(new SingleBatchCursor(definition == null ? Array.Empty() : new[] { definition }))); + A.CallTo(() => triggers.InsertOneAsync(A._, A._, A._)) + .Invokes((JobTrigger trigger, InsertOneOptions _, CancellationToken _) => Inserted.Add(trigger)) + .Returns(Task.CompletedTask); + Node = new JobScheduler(_provider, Options.Create(new JobSchedulerOptions()), NullLogger.Instance, context, Clock); + } + public void Dispose() { Node.Dispose(); _provider.Dispose(); } + } + + private sealed class SingleBatchCursor(IEnumerable items) : IAsyncCursor + { + private bool _moved; + public IEnumerable Current => items; + public bool MoveNext(CancellationToken cancellationToken = default) { var first = !_moved; _moved = true; return first; } + public Task MoveNextAsync(CancellationToken cancellationToken = default) => Task.FromResult(MoveNext(cancellationToken)); + public void Dispose() { } + } + + public sealed class PlainJob : IJob { public Task ExecuteAsync(CancellationToken cancellationToken) => Task.CompletedTask; } + public sealed class Payload { public string? Text { get; set; } public int[]? Values { get; set; } } + public sealed class SettingsJob : IJob { public Task ExecuteAsync(Payload settings, CancellationToken cancellationToken) => Task.CompletedTask; } +} diff --git a/tests/Influunt.Scheduler.Tests/ManualTimeProvider.cs b/tests/Influunt.Scheduler.Tests/ManualTimeProvider.cs new file mode 100644 index 0000000..c0258f0 --- /dev/null +++ b/tests/Influunt.Scheduler.Tests/ManualTimeProvider.cs @@ -0,0 +1,8 @@ +namespace Influunt.Scheduler.Tests; + +internal sealed class ManualTimeProvider : TimeProvider +{ + private long _ticks = new DateTimeOffset(2030, 1, 1, 12, 0, 0, TimeSpan.Zero).Ticks; + public override DateTimeOffset GetUtcNow() => new(Interlocked.Read(ref _ticks), TimeSpan.Zero); + public void Advance(TimeSpan duration) => Interlocked.Add(ref _ticks, duration.Ticks); +} diff --git a/tests/Influunt.Scheduler.Tests/SchedulerBoundaryTests.cs b/tests/Influunt.Scheduler.Tests/SchedulerBoundaryTests.cs new file mode 100644 index 0000000..34edf87 --- /dev/null +++ b/tests/Influunt.Scheduler.Tests/SchedulerBoundaryTests.cs @@ -0,0 +1,479 @@ +using System.Collections.Concurrent; +using FakeItEasy; +using Influunt.Scheduler.Abstractions.Models; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; + +namespace Influunt.Scheduler.Tests; + +public sealed partial class SchedulerReliabilityTests +{ + [Theory] + [InlineData(ExecutionStatus.Pending, -1, true)] + [InlineData(ExecutionStatus.Pending, 0, true)] + [InlineData(ExecutionStatus.Pending, 1, false)] + [InlineData(ExecutionStatus.Running, -1, true)] + [InlineData(ExecutionStatus.Running, 0, true)] + [InlineData(ExecutionStatus.Running, 1, false)] + public async Task Lease_Is_Acquirable_Exactly_At_Expiry(ExecutionStatus status, int offsetMilliseconds, bool expectedStart) + { + var clock = new ManualTimeProvider(); + await using var h = await Harness.CreateAsync(mongo.ConnectionString, clock: clock); + var trigger = await h.ScheduleAsync(); + var expiry = clock.GetUtcNow().UtcDateTime.AddMilliseconds(offsetMilliseconds); + await h.Executions.InsertOneAsync(new JobExecution + { + TriggerId = trigger.TriggerId, TriggerDocumentId = trigger.Id, ScheduledRunTime = trigger.NextRunTime, + JobType = trigger.JobType, Status = status, LockId = "previous", LockExpiry = expiry + }); + await h.Locks.InsertOneAsync(new JobTypeLock { Id = trigger.JobType, LockId = "previous", LockExpiry = expiry }); + + Assert.Equal(expectedStart, await h.Node.ScheduleJobAsync(trigger)); + if (expectedStart) + await h.WaitForStatusAsync(ExecutionStatus.Completed); + Assert.Equal(expectedStart ? 1 : 0, h.Job.Count); + } + + [Theory] + [InlineData(false, 0)] + [InlineData(false, 1)] + [InlineData(true, 0)] + [InlineData(true, 1)] + public async Task Heartbeat_Does_Not_Revive_An_Expired_Lease(bool jobTypeLease, int remainingMilliseconds) + { + var clock = new ManualTimeProvider(); + await using var h = await Harness.CreateAsync(mongo.ConnectionString, clock: clock); + h.Job.Block(); + var trigger = await h.ScheduleAsync(allowConcurrent: !jobTypeLease); + Assert.True(await h.Node.ScheduleJobAsync(trigger)); + await h.WaitUntilAsync(() => h.Job.Count == 1); + var expiry = clock.GetUtcNow().UtcDateTime.AddMilliseconds(remainingMilliseconds); + if (jobTypeLease) + await h.Locks.UpdateOneAsync(x => true, Builders.Update.Set(x => x.LockExpiry, expiry)); + else + await h.Executions.UpdateOneAsync(x => true, Builders.Update.Set(x => x.LockExpiry, expiry)); + + await h.Node.HeartbeatAsync(); + if (remainingMilliseconds == 0) + { + await h.Job.Cancelled.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await h.Node.StopAsync(default); + Assert.Equal(ExecutionStatus.Running, (await h.Executions.Find(x => true).SingleAsync()).Status); + Assert.True(await h.Triggers.Find(x => true).AnyAsync()); + } + else + { + Assert.False(h.Job.Cancelled.Task.IsCompleted); + Assert.Equal(clock.GetUtcNow().UtcDateTime.AddSeconds(30), (await h.Executions.Find(x => true).SingleAsync()).LockExpiry); + } + } + + [Fact] + public async Task Finishing_At_Lease_Expiry_Cannot_Consume_The_Occurrence() + { + var clock = new ManualTimeProvider(); + await using var h = await Harness.CreateAsync(mongo.ConnectionString, clock: clock); + h.Job.Block(); + var trigger = await h.ScheduleAsync(); + Assert.True(await h.Node.ScheduleJobAsync(trigger)); + await h.WaitUntilAsync(() => h.Job.Count == 1); + clock.Advance(TimeSpan.FromSeconds(30)); + h.Job.Release.TrySetResult(); + await h.Node.StopAsync(default); + Assert.Equal(ExecutionStatus.Running, (await h.Executions.Find(x => true).SingleAsync()).Status); + Assert.True(await h.Triggers.Find(x => true).AnyAsync()); + Assert.True(await h.NewNode().ScheduleJobAsync(trigger)); + await h.WaitForStatusAsync(ExecutionStatus.Completed); + Assert.Equal(2, h.Job.Count); + } + + [Fact] + public async Task Future_Trigger_Fires_At_Its_Due_Time_And_Not_Before() + { + var clock = new ManualTimeProvider(); + await using var h = await Harness.CreateAsync(mongo.ConnectionString, clock: clock); + var trigger = await h.ScheduleAsync(); + var due = clock.GetUtcNow().UtcDateTime.AddMilliseconds(1); + await h.Triggers.UpdateOneAsync(x => x.Id == trigger.Id, Builders.Update.Set(x => x.NextRunTime, due)); + await h.Node.ScanTriggersAsync(); + Assert.Equal(0, h.Job.Count); + clock.Advance(TimeSpan.FromMilliseconds(1)); + await h.Node.ScanTriggersAsync(); + await h.WaitForStatusAsync(ExecutionStatus.Completed); + Assert.Equal(1, h.Job.Count); + } + + [Fact] + public async Task Scan_Respects_MaxConcurrentJobs_And_Resumes_Queued_Work() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString, maxJobs: 2, batchSize: 1); + var active = 0; + var peak = 0; + var monitor = new object(); + h.Job.Handler = async token => + { + lock (monitor) { active++; peak = Math.Max(peak, active); } + try { await h.Job.Release.Task.WaitAsync(token); } + finally { lock (monitor) active--; } + }; + for (var i = 0; i < 6; i++) + await h.ScheduleAsync("queued-" + i, allowConcurrent: true); + await h.Node.ScanTriggersAsync(); + await h.WaitUntilAsync(() => h.Job.Count == 2); + await h.Node.ScanTriggersAsync(); + Assert.Equal(2, h.Job.Count); + h.Job.Release.TrySetResult(); + await h.WaitUntilAsync(async () => + { + await h.Node.ScanTriggersAsync(); + return await h.Executions.CountDocumentsAsync(x => x.Status == ExecutionStatus.Completed) == 6; + }); + Assert.Equal(2, peak); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Completion_And_Trigger_Update_Are_Recoverable_After_A_Write_Failure(bool recurring) + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + var trigger = await h.ScheduleAsync(recurring: recurring); + var triggers = A.Fake>(o => o.Wrapping(h.Triggers)); + if (recurring) + A.CallTo(() => triggers.UpdateOneAsync(A>._, A>._, A._, A._)) + .ThrowsAsync(new MongoException("trigger write unavailable")); + else + A.CallTo(() => triggers.DeleteOneAsync(A>._, A._)) + .ThrowsAsync(new MongoException("trigger write unavailable")); + var failedNode = h.NewNode(ContextWith(h, Constants.JobTriggersCollectionName, triggers)); + Assert.True(await failedNode.ScheduleJobAsync(trigger)); + await h.WaitForStatusAsync(ExecutionStatus.Completed); + await failedNode.StopAsync(default); + Assert.Equal(trigger.NextRunTime, (await h.Triggers.Find(x => true).SingleAsync()).NextRunTime); + + await h.Node.ScanTriggersAsync(); + Assert.Equal(1, h.Job.Count); + Assert.Single(await h.Executions.Find(x => true).ToListAsync()); + if (recurring) + Assert.True((await h.Triggers.Find(x => true).SingleAsync()).NextRunTime > DateTime.UtcNow); + else + Assert.False(await h.Triggers.Find(x => true).AnyAsync()); + } + + [Fact] + public async Task Failed_Completion_Write_Leaves_Work_Recoverable() + { + var clock = new ManualTimeProvider(); + await using var h = await Harness.CreateAsync(mongo.ConnectionString, clock: clock); + var trigger = await h.ScheduleAsync(); + var executions = A.Fake>(o => o.Wrapping(h.Executions)); + A.CallTo(() => executions.UpdateOneAsync(A>._, A>._, A._, A._)) + .ThrowsAsync(new MongoException("completion write unavailable")); + var failedNode = h.NewNode(ContextWith(h, Constants.JobExecutionsCollectionName, executions)); + Assert.True(await failedNode.ScheduleJobAsync(trigger)); + await h.WaitUntilAsync(() => h.Job.Count == 1); + await failedNode.StopAsync(default); + Assert.Equal(ExecutionStatus.Running, (await h.Executions.Find(x => true).SingleAsync()).Status); + Assert.True(await h.Triggers.Find(x => true).AnyAsync()); + clock.Advance(TimeSpan.FromSeconds(30)); + Assert.True(await h.Node.ScheduleJobAsync(trigger)); + await h.WaitForStatusAsync(ExecutionStatus.Completed); + Assert.Equal(2, h.Job.Count); + } + + [Fact] + public async Task Scan_Recovers_After_A_Query_Exception() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + await h.ScheduleAsync(); + var calls = 0; + var triggers = A.Fake>(o => o.Wrapping(h.Triggers)); + A.CallTo(() => triggers.FindAsync(A>._, A>._, A._)) + .ReturnsLazily((FilterDefinition filter, FindOptions options, CancellationToken token) => + Interlocked.Increment(ref calls) == 1 + ? Task.FromException>(new MongoException("query unavailable")) + : h.Triggers.FindAsync(filter, options, token)); + var node = h.NewNode(ContextWith(h, Constants.JobTriggersCollectionName, triggers)); + await node.ScanTriggersAsync(); + Assert.Equal(0, h.Job.Count); + await node.ScanTriggersAsync(); + await h.WaitForStatusAsync(ExecutionStatus.Completed); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Overlapping_Scan_Is_Skipped_And_Stop_Drains_An_InFlight_Scan(bool stop) + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + await h.ScheduleAsync(); + var reached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var resume = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var calls = 0; + var triggers = A.Fake>(o => o.Wrapping(h.Triggers)); + A.CallTo(() => triggers.FindAsync(A>._, A>._, A._)) + .ReturnsLazily(async (FilterDefinition filter, FindOptions options, CancellationToken token) => + { + Interlocked.Increment(ref calls); + reached.TrySetResult(); + await resume.Task; + return await h.Triggers.FindAsync(filter, options, token); + }); + var node = h.NewNode(ContextWith(h, Constants.JobTriggersCollectionName, triggers)); + var scan = node.ScanTriggersAsync(); + try + { + await reached.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var next = stop ? node.StopAsync(default) : node.ScanTriggersAsync(); + if (stop) + Assert.False(next.IsCompleted); + else + await next.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(1, calls); + resume.TrySetResult(); + await Task.WhenAll(scan, next).WaitAsync(TimeSpan.FromSeconds(5)); + if (stop) + Assert.Equal(0, h.Job.Count); + else + await h.WaitForStatusAsync(ExecutionStatus.Completed); + } + finally { resume.TrySetResult(); } + } + + [Fact] + public async Task Missing_Job_Definition_Does_Not_Block_Later_Triggers() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString, batchSize: 1); + await h.Triggers.InsertOneAsync(new JobTrigger { TriggerId = "orphan", JobType = "missing", NextRunTime = DateTime.UtcNow.AddDays(-1) }); + await h.ScheduleAsync(); + await h.Node.ScanTriggersAsync(); + await h.WaitForStatusAsync(ExecutionStatus.Completed); + Assert.Equal(1, h.Job.Count); + Assert.True(await h.Triggers.Find(x => x.TriggerId == "orphan").AnyAsync()); + } + + [Fact] + public async Task Stop_Respects_Host_Deadline_When_A_Job_Ignores_Cancellation() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + h.Job.Handler = _ => h.Job.Release.Task; + var trigger = await h.ScheduleAsync(); + Assert.True(await h.Node.ScheduleJobAsync(trigger)); + await h.WaitUntilAsync(() => h.Job.Count == 1); + try + { + await h.Node.StopAsync(new CancellationToken(true)).WaitAsync(TimeSpan.FromSeconds(5)); + Assert.False(await h.Node.ScheduleJobAsync(trigger)); + Assert.Equal(ExecutionStatus.Running, (await h.Executions.Find(x => true).SingleAsync()).Status); + } + finally { h.Job.Release.TrySetResult(); } + } + + [Fact] + public async Task Stop_During_Final_Lease_Renewal_Cannot_Register_A_New_Job() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + await h.ScheduleAsync(); + var reached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var resume = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var calls = 0; + var locks = A.Fake>(o => o.Wrapping(h.Locks)); + A.CallTo(() => locks.UpdateOneAsync(A>._, A>._, A._, A._)) + .ReturnsLazily(async (FilterDefinition filter, UpdateDefinition update, UpdateOptions options, CancellationToken token) => + { + var result = await h.Locks.UpdateOneAsync(filter, update, options, token); + if (Interlocked.Increment(ref calls) == 1) + { + reached.TrySetResult(); + await resume.Task; + } + return result; + }); + var node = h.NewNode(ContextWith(h, Constants.JobLocksCollectionName, locks)); + var scan = node.ScanTriggersAsync(); + try + { + await reached.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await node.StopAsync(new CancellationToken(true)).WaitAsync(TimeSpan.FromSeconds(5)); + resume.TrySetResult(); + await scan.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(0, h.Job.Count); + var execution = await h.Executions.Find(x => true).SingleAsync(); + Assert.Equal(ExecutionStatus.Running, execution.Status); + Assert.True(execution.LockExpiry <= DateTime.UtcNow); + Assert.False(await h.Locks.Find(x => x.LockId != null).AnyAsync()); + } + finally { resume.TrySetResult(); } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Removed_Trigger_Or_Expired_Lease_During_Acquisition_Does_Not_Start_Work(bool removeTrigger) + { + var clock = new ManualTimeProvider(); + await using var h = await Harness.CreateAsync(mongo.ConnectionString, clock: clock); + var trigger = await h.ScheduleAsync(); + var executions = A.Fake>(o => o.Wrapping(h.Executions)); + A.CallTo(() => executions.FindOneAndUpdateAsync(A>._, A>._, + A>._, A._)) + .ReturnsLazily(async (FilterDefinition filter, UpdateDefinition update, + FindOneAndUpdateOptions options, CancellationToken token) => + { + var result = await h.Executions.FindOneAndUpdateAsync(filter, update, options, token); + if (!options.IsUpsert) + { + if (removeTrigger) + await h.Triggers.DeleteOneAsync(x => x.Id == trigger.Id); + else + clock.Advance(TimeSpan.FromSeconds(30)); + } + return result; + }); + var node = h.NewNode(ContextWith(h, Constants.JobExecutionsCollectionName, executions)); + Assert.False(await node.ScheduleJobAsync(trigger)); + Assert.Equal(0, h.Job.Count); + Assert.True((await h.Executions.Find(x => true).SingleAsync()).LockExpiry <= clock.GetUtcNow().UtcDateTime); + Assert.False(await h.Locks.Find(x => x.LockId != null).AnyAsync()); + } + + [Theory] + [InlineData("job-type")] + [InlineData("settings-type")] + public async Task Unloadable_Types_Fail_Once_Without_Poisoning_The_Queue(string invalid) + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + var trigger = await h.ScheduleAsync(); + var definitions = h.Context.GetCollection(Constants.JobDefinitionsCollectionName); + if (invalid == "job-type") + { + trigger.JobType = "Missing.Job, Missing.Assembly"; + await definitions.UpdateOneAsync(x => true, Builders.Update.Set(x => x.JobType, trigger.JobType)); + await h.Triggers.UpdateOneAsync(x => true, Builders.Update.Set(x => x.JobType, trigger.JobType)); + } + else + await definitions.UpdateOneAsync(x => true, Builders.Update.Set(x => x.SettingsType, "Missing.Settings, Missing.Assembly")); + Assert.True(await h.Node.ScheduleJobAsync(trigger)); + await h.WaitForStatusAsync(ExecutionStatus.Failed); + await h.WaitUntilAsync(async () => !await h.Triggers.Find(x => true).AnyAsync()); + Assert.Equal(0, h.Job.Count); + await h.Node.ScanTriggersAsync(); + Assert.Single(await h.Executions.Find(x => true).ToListAsync()); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + public async Task Generic_Job_Handles_Explicit_Interface_Default_Settings_And_Invalid_Json(bool emptySettings, bool invalidJson) + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString, configure: services => services.AddSingleton()); + await h.Node.RegisterJobAsync("settings"); + var id = await h.Node.ScheduleRecurringJobAsync("0 0 1 1 *", new() { Text = "payload" }); + var update = Builders.Update.Set(x => x.NextRunTime, DateTime.UtcNow.AddSeconds(-1)); + if (emptySettings || invalidJson) + update = update.Set(x => x.SettingsJson, invalidJson ? "{ broken json" : null); + await h.Triggers.UpdateOneAsync(x => x.TriggerId == id, update); + await h.Node.ScanTriggersAsync(); + await h.WaitForStatusAsync(invalidJson ? ExecutionStatus.Failed : ExecutionStatus.Completed); + var job = h.Services.GetRequiredService(); + Assert.Equal(invalidJson ? 0 : 1, job.Calls); + if (!invalidJson) + Assert.Equal(emptySettings ? null : "payload", job.Last!.Text); + } + + [Fact] + public async Task Job_Scopes_Are_Separate_And_Disposed_On_Success_And_Failure() + { + var observer = new ScopeObserver(); + await using var h = await Harness.CreateAsync(mongo.ConnectionString, configure: services => + services.AddSingleton(observer).AddScoped().AddTransient()); + await h.Node.RegisterJobAsync("scoped", allowConcurrentExecution: true); + await h.Node.ScheduleOneTimeJobAsync(); + await h.Node.ScheduleOneTimeJobAsync(); + await h.Node.ScanTriggersAsync(); + await h.WaitUntilAsync(async () => await h.Executions.CountDocumentsAsync(x => x.Status == ExecutionStatus.Completed || x.Status == ExecutionStatus.Failed) == 2); + Assert.Equal(2, observer.Used.Distinct().Count()); + Assert.Equal(observer.Used.OrderBy(x => x), observer.Disposed.OrderBy(x => x)); + Assert.Equal(1, await h.Executions.CountDocumentsAsync(x => x.Status == ExecutionStatus.Failed)); + } + + [Fact] + public async Task ReRegistering_A_Job_Updates_Options_Without_Changing_Its_Identity() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + await h.Node.RegisterJobAsync("controlled", "first"); + var before = Assert.Single(await h.Node.GetRegisteredJobsAsync()); + await h.NewNode().RegisterJobAsync("controlled", "changed", allowConcurrentExecution: true); + var after = Assert.Single(await h.Node.GetRegisteredJobsAsync()); + Assert.Equal(before.Id, after.Id); + Assert.Equal("changed", after.Description); + Assert.True(after.AllowConcurrentExecution); + } + + [Fact] + public async Task Synchronous_Cancellation_In_A_Generic_Job_Remains_Recoverable() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString, configure: s => s.AddSingleton()); + await h.Node.RegisterJobAsync("sync-cancel"); + await h.Node.ScheduleOneTimeJobAsync(new()); + await h.Node.ScanTriggersAsync(); + await h.Services.GetRequiredService().Started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await h.Node.StopAsync(default); + var interrupted = await h.Executions.Find(x => true).SingleAsync(); + Assert.Equal(ExecutionStatus.Running, interrupted.Status); + Assert.True(interrupted.LockExpiry <= DateTime.UtcNow); + Assert.True(await h.Triggers.Find(x => true).AnyAsync()); + } + + private static IMongoContext ContextWith(Harness h, string name, IMongoCollection collection) + { + var context = A.Fake(o => o.Wrapping(h.Context)); + A.CallTo(() => context.GetCollection(name)).Returns(collection); + return context; + } + + public sealed class ExplicitSettingsJob : IJob + { + public int Calls; + public JobSchedulerUnitTests.Payload? Last; + Task IJob.ExecuteAsync(JobSchedulerUnitTests.Payload settings, CancellationToken token) + { Last = settings; Interlocked.Increment(ref Calls); return Task.CompletedTask; } + } + + public sealed class SynchronousCancellationJob : IJob + { + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public Task ExecuteAsync(JobSchedulerUnitTests.Payload settings, CancellationToken token) + { + Started.TrySetResult(); + if (!token.WaitHandle.WaitOne(TimeSpan.FromSeconds(5))) + throw new TimeoutException("Cancellation was not requested."); + token.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + } + + public sealed class ScopeObserver + { + public ConcurrentBag Used { get; } = new(); + public ConcurrentBag Disposed { get; } = new(); + public int Calls; + } + public sealed class ScopedDependency(ScopeObserver observer) : IDisposable + { + public Guid Id { get; } = Guid.NewGuid(); + public ScopeObserver Observer => observer; + public void Dispose() => observer.Disposed.Add(Id); + } + public sealed class ScopedJob(ScopedDependency dependency) : IJob + { + public Task ExecuteAsync(CancellationToken token) + { + dependency.Observer.Used.Add(dependency.Id); + if (Interlocked.Increment(ref dependency.Observer.Calls) == 1) + throw new InvalidOperationException("scoped job failure"); + return Task.CompletedTask; + } + } +} diff --git a/tests/Influunt.Scheduler.Tests/SchedulerIntegrationTestBase.cs b/tests/Influunt.Scheduler.Tests/SchedulerIntegrationTestBase.cs index d3c56dc..1806378 100644 --- a/tests/Influunt.Scheduler.Tests/SchedulerIntegrationTestBase.cs +++ b/tests/Influunt.Scheduler.Tests/SchedulerIntegrationTestBase.cs @@ -49,9 +49,9 @@ public async Task InitializeAsync() await InitializeTestDataAsync(); - _host.StartAsync(CancellationToken.None); + await _host.StartAsync(CancellationToken.None); - await Task.Delay(TimeSpan.FromMinutes(1)); // Time for index creation + await Task.Delay(TimeSpan.FromSeconds(5)); // Time for index creation and scheduler startup } protected virtual void ConfigureServices(IServiceCollection services, string connectionString) diff --git a/tests/Influunt.Scheduler.Tests/SchedulerReliabilityTests.cs b/tests/Influunt.Scheduler.Tests/SchedulerReliabilityTests.cs new file mode 100644 index 0000000..c15dd64 --- /dev/null +++ b/tests/Influunt.Scheduler.Tests/SchedulerReliabilityTests.cs @@ -0,0 +1,505 @@ +using Influunt.Scheduler.Abstractions.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using MongoDB.Bson; +using MongoDB.Driver; +using Testcontainers.MongoDb; + +namespace Influunt.Scheduler.Tests; + +public sealed class SchedulerMongoFixture : IAsyncLifetime +{ + private readonly MongoDbContainer _container = new MongoDbBuilder().WithImage("mongo:latest").Build(); + public string ConnectionString => _container.GetConnectionString(); + public Task InitializeAsync() => _container.StartAsync(); + public Task DisposeAsync() => _container.DisposeAsync().AsTask(); +} + +[Trait("Category", "Integration")] +public sealed partial class SchedulerReliabilityTests(SchedulerMongoFixture mongo) : IClassFixture +{ + [Fact] + public async Task Acquired_Execution_Is_Running_And_Heartbeat_Renews_It() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + h.Job.Block(); + var trigger = await h.ScheduleAsync(); + Assert.True(await h.Node.ScheduleJobAsync(trigger)); + await h.WaitUntilAsync(() => h.Job.Count == 1); + var execution = await h.Executions.Find(x => true).SingleAsync(); + Assert.Equal(ExecutionStatus.Running, execution.Status); + + var shortenedExpiry = DateTime.UtcNow.AddSeconds(2); + await h.Executions.UpdateOneAsync(x => x.Id == execution.Id, + Builders.Update.Set(x => x.LockExpiry, shortenedExpiry)); + await h.Node.HeartbeatAsync(); + execution = await h.Executions.Find(x => true).SingleAsync(); + Assert.True(execution.LockExpiry > shortenedExpiry); + Assert.False(await h.NewNode().ScheduleJobAsync(trigger)); + } + + [Theory] + [InlineData(ExecutionStatus.Pending, false)] + [InlineData(ExecutionStatus.Running, false)] + [InlineData(ExecutionStatus.Pending, true)] + [InlineData(ExecutionStatus.Running, true)] + public async Task Expired_Legacy_Execution_Is_Recovered(ExecutionStatus status, bool recurring) + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + var trigger = await h.ScheduleAsync(recurring: recurring, legacyTrigger: true); + var abandoned = new JobExecution + { + TriggerId = trigger.TriggerId, JobType = trigger.JobType, Status = status, + StartTime = trigger.NextRunTime.AddSeconds(5), LockId = "dead-node", + LockExpiry = DateTime.UtcNow.AddMinutes(-1) + }; + await h.Executions.InsertOneAsync(abandoned); + await h.Locks.InsertOneAsync(new JobTypeLock + { + Id = trigger.JobType, LockId = "dead-node", LockExpiry = DateTime.UtcNow.AddMinutes(-1) + }); + + await h.Node.ScanTriggersAsync(); + await h.WaitForStatusAsync(ExecutionStatus.Completed); + var recovered = await h.Executions.Find(x => true).SingleAsync(); + Assert.Equal(1, h.Job.Count); + Assert.Equal(abandoned.Id, recovered.Id); + Assert.Equal(trigger.Id, recovered.TriggerDocumentId); + Assert.Equal(trigger.NextRunTime, recovered.ScheduledRunTime); + Assert.NotEqual("dead-node", recovered.LockId); + } + + [Theory] + [InlineData(ExecutionStatus.Pending)] + [InlineData(ExecutionStatus.Running)] + public async Task Active_Legacy_Lease_Is_Not_Stolen(ExecutionStatus status) + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + var trigger = await h.ScheduleAsync(allowConcurrent: true, legacyTrigger: true); + await h.Executions.InsertOneAsync(new JobExecution + { + TriggerId = trigger.TriggerId, JobType = trigger.JobType, Status = status, + LockId = "active-node", LockExpiry = DateTime.UtcNow.AddMinutes(1) + }); + + Assert.False(await h.Node.ScheduleJobAsync(trigger)); + Assert.Equal(0, h.Job.Count); + Assert.Equal("active-node", (await h.Executions.Find(x => true).SingleAsync()).LockId); + } + + [Fact] + public async Task Legacy_Pending_Document_Without_Default_Status_Is_Recovered() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + var trigger = await h.ScheduleAsync(legacyTrigger: true); + await h.Executions.InsertOneAsync(new JobExecution + { + TriggerId = trigger.TriggerId, JobType = trigger.JobType, + LockId = "dead-node", LockExpiry = DateTime.UtcNow.AddMinutes(-1) + }); + await h.Executions.UpdateOneAsync(x => true, Builders.Update.Unset(x => x.Status)); + + await h.Node.ScanTriggersAsync(); + await h.WaitForStatusAsync(ExecutionStatus.Completed); + Assert.Equal(1, h.Job.Count); + } + + [Theory] + [InlineData(ExecutionStatus.Completed)] + [InlineData(ExecutionStatus.Failed)] + public async Task Legacy_Terminal_Document_Consumes_An_Unadvanced_Occurrence(ExecutionStatus status) + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + var trigger = await h.ScheduleAsync(recurring: true, legacyTrigger: true); + await h.Executions.InsertOneAsync(new JobExecution + { + TriggerId = trigger.TriggerId, JobType = trigger.JobType, Status = status, + StartTime = trigger.NextRunTime, EndTime = DateTime.UtcNow.AddSeconds(-10) + }); + + await h.Node.ScanTriggersAsync(); + Assert.Equal(0, h.Job.Count); + Assert.True((await h.Triggers.Find(x => true).SingleAsync()).NextRunTime > DateTime.UtcNow); + Assert.Equal(trigger.Id, (await h.Executions.Find(x => true).SingleAsync()).TriggerDocumentId); + } + + [Fact] + public async Task New_Trigger_In_The_Past_Does_Not_Inherit_A_Legacy_Completion() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + var trigger = await h.ScheduleAsync(); + await h.Executions.InsertOneAsync(new JobExecution + { + TriggerId = trigger.TriggerId, JobType = trigger.JobType, + Status = ExecutionStatus.Completed, EndTime = DateTime.UtcNow + }); + + Assert.True(await h.Node.ScheduleJobAsync(trigger)); + await h.WaitUntilAsync(() => h.Job.Count == 1); + await h.WaitUntilAsync(async () => await h.Executions.CountDocumentsAsync(x => x.Status == ExecutionStatus.Completed) == 2); + } + + [Fact] + public async Task Interrupted_Execution_Remains_Recoverable_After_Shutdown() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + h.Job.Block(); + var trigger = await h.ScheduleAsync(); + Assert.True(await h.Node.ScheduleJobAsync(trigger)); + await h.WaitUntilAsync(() => h.Job.Count == 1); + await h.Node.StopAsync(default); + + var interrupted = await h.Executions.Find(x => true).SingleAsync(); + Assert.Equal(ExecutionStatus.Running, interrupted.Status); + Assert.True(interrupted.LockExpiry <= DateTime.UtcNow); + Assert.True(await h.Triggers.Find(x => true).AnyAsync()); + h.Job.Release.TrySetResult(); + Assert.True(await h.NewNode().ScheduleJobAsync(trigger)); + await h.WaitForStatusAsync(ExecutionStatus.Completed); + Assert.Equal(2, h.Job.Count); + } + + [Theory] + [InlineData(false, 1)] + [InlineData(true, 2)] + public async Task Job_Type_Concurrency_Is_Enforced_Across_Nodes(bool allowConcurrent, int expectedStarts) + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + h.Job.Block(); + var first = await h.ScheduleAsync("first", allowConcurrent: allowConcurrent); + var second = await h.ScheduleAsync("second", allowConcurrent: allowConcurrent); + var other = h.NewNode(); + + var started = await Task.WhenAll(h.Node.ScheduleJobAsync(first), other.ScheduleJobAsync(second)); + Assert.Equal(expectedStarts, started.Count(x => x)); + await h.WaitUntilAsync(() => h.Job.Count == expectedStarts); + Assert.Equal(expectedStarts, await h.Executions.CountDocumentsAsync(x => x.Status == ExecutionStatus.Running)); + + h.Job.Release.TrySetResult(); + await h.WaitUntilAsync(async () => await h.Executions.CountDocumentsAsync(x => x.Status == ExecutionStatus.Completed) == expectedStarts); + // Wait for the owner to release its job-type lease before looking for remaining work. + await h.WaitUntilAsync(async () => !await h.Locks.Find(x => x.LockId != null).AnyAsync()); + await other.ScanTriggersAsync(); + await h.WaitUntilAsync(() => h.Job.Count == 2); + } + + [Fact] + public async Task Same_Occurrence_Is_Acquired_Only_Once_Across_Nodes() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + h.Job.Block(); + var trigger = await h.ScheduleAsync(allowConcurrent: true); + var nodes = Enumerable.Range(0, 8).Select(_ => h.NewNode()).ToArray(); + var results = await Task.WhenAll(nodes.Select(n => n.ScheduleJobAsync(trigger))); + Assert.Single(results, x => x); + await h.WaitUntilAsync(() => h.Job.Count == 1); + Assert.Equal(1, await h.Executions.CountDocumentsAsync(x => true)); + } + + [Fact] + public async Task Recurring_Occurrences_Keep_Separate_Completion_Records() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + var first = await h.ScheduleAsync(recurring: true); + Assert.True(await h.Node.ScheduleJobAsync(first)); + await h.WaitForStatusAsync(ExecutionStatus.Completed); + await h.WaitUntilAsync(async () => !await h.Locks.Find(x => x.LockId != null).AnyAsync()); + + await h.Triggers.UpdateOneAsync(x => x.Id == first.Id, + Builders.Update.Set(x => x.NextRunTime, first.NextRunTime.AddSeconds(10))); + var second = await h.Triggers.Find(x => x.Id == first.Id).SingleAsync(); + Assert.True(await h.Node.ScheduleJobAsync(second)); + await h.WaitUntilAsync(async () => await h.Executions.CountDocumentsAsync(x => x.Status == ExecutionStatus.Completed) == 2); + Assert.False(await h.NewNode().ScheduleJobAsync(first)); + Assert.Equal(2, h.Job.Count); + } + + [Fact] + public async Task Stale_OneTime_Trigger_Cannot_Run_Again_Or_Affect_A_New_Generation() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + var oldTrigger = await h.ScheduleAsync("reused", allowConcurrent: true); + Assert.True(await h.Node.ScheduleJobAsync(oldTrigger)); + await h.WaitUntilAsync(async () => !await h.Triggers.Find(x => true).AnyAsync()); + Assert.False(await h.NewNode().ScheduleJobAsync(oldTrigger)); + + var replacement = await h.ScheduleAsync("reused", allowConcurrent: true); + Assert.NotEqual(oldTrigger.Id, replacement.Id); + Assert.False(await h.Node.ScheduleJobAsync(oldTrigger)); + Assert.True(await h.Node.ScheduleJobAsync(replacement)); + await h.WaitUntilAsync(async () => await h.Executions.CountDocumentsAsync(x => x.Status == ExecutionStatus.Completed) == 2); + Assert.Equal(2, h.Job.Count); + } + + [Theory] + [InlineData(false, ExecutionStatus.Completed)] + [InlineData(true, ExecutionStatus.Completed)] + [InlineData(false, ExecutionStatus.Failed)] + [InlineData(true, ExecutionStatus.Failed)] + public async Task Terminal_Receipt_Repairs_Unadvanced_Trigger_Without_Reexecution(bool recurring, ExecutionStatus status) + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + var trigger = await h.ScheduleAsync(recurring: recurring); + var nextRun = recurring ? DateTime.UtcNow.AddDays(2) : (DateTime?)null; + await h.Executions.InsertOneAsync(new JobExecution + { + TriggerId = trigger.TriggerId, TriggerDocumentId = trigger.Id, ScheduledRunTime = trigger.NextRunTime, + JobType = trigger.JobType, Status = status, EndTime = DateTime.UtcNow, NextRunTime = nextRun, + LockId = "finished-attempt" + }); + + await h.Node.ScanTriggersAsync(); + Assert.Equal(0, h.Job.Count); + if (recurring) + { + var repaired = await h.Triggers.Find(x => true).SingleAsync(); + Assert.Equal(nextRun!.Value.Ticks / TimeSpan.TicksPerMillisecond, repaired.NextRunTime.Ticks / TimeSpan.TicksPerMillisecond); + } + else + Assert.False(await h.Triggers.Find(x => true).AnyAsync()); + Assert.Equal(status, (await h.Executions.Find(x => true).SingleAsync()).Status); + } + + [Fact] + public async Task Old_Owner_Cannot_Complete_Replacement_Attempt() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + var firstDone = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var replacementDone = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + h.Job.Handler = _ => h.Job.Count == 1 ? firstDone.Task : replacementDone.Task; + var trigger = await h.ScheduleAsync(allowConcurrent: true); + try + { + Assert.True(await h.Node.ScheduleJobAsync(trigger)); + await h.WaitUntilAsync(() => h.Job.Count == 1); + var first = await h.Executions.Find(x => true).SingleAsync(); + await h.Executions.UpdateOneAsync(x => x.Id == first.Id, + Builders.Update.Set(x => x.LockExpiry, DateTime.UtcNow.AddMinutes(-1))); + + var replacementNode = h.NewNode(); + Assert.True(await replacementNode.ScheduleJobAsync(trigger)); + await h.WaitUntilAsync(() => h.Job.Count == 2); + var replacement = await h.Executions.Find(x => true).SingleAsync(); + Assert.NotEqual(first.LockId, replacement.LockId); + + firstDone.TrySetResult(); + await h.Node.StopAsync(default); + var stillRunning = await h.Executions.Find(x => true).SingleAsync(); + Assert.Equal(ExecutionStatus.Running, stillRunning.Status); + Assert.Equal(replacement.LockId, stillRunning.LockId); + Assert.True(await h.Triggers.Find(x => x.Id == trigger.Id).AnyAsync()); + await replacementNode.HeartbeatAsync(); + } + finally + { + firstDone.TrySetResult(); + replacementDone.TrySetResult(); + } + await h.WaitForStatusAsync(ExecutionStatus.Completed); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Heartbeat_Cancels_Job_When_Ownership_Is_Lost(bool loseJobTypeLock) + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + h.Job.Block(); + var trigger = await h.ScheduleAsync(allowConcurrent: !loseJobTypeLock); + Assert.True(await h.Node.ScheduleJobAsync(trigger)); + await h.WaitUntilAsync(() => h.Job.Count == 1); + if (loseJobTypeLock) + await h.Locks.UpdateOneAsync(x => x.Id == trigger.JobType, Builders.Update.Set(x => x.LockId, "replacement")); + else + await h.Executions.UpdateOneAsync(x => true, Builders.Update.Set(x => x.LockId, "replacement")); + + await h.Node.HeartbeatAsync(); + await h.Job.Cancelled.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await h.Node.StopAsync(default); + Assert.Equal(ExecutionStatus.Running, (await h.Executions.Find(x => true).SingleAsync()).Status); + Assert.True(await h.Triggers.Find(x => true).AnyAsync()); + if (loseJobTypeLock) + Assert.Equal("replacement", (await h.Locks.Find(x => true).SingleAsync()).LockId); + else + Assert.Equal("replacement", (await h.Executions.Find(x => true).SingleAsync()).LockId); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Scan_Finds_Work_Beyond_Busy_Triggers_And_Batches(bool allowConcurrent) + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString, maxJobs: 3, batchSize: 1); + h.Job.Block(); + var blockedCount = allowConcurrent ? 2 : 1; + for (var i = 0; i < blockedCount; i++) + await h.ScheduleAsync("blocked-" + i, allowConcurrent: allowConcurrent); + await h.Node.ScanTriggersAsync(); + await h.WaitUntilAsync(() => h.Job.Count == blockedCount); + + if (!allowConcurrent) + for (var i = 0; i < 8; i++) + await h.ScheduleAsync("waiting-" + i); + await h.Node.RegisterJobAsync("quick"); + await h.Node.ScheduleOneTimeJobAsync(); + await h.Node.ScanTriggersAsync(); + await h.WaitUntilAsync(() => h.Quick.Count == 1); + Assert.Equal(blockedCount, h.Job.Count); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Failed_Occurrence_Is_Consumed_Without_Unbounded_Retry(bool recurring) + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + h.Job.Handler = _ => throw new InvalidOperationException("job failure"); + await h.ScheduleAsync(recurring: recurring); + await h.Node.ScanTriggersAsync(); + await h.WaitForStatusAsync(ExecutionStatus.Failed); + await h.WaitUntilAsync(async () => !await h.Locks.Find(x => x.LockId != null).AnyAsync()); + + for (var i = 0; i < 3; i++) + await h.Node.ScanTriggersAsync(); + Assert.Equal(1, h.Job.Count); + Assert.Single(await h.Executions.Find(x => true).ToListAsync()); + Assert.Contains("job failure", (await h.Executions.Find(x => true).SingleAsync()).Result); + if (recurring) + Assert.True((await h.Triggers.Find(x => true).SingleAsync()).NextRunTime > DateTime.UtcNow); + else + Assert.False(await h.Triggers.Find(x => true).AnyAsync()); + } + + [Fact] + public async Task Index_Migration_Preserves_Legacy_Executions_And_Allows_Recurring_History() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString, migrateLegacyIndex: true); + using var cursor = await h.Executions.Indexes.ListAsync(); + var indexes = await cursor.ToListAsync(); + Assert.Contains(indexes, x => x["name"] == "TriggerOccurrence" && x["unique"].AsBoolean); + Assert.DoesNotContain(indexes, x => x["key"].AsBsonDocument.ElementCount == 1 && + x["key"].AsBsonDocument.Contains("TriggerId") && x.GetValue("unique", false).AsBoolean); + Assert.True(await h.Executions.Find(x => x.TriggerId == "legacy-history").AnyAsync()); + } + + private sealed class Harness : IAsyncDisposable + { + private readonly ServiceProvider _provider; + public IServiceProvider Services => _provider; + public IMongoContext Context => _provider.GetRequiredService(); + public TimeProvider Clock => _provider.GetRequiredService(); + private readonly List _nodes = new(); + public JobScheduler Node => _nodes[0]; + public ControlledJob Job { get; } = new(); + public QuickJob Quick { get; } = new(); + public IMongoCollection Executions { get; } + public IMongoCollection Triggers { get; } + public IMongoCollection Locks { get; } + + private Harness(string connectionString, int maxJobs, int batchSize, TimeProvider? clock, Action? configure) + { + var uri = new UriBuilder(connectionString) { Path = "reliability_" + Guid.NewGuid().ToString("N") }; + uri.Query += "&authMechanism=SCRAM-SHA-256&authSource=admin"; + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(Job); + services.AddSingleton(Quick); + services.AddSingleton(clock ?? TimeProvider.System); + configure?.Invoke(services); + services.AddJobScheduler(o => + { + o.ConnectionString = uri.ToString(); + o.MaxConcurrentJobs = maxJobs; + o.ScanBatchSize = batchSize; + o.HeartbeatIntervalSeconds = 1; + o.LockTimeoutSeconds = 30; + }); + _provider = services.BuildServiceProvider(); + var context = _provider.GetRequiredService(); + Executions = context.GetCollection(Constants.JobExecutionsCollectionName); + Triggers = context.GetCollection(Constants.JobTriggersCollectionName); + Locks = context.GetCollection(Constants.JobLocksCollectionName); + NewNode(); + } + + public static async Task CreateAsync(string connectionString, int maxJobs = 10, int batchSize = 50, bool migrateLegacyIndex = false, + TimeProvider? clock = null, Action? configure = null) + { + var h = new Harness(connectionString, maxJobs, batchSize, clock, configure); + if (migrateLegacyIndex) + { + await h.Executions.Indexes.CreateOneAsync(new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.TriggerId), new CreateIndexOptions { Unique = true })); + await h.Executions.InsertOneAsync(new JobExecution { TriggerId = "legacy-history", Status = ExecutionStatus.Completed }); + } + await h._provider.GetServices().OfType().Single().StartAsync(default); + return h; + } + + public JobScheduler NewNode(IMongoContext? context = null) + { + var node = new JobScheduler(_provider, _provider.GetRequiredService>(), + NullLogger.Instance, context ?? Context, Clock); + _nodes.Add(node); + return node; + } + + public async Task ScheduleAsync(string id = "trigger", bool recurring = false, bool allowConcurrent = false, bool legacyTrigger = false) + { + await Node.RegisterJobAsync("controlled", allowConcurrentExecution: allowConcurrent); + if (recurring) + await Node.ScheduleRecurringJobAsync("0 0 1 1 *", id); + else + await Node.ScheduleOneTimeJobAsync(triggerId: id); + var update = Builders.Update.Set(x => x.NextRunTime, Clock.GetUtcNow().UtcDateTime.AddMinutes(-1)); + if (legacyTrigger) + update = update.Unset(x => x.CreatedAt); + await Triggers.UpdateOneAsync(x => x.TriggerId == id, update); + return await Triggers.Find(x => x.TriggerId == id).SingleAsync(); + } + + public Task WaitForStatusAsync(ExecutionStatus status) => WaitUntilAsync(async () => await Executions.Find(x => x.Status == status).AnyAsync()); + public Task WaitUntilAsync(Func condition) => WaitUntilAsync(() => Task.FromResult(condition())); + public async Task WaitUntilAsync(Func> condition) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + while (!await condition()) + await Task.Delay(10, timeout.Token); + } + + public async ValueTask DisposeAsync() + { + Job.Release.TrySetResult(); + foreach (var node in _nodes) + { + await node.StopAsync(default); + node.Dispose(); + } + await _provider.DisposeAsync(); + } + } + + public sealed class ControlledJob : IJob + { + private int _count; + public int Count => Volatile.Read(ref _count); + public Func Handler { get; set; } = _ => Task.CompletedTask; + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Cancelled { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public void Block() => Handler = token => Release.Task.WaitAsync(token); + public async Task ExecuteAsync(CancellationToken cancellationToken) + { + Interlocked.Increment(ref _count); + try { await Handler(cancellationToken); } + catch (OperationCanceledException) { Cancelled.TrySetResult(); throw; } + } + } + + public sealed class QuickJob : IJob + { + public int Count; + public Task ExecuteAsync(CancellationToken cancellationToken) { Interlocked.Increment(ref Count); return Task.CompletedTask; } + } +} diff --git a/tests/Influunt.Scheduler.Tests/TestJobs.cs b/tests/Influunt.Scheduler.Tests/TestJobs.cs index 6320d4c..381cf79 100644 --- a/tests/Influunt.Scheduler.Tests/TestJobs.cs +++ b/tests/Influunt.Scheduler.Tests/TestJobs.cs @@ -34,4 +34,21 @@ public class TestJobSettings { public string Message { get; set; } = string.Empty; public int Count { get; set; } +} + +public class TestBlockingJob : IJob +{ + public static int ExecutionCount = 0; + public static TaskCompletionSource? ContinueExecution; + + public async Task ExecuteAsync(CancellationToken cancellationToken) + { + Interlocked.Increment(ref ExecutionCount); + var tcs = ContinueExecution; + if (tcs != null) + { + using var registration = cancellationToken.Register(() => tcs.TrySetCanceled()); + await tcs.Task; + } + } } \ No newline at end of file