• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

ImmediatePlatform / Immediate.Jobs / 30304771671

27 Jul 2026 08:56PM UTC coverage: 65.995%. First build
30304771671

Pull #33

github

web-flow
Merge 46c3047f1 into e9a147742
Pull Request #33: Improve Storage API

1521 of 2481 new or added lines in 28 files covered. (61.31%)

4326 of 6555 relevant lines covered (66.0%)

2.22 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.03
/src/Immediate.Jobs.Shared/JobSchedulerService.cs
1
using System.Collections.Concurrent;
2
using System.Diagnostics;
3
using System.Threading.Channels;
4
using Microsoft.Extensions.DependencyInjection;
5
using Microsoft.Extensions.Hosting;
6
using Microsoft.Extensions.Logging;
7

8
namespace Immediate.Jobs.Shared;
9

10
/// <summary>Coordinates recurring schedules, durable leases, and the bounded worker pool.</summary>
11
public sealed partial class JobSchedulerService : BackgroundService
12
{
13
        private readonly IServiceScopeFactory _scopeFactory;
14
        private readonly IJobStorage _storage;
15
        private readonly IRecurringJobStorage? _recurringStorage;
16
        private readonly IJobGraphStorage? _graphStorage;
17
        private readonly ImmediateJobsOptions _options;
18
        private readonly FairQueuePolicy? _fairQueuePolicy;
19
        private readonly TimeProvider _timeProvider;
20
        private readonly IIdGenerator _idGenerator;
21
        private readonly ILogger<JobSchedulerService> _logger;
22
        private readonly JobSchedulerState _state;
23
        private readonly IReadOnlyDictionary<string, JobDefinition> _definitions;
24
        private readonly IReadOnlyDictionary<string, JobQueueDefinition> _queues;
25
        private readonly ConcurrentDictionary<string, int> _queueReservations = new(StringComparer.Ordinal);
4✔
26
        private readonly ConcurrentDictionary<string, int> _jobReservations = new(StringComparer.Ordinal);
4✔
27
        private readonly Dictionary<int, int> _priorityOffsets = [];
4✔
28
        private readonly SemaphoreSlim _scheduleInitialization = new(1, 1);
4✔
29
        private readonly string _workerId = $"{Environment.MachineName}:{Environment.ProcessId}:{Guid.NewGuid():N}";
4✔
30
        private readonly Channel<JobRecord> _channel;
31
        private int _reservations;
32
        private int _fairQueuesDisabledWarningLogged;
33
        private long _nextPurgeTimestamp;
34

35
        /// <summary>Creates the hosted scheduler from generated definitions.</summary>
36
        public JobSchedulerService(
4✔
37
                IServiceScopeFactory scopeFactory,
4✔
38
                IJobStorage storage,
4✔
39
                IJobSerializer serializer,
4✔
40
                IEnumerable<JobDefinition> definitions,
4✔
41
                IEnumerable<JobQueueDefinition> queueDefinitions,
4✔
42
                ImmediateJobsOptions options,
4✔
43
                TimeProvider timeProvider,
4✔
44
                IIdGenerator idGenerator,
4✔
45
                ILogger<JobSchedulerService> logger,
4✔
46
                JobSchedulerState state
4✔
47
        )
4✔
48
        {
49
                ArgumentNullException.ThrowIfNull(scopeFactory);
4✔
50
                ArgumentNullException.ThrowIfNull(storage);
4✔
51
                ArgumentNullException.ThrowIfNull(serializer);
4✔
52
                ArgumentNullException.ThrowIfNull(definitions);
4✔
53
                ArgumentNullException.ThrowIfNull(queueDefinitions);
4✔
54
                ArgumentNullException.ThrowIfNull(options);
4✔
55
                ArgumentNullException.ThrowIfNull(timeProvider);
4✔
56
                ArgumentNullException.ThrowIfNull(idGenerator);
4✔
57
                ArgumentNullException.ThrowIfNull(logger);
4✔
58
                ArgumentNullException.ThrowIfNull(state);
4✔
59

60
                _scopeFactory = scopeFactory;
4✔
61
                _storage = storage;
4✔
62
                _recurringStorage = storage as IRecurringJobStorage;
4✔
63
                _graphStorage = storage as IJobGraphStorage;
4✔
64
                _options = options;
4✔
65
                _fairQueuePolicy = options.FairQueues?.ToPolicy();
4✔
66
                _timeProvider = timeProvider;
4✔
67
                _idGenerator = idGenerator;
4✔
68
                _logger = logger;
4✔
69
                _state = state;
4✔
70
                if (_graphStorage is null)
4✔
71
                        GraphFeaturesDisabled(_logger, storage.GetType().Name);
4✔
72
                _definitions = definitions.ToDictionary(x => x.Name, StringComparer.Ordinal);
4✔
73
                _queues = queueDefinitions
4✔
74
                        .Concat(_definitions.Values.Select(static definition => definition.Queue))
4✔
75
                        .Append(JobQueueDefinition.Default)
4✔
76
                        .GroupBy(static queue => queue.Name, StringComparer.Ordinal)
4✔
77
                        .ToDictionary(
4✔
78
                                static group => group.Key,
4✔
79
                                static group => group.Distinct().Single(),
4✔
80
                                StringComparer.Ordinal
4✔
81
                        );
4✔
82
                _channel = Channel.CreateBounded<JobRecord>(new BoundedChannelOptions(options.MaxParallelJobs * 2)
4✔
83
                {
4✔
84
                        FullMode = BoundedChannelFullMode.Wait,
4✔
85
                        SingleWriter = true,
4✔
86
                        SingleReader = options.MaxParallelJobs == 1,
4✔
87
                });
4✔
88
        }
4✔
89

90
        /// <inheritdoc />
91
        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
92
        {
93
                await _storage.InitializeAsync(stoppingToken).ConfigureAwait(false);
4✔
94
                await EnsureCodeSchedulesAsync(stoppingToken).ConfigureAwait(false);
4✔
95
                _state.MarkStarted(_timeProvider.GetUtcNow());
4✔
96

97
                var workers = Enumerable.Range(0, _options.MaxParallelJobs)
4✔
98
                        .Select(_ => RunWorkerAsync(stoppingToken))
4✔
99
                        .ToArray();
4✔
100

101
                try
102
                {
103
                        while (!stoppingToken.IsCancellationRequested)
4✔
104
                        {
105
                                try
106
                                {
107
                                        await RunSchedulerIterationAsync(stoppingToken).ConfigureAwait(false);
4✔
108
                                }
4✔
109
                                catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
×
110
                                {
111
                                        break;
×
112
                                }
113
#pragma warning disable CA1031 // A scheduler iteration failure must not terminate the hosted service.
114
                                catch (Exception exception)
×
115
#pragma warning restore CA1031
116
                                {
117
                                        SchedulerIterationFailed(_logger, exception);
×
118
                                }
×
119

120
                                await Task.Delay(_options.PollingInterval, _timeProvider, stoppingToken).ConfigureAwait(false);
4✔
121
                        }
122
                }
123
                finally
124
                {
125
                        _ = _channel.Writer.TryComplete();
4✔
126
                        using var drain = new CancellationTokenSource(_options.ShutdownTimeout, _timeProvider);
4✔
127
                        try
128
                        {
129
                                await Task.WhenAll(workers).WaitAsync(drain.Token).ConfigureAwait(false);
4✔
130
                        }
4✔
131
                        catch (OperationCanceledException)
×
132
                        {
133
                                ShutdownDrainExceeded(_logger, _options.ShutdownTimeout);
×
134
                        }
×
135
                }
3✔
136
        }
×
137

138
        /// <summary>Executes one already-acquired record. Intended for deterministic test harnesses.</summary>
139
        public ValueTask ExecuteSingleAsync(JobRecord record, CancellationToken cancellationToken = default)
140
        {
141
                ArgumentNullException.ThrowIfNull(record);
×
142
                return ExecuteJobAsync(record, cancellationToken);
×
143
        }
144

145
        /// <summary>
146
        /// Materializes and executes all work currently due, returning when the due queue is empty.
147
        /// Delayed work is left in storage. This method is intended for deterministic test harnesses.
148
        /// </summary>
149
        public async ValueTask DrainAsync(CancellationToken cancellationToken = default)
150
        {
151
                await _storage.InitializeAsync(cancellationToken).ConfigureAwait(false);
4✔
152
                await EnsureCodeSchedulesAsync(cancellationToken).ConfigureAwait(false);
4✔
153
                while (true)
3✔
154
                {
155
                        await MaterializeRecurringAsync(cancellationToken).ConfigureAwait(false);
4✔
156
                        var request = BuildAcquisitionRequest();
4✔
157
                        if (request is null)
4✔
158
                                return;
×
159
                        var jobs = await _storage.AcquireDueJobsAsync(request, cancellationToken).ConfigureAwait(false);
4✔
160
                        if (jobs.Count == 0)
4✔
161
                                return;
4✔
162
                        WarnIfGroupedJobsAreInert(jobs);
4✔
163

164
                        foreach (var job in jobs)
4✔
165
                        {
166
                                Reserve(job);
4✔
167
                                await ExecuteJobAsync(job, cancellationToken, releaseReservation: true).ConfigureAwait(false);
4✔
168
                        }
169
                }
170
        }
3✔
171

172
        private async Task RunSchedulerIterationAsync(CancellationToken cancellationToken)
173
        {
174
                await MaterializeRecurringAsync(cancellationToken).ConfigureAwait(false);
4✔
175
                var request = BuildAcquisitionRequest();
4✔
176
                var acquired = request is null
4✔
177
                        ? []
4✔
178
                        : await _storage.AcquireDueJobsAsync(request, cancellationToken).ConfigureAwait(false);
4✔
179
                WarnIfGroupedJobsAreInert(acquired);
4✔
180

181
                foreach (var job in acquired)
4✔
182
                {
183
                        Reserve(job);
4✔
184
                        try
185
                        {
186
                                JobTelemetry.Acquired();
4✔
187
                                await _channel.Writer.WriteAsync(job, cancellationToken).ConfigureAwait(false);
4✔
188
                        }
4✔
189
                        catch
×
190
                        {
191
                                Release(job);
×
192
                                throw;
×
193
                        }
194
                }
3✔
195

196
                var now = _timeProvider.GetUtcNow();
4✔
197
                await _storage.HeartbeatAsync(
4✔
198
                        new(_workerId, now, _state.ActiveWorkers, _options.MaxParallelJobs),
4✔
199
                        cancellationToken
4✔
200
                ).ConfigureAwait(false);
4✔
201
                _state.MarkHeartbeat(now);
4✔
202

203
                if (_timeProvider.GetTimestamp() >= Interlocked.Read(ref _nextPurgeTimestamp))
4✔
204
                {
205
                        await _storage.PurgeJobsAsync(
4✔
206
                                _options.SucceededRetention,
4✔
207
                                _options.FailedRetention,
4✔
208
                                cancellationToken
4✔
209
                        ).ConfigureAwait(false);
4✔
210
                        if (_graphStorage is not null)
4✔
211
                        {
212
                                await _graphStorage.PurgeBatchesAsync(
4✔
213
                                        _options.BatchSucceededRetention,
4✔
214
                                        _options.BatchFailedRetention,
4✔
215
                                        cancellationToken
4✔
216
                                ).ConfigureAwait(false);
4✔
217
                        }
218

219
                        _ = Interlocked.Exchange(ref _nextPurgeTimestamp, _timeProvider.GetTimestamp() + ToTimestampTicks(_options.PurgeInterval));
4✔
220
                }
221
        }
4✔
222

223
        private async Task RunWorkerAsync(CancellationToken cancellationToken)
224
        {
225
                await foreach (var record in _channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
4✔
226
                {
227
                        try
228
                        {
229
                                await ExecuteJobAsync(record, cancellationToken, releaseReservation: true).ConfigureAwait(false);
4✔
230
                        }
×
231
                        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
4✔
232
                        {
233
                                break;
4✔
234
                        }
235
#pragma warning disable CA1031 // A failed job must not terminate its worker loop.
236
                        catch (Exception exception)
×
237
#pragma warning restore CA1031
238
                        {
239
                                UnhandledWorkerError(_logger, exception, record.Id);
×
240
                        }
×
241
                }
242
        }
4✔
243

244
        private async ValueTask ExecuteJobAsync(
245
                JobRecord record,
246
                CancellationToken stoppingToken,
247
                bool releaseReservation = false
248
        )
249
        {
250
                if (!_definitions.TryGetValue(record.JobName, out var definition))
4✔
251
                {
252
                        try
253
                        {
254
                                await _storage.FailAsync(record.Id, _workerId, $"No generated job definition exists for '{record.JobName}'.", null, stoppingToken)
×
255
                                        .ConfigureAwait(false);
×
256
                        }
×
257
                        finally
258
                        {
259
                                if (releaseReservation)
×
260
                                        Release(record);
×
261
                        }
262

263
                        return;
×
264
                }
265

266
                _state.IncrementActive();
4✔
267
                JobTelemetry.ExecutionStarted();
4✔
268
                var started = _timeProvider.GetTimestamp();
4✔
269
                var startedAt = _timeProvider.GetUtcNow();
4✔
270
                using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
4✔
271
                ITimer? timeoutTimer = definition.Timeout is { } timeoutValue
4✔
272
                        ? _timeProvider.CreateTimer(static state => ((CancellationTokenSource)state!).Cancel(), timeout, timeoutValue, Timeout.InfiniteTimeSpan)
×
273
                        : null;
4✔
274
                using var leaseCancellation = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
4✔
275
                var leaseTask = RenewLeaseLoopAsync(record.Id, leaseCancellation.Token);
4✔
276

277
                var parent = default(ActivityContext);
4✔
278
                if (record.TraceParent is not null)
4✔
279
                        _ = ActivityContext.TryParse(record.TraceParent, record.TraceState, true, out parent);
2✔
280
                IEnumerable<ActivityLink>? links = parent != default ? [new(parent)] : null;
4✔
281
                using var activity = JobTelemetry.ActivitySource.StartActivity(
4✔
282
                        $"job {record.JobName}",
4✔
283
                        ActivityKind.Consumer,
4✔
284
                        default(ActivityContext),
4✔
285
                        tags:
4✔
286
                        [
4✔
287
                                new("job.name", record.JobName),
4✔
288
                                new("job.queue", record.QueueName),
4✔
289
                                new("job.id", record.Id),
4✔
290
                                new("job.attempt", record.Attempt),
4✔
291
                        ],
4✔
292
                        links: links
4✔
293
                );
4✔
294
                using var logScope = _logger.BeginScope(new Dictionary<string, object>
4✔
295
                {
4✔
296
                        ["JobName"] = record.JobName,
4✔
297
                        ["QueueName"] = record.QueueName,
4✔
298
                        ["JobId"] = record.Id,
4✔
299
                        ["Attempt"] = record.Attempt,
4✔
300
                });
4✔
301

302
                try
303
                {
304
                        await _storage.SetExecutionTelemetryAsync(
4✔
305
                                record.Id,
4✔
306
                                _workerId,
4✔
307
                                activity?.TraceId.ToString(),
4✔
308
                                activity?.SpanId.ToString(),
4✔
309
                                startedAt,
4✔
310
                                stoppingToken
4✔
311
                        ).ConfigureAwait(false);
4✔
312
                        await using var scope = _scopeFactory.CreateAsyncScope();
4✔
313
                        if (record.Context is { } orphanedEnvelope && definition.Invoker is not IJobContextAwareInvoker)
4✔
314
                        {
315
                                var orphanedSlices = JobContextEnvelope.Read(orphanedEnvelope);
4✔
316
                                JobContextEnvelope.LogOrphanedSlices(scope.ServiceProvider, record, orphanedSlices.Keys);
4✔
317
                        }
318

319
                        var executionBuffer = new JobExecutionBuffer();
4✔
320
                        await definition.Invoker.InvokeAsync(
4✔
321
                                scope.ServiceProvider,
4✔
322
                                new(record, definition, timeout.Token, executionBuffer)
4✔
323
                        ).ConfigureAwait(false);
4✔
324
                        if (_graphStorage is not null)
4✔
325
                        {
326
                                await _graphStorage.CompleteWithContinuationsAsync(
4✔
327
                                        record.Id,
4✔
328
                                        _workerId,
4✔
329
                                        executionBuffer.Snapshot(),
4✔
330
                                        stoppingToken
4✔
331
                                ).ConfigureAwait(false);
4✔
332
                        }
333
                        else
334
                        {
335
                                await _storage.CompleteAsync(record.Id, _workerId, stoppingToken).ConfigureAwait(false);
4✔
336
                        }
337

338
                        var duration = _timeProvider.GetElapsedTime(started);
4✔
339
                        JobTelemetry.Succeeded(record.JobName, record.QueueName, duration);
4✔
340
                        _ = activity?.SetStatus(ActivityStatusCode.Ok);
4✔
341
                        JobCompleted(_logger, duration.TotalMilliseconds);
4✔
342
                }
4✔
343
                catch (Exception exception) when (exception is not OperationCanceledException || !stoppingToken.IsCancellationRequested)
4✔
344
                {
345
                        var retry = record.Attempt < definition.MaxAttempts;
4✔
346
                        DateTimeOffset? nextRetryAt = retry ? _timeProvider.GetUtcNow() + GetRetryDelay(definition, record.Attempt) : null;
4✔
347
                        await _storage.FailAsync(record.Id, _workerId, exception.ToString(), nextRetryAt, stoppingToken).ConfigureAwait(false);
4✔
348
                        var duration = _timeProvider.GetElapsedTime(started);
4✔
349
                        JobTelemetry.Failed(record.JobName, record.QueueName, duration);
4✔
350
                        _ = activity?.SetStatus(ActivityStatusCode.Error, exception.Message);
4✔
351

352
                        if (retry)
4✔
353
                        {
354
                                JobTelemetry.Retried(record.JobName, record.QueueName);
4✔
355
                                JobWillRetry(_logger, exception, nextRetryAt);
4✔
356
                        }
357
                        else
358
                        {
359
                                JobExhaustedAttempts(_logger, exception, definition.MaxAttempts);
4✔
360
                        }
361
                }
362
                finally
363
                {
364
                        timeoutTimer?.Dispose();
4✔
365
                        await leaseCancellation.CancelAsync().ConfigureAwait(false);
4✔
366
                        try
367
                        {
368
                                await leaseTask.ConfigureAwait(false);
4✔
369
                        }
×
370
                        catch (OperationCanceledException)
4✔
371
                        {
372
                        }
4✔
373

374
                        _state.DecrementActive();
4✔
375
                        JobTelemetry.ExecutionFinished();
4✔
376
                        if (releaseReservation)
4✔
377
                                Release(record);
4✔
378
                }
379
        }
4✔
380

381
        private JobAcquisitionRequest? BuildAcquisitionRequest()
382
        {
383
                var capacity = Math.Min(
4✔
384
                        _options.AcquisitionBatchSize,
4✔
385
                        _options.MaxParallelJobs - Volatile.Read(ref _reservations)
4✔
386
                );
4✔
387
                if (capacity <= 0)
4✔
388
                        return null;
4✔
389

390
                var queues = new List<JobQueueAcquisition>();
4✔
391
                foreach (var priorityGroup in _queues.Values
4✔
392
                        .GroupBy(static queue => queue.Priority)
4✔
393
                        .OrderByDescending(static group => group.Key))
4✔
394
                {
395
                        var priorityQueues = priorityGroup.OrderBy(static queue => queue.Name, StringComparer.Ordinal).ToArray();
4✔
396
                        var offset = _priorityOffsets.GetValueOrDefault(priorityGroup.Key) % priorityQueues.Length;
4✔
397
                        for (var index = 0; index < priorityQueues.Length; index++)
4✔
398
                        {
399
                                var queue = priorityQueues[(index + offset) % priorityQueues.Length];
4✔
400
                                var queueCapacity = queue.Concurrency == 0
4✔
401
                                        ? capacity
4✔
402
                                        : queue.Concurrency - _queueReservations.GetValueOrDefault(queue.Name);
4✔
403
                                if (queueCapacity <= 0)
4✔
404
                                        continue;
405

406
                                var jobCapacities = _definitions.Values
4✔
407
                                        .Select(definition => new
4✔
408
                                        {
4✔
409
                                                definition.Name,
4✔
410
                                                Capacity = definition.MaxConcurrency == 0
4✔
411
                                                        ? capacity
4✔
412
                                                        : definition.MaxConcurrency - _jobReservations.GetValueOrDefault(definition.Name),
4✔
413
                                        })
4✔
414
                                        .Where(static item => item.Capacity > 0)
4✔
415
                                        .ToDictionary(static item => item.Name, static item => item.Capacity, StringComparer.Ordinal);
4✔
416
                                if (jobCapacities.Count == 0)
4✔
417
                                        continue;
418

419
                                queues.Add(new()
4✔
420
                                {
4✔
421
                                        QueueName = queue.Name,
4✔
422
                                        Capacity = (int)Math.Min(queueCapacity, jobCapacities.Values.Sum(static value => (long)value)),
4✔
423
                                        JobCapacities = jobCapacities,
4✔
424
                                });
4✔
425
                        }
426

427
                        _priorityOffsets[priorityGroup.Key] = offset + 1;
4✔
428
                }
429

430
                return queues.Count == 0
4✔
431
                        ? null
4✔
432
                        : new()
4✔
433
                        {
4✔
434
                                WorkerId = _workerId,
4✔
435
                                Lease = _options.LeaseDuration,
4✔
436
                                BatchSize = capacity,
4✔
437
                                Queues = queues,
4✔
438
                                FairQueues = _fairQueuePolicy,
4✔
439
                        };
4✔
440
        }
441

442
        private void WarnIfGroupedJobsAreInert(IReadOnlyList<JobRecord> acquired)
443
        {
444
                if (_fairQueuePolicy is not null
4✔
445
                        || Volatile.Read(ref _fairQueuesDisabledWarningLogged) != 0
4✔
446
                        || !acquired.Any(static job => job.GroupId is not null)
4✔
447
                        || Interlocked.Exchange(ref _fairQueuesDisabledWarningLogged, 1) != 0)
4✔
448
                {
449
                        return;
4✔
450
                }
451

NEW
452
                GroupedJobsAcquiredWithoutFairQueues(_logger);
×
NEW
453
        }
×
454

455
        private void Reserve(JobRecord record)
456
        {
457
                _ = Interlocked.Increment(ref _reservations);
4✔
458
                _ = _queueReservations.AddOrUpdate(record.QueueName, 1, static (_, count) => count + 1);
4✔
459
                _ = _jobReservations.AddOrUpdate(record.JobName, 1, static (_, count) => count + 1);
4✔
460
        }
4✔
461

462
        private void Release(JobRecord record)
463
        {
464
                _ = Interlocked.Decrement(ref _reservations);
4✔
465
                _ = _queueReservations.AddOrUpdate(record.QueueName, 0, static (_, count) => Math.Max(0, count - 1));
4✔
466
                _ = _jobReservations.AddOrUpdate(record.JobName, 0, static (_, count) => Math.Max(0, count - 1));
4✔
467
        }
4✔
468

469
        private async Task RenewLeaseLoopAsync(string jobId, CancellationToken cancellationToken)
470
        {
471
                var interval = TimeSpan.FromTicks(Math.Max(1, _options.LeaseDuration.Ticks / 3));
4✔
472
                while (true)
×
473
                {
474
                        await Task.Delay(interval, _timeProvider, cancellationToken).ConfigureAwait(false);
4✔
475
                        await _storage.RenewLeaseAsync(jobId, _workerId, _options.LeaseDuration, cancellationToken).ConfigureAwait(false);
×
476
                }
477
        }
478

479
        private async Task AssertCodeSchedulesAsync(CancellationToken cancellationToken)
480
        {
481
                var recurringStorage = _recurringStorage;
4✔
482
                if (recurringStorage is null)
4✔
NEW
483
                        return;
×
484

485
                var now = _timeProvider.GetUtcNow();
4✔
486
                var codeDefinitions = _definitions.Values.Where(static definition => definition.Cron is not null).ToArray();
4✔
487
                foreach (var definition in codeDefinitions)
4✔
488
                {
489
                        var zone = JobCron.GetTimeZone(definition.TimeZone);
4✔
490
                        var next = JobCron.Parse(definition.Cron!).GetNextOccurrence(now, zone)
4✔
491
                                ?? throw new ImmediateJobException($"Cron for '{definition.Name}' has no future occurrence.");
4✔
492
                        await recurringStorage.UpsertRecurringAsync(
4✔
493
                                new()
4✔
494
                                {
4✔
495
                                        Name = definition.Name,
4✔
496
                                        JobName = definition.Name,
4✔
497
                                        Cron = definition.Cron!,
4✔
498
                                        TimeZone = definition.TimeZone,
4✔
499
                                        IsCodeDefined = true,
4✔
500
                                        NextRunAt = next,
4✔
501
                                },
4✔
502
                                cancellationToken
4✔
503
                        ).ConfigureAwait(false);
4✔
504
                }
505

506
                var activeScheduleNames = codeDefinitions.Select(static definition => definition.Name).ToArray();
4✔
507
                await recurringStorage.RemoveObsoleteCodeDefinedRecurringAsync(
4✔
508
                        activeScheduleNames,
4✔
509
                        cancellationToken
4✔
510
                ).ConfigureAwait(false);
4✔
511
        }
4✔
512

513
        private async Task EnsureCodeSchedulesAsync(CancellationToken cancellationToken)
514
        {
515
                if (_state.CodeSchedulesAsserted)
4✔
516
                        return;
4✔
517
                if (_recurringStorage is null)
4✔
518
                {
519
                        _state.MarkCodeSchedulesAsserted();
4✔
520
                        return;
4✔
521
                }
522

523
                await _scheduleInitialization.WaitAsync(cancellationToken).ConfigureAwait(false);
4✔
524
                try
525
                {
526
                        if (_state.CodeSchedulesAsserted)
4✔
527
                                return;
×
528
                        await AssertCodeSchedulesAsync(cancellationToken).ConfigureAwait(false);
4✔
529
                        _state.MarkCodeSchedulesAsserted();
4✔
530
                }
4✔
531
                finally
532
                {
533
                        _ = _scheduleInitialization.Release();
4✔
534
                }
1✔
535
        }
4✔
536

537
        private async Task MaterializeRecurringAsync(CancellationToken cancellationToken)
538
        {
539
                var recurringStorage = _recurringStorage;
4✔
540
                if (recurringStorage is null)
4✔
541
                        return;
4✔
542

543
                var now = _timeProvider.GetUtcNow();
4✔
544
                var schedules = await recurringStorage.GetDueRecurringAsync(now, _options.AcquisitionBatchSize, cancellationToken).ConfigureAwait(false);
4✔
545
                foreach (var schedule in schedules)
4✔
546
                {
547
                        if (!_definitions.TryGetValue(schedule.JobName, out var definition))
4✔
548
                                continue;
549

550
                        var expression = JobCron.Parse(schedule.Cron);
4✔
551
                        var next = expression.GetNextOccurrence(schedule.NextRunAt, JobCron.GetTimeZone(schedule.TimeZone))
4✔
552
                                ?? throw new ImmediateJobException($"Recurring schedule '{schedule.Name}' has no future occurrence.");
4✔
553
                        var (traceParent, traceState) = TraceContextCapture.Current();
4✔
554
                        var record = new JobRecord
4✔
555
                        {
4✔
556
                                Id = _idGenerator.CreateId(IdKind.Job),
4✔
557
                                JobName = schedule.JobName,
4✔
558
                                QueueName = definition.Queue.Name,
4✔
559
                                Payload = "{}",
4✔
560
                                State = JobState.Pending,
4✔
561
                                DueAt = schedule.NextRunAt,
4✔
562
                                CreatedAt = now,
4✔
563
                                RecurringKey = $"{schedule.Name}:{schedule.NextRunAt.UtcTicks}",
4✔
564
                                TraceParent = traceParent,
4✔
565
                                TraceState = traceState,
4✔
566
                        };
4✔
567

568
                        if (definition.OverlapPolicy == OverlapPolicy.Skip)
4✔
569
                        {
570
                                var active = await _storage.QueryJobsAsync(new() { State = JobState.Active, Search = definition.Name, Take = 1 }, cancellationToken)
4✔
571
                                        .ConfigureAwait(false);
4✔
572
                                if (active.Any(x => string.Equals(x.JobName, definition.Name, StringComparison.Ordinal)))
3✔
573
                                {
574
                                        record = record with { State = JobState.Cancelled, CompletedAt = now };
×
575
                                }
576
                        }
577

578
                        if (await recurringStorage.MaterializeRecurringAsync(schedule, record, next, cancellationToken).ConfigureAwait(false)
4✔
579
                                && record.State == JobState.Pending)
4✔
580
                        {
581
                                JobTelemetry.Enqueued(record.JobName, record.QueueName);
4✔
582
                        }
583
                }
3✔
584
        }
4✔
585

586
        private static TimeSpan GetRetryDelay(JobDefinition definition, int attempt)
587
        {
588
                if (definition.Backoff == BackoffStrategy.Fixed)
4✔
589
                        return definition.BackoffBase;
4✔
590

591
                var exponent = Math.Min(30, Math.Max(0, attempt - 1));
×
592
                var ticks = Math.Min(TimeSpan.MaxValue.Ticks, definition.BackoffBase.Ticks * Math.Pow(2, exponent));
×
593
                if (definition.Backoff == BackoffStrategy.ExponentialJitter)
×
594
                        ticks *= 0.5 + Random.Shared.NextDouble();
×
595
                return TimeSpan.FromTicks((long)ticks);
×
596
        }
597

598
        private long ToTimestampTicks(TimeSpan duration) => (long)(duration.TotalSeconds * _timeProvider.TimestampFrequency);
4✔
599

600
        /// <inheritdoc />
601
        public override void Dispose()
602
        {
603
                _scheduleInitialization.Dispose();
4✔
604
                base.Dispose();
4✔
605
        }
4✔
606

607
        [LoggerMessage(EventId = 1, Level = LogLevel.Error, Message = "Immediate.Jobs scheduler iteration failed; polling will continue")]
608
        private static partial void SchedulerIterationFailed(ILogger logger, Exception exception);
609

610
        [LoggerMessage(EventId = 2, Level = LogLevel.Warning, Message = "Immediate.Jobs shutdown drain exceeded {shutdownTimeout}")]
611
        private static partial void ShutdownDrainExceeded(ILogger logger, TimeSpan shutdownTimeout);
612

613
        [LoggerMessage(EventId = 3, Level = LogLevel.Error, Message = "Unhandled worker error for job {jobId}; its lease will expire")]
614
        private static partial void UnhandledWorkerError(ILogger logger, Exception exception, string jobId);
615

616
        [LoggerMessage(EventId = 4, Level = LogLevel.Information, Message = "Job completed in {durationMs} ms")]
617
        private static partial void JobCompleted(ILogger logger, double durationMs);
618

619
        [LoggerMessage(EventId = 5, Level = LogLevel.Warning, Message = "Job failed and will retry at {nextRetryAt}")]
620
        private static partial void JobWillRetry(ILogger logger, Exception exception, DateTimeOffset? nextRetryAt);
621

622
        [LoggerMessage(EventId = 6, Level = LogLevel.Error, Message = "Job exhausted all {maxAttempts} attempts")]
623
        private static partial void JobExhaustedAttempts(ILogger logger, Exception exception, int maxAttempts);
624

625
        [LoggerMessage(
626
                EventId = 7,
627
                Level = LogLevel.Information,
628
                Message = "Batch & continuation features are disabled: the configured storage '{storageType}' implements the queue capability only. Configure a SQL provider to enable them."
629
        )]
630
        private static partial void GraphFeaturesDisabled(ILogger logger, string storageType);
631

632
        [LoggerMessage(
633
                EventId = 8,
634
                Level = LogLevel.Warning,
635
                Message = "Grouped jobs were acquired while fair queues are disabled. Their group ids are persisted but do not affect dispatch order; call UseFairQueues() to enable fair acquisition."
636
        )]
637
        private static partial void GroupedJobsAcquiredWithoutFairQueues(ILogger logger);
638
}
639

640
/// <summary>Scheduler liveness state shared with health checks and monitoring.</summary>
641
public sealed class JobSchedulerState
642
{
643
        private long _activeWorkers;
644

645
        /// <summary>UTC time at which the scheduler initialized.</summary>
646
        public DateTimeOffset? StartedAt { get; private set; }
647

648
        /// <summary>UTC time of the latest successful scheduler iteration.</summary>
649
        public DateTimeOffset? LastHeartbeat { get; private set; }
650

651
        /// <summary>Number of invocations currently executing.</summary>
652
        public int ActiveWorkers => checked((int)Interlocked.Read(ref _activeWorkers));
4✔
653

654
        internal bool CodeSchedulesAsserted { get; private set; }
655

656
        internal void MarkStarted(DateTimeOffset timestamp) => StartedAt = timestamp;
4✔
657
        internal void MarkHeartbeat(DateTimeOffset timestamp) => LastHeartbeat = timestamp;
4✔
658
        internal void MarkCodeSchedulesAsserted() => CodeSchedulesAsserted = true;
4✔
659
        internal void IncrementActive() => Interlocked.Increment(ref _activeWorkers);
4✔
660
        internal void DecrementActive() => Interlocked.Decrement(ref _activeWorkers);
4✔
661
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc