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

ImmediatePlatform / Immediate.Jobs / 30639748860

31 Jul 2026 02:42PM UTC coverage: 83.192% (+0.1%) from 83.088%
30639748860

Pull #80

github

web-flow
Merge 69689179b into 15ab68066
Pull Request #80: Fast-forward scheduled jobs from the dashboard

23 of 25 new or added lines in 4 files covered. (92.0%)

5 existing lines in 1 file now uncovered.

7464 of 8972 relevant lines covered (83.19%)

2.75 hits per line

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

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

9
namespace Immediate.Jobs.Shared;
10

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

37
        /// <summary>Creates the hosted scheduler from generated definitions.</summary>
38
        /// <param name="scopeFactory">The factory used to create a dependency injection scope for each execution.</param>
39
        /// <param name="storage">The durable job storage provider.</param>
40
        /// <param name="definitions">The generated job definitions available to the scheduler.</param>
41
        /// <param name="queueDefinitions">The configured queue definitions.</param>
42
        /// <param name="options">The scheduler runtime options.</param>
43
        /// <param name="timeProvider">The clock used for scheduling, leases, and timestamps.</param>
44
        /// <param name="idGenerator">The generator used to create job identifiers.</param>
45
        /// <param name="logger">The scheduler logger.</param>
46
        /// <param name="state">The service that tracks scheduler runtime state.</param>
47
        public JobSchedulerService(
4✔
48
                IServiceScopeFactory scopeFactory,
4✔
49
                IJobStorage storage,
4✔
50
                IEnumerable<JobDefinition> definitions,
4✔
51
                IEnumerable<JobQueueDefinition> queueDefinitions,
4✔
52
                ImmediateJobsOptions options,
4✔
53
                TimeProvider timeProvider,
4✔
54
                IIdGenerator idGenerator,
4✔
55
                ILogger<JobSchedulerService> logger,
4✔
56
                JobSchedulerState state
4✔
57
        )
4✔
58
        {
59
                ArgumentNullException.ThrowIfNull(scopeFactory);
4✔
60
                ArgumentNullException.ThrowIfNull(storage);
4✔
61
                ArgumentNullException.ThrowIfNull(definitions);
4✔
62
                ArgumentNullException.ThrowIfNull(queueDefinitions);
4✔
63
                ArgumentNullException.ThrowIfNull(options);
4✔
64
                ArgumentNullException.ThrowIfNull(timeProvider);
4✔
65
                ArgumentNullException.ThrowIfNull(idGenerator);
4✔
66
                ArgumentNullException.ThrowIfNull(logger);
4✔
67
                ArgumentNullException.ThrowIfNull(state);
4✔
68

69
                _scopeFactory = scopeFactory;
4✔
70
                _storage = storage;
4✔
71
                _recurringStorage = storage as IRecurringJobStorage;
4✔
72
                _graphStorage = storage as IJobGraphStorage;
4✔
73
                _options = options;
4✔
74
                _fairQueuePolicy = options.FairQueues?.ToPolicy();
4✔
75
                _timeProvider = timeProvider;
4✔
76
                _idGenerator = idGenerator;
4✔
77
                _logger = logger;
4✔
78
                _state = state;
4✔
79
                if (_graphStorage is null)
4✔
80
                        GraphFeaturesDisabled(_logger, storage.GetType().Name);
4✔
81
                _definitions = definitions.ToDictionary(x => x.Name, StringComparer.Ordinal);
4✔
82
                _queues = queueDefinitions
4✔
83
                        .Concat(_definitions.Values.Select(static definition => definition.Queue))
4✔
84
                        .Append(JobQueueDefinition.Default)
4✔
85
                        .GroupBy(static queue => queue.Name, StringComparer.Ordinal)
4✔
86
                        .ToDictionary(
4✔
87
                                static group => group.Key,
4✔
88
                                static group => group.Distinct().Single(),
4✔
89
                                StringComparer.Ordinal
4✔
90
                        );
4✔
91
                // Reservation accounting in BuildAcquisitionRequest is the admission control, so the channel is
92
                // only a handoff buffer. A bounded channel would add a second, redundant limit whose sole effect
93
                // is to block the scheduler loop -- and with it the heartbeat -- if the two ever disagree.
94
                _channel = Channel.CreateUnbounded<JobRecord>(new UnboundedChannelOptions
4✔
95
                {
4✔
96
                        SingleWriter = true,
4✔
97
                        SingleReader = options.MaxParallelJobs == 1,
4✔
98
                });
4✔
99
        }
4✔
100

101
        /// <inheritdoc />
102
        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
