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

ImmediatePlatform / Immediate.Jobs / 30642381675

31 Jul 2026 03:19PM UTC coverage: 83.228% (+0.01%) from 83.214%
30642381675

push

github

web-flow
Add a durable skipped job state (#81)

63 of 70 new or added lines in 8 files covered. (90.0%)

6 existing lines in 4 files now uncovered.

7493 of 9003 relevant lines covered (83.23%)

2.75 hits per line

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

86.39
/src/Immediate.Jobs.Redis/RedisJobStorage.cs
1
using System.Globalization;
2
using System.Text.Json;
3
using StackExchange.Redis;
4

5
// TODO: remove and fix diagnostics
6
#pragma warning disable MA0015 // Specify the parameter name in ArgumentException
7

8
namespace Immediate.Jobs.Redis;
9

10
/// <summary>
11
/// Distributed Redis storage for ordinary queue jobs and recurring schedules.
12
/// Batches and continuations require a graph-capable SQL provider.
13
/// </summary>
14
public sealed class RedisJobStorage : IRecurringJobStorage, IDisposable
15
{
16
        private const int QueryWindowSize = 256;
17
        private const int MaximumQueryTake = 1000;
18

19
        private static readonly RedisValue[] JobMutableFields =
1✔
20
        [
1✔
21
                "record",
1✔
22
                "state",
1✔
23
                "due",
1✔
24
                "attempt",
1✔
25
                "worker",
1✔
26
                "lease",
1✔
27
                "error",
1✔
28
                "completed",
1✔
29
                "executionTraceId",
1✔
30
                "executionSpanId",
1✔
31
                "executionStartedAt",
1✔
32
        ];
1✔
33

34
        private static readonly RedisValue[] RecurringMutableFields = ["record", "paused", "next", "last"];
1✔
35

36
        private readonly IConnectionMultiplexer _connection;
37
        private readonly IDatabase _database;
38
        private readonly TimeProvider _timeProvider;
39
        private readonly string _root;
40
        private readonly bool _ownsConnection;
41
        private readonly Lock _disposeGate = new();
1✔
42
        private Task? _disposeTask;
43

44
        /// <summary>Creates storage over an existing Redis connection.</summary>
45
        /// <param name="connection">The application-owned Redis connection.</param>
46
        /// <param name="options">The Redis storage options, or <see langword="null"/> to use defaults.</param>
47
        /// <param name="timeProvider">The clock used for storage timestamps, or <see langword="null"/> to use the system clock.</param>
48
        public RedisJobStorage(
49
                IConnectionMultiplexer connection,
50
                RedisJobStorageOptions? options = null,
51
                TimeProvider? timeProvider = null
52
        ) : this(connection, options ?? new(), timeProvider, ownsConnection: false)
1✔
53
        {
54
        }
1✔
55

56
        internal RedisJobStorage(
1✔
57
                IConnectionMultiplexer connection,
1✔
58
                RedisJobStorageOptions options,
1✔
59
                TimeProvider? timeProvider,
1✔
60
                bool ownsConnection
1✔
61
        )
1✔
62
        {
63
                ArgumentNullException.ThrowIfNull(connection);
1✔
64
                ArgumentNullException.ThrowIfNull(options);
1✔
65
                ArgumentException.ThrowIfNullOrWhiteSpace(options.KeyPrefix);
1✔
66
                if (options.KeyPrefix.IndexOfAny(['{', '}']) >= 0)
1✔
67
                        throw new ArgumentException("The Redis key prefix cannot contain '{' or '}'.", nameof(options));
×
68

69
                _connection = connection;
1✔
70
                _database = connection.GetDatabase(options.Database);
1✔
71
                _timeProvider = timeProvider ?? TimeProvider.System;
1✔
72
                _root = $"{{{options.KeyPrefix}}}:";
1✔
73
                _ownsConnection = ownsConnection;
1✔
74
        }
1✔
75

76
        /// <inheritdoc />
77
        public async ValueTask InitializeAsync(CancellationToken cancellationToken = default)
78
        {
79
                _ = await _database.PingAsync().WaitAsync(cancellationToken).ConfigureAwait(false);
×
80
        }
×
81

82
        /// <inheritdoc />
83
        public async ValueTask EnqueueAsync(JobRecord job, CancellationToken cancellationToken = default)
84
        {
85
                ValidateQueueJob(job);
1✔
86
                var result = await EvaluateInt64Async(
1✔
87
                        RedisScripts.Enqueue,
1✔
88
                        [JobKey(job.Id), AllJobsKey, StateKey(job.State), DueKey(job.QueueName)],
1✔
89
                        CreateEnqueueArguments(job),
1✔
90
                        cancellationToken
1✔
91
                ).ConfigureAwait(false);
1✔
92
                if (result == 0)
1✔
93
                        throw new ImmediateJobException($"Job '{job.Id}' already exists.");
×
94
        }
1✔
95

96
        /// <inheritdoc />
97
        public async ValueTask<IReadOnlyList<JobRecord>> AcquireDueJobsAsync(
98
                JobAcquisitionRequest request,
99
                CancellationToken cancellationToken = default
100
        )
101
        {
102
                ArgumentNullException.ThrowIfNull(request);
1✔
103
                ArgumentException.ThrowIfNullOrWhiteSpace(request.WorkerId);
1✔
104
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(request.Lease, TimeSpan.Zero);
1✔
105
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(request.BatchSize, 0);
1✔
106
                ArgumentNullException.ThrowIfNull(request.Queues);
1✔
107
                if (request.FairQueues is not null)
1✔
108
                {
109
                        throw new NotSupportedException(
1✔
110
                                "Direct distributed fair-queue acquisition is not supported by the Redis provider."
1✔
111
                        );
1✔
112
                }
113

114
                var keys = new List<RedisKey>(4 + request.Queues.Count)
1✔
115
                {
1✔
116
                        LeasesKey,
1✔
117
                        StateKey(JobState.Active),
1✔
118
                        StateKey(JobState.Pending),
1✔
119
                        StateKey(JobState.Scheduled),
1✔
120
                };
1✔
121
                var now = _timeProvider.GetUtcNow();
1✔
122
                var leaseExpiresAt = now + request.Lease;
1✔
123
                var values = new List<RedisValue>
1✔
124
                {
1✔
125
                        Score(now),
1✔
126
                        Score(leaseExpiresAt),
1✔
127
                        Ticks(leaseExpiresAt),
1✔
128
                        request.WorkerId,
1✔
129
                        request.BatchSize,
1✔
130
                        request.Queues.Count,
1✔
131
                        _root,
1✔
132
                };
1✔
133
                foreach (var queue in request.Queues)
1✔
134
                {
135
                        ArgumentNullException.ThrowIfNull(queue);
1✔
136
                        ArgumentException.ThrowIfNullOrWhiteSpace(queue.QueueName);
1✔
137
                        keys.Add(DueKey(queue.QueueName));
1✔
138
                        values.Add(queue.QueueName);
1✔
139
                        values.Add(Math.Max(0, queue.Capacity));
1✔
140
                        values.Add(queue.JobCapacities.Count);
1✔
141
                        foreach (var capacity in queue.JobCapacities)
1✔
142
                        {
143
                                values.Add(capacity.Key);
1✔
144
                                values.Add(Math.Max(0, capacity.Value));
1✔
145
                        }
146
                }
147

148
                var result = await _database.ScriptEvaluateAsync(
1✔
149
                        RedisScripts.Acquire,
1✔
150
                        [.. keys],
1✔
151
                        [.. values]
1✔
152
                ).WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
153
                var ids = ((RedisResult[])result!)
1✔
154
                        .Select(static value => (string)value!)
1✔
155
                        .ToArray();
1✔
156
                return await ReadJobsAsync(ids, cancellationToken).ConfigureAwait(false);
1✔
157
        }
1✔
158

159
        /// <inheritdoc />
160
        public async ValueTask SetExecutionTelemetryAsync(
161
                string jobId,
162
                string workerId,
163
                string? traceId,
164
                string? spanId,
165
                DateTimeOffset startedAt,
166
                CancellationToken cancellationToken = default
167
        )
168
        {
169
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
1✔
170
                ArgumentException.ThrowIfNullOrWhiteSpace(workerId);
1✔
171
                var result = await EvaluateInt64Async(
1✔
172
                        RedisScripts.SetTelemetry,
1✔
173
                        [JobKey(jobId)],
1✔
174
                        [workerId, traceId ?? "", spanId ?? "", Ticks(startedAt)],
1✔
175
                        cancellationToken
1✔
176
                ).ConfigureAwait(false);
1✔
177
                ThrowIfNotOwned(result, jobId, workerId);
1✔
178
        }
1✔
179

180
        /// <inheritdoc />
181
        public async ValueTask RenewLeaseAsync(
182
                string jobId,
183
                string workerId,
184
                TimeSpan lease,
185
                CancellationToken cancellationToken = default
186
        )
187
        {
188
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
×
189
                ArgumentException.ThrowIfNullOrWhiteSpace(workerId);
×
190
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lease, TimeSpan.Zero);
×
191
                var expiresAt = _timeProvider.GetUtcNow() + lease;
×
192
                var result = await EvaluateInt64Async(
×
193
                        RedisScripts.RenewLease,
×
194
                        [JobKey(jobId), LeasesKey],
×
195
                        [workerId, Ticks(expiresAt), Score(expiresAt), jobId],
×
196
                        cancellationToken
×
197
                ).ConfigureAwait(false);
×
198
                ThrowIfNotOwned(result, jobId, workerId);
×
199
        }
×
200

201
        /// <inheritdoc />
202
        public async ValueTask CompleteAsync(
203
                string jobId,
204
                string workerId,
205
                CancellationToken cancellationToken = default
206
        )
207
        {
208
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
1✔
209
                ArgumentException.ThrowIfNullOrWhiteSpace(workerId);
1✔
210
                var now = _timeProvider.GetUtcNow();
1✔
211
                var result = await EvaluateInt64Async(
1✔
212
                        RedisScripts.Complete,
1✔
213
                        [
1✔
214
                                JobKey(jobId),
1✔
215
                                LeasesKey,
1✔
216
                                StateKey(JobState.Active),
1✔
217
                                StateKey(JobState.Succeeded),
1✔
218
                                CompletedKey(JobState.Succeeded),
1✔
219
                        ],
1✔
220
                        [workerId, Ticks(now), jobId, Score(now)],
1✔
221
                        cancellationToken
1✔
222
                ).ConfigureAwait(false);
1✔
223
                ThrowIfNotOwned(result, jobId, workerId);
1✔
224
        }
1✔
225

226
        /// <inheritdoc />
227
        public async ValueTask FailAsync(
228
                string jobId,
229
                string workerId,
230
                string error,
231
                DateTimeOffset? nextRetryAt,
232
                CancellationToken cancellationToken = default
233
        )
234
        {
235
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
×
236
                ArgumentException.ThrowIfNullOrWhiteSpace(workerId);
×
237
                ArgumentNullException.ThrowIfNull(error);
×
238
                var now = _timeProvider.GetUtcNow();
×
239
                var nextTicks = nextRetryAt is { } retryAt ? Ticks(retryAt) : "";
×
240
                var nextScore = nextRetryAt is { } retryScore ? Score(retryScore) : 0;
×
241
                var result = await EvaluateInt64Async(
×
242
                        RedisScripts.Fail,
×
243
                        [
×
244
                                JobKey(jobId),
×
245
                                LeasesKey,
×
246
                                StateKey(JobState.Active),
×
247
                                StateKey(JobState.Failed),
×
248
                                CompletedKey(JobState.Failed),
×
249
                                StateKey(JobState.Scheduled),
×
250
                                StateKey(JobState.Pending),
×
251
                        ],
×
252
                        [
×
253
                                workerId,
×
254
                                jobId,
×
255
                                nextTicks,
×
256
                                error,
×
257
                                Ticks(now),
×
258
                                Score(now),
×
259
                                nextScore,
×
260
                                Score(now),
×
261
                                _root,
×
262
                        ],
×
263
                        cancellationToken
×
264
                ).ConfigureAwait(false);
×
265
                ThrowIfNotOwned(result, jobId, workerId);
×
266
        }
×
267

268
        /// <inheritdoc />
269
        public async ValueTask<JobMonitoringSnapshot> GetMonitoringSnapshotAsync(
270
                CancellationToken cancellationToken = default
271
        )
272
        {
273
                var states = Enum.GetValues<JobState>();
1✔
274
                var countTasks = states
1✔
275
                        .Select(state => _database.SetLengthAsync(StateKey(state)))
1✔
276
                        .ToArray();
1✔
277
                var recurringTask = ReadAllRecurringAsync(cancellationToken);
1✔
278
                var serversTask = ReadLiveServersAsync(cancellationToken);
1✔
279
                _ = await Task.WhenAll(countTasks).WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
280
                var recurring = await recurringTask.ConfigureAwait(false);
1✔
281
                var servers = await serversTask.ConfigureAwait(false);
1✔
282
                var counts = states
1✔
283
                        .Select((state, index) => KeyValuePair.Create(state, countTasks[index].Result))
1✔
284
                        .ToDictionary();
1✔
285
                return new(_timeProvider.GetUtcNow(), counts, recurring, servers)
1✔
286
                {
1✔
287
                        Capabilities = this.GetCapabilities(),
1✔
288
                };
1✔
289
        }
1✔
290

291
        /// <inheritdoc />
292
        public async ValueTask<IReadOnlyList<JobRecord>> QueryJobsAsync(
293
                JobQuery query,
294
                CancellationToken cancellationToken = default
295
        )
296
        {
297
                ArgumentNullException.ThrowIfNull(query);
1✔
298
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
1✔
299
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
1✔
300
                var take = Math.Min(query.Take, MaximumQueryTake);
1✔
301
                if (query.Id is { } id)
1✔
302
                {
303
                        var job = await ReadJobAsync(id, cancellationToken).ConfigureAwait(false);
1✔
304
                        return job is not null && query.Skip == 0 && MatchesQuery(job, query) ? [job] : [];
1✔
305
                }
306

307
                if (!HasFilters(query))
1✔
308
                {
309
                        var ids = await ReadJobIdsByRankAsync(query.Skip, take, cancellationToken).ConfigureAwait(false);
1✔
310
                        return await ReadJobsAsync(ids, cancellationToken).ConfigureAwait(false);
1✔
311
                }
312

313
                var matchLimit = (long)query.Skip + take;
1✔
314
                var matches = new List<JobRecord>(take);
1✔
315
                long rank = 0;
1✔
316
                long matched = 0;
1✔
317
                while (matched < matchLimit)
1✔
318
                {
319
                        cancellationToken.ThrowIfCancellationRequested();
1✔
320
                        var ids = await ReadJobIdsByRankAsync(rank, QueryWindowSize, cancellationToken).ConfigureAwait(false);
1✔
321
                        if (ids.Count == 0)
1✔
322
                                break;
323

324
                        var jobs = await ReadJobsAsync(ids, cancellationToken).ConfigureAwait(false);
1✔
325
                        foreach (var job in jobs)
1✔
326
                        {
327
                                if (!MatchesQuery(job, query))
1✔
328
                                        continue;
329
                                if (matched++ >= query.Skip)
1✔
330
                                        matches.Add(job);
1✔
331
                                if (matched == matchLimit)
1✔
332
                                        break;
333
                        }
334

335
                        rank += ids.Count;
1✔
336
                }
1✔
337

338
                return matches;
1✔
339
        }
1✔
340

341
        /// <inheritdoc />
342
        public async ValueTask<JobStatus?> GetJobStatusAsync(
343
                string jobId,
344
                CancellationToken cancellationToken = default
345
        )
346
        {
347
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
1✔
348
                var job = await ReadJobAsync(jobId, cancellationToken).ConfigureAwait(false);
1✔
349
                return job is null
1✔
350
                        ? null
1✔
351
                        : new(
1✔
352
                                job.Id,
1✔
353
                                job.JobName,
1✔
354
                                job.QueueName,
1✔
355
                                job.State,
1✔
356
                                job.Attempt,
1✔
357
                                MaxAttempts: null,
1✔
358
                                job.CreatedAt,
1✔
359
                                job.DueAt,
1✔
360
                                job.CompletedAt,
1✔
361
                                job.LastError,
1✔
362
                                BatchId: null,
1✔
363
                                DependsOn: []
1✔
364
                        );
1✔
365
        }
1✔
366

367
        /// <inheritdoc />
368
        public async ValueTask RetryAsync(string jobId, CancellationToken cancellationToken = default)
369
        {
370
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
1✔
371
                var now = _timeProvider.GetUtcNow();
1✔
372
                var result = await EvaluateInt64Async(
1✔
373
                        RedisScripts.Retry,
1✔
374
                        [
1✔
375
                                JobKey(jobId),
1✔
376
                                StateKey(JobState.Failed),
1✔
377
                                StateKey(JobState.Scheduled),
1✔
378
                                StateKey(JobState.Pending),
1✔
379
                                CompletedKey(JobState.Failed),
1✔
380
                        ],
1✔
381
                        [Ticks(now), Score(now), jobId, _root],
1✔
382
                        cancellationToken
1✔
383
                ).ConfigureAwait(false);
1✔
384
                if (result == 0)
1✔
385
                        throw new KeyNotFoundException($"Job '{jobId}' was not found.");
1✔
386
                if (result < 0)
1✔
387
                        throw new ImmediateJobException("Only failed or scheduled jobs can be retried.");
1✔
388
        }
1✔
389

390
        /// <inheritdoc />
391
        public async ValueTask DeleteAsync(string jobId, CancellationToken cancellationToken = default)
392
        {
393
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
1✔
394
                var result = await EvaluateInt64Async(
1✔
395
                        RedisScripts.Delete,
1✔
396
                        [JobKey(jobId), AllJobsKey, RecurringDedupeKey],
1✔
397
                        [jobId, _root],
1✔
398
                        cancellationToken
1✔
399
                ).ConfigureAwait(false);
1✔
400
                if (result == 0)
1✔
401
                        throw new KeyNotFoundException($"Job '{jobId}' was not found.");
1✔
402
                if (result < 0)
1✔
403
                        throw new ImmediateJobException("Only terminal jobs can be deleted.");
1✔
404
        }
×
405

406
        /// <inheritdoc />
407
        public async ValueTask PurgeJobsAsync(
408
                TimeSpan succeededRetention,
409
                TimeSpan failedRetention,
410
                CancellationToken cancellationToken = default
411
        )
412
        {
413
                ArgumentOutOfRangeException.ThrowIfLessThan(succeededRetention, TimeSpan.Zero);
1✔
414
                ArgumentOutOfRangeException.ThrowIfLessThan(failedRetention, TimeSpan.Zero);
1✔
415
                var now = _timeProvider.GetUtcNow();
1✔
416
                await PurgeStateAsync(JobState.Succeeded, now - succeededRetention, cancellationToken).ConfigureAwait(false);
1✔
417
                await PurgeStateAsync(JobState.Failed, now - failedRetention, cancellationToken).ConfigureAwait(false);
1✔
418
                await PurgeStateAsync(JobState.Cancelled, now - failedRetention, cancellationToken).ConfigureAwait(false);
1✔
419
                await PurgeStateAsync(JobState.Skipped, now - failedRetention, cancellationToken).ConfigureAwait(false);
1✔
420
        }
1✔
421

422
        /// <inheritdoc />
423
        public async ValueTask HeartbeatAsync(
424
                JobServerSnapshot server,
425
                CancellationToken cancellationToken = default
426
        )
427
        {
428
                ArgumentNullException.ThrowIfNull(server);
1✔
429
                ArgumentException.ThrowIfNullOrWhiteSpace(server.WorkerId);
1✔
430
                _ = await EvaluateInt64Async(
1✔
431
                        RedisScripts.Heartbeat,
1✔
432
                        [ServerKey(server.WorkerId), ServersKey],
1✔
433
                        [
1✔
434
                                Ticks(server.LastHeartbeat),
1✔
435
                                server.ActiveWorkers,
1✔
436
                                server.MaxWorkers,
1✔
437
                                Score(server.LastHeartbeat),
1✔
438
                                server.WorkerId,
1✔
439
                                (long)TimeSpan.FromMinutes(2).TotalMilliseconds,
1✔
440
                        ],
1✔
441
                        cancellationToken
1✔
442
                ).ConfigureAwait(false);
1✔
443
        }
1✔
444

445
        /// <inheritdoc />
446
        public async ValueTask<bool> IsHealthyAsync(CancellationToken cancellationToken = default)
447
        {
448
                try
449
                {
450
                        _ = await _database.PingAsync().WaitAsync(cancellationToken).ConfigureAwait(false);
×
451
                        return true;
×
452
                }
453
                catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
×
454
                {
455
                        throw;
×
456
                }
457
                catch (RedisException)
×
458
                {
459
                        return false;
×
460
                }
461
        }
×
462

463
        /// <inheritdoc />
464
        public async ValueTask UpsertRecurringAsync(
465
                RecurringJobSchedule schedule,
466
                CancellationToken cancellationToken = default
467
        )
468
        {
469
                ValidateRecurring(schedule);
1✔
470
                var result = await EvaluateInt64Async(
1✔
471
                        RedisScripts.UpsertRecurring,
1✔
472
                        [RecurringKey(schedule.Name), RecurringNamesKey, RecurringDueKey],
1✔
473
                        [
1✔
474
                                JsonSerializer.Serialize(schedule, RedisJsonSerializerContext.Default.RecurringJobSchedule),
1✔
475
                                schedule.IsCodeDefined ? 1 : 0,
1✔
476
                                schedule.IsPaused ? 1 : 0,
1✔
477
                                Ticks(schedule.NextRunAt),
1✔
478
                                NullableTicks(schedule.LastRunAt),
1✔
479
                                schedule.Name,
1✔
480
                                Score(schedule.NextRunAt),
1✔
481
                                RecurringDueMember(schedule.NextRunAt, schedule.Name),
1✔
482
                        ],
1✔
483
                        cancellationToken
1✔
484
                ).ConfigureAwait(false);
1✔
485
                if (result < 0)
1✔
486
                        throw new ImmediateJobException("Code-defined recurring schedules cannot be replaced by dynamic schedules.");
×
487
        }
1✔
488

489
        /// <inheritdoc />
490
        public async ValueTask RemoveObsoleteCodeDefinedRecurringAsync(
491
                IReadOnlyCollection<string> activeScheduleNames,
492
                CancellationToken cancellationToken = default
493
        )
494
        {
495
                ArgumentNullException.ThrowIfNull(activeScheduleNames);
×
496
                var values = new RedisValue[activeScheduleNames.Count + 1];
×
497
                values[0] = _root;
×
498
                var index = 1;
×
499
                foreach (var name in activeScheduleNames)
×
500
                        values[index++] = name;
×
501
                _ = await EvaluateInt64Async(
×
502
                        RedisScripts.RemoveObsoleteRecurring,
×
503
                        [RecurringNamesKey, RecurringDueKey],
×
504
                        values,
×
505
                        cancellationToken
×
506
                ).ConfigureAwait(false);
×
507
        }
×
508

509
        /// <inheritdoc />
510
        public async ValueTask RemoveRecurringAsync(string name, CancellationToken cancellationToken = default)
511
        {
512
                ArgumentException.ThrowIfNullOrWhiteSpace(name);
1✔
513
                var result = await EvaluateInt64Async(
1✔
514
                        RedisScripts.RemoveRecurring,
1✔
515
                        [RecurringKey(name), RecurringNamesKey, RecurringDueKey],
1✔
516
                        [name],
1✔
517
                        cancellationToken
1✔
518
                ).ConfigureAwait(false);
1✔
519
                if (result == 0)
1✔
520
                        throw new KeyNotFoundException($"Recurring schedule '{name}' was not found.");
1✔
521
                if (result < 0)
1✔
522
                        throw new ImmediateJobException("Code-defined recurring schedules cannot be deleted.");
1✔
523
        }
×
524

525
        /// <inheritdoc />
526
        public ValueTask PauseRecurringAsync(string name, CancellationToken cancellationToken = default) =>
527
                SetRecurringPausedAsync(name, isPaused: true, cancellationToken);
1✔
528

529
        /// <inheritdoc />
530
        public ValueTask ResumeRecurringAsync(string name, CancellationToken cancellationToken = default) =>
531
                SetRecurringPausedAsync(name, isPaused: false, cancellationToken);
1✔
532

533
        /// <inheritdoc />
534
        public async ValueTask<IReadOnlyList<RecurringJobSchedule>> GetDueRecurringAsync(
535
                DateTimeOffset now,
536
                int batchSize,
537
                CancellationToken cancellationToken = default
538
        )
539
        {
540
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(batchSize, 0);
1✔
541
                var values = await _database.SortedSetRangeByScoreAsync(
1✔
542
                        RecurringDueKey,
1✔
543
                        stop: Score(now),
1✔
544
                        take: batchSize
1✔
545
                ).WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
546
                var members = values.Select(static value => (string)value!).ToArray();
1✔
547
                var names = members.Select(static member => member[20..]).Distinct(StringComparer.Ordinal).ToArray();
1✔
548
                var schedules = await ReadRecurringAsync(
1✔
549
                        names,
1✔
550
                        cancellationToken
1✔
551
                ).ConfigureAwait(false);
1✔
552
                var schedulesByName = schedules.ToDictionary(static schedule => schedule.Name, StringComparer.Ordinal);
1✔
553
                var due = new List<RecurringJobSchedule>(schedules.Count);
1✔
554
                var added = new HashSet<string>(StringComparer.Ordinal);
1✔
555
                var stale = new List<RedisValue>();
1✔
556
                foreach (var member in members)
1✔
557
                {
558
                        var name = member[20..];
1✔
559
                        if (!schedulesByName.TryGetValue(name, out var schedule)
1✔
560
                                || !string.Equals(member, RecurringDueMember(schedule.NextRunAt, name), StringComparison.Ordinal))
1✔
561
                        {
562
                                stale.Add(member);
1✔
563
                                continue;
1✔
564
                        }
565

566
                        if (added.Add(name))
1✔
567
                                due.Add(schedule);
1✔
568
                }
569

570
                if (stale.Count != 0)
1✔
571
                {
572
                        _ = await _database.SortedSetRemoveAsync(RecurringDueKey, [.. stale])
1✔
573
                                .WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
574
                }
575

576
                return due;
1✔
577
        }
1✔
578

579
        /// <inheritdoc />
580
        public async ValueTask<bool> MaterializeRecurringAsync(
581
                RecurringJobSchedule schedule,
582
                JobRecord job,
583
                DateTimeOffset nextRunAt,
584
                CancellationToken cancellationToken = default
585
        )
586
        {
587
                ValidateRecurring(schedule);
1✔
588
                ValidateMaterializedJob(job);
1✔
589
                var jobArguments = CreateMaterializeArguments(schedule, job, nextRunAt, _timeProvider.GetUtcNow());
1✔
590
                var result = await EvaluateInt64Async(
1✔
591
                        RedisScripts.MaterializeRecurring,
1✔
592
                        [
1✔
593
                                RecurringKey(schedule.Name),
1✔
594
                                RecurringDedupeKey,
1✔
595
                                JobKey(job.Id),
1✔
596
                                AllJobsKey,
1✔
597
                                StateKey(job.State),
1✔
598
                                DueKey(job.QueueName),
1✔
599
                                RecurringDueKey,
1✔
600
                                CompletedKey(job.State),
1✔
601
                        ],
1✔
602
                        jobArguments,
1✔
603
                        cancellationToken
1✔
604
                ).ConfigureAwait(false);
1✔
605
                if (result < 0)
1✔
606
                        throw new ImmediateJobException($"Job '{job.Id}' already exists.");
×
607
                return result == 1;
1✔
608
        }
1✔
609

610
        /// <inheritdoc />
611
        public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
1✔
612

613
        /// <inheritdoc />
614
        public ValueTask DisposeAsync()
615
        {
1✔
616
                lock (_disposeGate)
617
                        return new(_disposeTask ??= DisposeCoreAsync());
1✔
618
        }
1✔
619

620
        private async Task DisposeCoreAsync()
621
        {
622
                if (_ownsConnection)
1✔
623
                {
624
                        try
625
                        {
626
                                await _connection.CloseAsync().ConfigureAwait(false);
×
627
                        }
×
628
                        finally
629
                        {
630
                                _connection.Dispose();
×
631
                        }
632
                }
633
        }
1✔
634

635
        private async ValueTask SetRecurringPausedAsync(
636
                string name,
637
                bool isPaused,
638
                CancellationToken cancellationToken
639
        )
640
        {
641
                ArgumentException.ThrowIfNullOrWhiteSpace(name);
1✔
642
                var result = await EvaluateInt64Async(
1✔
643
                        RedisScripts.SetRecurringPaused,
1✔
644
                        [RecurringKey(name), RecurringDueKey],
1✔
645
                        [isPaused ? 1 : 0],
1✔
646
                        cancellationToken
1✔
647
                ).ConfigureAwait(false);
1✔
648
                if (result == 0)
1✔
649
                        throw new KeyNotFoundException($"Recurring schedule '{name}' was not found.");
1✔
650
        }
1✔
651

652
        private async Task PurgeStateAsync(
653
                JobState state,
654
                DateTimeOffset cutoff,
655
                CancellationToken cancellationToken
656
        )
657
        {
658
                while (true)
1✔
659
                {
660
                        var ids = await _database.SortedSetRangeByScoreAsync(
1✔
661
                                CompletedKey(state),
1✔
662
                                stop: Score(cutoff),
1✔
663
                                exclude: Exclude.Stop,
1✔
664
                                take: 256
1✔
665
                        ).WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
666
                        if (ids.Length == 0)
1✔
667
                                return;
1✔
668
                        foreach (var value in ids)
1✔
669
                        {
670
                                var id = (string)value!;
1✔
671
                                _ = await EvaluateInt64Async(
1✔
672
                                        RedisScripts.Purge,
1✔
673
                                        [JobKey(id), CompletedKey(state), AllJobsKey, StateKey(state), RecurringDedupeKey],
1✔
674
                                        [id, (int)state],
1✔
675
                                        cancellationToken
1✔
676
                                ).ConfigureAwait(false);
1✔
677
                        }
678
                }
679
        }
1✔
680

681
        private async Task<IReadOnlyList<JobServerSnapshot>> ReadLiveServersAsync(CancellationToken cancellationToken)
682
        {
683
                var cutoff = _timeProvider.GetUtcNow() - TimeSpan.FromMinutes(2);
1✔
684
                var stale = await _database.SortedSetRangeByScoreAsync(
1✔
685
                        ServersKey,
1✔
686
                        stop: Score(cutoff),
1✔
687
                        exclude: Exclude.Stop
1✔
688
                ).WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
689
                if (stale.Length != 0)
1✔
690
                        _ = await _database.SortedSetRemoveAsync(ServersKey, stale).WaitAsync(cancellationToken).ConfigureAwait(false);
×
691
                var ids = await _database.SortedSetRangeByScoreAsync(
1✔
692
                        ServersKey,
1✔
693
                        start: Score(cutoff)
1✔
694
                ).WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
695
                var tasks = ids
1✔
696
                        .Select(id => _database.HashGetAsync(ServerKey((string)id!), ["last", "active", "max"]))
1✔
697
                        .ToArray();
1✔
698
                _ = await Task.WhenAll(tasks).WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
699
                return
1✔
700
                [
1✔
701
                        .. tasks
1✔
702
                                .Select((task, index) => (Id: (string)ids[index]!, Values: task.Result))
1✔
703
                                .Where(static server => !server.Values[0].IsNullOrEmpty)
1✔
704
                                .Select(static server => new JobServerSnapshot(
1✔
705
                                        server.Id,
1✔
706
                                        FromTicks(server.Values[0]),
1✔
707
                                        ParseInt32(server.Values[1]),
1✔
708
                                        ParseInt32(server.Values[2])
1✔
709
                                )),
1✔
710
                ];
1✔
711
        }
1✔
712

713
        private async Task<IReadOnlyList<RecurringJobSchedule>> ReadAllRecurringAsync(CancellationToken cancellationToken)
714
        {
715
                var names = await _database.SetMembersAsync(RecurringNamesKey)
1✔
716
                        .WaitAsync(cancellationToken)
1✔
717
                        .ConfigureAwait(false);
1✔
718
                return await ReadRecurringAsync(
1✔
719
                        [.. names.Select(static value => (string)value!)],
1✔
720
                        cancellationToken
1✔
721
                ).ConfigureAwait(false);
1✔
722
        }
1✔
723

724
        private async Task<IReadOnlyList<RecurringJobSchedule>> ReadRecurringAsync(
725
                IReadOnlyList<string> names,
726
                CancellationToken cancellationToken
727
        )
728
        {
729
                var tasks = names.Select(name => ReadRecurringAsync(name, cancellationToken).AsTask()).ToArray();
1✔
730
                var schedules = await Task.WhenAll(tasks).WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
731
                return [.. schedules.OfType<RecurringJobSchedule>().OrderBy(schedule => schedule.NextRunAt).ThenBy(schedule => schedule.Name, StringComparer.Ordinal)];
1✔
732
        }
1✔
733

734
        private async ValueTask<RecurringJobSchedule?> ReadRecurringAsync(
735
                string name,
736
                CancellationToken cancellationToken
737
        )
738
        {
739
                var values = await _database.HashGetAsync(RecurringKey(name), RecurringMutableFields)
1✔
740
                        .WaitAsync(cancellationToken)
1✔
741
                        .ConfigureAwait(false);
1✔
742
                if (values[0].IsNull)
1✔
743
                        return null;
×
744
                var schedule = JsonSerializer.Deserialize(
1✔
745
                        (string)values[0]!,
1✔
746
                        RedisJsonSerializerContext.Default.RecurringJobSchedule
1✔
747
                ) ?? throw new ImmediateJobException($"Recurring schedule '{name}' contains invalid data.");
1✔
748
                return schedule with
1✔
749
                {
1✔
750
                        IsPaused = values[1] == "1",
1✔
751
                        NextRunAt = FromTicks(values[2]),
1✔
752
                        LastRunAt = FromNullableTicks(values[3]),
1✔
753
                };
1✔
754
        }
1✔
755

756
        private async Task<IReadOnlyList<JobRecord>> ReadJobsAsync(
757
                IReadOnlyList<string> ids,
758
                CancellationToken cancellationToken
759
        )
760
        {
761
                var tasks = ids.Select(id => ReadJobAsync(id, cancellationToken).AsTask()).ToArray();
1✔
762
                var jobs = await Task.WhenAll(tasks).WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
763
                return [.. jobs.OfType<JobRecord>()];
1✔
764
        }
1✔
765

766
        private async Task<IReadOnlyList<string>> ReadJobIdsByRankAsync(
767
                long start,
768
                int count,
769
                CancellationToken cancellationToken
770
        )
771
        {
772
                var values = await _database.SortedSetRangeByRankAsync(
1✔
773
                        AllJobsKey,
1✔
774
                        start,
1✔
775
                        start + count - 1,
1✔
776
                        Order.Descending
1✔
777
                ).WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
778
                return [.. values.Select(static value => (string)value!)];
1✔
779
        }
1✔
780

781
        private static bool HasFilters(JobQuery query) =>
782
                query.State is not null ||
1✔
783
                !string.IsNullOrWhiteSpace(query.QueueName) ||
1✔
784
                !string.IsNullOrWhiteSpace(query.JobName) ||
1✔
785
                !string.IsNullOrWhiteSpace(query.Search);
1✔
786

787
        private static bool MatchesQuery(JobRecord job, JobQuery query) =>
788
                (query.State is not { } state || job.State == state) &&
1✔
789
                (string.IsNullOrWhiteSpace(query.QueueName) || string.Equals(job.QueueName, query.QueueName, StringComparison.Ordinal)) &&
1✔
790
                (string.IsNullOrWhiteSpace(query.JobName) || string.Equals(job.JobName, query.JobName, StringComparison.Ordinal)) &&
1✔
791
                (string.IsNullOrWhiteSpace(query.Search) ||
1✔
792
                        job.JobName.Contains(query.Search, StringComparison.OrdinalIgnoreCase));
1✔
793

794
        private async ValueTask<JobRecord?> ReadJobAsync(string id, CancellationToken cancellationToken)
795
        {
796
                var values = await _database.HashGetAsync(JobKey(id), JobMutableFields)
1✔
797
                        .WaitAsync(cancellationToken)
1✔
798
                        .ConfigureAwait(false);
1✔
799
                if (values[0].IsNull)
1✔
800
                        return null;
1✔
801
                var job = JsonSerializer.Deserialize(
1✔
802
                        (string)values[0]!,
1✔
803
                        RedisJsonSerializerContext.Default.JobRecord
1✔
804
                ) ?? throw new ImmediateJobException($"Job '{id}' contains invalid data.");
1✔
805
                return job with
1✔
806
                {
1✔
807
                        State = (JobState)ParseInt32(values[1]),
1✔
808
                        DueAt = FromTicks(values[2]),
1✔
809
                        Attempt = ParseInt32(values[3]),
1✔
810
                        WorkerId = NullIfEmpty(values[4]),
1✔
811
                        LeaseExpiresAt = FromNullableTicks(values[5]),
1✔
812
                        LastError = NullIfEmpty(values[6]),
1✔
813
                        CompletedAt = FromNullableTicks(values[7]),
1✔
814
                        ExecutionTraceId = NullIfEmpty(values[8]),
1✔
815
                        ExecutionSpanId = NullIfEmpty(values[9]),
1✔
816
                        ExecutionStartedAt = FromNullableTicks(values[10]),
1✔
817
                };
1✔
818
        }
1✔
819

820
        private async ValueTask<long> EvaluateInt64Async(
821
                string script,
822
                RedisKey[] keys,
823
                RedisValue[] values,
824
                CancellationToken cancellationToken
825
        )
826
        {
827
                var result = await _database.ScriptEvaluateAsync(script, keys, values)
1✔
828
                        .WaitAsync(cancellationToken)
1✔
829
                        .ConfigureAwait(false);
1✔
830
                return (long)result;
1✔
831
        }
1✔
832

833
        private static RedisValue[] CreateEnqueueArguments(JobRecord job) =>
834
        [
1✔
835
                JsonSerializer.Serialize(job, RedisJsonSerializerContext.Default.JobRecord),
1✔
836
                (int)job.State,
1✔
837
                Ticks(job.DueAt),
1✔
838
                job.Attempt,
1✔
839
                job.WorkerId ?? "",
1✔
840
                NullableTicks(job.LeaseExpiresAt),
1✔
841
                job.LastError ?? "",
1✔
842
                NullableTicks(job.CompletedAt),
1✔
843
                job.ExecutionTraceId ?? "",
1✔
844
                job.ExecutionSpanId ?? "",
1✔
845
                NullableTicks(job.ExecutionStartedAt),
1✔
846
                job.QueueName,
1✔
847
                job.JobName,
1✔
848
                Score(job.CreatedAt),
1✔
849
                job.Id,
1✔
850
                Score(job.DueAt),
1✔
851
                Ticks(job.CreatedAt),
1✔
852
                DueMember(job),
1✔
853
        ];
1✔
854

855
        private static RedisValue[] CreateMaterializeArguments(
856
                RecurringJobSchedule schedule,
857
                JobRecord job,
858
                DateTimeOffset nextRunAt,
859
                DateTimeOffset now
860
        ) =>
861
        [
1✔
862
                Ticks(schedule.NextRunAt),
1✔
863
                job.RecurringKey ?? "",
1✔
864
                job.Id,
1✔
865
                JsonSerializer.Serialize(job, RedisJsonSerializerContext.Default.JobRecord),
1✔
866
                (int)job.State,
1✔
867
                Ticks(job.DueAt),
1✔
868
                Score(job.DueAt),
1✔
869
                job.Attempt,
1✔
870
                job.WorkerId ?? "",
1✔
871
                NullableTicks(job.LeaseExpiresAt),
1✔
872
                job.LastError ?? "",
1✔
873
                NullableTicks(job.CompletedAt),
1✔
874
                job.ExecutionTraceId ?? "",
1✔
875
                job.ExecutionSpanId ?? "",
1✔
876
                NullableTicks(job.ExecutionStartedAt),
1✔
877
                job.QueueName,
1✔
878
                job.JobName,
1✔
879
                Score(job.CreatedAt),
1✔
880
                Ticks(nextRunAt),
1✔
881
                Score(nextRunAt),
1✔
882
                schedule.Name,
1✔
883
                Ticks(job.CreatedAt),
1✔
884
                job.CompletedAt is { } completedAt ? Score(completedAt) : 0,
1✔
885
                Score(now),
1✔
886
        ];
1✔
887

888
        private static void ValidateQueueJob(JobRecord job)
889
        {
890
                ArgumentNullException.ThrowIfNull(job);
1✔
891
                ArgumentException.ThrowIfNullOrWhiteSpace(job.Id);
1✔
892
                ArgumentException.ThrowIfNullOrWhiteSpace(job.JobName);
1✔
893
                ArgumentException.ThrowIfNullOrWhiteSpace(job.QueueName);
1✔
894
                if (job.BatchId is not null || job.RemainingDependencies != 0 || job.FailedDependencies != 0)
1✔
895
                {
896
                        throw new NotSupportedException(
×
897
                                "Batches & continuations require a graph-capable storage provider (a SQL database)."
×
898
                        );
×
899
                }
900

901
                if (job.State is not (JobState.Pending or JobState.Scheduled))
1✔
902
                        throw new ImmediateJobException($"Queue job '{job.Id}' has invalid state '{job.State}'.");
×
903
        }
1✔
904

905
        private static void ValidateMaterializedJob(JobRecord job)
906
        {
907
                ArgumentNullException.ThrowIfNull(job);
1✔
908
                ArgumentException.ThrowIfNullOrWhiteSpace(job.Id);
1✔
909
                ArgumentException.ThrowIfNullOrWhiteSpace(job.JobName);
1✔
910
                ArgumentException.ThrowIfNullOrWhiteSpace(job.QueueName);
1✔
911
                if (job.BatchId is not null || job.RemainingDependencies != 0 || job.FailedDependencies != 0)
1✔
912
                {
913
                        throw new NotSupportedException(
×
914
                                "Batches & continuations require a graph-capable storage provider (a SQL database)."
×
915
                        );
×
916
                }
917

918
                if (job.State is not (JobState.Pending or JobState.Scheduled or JobState.Cancelled or JobState.Skipped))
1✔
919
                        throw new ImmediateJobException($"Recurring job '{job.Id}' has invalid state '{job.State}'.");
×
920
                if (job.CompletedAt is null && (job.State == JobState.Cancelled || job.State == JobState.Skipped))
1✔
NEW
921
                        throw new ImmediateJobException($"Terminal recurring job '{job.Id}' must have a completion time.");
×
922
        }
1✔
923

924
        private static void ValidateRecurring(RecurringJobSchedule schedule)
925
        {
926
                ArgumentNullException.ThrowIfNull(schedule);
1✔
927
                ArgumentException.ThrowIfNullOrWhiteSpace(schedule.Name);
1✔
928
                ArgumentException.ThrowIfNullOrWhiteSpace(schedule.JobName);
1✔
929
                ArgumentException.ThrowIfNullOrWhiteSpace(schedule.Cron);
1✔
930
                ArgumentException.ThrowIfNullOrWhiteSpace(schedule.TimeZone);
1✔
931
        }
1✔
932

933
        private static void ThrowIfNotOwned(long result, string jobId, string workerId)
934
        {
935
                if (result <= 0)
1✔
936
                        throw new ImmediateJobException($"Worker '{workerId}' does not own active job '{jobId}'.");
1✔
937
        }
1✔
938

939
        private RedisKey JobKey(string id) => _root + "job:" + id;
1✔
940
        private RedisKey DueKey(string queue) => _root + "due:" + queue;
1✔
941
        private RedisKey StateKey(JobState state) => string.Create(CultureInfo.InvariantCulture, $"{_root}state:{(int)state}");
1✔
942
        private RedisKey CompletedKey(JobState state) => string.Create(CultureInfo.InvariantCulture, $"{_root}completed:{(int)state}");
1✔
943
        private RedisKey RecurringKey(string name) => _root + "recurring:" + name;
1✔
944
        private RedisKey ServerKey(string workerId) => _root + "server:" + workerId;
1✔
945
        private RedisKey AllJobsKey => _root + "jobs";
1✔
946
        private RedisKey LeasesKey => _root + "leases";
1✔
947
        private RedisKey RecurringNamesKey => _root + "recurring:names";
1✔
948
        private RedisKey RecurringDueKey => _root + "recurring:due";
1✔
949
        private RedisKey RecurringDedupeKey => _root + "recurring:dedupe";
1✔
950
        private RedisKey ServersKey => _root + "servers";
1✔
951

952
        private static long Score(DateTimeOffset value) => value.ToUnixTimeMilliseconds();
1✔
953
        private static string Ticks(DateTimeOffset value) => value.UtcTicks.ToString("D19", CultureInfo.InvariantCulture);
1✔
954
        private static string DueMember(JobRecord job) => $"{Ticks(job.DueAt)}|{Ticks(job.CreatedAt)}|{job.Id}";
1✔
955
        private static string RecurringDueMember(DateTimeOffset nextRunAt, string name) => $"{Ticks(nextRunAt)}|{name}";
1✔
956
        private static string NullableTicks(DateTimeOffset? value) => value is { } actual ? Ticks(actual) : "";
1✔
957
        private static DateTimeOffset FromTicks(RedisValue value) =>
958
                new(long.Parse((string)value!, NumberStyles.None, CultureInfo.InvariantCulture), TimeSpan.Zero);
1✔
959
        private static DateTimeOffset? FromNullableTicks(RedisValue value) =>
960
                value.IsNullOrEmpty ? null : FromTicks(value);
1✔
961
        private static int ParseInt32(RedisValue value) =>
962
                int.Parse((string)value!, NumberStyles.Integer, CultureInfo.InvariantCulture);
1✔
963
        private static string? NullIfEmpty(RedisValue value) => value.IsNullOrEmpty ? null : (string)value!;
1✔
964
}
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