103
        {
104
                await _storage.InitializeAsync(stoppingToken).ConfigureAwait(false);
4✔
105
                await EnsureCodeSchedulesAsync(stoppingToken).ConfigureAwait(false);
4✔
106
                _state.MarkStarted(_timeProvider.GetUtcNow());
4✔
107

108
                // Workers observe _workerCancellation rather than stoppingToken: shutdown completes the channel so
109
                // buffered records still drain, and only an exceeded drain deadline cancels a running job.
110
                var workers = Enumerable.Range(0, _options.MaxParallelJobs)
4✔
111
                        .Select(_ => RunWorkerAsync(_workerCancellation.Token))
4✔
112
                        .ToArray();
4✔
113

114
                try
115
                {
116
                        while (!stoppingToken.IsCancellationRequested)
4✔
117
                        {
118
                                try
119
                                {
120
                                        await RunSchedulerIterationAsync(stoppingToken).ConfigureAwait(false);
4✔
121
                                }
4✔
122
                                catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
×
123
                                {
124
                                        break;
×
125
                                }
126
#pragma warning disable CA1031 // A scheduler iteration failure must not terminate the hosted service.
127
                                catch (Exception exception)
×
128
#pragma warning restore CA1031
129
                                {
130
                                        SchedulerIterationFailed(_logger, exception);
×
131
                                }
×
132

133
                                await Task.Delay(_options.PollingInterval, _timeProvider, stoppingToken).ConfigureAwait(false);
4✔
134
                        }
135
                }
136
                finally
137
                {
138
                        _ = _channel.Writer.TryComplete();
4✔
139
                        try
140
                        {
141
                                // stoppingToken is already cancelled here; forwarding it would abort the drain immediately.
142
                                await Task.WhenAll(workers)
4✔
143
                                        .WaitAsync(_options.ShutdownTimeout, _timeProvider, CancellationToken.None)
4✔
144
                                        .ConfigureAwait(false);
4✔
145
                        }
4✔
146
                        catch (TimeoutException)
×
147
                        {
148
                                ShutdownDrainExceeded(_logger, _options.ShutdownTimeout);
×
149
                        }
×
150
                        finally
151
                        {
152
                                await _workerCancellation.CancelAsync().ConfigureAwait(false);
4✔
153
                        }
154
                }
155
        }
1✔
156

157
        /// <summary>Executes one already-acquired record. Intended for deterministic test harnesses.</summary>
158
        /// <param name="record">The acquired job record to execute.</param>
159
        /// <param name="cancellationToken">A token that can cancel execution.</param>
160
        /// <returns>A task that completes when the job attempt finishes.</returns>
161
        public ValueTask ExecuteSingleAsync(JobRecord record, CancellationToken cancellationToken = default)
162
        {
163
                ArgumentNullException.ThrowIfNull(record);
4✔
164
                return ExecuteJobAsync(record, cancellationToken);
4✔
165
        }
166

167
        /// <summary>
168
        /// Materializes and executes all work currently due, returning when the due queue is empty.
169
        /// Delayed work is left in storage. This method is intended for deterministic test harnesses.
170
        /// </summary>
171
        /// <param name="cancellationToken">A token that can cancel draining.</param>
172
        /// <returns>A task that completes when no currently due work remains.</returns>
173
        public async ValueTask DrainAsync(CancellationToken cancellationToken = default)
174
        {
175
                await _storage.InitializeAsync(cancellationToken).ConfigureAwait(false);
4✔
176
                await EnsureCodeSchedulesAsync(cancellationToken).ConfigureAwait(false);
4✔
177
                while (true)
3✔
178
                {
179
                        await MaterializeRecurringAsync(cancellationToken).ConfigureAwait(false);
4✔
180
                        var request = BuildAcquisitionRequest();
4✔
181
                        if (request is null)
4✔
182
                                return;
×
183
                        var jobs = await _storage.AcquireDueJobsAsync(request, cancellationToken).ConfigureAwait(false);
4✔
184
                        if (jobs.Count == 0)
4✔
185
                                return;
4✔
186
                        WarnIfGroupedJobsAreInert(jobs);
4✔
187

188
                        foreach (var job in jobs)
4✔
189
                        {
190
                                Reserve(job);
4✔
191
                                await ExecuteJobAsync(job, cancellationToken, releaseReservation: true).ConfigureAwait(false);
4✔
192
                        }
193
                }
194
        }
3✔
195

196
        private async Task RunSchedulerIterationAsync(CancellationToken cancellationToken)
197
        {
198
                // The heartbeat runs first so that a failure in any later stage cannot make a polling scheduler
199
                // look dead to ImmediateJobsHealthCheck.
200
                var now = _timeProvider.GetUtcNow();
4✔
201
                await _storage.HeartbeatAsync(
4✔
202
                        new(_workerId, now, _state.ActiveWorkers, _options.MaxParallelJobs),
4✔
203
                        cancellationToken
4✔
204
                ).ConfigureAwait(false);
4✔
205
                _state.MarkHeartbeat(now);
4✔
206

207
                await MaterializeRecurringAsync(cancellationToken).ConfigureAwait(false);
4✔
208
                var request = BuildAcquisitionRequest();
4✔
209
                var acquired = request is null
4✔
210
                        ? []
4✔
211
                        : await _storage.AcquireDueJobsAsync(request, cancellationToken).ConfigureAwait(false);
4✔
212
                WarnIfGroupedJobsAreInert(acquired);
4✔
213

214
                foreach (var job in acquired)
4✔
215
                {
216
                        Reserve(job);
4✔
217
                        try
218
                        {
219
                                JobTelemetry.Acquired();
4✔
220
                                await _channel.Writer.WriteAsync(job, cancellationToken).ConfigureAwait(false);
4✔
221
                        }
4✔
222
                        catch
×
223
                        {
224
                                Release(job);
×
225
                                throw;
×
226
                        }
227
                }
3✔
228

229
                if (_timeProvider.GetTimestamp() >= Interlocked.Read(ref _nextPurgeTimestamp))
4✔
230
                {
231
                        await _storage.PurgeJobsAsync(
4✔
232
                                _options.SucceededRetention,
4✔
233
                                _options.FailedRetention,
4✔
234
                                cancellationToken
4✔
235
                        ).ConfigureAwait(false);
4✔
236
                        if (_graphStorage is not null)
4✔
237
                        {
238
                                await _graphStorage.PurgeBatchesAsync(
4✔
239
                                        _options.BatchSucceededRetention,
4✔
240
                                        _options.BatchFailedRetention,
4✔
241
                                        cancellationToken
4✔
242
                                ).ConfigureAwait(false);
4✔
243
                        }
244

245
                        _ = Interlocked.Exchange(ref _nextPurgeTimestamp, _timeProvider.GetTimestamp() + ToTimestampTicks(_options.PurgeInterval));
4✔
246
                }
247
        }
4✔
248

249
        private async Task RunWorkerAsync(CancellationToken cancellationToken)
250
        {
251
                try
252
                {
253
                        await foreach (var record in _channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
4✔
254
                        {
255
                                try
256
                                {
257
                                        await ExecuteJobAsync(record, cancellationToken, releaseReservation: true).ConfigureAwait(false);
4✔
258
                                }
4✔
259
                                catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
×
260
                                {
261
                                        break;
×
262
                                }
263
#pragma warning disable CA1031 // A failed job must not terminate its worker loop.
264
                                catch (Exception exception)
×
265
#pragma warning restore CA1031
266
                                {
267
                                        UnhandledWorkerError(_logger, exception, record.Id);
×
268
                                }
×
269
                        }
3✔
270
                }
4✔
271
                catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
×
272
                {
273
                        // The drain deadline expired. Records still buffered stay Active until their lease expires,
274
                        // and the worker completes normally so a drained shutdown is not reported as an error.
275
                }
×
276
        }
4✔
277

278
        private async ValueTask ExecuteJobAsync(
279
                JobRecord record,
280
                CancellationToken stoppingToken,
281
                bool releaseReservation = false
282
        )
283
        {
284
                if (!_definitions.TryGetValue(record.JobName, out var definition))
4✔
285
                {
286
                        try
287
                        {
288
                                await _storage
4✔
289
                                        .FailAsync(
4✔
290
                                                record.Id,
4✔
291
                                                _workerId,
4✔
292
                                                $"No generated job definition exists for '{record.JobName}'.",
4✔
293
                                                nextRetryAt: null,
4✔
294
                                                stoppingToken
4✔
295
                                        )
4✔
296
                                        .ConfigureAwait(false);
4✔
297
                        }
4✔
298
                        finally
299
                        {
300
                                if (releaseReservation)
4✔
301
                                        Release(record);
×
302
                        }
1✔
303

304
                        return;
4✔
305
                }
306

307
                var started = _timeProvider.GetTimestamp();
4✔
308
                var startedAt = _timeProvider.GetUtcNow();
4✔
309
                using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
4✔
310
                var timeoutTimer = definition.Timeout is { } timeoutValue
4✔
311
                        ? _timeProvider.CreateTimer(static state => ((CancellationTokenSource)state!).Cancel(), timeout, timeoutValue, Timeout.InfiniteTimeSpan)
4✔
312
                        : null;
4✔
313
                using var leaseCancellation = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
4✔
314
                var leaseTask = RenewLeaseLoopAsync(record.Id, leaseCancellation.Token);
4✔
315

316
                var parent = default(ActivityContext);
4✔
317
                if (record.TraceParent is not null)
4✔
318
                        _ = ActivityContext.TryParse(record.TraceParent, record.TraceState, isRemote: true, out parent);
1✔
319
                IEnumerable<ActivityLink>? links = parent != default ? [new(parent)] : null;
4✔
320
                using var activity = JobTelemetry.ActivitySource.StartActivity(
4✔
321
                        $"job {record.JobName}",
4✔
322
                        ActivityKind.Consumer,
4✔
323
                        default(ActivityContext),
4✔
324
                        tags:
4✔
325
                        [
4✔
326
                                new("job.name", record.JobName),
4✔
327
                                new("job.queue", record.QueueName),
4✔
328
                                new("job.id", record.Id),
4✔
329
                                new("job.attempt", record.Attempt),
4✔
330
                        ],
4✔
331
                        links: links
4✔
332
                );
4✔
333
                using var logScope = _logger.BeginScope(new Dictionary<string, object>(StringComparer.Ordinal)
4✔
334
                {
4✔
335
                        ["JobName"] = record.JobName,
4✔
336
                        ["QueueName"] = record.QueueName,
4✔
337
                        ["JobId"] = record.Id,
4✔
338
                        ["Attempt"] = record.Attempt,
4✔
339
                });
4✔
340

341
                // Paired with DecrementActive/ExecutionFinished in the finally below, so nothing that can throw
342
                // may sit between this and the try.
343
                _state.IncrementActive();
4✔
344
                JobTelemetry.ExecutionStarted();
4✔
345

346
                try
347
                {
348
                        try
349
                        {
350
                                await _storage.SetExecutionTelemetryAsync(
4✔
351
                                        record.Id,
4✔
352
                                        _workerId,
4✔
353
                                        activity?.TraceId.ToString(),
4✔
354
                                        activity?.SpanId.ToString(),
4✔
355
                                        startedAt,
4✔
356
                                        stoppingToken
4✔
357
                                ).ConfigureAwait(false);
4✔
358
                        }
4✔
359
                        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
4✔
360
                        {
361
                                throw;
4✔
362
                        }
363
#pragma warning disable CA1031 // Telemetry persistence is best-effort and must not consume a job attempt.
364
                        catch (Exception exception)
4✔
365
#pragma warning restore CA1031
366
                        {
367
                                ExecutionTelemetryPersistenceFailed(_logger, exception);
4✔
368
                        }
4✔
369

370
                        await using var scope = _scopeFactory.CreateAsyncScope();
4✔
371
                        if (record.Context is { } orphanedEnvelope && definition.Invoker is not IJobContextAwareInvoker)
4✔
372
                        {
373
                                var orphanedSlices = JobContextEnvelope.Read(orphanedEnvelope);
4✔
374
                                JobContextEnvelope.LogOrphanedSlices(scope.ServiceProvider, record, orphanedSlices.Keys);
4✔
375
                        }
376

377
                        var executionBuffer = new JobExecutionBuffer();
4✔
378
                        await definition.Invoker.InvokeAsync(
4✔
379
                                scope.ServiceProvider,
4✔
380
                                new(record, definition, timeout.Token, executionBuffer)
4✔
381
                        ).ConfigureAwait(false);
4✔
382
                        if (_graphStorage is not null)
4✔
383
                        {
384
                                await _graphStorage.CompleteWithContinuationsAsync(
4✔
385
                                        record.Id,
4✔
386
                                        _workerId,
4✔
387
                                        executionBuffer.SealAndSnapshot(),
4✔
388
                                        stoppingToken
4✔
389
                                ).ConfigureAwait(false);
4✔
390
                        }
391
                        else
392
                        {
393
                                await _storage.CompleteAsync(record.Id, _workerId, stoppingToken).ConfigureAwait(false);
4✔
394
                        }
395

396
                        var duration = _timeProvider.GetElapsedTime(started);
4✔
397
                        JobTelemetry.Succeeded(record.JobName, record.QueueName, duration);
4✔
398
                        _ = activity?.SetStatus(ActivityStatusCode.Ok);
4✔
399
                        JobCompleted(_logger, duration.TotalMilliseconds);
4✔
400
                }
4✔
401
                catch (Exception exception) when (exception is not OperationCanceledException || !stoppingToken.IsCancellationRequested)
4✔
402
                {
403
                        var retry = record.Attempt < definition.MaxAttempts;
4✔
404
                        DateTimeOffset? nextRetryAt = retry ? _timeProvider.GetUtcNow() + GetRetryDelay(definition, record.Attempt) : null;
4✔
405
                        await _storage.FailAsync(record.Id, _workerId, exception.ToString(), nextRetryAt, stoppingToken).ConfigureAwait(false);
4✔
406
                        var duration = _timeProvider.GetElapsedTime(started);
4✔
407
                        JobTelemetry.Failed(record.JobName, record.QueueName, duration);
4✔
408
                        _ = activity?.SetStatus(ActivityStatusCode.Error, exception.Message);
4✔
409

410
                        if (retry)
4✔
411
                        {
412
                                JobTelemetry.Retried(record.JobName, record.QueueName);
4✔
413
                                JobWillRetry(_logger, exception, nextRetryAt);
4✔
414
                        }
415
                        else
416
                        {
417
                                JobExhaustedAttempts(_logger, exception, definition.MaxAttempts);
4✔
418
                        }
419
                }
420
                finally
421
                {
422
                        timeoutTimer?.Dispose();
4✔
423
                        await leaseCancellation.CancelAsync().ConfigureAwait(false);
4✔
424
                        try
425
                        {
426
                                await leaseTask.ConfigureAwait(false);
4✔
427
                        }
×
428
                        catch (OperationCanceledException)
4✔
429
                        {
430
                        }
4✔
431

432
                        _state.DecrementActive();
4✔
433
                        JobTelemetry.ExecutionFinished();
4✔
434
                        if (releaseReservation)
4✔
435
                                Release(record);
4✔
436
                }
437
        }
4✔
438

439
        private JobAcquisitionRequest? BuildAcquisitionRequest()
440
        {
441
                var capacity = Math.Min(
4✔
442
                        _options.AcquisitionBatchSize,
4✔
443
                        _options.MaxParallelJobs - Volatile.Read(ref _reservations)
4✔
444
                );
4✔
445
                if (capacity <= 0)
4✔
446
                        return null;
2✔
447

448
                var queues = new List<JobQueueAcquisition>();
4✔
449
                foreach (var priorityGroup in _queues.Values
4✔
450
                        .GroupBy(static queue => queue.Priority)
4✔
451
                        .OrderByDescending(static group => group.Key))
4✔
452
                {
453
                        var priorityQueues = priorityGroup.OrderBy(static queue => queue.Name, StringComparer.Ordinal).ToArray();
4✔
454
                        var offset = _priorityOffsets.GetValueOrDefault(priorityGroup.Key) % priorityQueues.Length;
4✔
455
                        for (var index = 0; index < priorityQueues.Length; index++)
4✔
456
                        {
457
                                var queue = priorityQueues[(index + offset) % priorityQueues.Length];
4✔
458
                                var queueCapacity = queue.Concurrency == 0
4✔
459
                                        ? capacity
4✔
460
                                        : queue.Concurrency - _queueReservations.GetValueOrDefault(queue.Name);
4✔
461
                                if (queueCapacity <= 0)
4✔
462
                                        continue;
463

464
                                var jobCapacities = _definitions.Values
4✔
465
                                        .Select(definition => new
4✔
466
                                        {
4✔
467
                                                definition.Name,
4✔
468
                                                Capacity = GetJobAcquisitionCapacity(definition, capacity),
4✔
469
                                        })
4✔
470
                                        .Where(static item => item.Capacity > 0)
4✔
471
                                        .ToDictionary(static item => item.Name, static item => item.Capacity, StringComparer.Ordinal);
4✔
472
                                if (jobCapacities.Count == 0)
4✔
473
                                        continue;
474

475
                                queues.Add(new()
4✔
476
                                {
4✔
477
                                        QueueName = queue.Name,
4✔
478
                                        Capacity = (int)Math.Min(queueCapacity, jobCapacities.Values.Sum(static value => (long)value)),
4✔
479
                                        JobCapacities = jobCapacities,
4✔
480
                                });
4✔
481
                        }
482

483
                        _priorityOffsets[priorityGroup.Key] = offset + 1;
4✔
484
                }
485

486
                return queues.Count == 0
4✔
487
                        ? null
4✔
488
                        : new()
4✔
489
                        {
4✔
490
                                WorkerId = _workerId,
4✔
491
                                Lease = _options.LeaseDuration,
4✔
492
                                BatchSize = capacity,
4✔
493
                                Queues = queues,
4✔
494
                                FairQueues = _fairQueuePolicy,
4✔
495
                        };
4✔
496
        }
497

498
        private int GetJobAcquisitionCapacity(JobDefinition definition, int availableCapacity)
499
        {
500
                var limit = definition.OverlapPolicy == OverlapPolicy.Queue ? 1 : definition.MaxConcurrency;
4✔
501
                return limit == 0
4✔
502
                        ? availableCapacity
4✔
503
                        : limit - _jobReservations.GetValueOrDefault(definition.Name);
4✔
504
        }
505

506
        private void WarnIfGroupedJobsAreInert(IReadOnlyList<JobRecord> acquired)
507
        {
508
                if (_fairQueuePolicy is not null
4✔
509
                        || Volatile.Read(ref _fairQueuesDisabledWarningLogged) != 0
4✔
510
                        || !acquired.Any(static job => job.GroupId is not null)
4✔
511
                        || Interlocked.Exchange(ref _fairQueuesDisabledWarningLogged, 1) != 0)
4✔
512
                {
513
                        return;
4✔
514
                }
515

516
                GroupedJobsAcquiredWithoutFairQueues(_logger);
4✔
517
        }
4✔
518

519
        private void Reserve(JobRecord record)
520
        {
521
                _ = Interlocked.Increment(ref _reservations);
4✔
522
                _ = _queueReservations.AddOrUpdate(record.QueueName, 1, static (_, count) => count + 1);
4✔
523
                _ = _jobReservations.AddOrUpdate(record.JobName, 1, static (_, count) => count + 1);
4✔
524
        }
4✔
525

526
        private void Release(JobRecord record)
527
        {
528
                _ = Interlocked.Decrement(ref _reservations);
4✔
529
                _ = _queueReservations.AddOrUpdate(record.QueueName, 0, static (_, count) => Math.Max(0, count - 1));
4✔
530
                _ = _jobReservations.AddOrUpdate(record.JobName, 0, static (_, count) => Math.Max(0, count - 1));
4✔
531
        }
4✔
532

533
        private async Task RenewLeaseLoopAsync(string jobId, CancellationToken cancellationToken)
534
        {
535
                var interval = TimeSpan.FromTicks(Math.Max(1, _options.LeaseDuration.Ticks / 3));
4✔
536
                while (true)
537
                {
538
                        await Task.Delay(interval, _timeProvider, cancellationToken).ConfigureAwait(false);
4✔
539
                        try
540
                        {
541
                                await _storage.RenewLeaseAsync(jobId, _workerId, _options.LeaseDuration, cancellationToken).ConfigureAwait(false);
×
542
                        }
×
543
                        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
×
544
                        {
545
                                throw;
×
546
                        }
547
#pragma warning disable CA1031 // A transient renewal failure must not stop later renewals or the job outcome.
548
                        catch (Exception exception)
×
549
#pragma warning restore CA1031
550
                        {
551
                                LeaseRenewalFailed(_logger, exception, jobId);
×
552
                        }
×
553
                }
554
        }
555

556
        private async Task AssertCodeSchedulesAsync(CancellationToken cancellationToken)
557
        {
558
                var recurringStorage = _recurringStorage;
4✔
559
                if (recurringStorage is null)
4✔
560
                        return;
×
561

562
                var now = _timeProvider.GetUtcNow();
4✔
563
                var codeDefinitions = _definitions.Values.Where(static definition => definition.Cron is not null).ToArray();
4✔
564
                var persisted = codeDefinitions.Length == 0
4✔
565
                        ? []
4✔
566
                        : (await _storage.GetMonitoringSnapshotAsync(cancellationToken).ConfigureAwait(false))
4✔
567
                                .Recurring
4✔
568
                                .ToDictionary(static schedule => schedule.Name, StringComparer.Ordinal);
4✔
569
                foreach (var definition in codeDefinitions)
4✔
570
                {
571
                        var zone = JobCron.GetTimeZone(definition.TimeZone);
4✔
572
                        // An unchanged schedule keeps the occurrence it is already waiting for, even when that
573
                        // occurrence is already due: recomputing from now would silently drop an occurrence that
574
                        // fell between shutdown and restart. Only a changed cron or time zone recomputes.
575
                        var next = persisted.GetValueOrDefault(definition.Name) is { } current
4✔
576
                                && string.Equals(current.Cron, definition.Cron, StringComparison.Ordinal)
4✔
577
                                && string.Equals(current.TimeZone, definition.TimeZone, StringComparison.Ordinal)
4✔
578
                                        ? current.NextRunAt
4✔
579
                                        : JobCron.Parse(definition.Cron!).GetNextOccurrence(now, zone)
4✔
580
                                                ?? throw new ImmediateJobException($"Cron for '{definition.Name}' has no future occurrence.");
4✔
581
                        await recurringStorage.UpsertRecurringAsync(
4✔
582
                                new()
4✔
583
                                {
4✔
584
                                        Name = definition.Name,
4✔
585
                                        JobName = definition.Name,
4✔
586
                                        Cron = definition.Cron!,
4✔
587
                                        TimeZone = definition.TimeZone,
4✔
588
                                        IsCodeDefined = true,
4✔
589
                                        NextRunAt = next,
4✔
590
                                },
4✔
591
                                cancellationToken
4✔
592
                        ).ConfigureAwait(false);
4✔
593
                }
594

595
                var activeScheduleNames = codeDefinitions.Select(static definition => definition.Name).ToArray();
4✔
596
                await recurringStorage.RemoveObsoleteCodeDefinedRecurringAsync(
4✔
597
                        activeScheduleNames,
4✔
598
                        cancellationToken
4✔
599
                ).ConfigureAwait(false);
4✔
600
        }
4✔
601

602
        private async Task EnsureCodeSchedulesAsync(CancellationToken cancellationToken)
603
        {
604
                if (_state.CodeSchedulesAsserted)
4✔
605
                        return;
4✔
606
                if (_recurringStorage is null)
4✔
607
                {
608
                        _state.MarkCodeSchedulesAsserted();
4✔
609
                        return;
4✔
610
                }
611

612
                await _scheduleInitialization.WaitAsync(cancellationToken).ConfigureAwait(false);
4✔
613
                try
614
                {
615
                        if (_state.CodeSchedulesAsserted)
4✔
616
                                return;
×
617
                        await AssertCodeSchedulesAsync(cancellationToken).ConfigureAwait(false);
4✔
618
                        _state.MarkCodeSchedulesAsserted();
4✔
619
                }
4✔
620
                finally
621
                {
622
                        _ = _scheduleInitialization.Release();
4✔
623
                }
1✔
624
        }
4✔
625

626
        private async Task MaterializeRecurringAsync(CancellationToken cancellationToken)
627
        {
628
                var recurringStorage = _recurringStorage;
4✔
629
                if (recurringStorage is null)
4✔
630
                        return;
4✔
631

632
                var now = _timeProvider.GetUtcNow();
4✔
633
                var schedules = await recurringStorage.GetDueRecurringAsync(now, _options.AcquisitionBatchSize, cancellationToken).ConfigureAwait(false);
4✔
634
                foreach (var schedule in schedules)
4✔
635
                {
636
                        if (!_definitions.TryGetValue(schedule.JobName, out var definition))
4✔
637
                                continue;
638

639
                        try
640
                        {
641
                                await MaterializeRecurringScheduleAsync(
4✔
642
                                        recurringStorage,
4✔
643
                                        schedule,
4✔
644
                                        definition,
4✔
645
                                        now,
4✔
646
                                        cancellationToken
4✔
647
                                ).ConfigureAwait(false);
4✔
648
                        }
4✔
649
                        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
4✔
650
                        {
651
                                throw;
×
652
                        }
653
#pragma warning disable CA1031 // One malformed schedule must not block unrelated schedules or job acquisition.
654
                        catch (Exception exception)
4✔
655
#pragma warning restore CA1031
656
                        {
657
                                RecurringMaterializationFailed(_logger, exception, schedule.Name);
4✔
658
                        }
4✔
659
                }
3✔
660
        }
4✔
661

662
        private async Task MaterializeRecurringScheduleAsync(
663
                IRecurringJobStorage recurringStorage,
664
                RecurringJobSchedule schedule,
665
                JobDefinition definition,
666
                DateTimeOffset now,
667
                CancellationToken cancellationToken
668
        )
669
        {
670
                var expression = JobCron.Parse(schedule.Cron);
4✔
671
                var next = expression.GetNextOccurrence(schedule.NextRunAt, JobCron.GetTimeZone(schedule.TimeZone))
4✔
672
                        ?? throw new ImmediateJobException($"Recurring schedule '{schedule.Name}' has no future occurrence.");
4✔
673
                var (traceParent, traceState) = TraceContextCapture.Current();
4✔
674
                var record = new JobRecord
4✔
675
                {
4✔
676
                        Id = _idGenerator.CreateId(IdKind.Job),
4✔
677
                        JobName = schedule.JobName,
4✔
678
                        QueueName = definition.Queue.Name,
4✔
679
                        Payload = "{}",
4✔
680
                        State = JobState.Pending,
4✔
681
                        DueAt = schedule.NextRunAt,
4✔
682
                        CreatedAt = now,
4✔
683
                        RecurringKey = string.Create(CultureInfo.InvariantCulture, $"{schedule.Name}:{schedule.NextRunAt.UtcTicks}"),
4✔
684
                        TraceParent = traceParent,
4✔
685
                        TraceState = traceState,
4✔
686
                };
4✔
687

688
                if (definition.OverlapPolicy == OverlapPolicy.Skip)
4✔
689
                {
690
                        var active = await _storage.QueryJobsAsync(
4✔
691
                                new() { State = JobState.Active, JobName = definition.Name, Take = 1 },
4✔
692
                                cancellationToken
4✔
693
                        ).ConfigureAwait(false);
4✔
694
                        var pending = await _storage.QueryJobsAsync(
4✔
695
                                new() { State = JobState.Pending, JobName = definition.Name, Take = 1 },
4✔
696
                                cancellationToken
4✔
697
                        ).ConfigureAwait(false);
4✔
698
                        if (active.Count != 0 || pending.Count != 0)
4✔
699
                                record = record with { State = JobState.Cancelled, CompletedAt = now };
4✔
700
                }
3✔
701

702
                if (await recurringStorage.MaterializeRecurringAsync(schedule, record, next, cancellationToken).ConfigureAwait(false)
4✔
703
                        && record.State == JobState.Pending)
4✔
704
                {
705
                        JobTelemetry.Enqueued(record.JobName, record.QueueName);
4✔
706
                }
707
        }
4✔
708

709
        private static TimeSpan GetRetryDelay(JobDefinition definition, int attempt)
710
        {
711
                if (definition.Backoff == BackoffStrategy.Fixed)
4✔
712
                        return definition.BackoffBase;
4✔
713

UNCOV
714
                var exponent = Math.Min(30, Math.Max(0, attempt - 1));
×
UNCOV
715
                var ticks = definition.BackoffBase.Ticks * Math.Pow(2, exponent);
×
UNCOV
716
                if (definition.Backoff == BackoffStrategy.ExponentialJitter)
×
UNCOV
717
                        ticks *= 0.5 + Random.Shared.NextDouble();
×
718

719
                // long.MaxValue converts to 2^63 as a double, which is one past the representable range, so the
720
                // bound has to be tested before the cast rather than clamped with Math.Min after it.
UNCOV
721
                return ticks >= long.MaxValue ? TimeSpan.MaxValue : TimeSpan.FromTicks((long)ticks);
×
722
        }
723

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

726
        /// <inheritdoc />
727
        public override void Dispose()
728
        {
729
                _scheduleInitialization.Dispose();
4✔
730
                _workerCancellation.Dispose();
4✔
731
                base.Dispose();
4✔
732
        }
4✔
733

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

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

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

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

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

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

752
        [LoggerMessage(
753
                EventId = 7,
754
                Level = LogLevel.Information,
755
                Message = "Batch & continuation features are disabled: the configured storage '{storageType}' implements the queue capability only. Configure a SQL provider to enable them."
756
        )]
757
        private static partial void GraphFeaturesDisabled(ILogger logger, string storageType);
758

759
        [LoggerMessage(
760
                EventId = 8,
761
                Level = LogLevel.Warning,
762
                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."
763
        )]
764
        private static partial void GroupedJobsAcquiredWithoutFairQueues(ILogger logger);
765

766
        [LoggerMessage(
767
                EventId = 9,
768
                Level = LogLevel.Warning,
769
                Message = "Could not persist execution telemetry; job invocation will continue"
770
        )]
771
        private static partial void ExecutionTelemetryPersistenceFailed(ILogger logger, Exception exception);
772

773
        [LoggerMessage(
774
                EventId = 10,
775
                Level = LogLevel.Warning,
776
                Message = "Could not renew the lease for job {jobId}; renewal will be retried until the attempt finishes"
777
        )]
778
        private static partial void LeaseRenewalFailed(ILogger logger, Exception exception, string jobId);
779

780
        [LoggerMessage(
781
                EventId = 11,
782
                Level = LogLevel.Error,
783
                Message = "Could not materialize recurring schedule {scheduleName}; other schedules and job acquisition will continue"
784
        )]
785
        private static partial void RecurringMaterializationFailed(
786
                ILogger logger,
787
                Exception exception,
788
                string scheduleName
789
        );
790
}
791

792
/// <summary>Scheduler liveness state shared with health checks and monitoring.</summary>
793
public sealed class JobSchedulerState
794
{
795
        private long _activeWorkers;
796

797
        /// <summary>UTC time at which the scheduler initialized.</summary>
798
        /// <value>The initialization timestamp, or <see langword="null"/> before the scheduler starts.</value>
799
        public DateTimeOffset? StartedAt { get; private set; }
800

801
        /// <summary>UTC time of the latest successful scheduler iteration.</summary>
802
        /// <value>The latest heartbeat timestamp, or <see langword="null"/> before the first heartbeat.</value>
803
        public DateTimeOffset? LastHeartbeat { get; private set; }
804

805
        /// <summary>Number of invocations currently executing.</summary>
806
        /// <value>The current number of active workers.</value>
807
        public int ActiveWorkers => checked((int)Interlocked.Read(ref _activeWorkers));
4✔
808

809
        internal bool CodeSchedulesAsserted { get; private set; }
810

811
        internal void MarkStarted(DateTimeOffset timestamp) => StartedAt = timestamp;
4✔
812
        internal void MarkHeartbeat(DateTimeOffset timestamp) => LastHeartbeat = timestamp;
4✔
813
        internal void MarkCodeSchedulesAsserted() => CodeSchedulesAsserted = true;
4✔
814
        internal void IncrementActive() => Interlocked.Increment(ref _activeWorkers);
4✔
815
        internal void DecrementActive() => Interlocked.Decrement(ref _activeWorkers);
4✔
816
}
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