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

ImmediatePlatform / Immediate.Jobs / 30657938276

31 Jul 2026 07:08PM UTC coverage: 84.111% (+0.9%) from 83.228%
30657938276

push

github

web-flow
Retain job execution history (#82)

* Retain job execution history

* Address job retention review feedback

* Bound relational concurrency retries

723 of 843 new or added lines in 13 files covered. (85.77%)

14 existing lines in 4 files now uncovered.

8216 of 9768 relevant lines covered (84.11%)

2.7 hits per line

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

92.48
/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
        private static readonly string[] ExecutionFieldNames =
1✔
36
        [
1✔
37
                "state",
1✔
38
                "worker",
1✔
39
                "acquired",
1✔
40
                "started",
1✔
41
                "completed",
1✔
42
                "trace",
1✔
43
                "span",
1✔
44
                "error",
1✔
45
                "synthetic",
1✔
46
        ];
1✔
47

48
        private readonly IConnectionMultiplexer _connection;
49
        private readonly IDatabase _database;
50
        private readonly TimeProvider _timeProvider;
51
        private readonly string _root;
52
        private readonly bool _ownsConnection;
53
        private readonly Lock _disposeGate = new();
1✔
54
        private Task? _disposeTask;
55

56
        /// <summary>Creates storage over an existing Redis connection.</summary>
57
        /// <param name="connection">The application-owned Redis connection.</param>
58
        /// <param name="options">The Redis storage options, or <see langword="null"/> to use defaults.</param>
59
        /// <param name="timeProvider">The clock used for storage timestamps, or <see langword="null"/> to use the system clock.</param>
60
        public RedisJobStorage(
61
                IConnectionMultiplexer connection,
62
                RedisJobStorageOptions? options = null,
63
                TimeProvider? timeProvider = null
64
        ) : this(connection, options ?? new(), timeProvider, ownsConnection: false)
1✔
65
        {
66
        }
1✔
67

68
        internal RedisJobStorage(
1✔
69
                IConnectionMultiplexer connection,
1✔
70
                RedisJobStorageOptions options,
1✔
71
                TimeProvider? timeProvider,
1✔
72
                bool ownsConnection
1✔
73
        )
1✔
74
        {
75
                ArgumentNullException.ThrowIfNull(connection);
1✔
76
                ArgumentNullException.ThrowIfNull(options);
1✔
77
                ArgumentException.ThrowIfNullOrWhiteSpace(options.KeyPrefix);
1✔
78
                if (options.KeyPrefix.IndexOfAny(['{', '}']) >= 0)
1✔
79
                        throw new ArgumentException("The Redis key prefix cannot contain '{' or '}'.", nameof(options));
×
80

81
                _connection = connection;
1✔
82
                _database = connection.GetDatabase(options.Database);
1✔
83
                _timeProvider = timeProvider ?? TimeProvider.System;
1✔
84
                _root = $"{{{options.KeyPrefix}}}:";
1✔
85
                _ownsConnection = ownsConnection;
1✔
86
        }
1✔
87

88
        /// <inheritdoc />
89
        public async ValueTask InitializeAsync(CancellationToken cancellationToken = default)
90
        {
91
                _ = await _database.PingAsync().WaitAsync(cancellationToken).ConfigureAwait(false);
×
92
        }
×
93

94
        /// <inheritdoc />
95
        public async ValueTask EnqueueAsync(JobRecord job, CancellationToken cancellationToken = default)
96
        {
97
                ValidateQueueJob(job);
1✔
98
                var result = await EvaluateInt64Async(
1✔
99
                        RedisScripts.Enqueue,
1✔
100
                        [JobKey(job.Id), AllJobsKey, StateKey(job.State), DueKey(job.QueueName)],
1✔
101
                        CreateEnqueueArguments(job),
1✔
102
                        cancellationToken
1✔
103
                ).ConfigureAwait(false);
1✔
104
                if (result == 0)
1✔
105
                        throw new ImmediateJobException($"Job '{job.Id}' already exists.");
×
106
        }
1✔
107

108
        /// <inheritdoc />
109
        public async ValueTask<IReadOnlyList<JobRecord>> AcquireDueJobsAsync(
110
                JobAcquisitionRequest request,
111
                CancellationToken cancellationToken = default
112
        )
113
        {
114
                ArgumentNullException.ThrowIfNull(request);
1✔
115
                ArgumentException.ThrowIfNullOrWhiteSpace(request.WorkerId);
1✔
116
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(request.Lease, TimeSpan.Zero);
1✔
117
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(request.BatchSize, 0);
1✔
118
                ArgumentNullException.ThrowIfNull(request.Queues);
1✔
119
                if (request.FairQueues is not null)
1✔
120
                {
121
                        throw new NotSupportedException(
1✔
122
                                "Direct distributed fair-queue acquisition is not supported by the Redis provider."
1✔
123
                        );
1✔
124
                }
125

126
                var keys = new List<RedisKey>(4 + request.Queues.Count)
1✔
127
                {
1✔
128
                        LeasesKey,
1✔
129
                        StateKey(JobState.Active),
1✔
130
                        StateKey(JobState.Pending),
1✔
131
                        StateKey(JobState.Scheduled),
1✔
132
                };
1✔
133
                var now = _timeProvider.GetUtcNow();
1✔
134
                var leaseExpiresAt = now + request.Lease;
1✔
135
                var values = new List<RedisValue>
1✔
136
                {
1✔
137
                        Score(now),
1✔
138
                        Score(leaseExpiresAt),
1✔
139
                        Ticks(leaseExpiresAt),
1✔
140
                        request.WorkerId,
1✔
141
                        request.BatchSize,
1✔
142
                        request.Queues.Count,
1✔
143
                        _root,
1✔
144
                        Ticks(now),
1✔
145
                };
1✔
146
                foreach (var queue in request.Queues)
1✔
147
                {
148
                        ArgumentNullException.ThrowIfNull(queue);
1✔
149
                        ArgumentException.ThrowIfNullOrWhiteSpace(queue.QueueName);
1✔
150
                        keys.Add(DueKey(queue.QueueName));
1✔
151
                        values.Add(queue.QueueName);
1✔
152
                        values.Add(Math.Max(0, queue.Capacity));
1✔
153
                        values.Add(queue.JobCapacities.Count);
1✔
154
                        foreach (var capacity in queue.JobCapacities)
1✔
155
                        {
156
                                values.Add(capacity.Key);
1✔
157
                                values.Add(Math.Max(0, capacity.Value));
1✔
158
                        }
159
                }
160

161
                var result = await _database.ScriptEvaluateAsync(
1✔
162
                        RedisScripts.Acquire,
1✔
163
                        [.. keys],
1✔
164
                        [.. values]
1✔
165
                ).WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
166
                var ids = ((RedisResult[])result!)
1✔
167
                        .Select(static value => (string)value!)
1✔
168
                        .ToArray();
1✔
169
                return await ReadJobsAsync(ids, cancellationToken).ConfigureAwait(false);
1✔
170
        }
1✔
171

172
        /// <inheritdoc />
173
        public async ValueTask SetExecutionTelemetryAsync(
174
                string jobId,
175
                int executionNumber,
176
                string workerId,
177
                string? traceId,
178
                string? spanId,
179
                DateTimeOffset startedAt,
180
                CancellationToken cancellationToken = default
181
        )
182
        {
183
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
1✔
184
                ArgumentException.ThrowIfNullOrWhiteSpace(workerId);
1✔
185
                var result = await EvaluateInt64Async(
1✔
186
                        RedisScripts.SetTelemetry,
1✔
187
                        [JobKey(jobId), ExecutionIndexKey(jobId), ExecutionDataKey(jobId)],
1✔
188
                        [workerId, executionNumber, traceId ?? "", spanId ?? "", Ticks(startedAt)],
1✔
189
                        cancellationToken
1✔
190
                ).ConfigureAwait(false);
1✔
191
                ThrowIfNotOwned(result, jobId, workerId);
1✔
192
        }
1✔
193

194
        /// <inheritdoc />
195
        public async ValueTask RenewLeaseAsync(
196
                string jobId,
197
                int executionNumber,
198
                string workerId,
199
                TimeSpan lease,
200
                CancellationToken cancellationToken = default
201
        )
202
        {
203
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
1✔
204
                ArgumentException.ThrowIfNullOrWhiteSpace(workerId);
1✔
205
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lease, TimeSpan.Zero);
1✔
206
                var expiresAt = _timeProvider.GetUtcNow() + lease;
1✔
207
                var result = await EvaluateInt64Async(
1✔
208
                        RedisScripts.RenewLease,
1✔
209
                        [JobKey(jobId), LeasesKey],
1✔
210
                        [workerId, executionNumber, Ticks(expiresAt), Score(expiresAt), jobId],
1✔
211
                        cancellationToken
1✔
212
                ).ConfigureAwait(false);
1✔
213
                ThrowIfNotOwned(result, jobId, workerId);
1✔
214
        }
×
215

216
        /// <inheritdoc />
217
        public async ValueTask CompleteAsync(
218
                string jobId,
219
                int executionNumber,
220
                string workerId,
221
                CancellationToken cancellationToken = default
222
        )
223
        {
224
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
1✔
225
                ArgumentException.ThrowIfNullOrWhiteSpace(workerId);
1✔
226
                var now = _timeProvider.GetUtcNow();
1✔
227
                var result = await EvaluateInt64Async(
1✔
228
                        RedisScripts.Complete,
1✔
229
                        [
1✔
230
                                JobKey(jobId),
1✔
231
                                LeasesKey,
1✔
232
                                StateKey(JobState.Active),
1✔
233
                                StateKey(JobState.Succeeded),
1✔
234
                                CompletedKey(JobState.Succeeded),
1✔
235
                                ExecutionIndexKey(jobId),
1✔
236
                                ExecutionDataKey(jobId),
1✔
237
                        ],
1✔
238
                        [workerId, executionNumber, Ticks(now), jobId, Score(now)],
1✔
239
                        cancellationToken
1✔
240
                ).ConfigureAwait(false);
1✔
241
                ThrowIfNotOwned(result, jobId, workerId);
1✔
242
        }
1✔
243

244
        /// <inheritdoc />
245
        public async ValueTask FailAsync(
246
                string jobId,
247
                int executionNumber,
248
                string workerId,
249
                string error,
250
                DateTimeOffset? nextRetryAt,
251
                CancellationToken cancellationToken = default
252
        )
253
        {
254
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
1✔
255
                ArgumentException.ThrowIfNullOrWhiteSpace(workerId);
1✔
256
                ArgumentNullException.ThrowIfNull(error);
1✔
257
                var now = _timeProvider.GetUtcNow();
1✔
258
                var nextTicks = nextRetryAt is { } retryAt ? Ticks(retryAt) : "";
1✔
259
                var nextScore = nextRetryAt is { } retryScore ? Score(retryScore) : 0;
1✔
260
                var result = await EvaluateInt64Async(
1✔
261
                        RedisScripts.Fail,
1✔
262
                        [
1✔
263
                                JobKey(jobId),
1✔
264
                                LeasesKey,
1✔
265
                                StateKey(JobState.Active),
1✔
266
                                StateKey(JobState.Failed),
1✔
267
                                CompletedKey(JobState.Failed),
1✔
268
                                StateKey(JobState.Scheduled),
1✔
269
                                StateKey(JobState.Pending),
1✔
270
                                ExecutionIndexKey(jobId),
1✔
271
                                ExecutionDataKey(jobId),
1✔
272
                        ],
1✔
273
                        [
1✔
274
                                workerId,
1✔
275
                                executionNumber,
1✔
276
                                jobId,
1✔
277
                                nextTicks,
1✔
278
                                error,
1✔
279
                                Ticks(now),
1✔
280
                                Score(now),
1✔
281
                                nextScore,
1✔
282
                                Score(now),
1✔
283
                                _root,
1✔
284
                        ],
1✔
285
                        cancellationToken
1✔
286
                ).ConfigureAwait(false);
1✔
287
                ThrowIfNotOwned(result, jobId, workerId);
1✔
288
        }
×
289

290
        /// <inheritdoc />
291
        public async ValueTask<JobMonitoringSnapshot> GetMonitoringSnapshotAsync(
292
                CancellationToken cancellationToken = default
293
        )
294
        {
295
                var states = Enum.GetValues<JobState>();
1✔
296
                var countTasks = states
1✔
297
                        .Select(state => _database.SetLengthAsync(StateKey(state)))
1✔
298
                        .ToArray();
1✔
299
                var recurringTask = ReadAllRecurringAsync(cancellationToken);
1✔
300
                var serversTask = ReadLiveServersAsync(cancellationToken);
1✔
301
                _ = await Task.WhenAll(countTasks).WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
302
                var recurring = await recurringTask.ConfigureAwait(false);
1✔
303
                var servers = await serversTask.ConfigureAwait(false);
1✔
304
                var counts = states
1✔
305
                        .Select((state, index) => KeyValuePair.Create(state, countTasks[index].Result))
1✔
306
                        .ToDictionary();
1✔
307
                return new(_timeProvider.GetUtcNow(), counts, recurring, servers)
1✔
308
                {
1✔
309
                        Capabilities = this.GetCapabilities(),
1✔
310
                };
1✔
311
        }
1✔
312

313
        /// <inheritdoc />
314
        public async ValueTask<IReadOnlyList<JobRecord>> QueryJobsAsync(
315
                JobQuery query,
316
                CancellationToken cancellationToken = default
317
        )
318
        {
319
                ArgumentNullException.ThrowIfNull(query);
1✔
320
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
1✔
321
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
1✔
322
                var take = Math.Min(query.Take, MaximumQueryTake);
1✔
323
                if (query.Id is { } id)
1✔
324
                {
325
                        var job = await ReadJobAsync(id, cancellationToken).ConfigureAwait(false);
1✔
326
                        return job is not null && query.Skip == 0 && MatchesQuery(job, query) ? [job] : [];
1✔
327
                }
328

329
                if (!HasFilters(query))
1✔
330
                {
331
                        var ids = await ReadJobIdsByRankAsync(query.Skip, take, cancellationToken).ConfigureAwait(false);
1✔
332
                        return await ReadJobsAsync(ids, cancellationToken).ConfigureAwait(false);
1✔
333
                }
334

335
                var matchLimit = (long)query.Skip + take;
1✔
336
                var matches = new List<JobRecord>(take);
1✔
337
                long rank = 0;
1✔
338
                long matched = 0;
1✔
339
                while (matched < matchLimit)
1✔
340
                {
341
                        cancellationToken.ThrowIfCancellationRequested();
1✔
342
                        var ids = await ReadJobIdsByRankAsync(rank, QueryWindowSize, cancellationToken).ConfigureAwait(false);
1✔
343
                        if (ids.Count == 0)
1✔
344
                                break;
345

346
                        var jobs = await ReadJobsAsync(ids, cancellationToken).ConfigureAwait(false);
1✔
347
                        foreach (var job in jobs)
1✔
348
                        {
349
                                if (!MatchesQuery(job, query))
1✔
350
                                        continue;
351
                                if (matched++ >= query.Skip)
1✔
352
                                        matches.Add(job);
1✔
353
                                if (matched == matchLimit)
1✔
354
                                        break;
355
                        }
356

357
                        rank += ids.Count;
1✔
358
                }
1✔
359

360
                return matches;
1✔
361
        }
1✔
362

363
        /// <inheritdoc />
364
        public async ValueTask<IReadOnlyList<JobExecutionRecord>> QueryJobExecutionsAsync(
365
                JobExecutionQuery query,
366
                CancellationToken cancellationToken = default
367
        )
368
        {
369
                ArgumentNullException.ThrowIfNull(query);
1✔
370
                query.Validate();
1✔
371
                var job = await ReadJobAsync(query.JobId, cancellationToken).ConfigureAwait(false);
1✔
372
                if (job is null)
1✔
NEW
373
                        return [];
×
374

375
                var synthetic = JobExecutionRecord.CreateSynthetic(job);
1✔
376
                var syntheticMissing = synthetic is not null
1✔
377
                        && (query.Attempt is null || query.Attempt == synthetic.Attempt)
1✔
378
                        && !await _database.HashExistsAsync(
1✔
379
                                ExecutionDataKey(query.JobId),
1✔
380
                                ExecutionField(synthetic.Attempt, "state")
1✔
381
                        ).WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
382
                var skip = query.Skip;
1✔
383
                var take = Math.Min(query.Take, MaximumQueryTake);
1✔
384
                var result = new List<JobExecutionRecord>(take);
1✔
385
                if (syntheticMissing && skip == 0)
1✔
386
                {
NEW
387
                        result.Add(synthetic!);
×
NEW
388
                        take--;
×
389
                }
390
                else if (syntheticMissing)
1✔
391
                {
NEW
392
                        skip--;
×
393
                }
394

395
                if (take == 0 || skip < 0)
1✔
NEW
396
                        return result;
×
397

398
                RedisValue[] attempts;
399
                if (query.Attempt is { } attempt)
1✔
400
                {
NEW
401
                        var exists = await _database.HashExistsAsync(
×
NEW
402
                                ExecutionDataKey(query.JobId),
×
NEW
403
                                ExecutionField(attempt, "state")
×
NEW
404
                        ).WaitAsync(cancellationToken).ConfigureAwait(false);
×
NEW
405
                        attempts = exists && skip == 0 ? [attempt] : [];
×
406
                }
407
                else
408
                {
409
                        attempts = await _database.SortedSetRangeByRankAsync(
1✔
410
                                ExecutionIndexKey(query.JobId),
1✔
411
                                skip,
1✔
412
                                skip + take - 1,
1✔
413
                                Order.Descending
1✔
414
                        ).WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
415
                }
416

417
                result.AddRange(await ReadExecutionsAsync(query.JobId, attempts, cancellationToken).ConfigureAwait(false));
1✔
418
                return result;
1✔
419
        }
1✔
420

421
        /// <inheritdoc />
422
        public async ValueTask<JobStatus?> GetJobStatusAsync(
423
                string jobId,
424
                CancellationToken cancellationToken = default
425
        )
426
        {
427
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
1✔
428
                var job = await ReadJobAsync(jobId, cancellationToken).ConfigureAwait(false);
1✔
429
                return job is null
1✔
430
                        ? null
1✔
431
                        : new(
1✔
432
                                job.Id,
1✔
433
                                job.JobName,
1✔
434
                                job.QueueName,
1✔
435
                                job.State,
1✔
436
                                job.Attempt,
1✔
437
                                MaxAttempts: null,
1✔
438
                                job.CreatedAt,
1✔
439
                                job.DueAt,
1✔
440
                                job.CompletedAt,
1✔
441
                                job.LastError,
1✔
442
                                BatchId: null,
1✔
443
                                DependsOn: []
1✔
444
                        );
1✔
445
        }
1✔
446

447
        /// <inheritdoc />
448
        public async ValueTask RetryAsync(string jobId, CancellationToken cancellationToken = default)
449
        {
450
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
1✔
451
                var now = _timeProvider.GetUtcNow();
1✔
452
                var result = await EvaluateInt64Async(
1✔
453
                        RedisScripts.Retry,
1✔
454
                        [
1✔
455
                                JobKey(jobId),
1✔
456
                                StateKey(JobState.Failed),
1✔
457
                                StateKey(JobState.Scheduled),
1✔
458
                                StateKey(JobState.Pending),
1✔
459
                                CompletedKey(JobState.Failed),
1✔
460
                                ExecutionIndexKey(jobId),
1✔
461
                                ExecutionDataKey(jobId),
1✔
462
                        ],
1✔
463
                        [Ticks(now), Score(now), jobId, _root],
1✔
464
                        cancellationToken
1✔
465
                ).ConfigureAwait(false);
1✔
466
                if (result == 0)
1✔
467
                        throw new KeyNotFoundException($"Job '{jobId}' was not found.");
1✔
468
                if (result < 0)
1✔
469
                        throw new ImmediateJobException("Only failed or scheduled jobs can be retried.");
1✔
470
        }
1✔
471

472
        /// <inheritdoc />
473
        public async ValueTask DeleteAsync(string jobId, CancellationToken cancellationToken = default)
474
        {
475
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
1✔
476
                var result = await EvaluateInt64Async(
1✔
477
                        RedisScripts.Delete,
1✔
478
                        [JobKey(jobId), AllJobsKey, RecurringDedupeKey, ExecutionIndexKey(jobId), ExecutionDataKey(jobId)],
1✔
479
                        [jobId, _root],
1✔
480
                        cancellationToken
1✔
481
                ).ConfigureAwait(false);
1✔
482
                if (result == 0)
1✔
483
                        throw new KeyNotFoundException($"Job '{jobId}' was not found.");
1✔
484
                if (result < 0)
1✔
485
                        throw new ImmediateJobException("Only terminal jobs can be deleted.");
1✔
486
        }
×
487

488
        /// <inheritdoc />
489
        public async ValueTask PurgeJobsAsync(
490
                TimeSpan succeededRetention,
491
                TimeSpan failedRetention,
492
                CancellationToken cancellationToken = default
493
        )
494
        {
495
                ArgumentOutOfRangeException.ThrowIfLessThan(succeededRetention, TimeSpan.Zero);
1✔
496
                ArgumentOutOfRangeException.ThrowIfLessThan(failedRetention, TimeSpan.Zero);
1✔
497
                var now = _timeProvider.GetUtcNow();
1✔
498
                await PurgeStateAsync(JobState.Succeeded, now - succeededRetention, cancellationToken).ConfigureAwait(false);
1✔
499
                await PurgeStateAsync(JobState.Failed, now - failedRetention, cancellationToken).ConfigureAwait(false);
1✔
500
                await PurgeStateAsync(JobState.Cancelled, now - failedRetention, cancellationToken).ConfigureAwait(false);
1✔
501
                await PurgeStateAsync(JobState.Skipped, now - failedRetention, cancellationToken).ConfigureAwait(false);
1✔
502
        }
1✔
503

504
        /// <inheritdoc />
505
        public async ValueTask HeartbeatAsync(
506
                JobServerSnapshot server,
507
                CancellationToken cancellationToken = default
508
        )
509
        {
510
                ArgumentNullException.ThrowIfNull(server);
1✔
511
                ArgumentException.ThrowIfNullOrWhiteSpace(server.WorkerId);
1✔
512
                _ = await EvaluateInt64Async(
1✔
513
                        RedisScripts.Heartbeat,
1✔
514
                        [ServerKey(server.WorkerId), ServersKey],
1✔
515
                        [
1✔
516
                                Ticks(server.LastHeartbeat),
1✔
517
                                server.ActiveWorkers,
1✔
518
                                server.MaxWorkers,
1✔
519
                                Score(server.LastHeartbeat),
1✔
520
                                server.WorkerId,
1✔
521
                                (long)TimeSpan.FromMinutes(2).TotalMilliseconds,
1✔
522
                        ],
1✔
523
                        cancellationToken
1✔
524
                ).ConfigureAwait(false);
1✔
525
        }
1✔
526

527
        /// <inheritdoc />
528
        public async ValueTask<bool> IsHealthyAsync(CancellationToken cancellationToken = default)
529
        {
530
                try
531
                {
532
                        _ = await _database.PingAsync().WaitAsync(cancellationToken).ConfigureAwait(false);
×
533
                        return true;
×
534
                }
535
                catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
×
536
                {
537
                        throw;
×
538
                }
539
                catch (RedisException)
×
540
                {
541
                        return false;
×
542
                }
543
        }
×
544

545
        /// <inheritdoc />
546
        public async ValueTask UpsertRecurringAsync(
547
                RecurringJobSchedule schedule,
548
                CancellationToken cancellationToken = default
549
        )
550
        {
551
                ValidateRecurring(schedule);
1✔
552
                var result = await EvaluateInt64Async(
1✔
553
                        RedisScripts.UpsertRecurring,
1✔
554
                        [RecurringKey(schedule.Name), RecurringNamesKey, RecurringDueKey],
1✔
555
                        [
1✔
556
                                JsonSerializer.Serialize(schedule, RedisJsonSerializerContext.Default.RecurringJobSchedule),
1✔
557
                                schedule.IsCodeDefined ? 1 : 0,
1✔
558
                                schedule.IsPaused ? 1 : 0,
1✔
559
                                Ticks(schedule.NextRunAt),
1✔
560
                                NullableTicks(schedule.LastRunAt),
1✔
561
                                schedule.Name,
1✔
562
                                Score(schedule.NextRunAt),
1✔
563
                                RecurringDueMember(schedule.NextRunAt, schedule.Name),
1✔
564
                        ],
1✔
565
                        cancellationToken
1✔
566
                ).ConfigureAwait(false);
1✔
567
                if (result < 0)
1✔
568
                        throw new ImmediateJobException("Code-defined recurring schedules cannot be replaced by dynamic schedules.");
×
569
        }
1✔
570

571
        /// <inheritdoc />
572
        public async ValueTask RemoveObsoleteCodeDefinedRecurringAsync(
573
                IReadOnlyCollection<string> activeScheduleNames,
574
                CancellationToken cancellationToken = default
575
        )
576
        {
577
                ArgumentNullException.ThrowIfNull(activeScheduleNames);
×
578
                var values = new RedisValue[activeScheduleNames.Count + 1];
×
579
                values[0] = _root;
×
580
                var index = 1;
×
581
                foreach (var name in activeScheduleNames)
×
582
                        values[index++] = name;
×
583
                _ = await EvaluateInt64Async(
×
584
                        RedisScripts.RemoveObsoleteRecurring,
×
585
                        [RecurringNamesKey, RecurringDueKey],
×
586
                        values,
×
587
                        cancellationToken
×
588
                ).ConfigureAwait(false);
×
589
        }
×
590

591
        /// <inheritdoc />
592
        public async ValueTask RemoveRecurringAsync(string name, CancellationToken cancellationToken = default)
593
        {
594
                ArgumentException.ThrowIfNullOrWhiteSpace(name);
1✔
595
                var result = await EvaluateInt64Async(
1✔
596
                        RedisScripts.RemoveRecurring,
1✔
597
                        [RecurringKey(name), RecurringNamesKey, RecurringDueKey],
1✔
598
                        [name],
1✔
599
                        cancellationToken
1✔
600
                ).ConfigureAwait(false);
1✔
601
                if (result == 0)
1✔
602
                        throw new KeyNotFoundException($"Recurring schedule '{name}' was not found.");
1✔
603
                if (result < 0)
1✔
604
                        throw new ImmediateJobException("Code-defined recurring schedules cannot be deleted.");
1✔
605
        }
×
606

607
        /// <inheritdoc />
608
        public ValueTask PauseRecurringAsync(string name, CancellationToken cancellationToken = default) =>
609
                SetRecurringPausedAsync(name, isPaused: true, cancellationToken);
1✔
610

611
        /// <inheritdoc />
612
        public ValueTask ResumeRecurringAsync(string name, CancellationToken cancellationToken = default) =>
613
                SetRecurringPausedAsync(name, isPaused: false, cancellationToken);
1✔
614

615
        /// <inheritdoc />
616
        public async ValueTask<IReadOnlyList<RecurringJobSchedule>> GetDueRecurringAsync(
617
                DateTimeOffset now,
618
                int batchSize,
619
                CancellationToken cancellationToken = default
620
        )
621
        {
622
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(batchSize, 0);
1✔
623
                var values = await _database.SortedSetRangeByScoreAsync(
1✔
624
                        RecurringDueKey,
1✔
625
                        stop: Score(now),
1✔
626
                        take: batchSize
1✔
627
                ).WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
628
                var members = values.Select(static value => (string)value!).ToArray();
1✔
629
                var names = members.Select(static member => member[20..]).Distinct(StringComparer.Ordinal).ToArray();
1✔
630
                var schedules = await ReadRecurringAsync(
1✔
631
                        names,
1✔
632
                        cancellationToken
1✔
633
                ).ConfigureAwait(false);
1✔
634
                var schedulesByName = schedules.ToDictionary(static schedule => schedule.Name, StringComparer.Ordinal);
1✔
635
                var due = new List<RecurringJobSchedule>(schedules.Count);
1✔
636
                var added = new HashSet<string>(StringComparer.Ordinal);
1✔
637
                var stale = new List<RedisValue>();
1✔
638
                foreach (var member in members)
1✔
639
                {
640
                        var name = member[20..];
1✔
641
                        if (!schedulesByName.TryGetValue(name, out var schedule)
1✔
642
                                || !string.Equals(member, RecurringDueMember(schedule.NextRunAt, name), StringComparison.Ordinal))
1✔
643
                        {
644
                                stale.Add(member);
1✔
645
                                continue;
1✔
646
                        }
647

648
                        if (added.Add(name))
1✔
649
                                due.Add(schedule);
1✔
650
                }
651

652
                if (stale.Count != 0)
1✔
653
                {
654
                        _ = await _database.SortedSetRemoveAsync(RecurringDueKey, [.. stale])
1✔
655
                                .WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
656
                }
657

658
                return due;
1✔
659
        }
1✔
660

661
        /// <inheritdoc />
662
        public async ValueTask<bool> MaterializeRecurringAsync(
663
                RecurringJobSchedule schedule,
664
                JobRecord job,
665
                DateTimeOffset nextRunAt,
666
                CancellationToken cancellationToken = default
667
        )
668
        {
669
                ValidateRecurring(schedule);
1✔
670
                ValidateMaterializedJob(job);
1✔
671
                var jobArguments = CreateMaterializeArguments(schedule, job, nextRunAt, _timeProvider.GetUtcNow());
1✔
672
                var result = await EvaluateInt64Async(
1✔
673
                        RedisScripts.MaterializeRecurring,
1✔
674
                        [
1✔
675
                                RecurringKey(schedule.Name),
1✔
676
                                RecurringDedupeKey,
1✔
677
                                JobKey(job.Id),
1✔
678
                                AllJobsKey,
1✔
679
                                StateKey(job.State),
1✔
680
                                DueKey(job.QueueName),
1✔
681
                                RecurringDueKey,
1✔
682
                                CompletedKey(job.State),
1✔
683
                        ],
1✔
684
                        jobArguments,
1✔
685
                        cancellationToken
1✔
686
                ).ConfigureAwait(false);
1✔
687
                if (result < 0)
1✔
688
                        throw new ImmediateJobException($"Job '{job.Id}' already exists.");
×
689
                return result == 1;
1✔
690
        }
1✔
691

692
        /// <inheritdoc />
693
        public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
1✔
694

695
        /// <inheritdoc />
696
        public ValueTask DisposeAsync()
697
        {
1✔
698
                lock (_disposeGate)
699
                        return new(_disposeTask ??= DisposeCoreAsync());
1✔
700
        }
1✔
701

702
        private async Task DisposeCoreAsync()
703
        {
704
                if (_ownsConnection)
1✔
705
                {
706
                        try
707
                        {
708
                                await _connection.CloseAsync().ConfigureAwait(false);
×
709
                        }
×
710
                        finally
711
                        {
712
                                _connection.Dispose();
×
713
                        }
714
                }
715
        }
1✔
716

717
        private async ValueTask SetRecurringPausedAsync(
718
                string name,
719
                bool isPaused,
720
                CancellationToken cancellationToken
721
        )
722
        {
723
                ArgumentException.ThrowIfNullOrWhiteSpace(name);
1✔
724
                var result = await EvaluateInt64Async(
1✔
725
                        RedisScripts.SetRecurringPaused,
1✔
726
                        [RecurringKey(name), RecurringDueKey],
1✔
727
                        [isPaused ? 1 : 0],
1✔
728
                        cancellationToken
1✔
729
                ).ConfigureAwait(false);
1✔
730
                if (result == 0)
1✔
731
                        throw new KeyNotFoundException($"Recurring schedule '{name}' was not found.");
1✔
732
        }
1✔
733

734
        private async Task PurgeStateAsync(
735
                JobState state,
736
                DateTimeOffset cutoff,
737
                CancellationToken cancellationToken
738
        )
739
        {
740
                while (true)
1✔
741
                {
742
                        var ids = await _database.SortedSetRangeByScoreAsync(
1✔
743
                                CompletedKey(state),
1✔
744
                                stop: Score(cutoff),
1✔
745
                                exclude: Exclude.Stop,
1✔
746
                                take: 256
1✔
747
                        ).WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
748
                        if (ids.Length == 0)
1✔
749
                                return;
1✔
750
                        foreach (var value in ids)
1✔
751
                        {
752
                                var id = (string)value!;
1✔
753
                                _ = await EvaluateInt64Async(
1✔
754
                                        RedisScripts.Purge,
1✔
755
                                        [
1✔
756
                                                JobKey(id),
1✔
757
                                                CompletedKey(state),
1✔
758
                                                AllJobsKey,
1✔
759
                                                StateKey(state),
1✔
760
                                                RecurringDedupeKey,
1✔
761
                                                ExecutionIndexKey(id),
1✔
762
                                                ExecutionDataKey(id),
1✔
763
                                        ],
1✔
764
                                        [id, (int)state],
1✔
765
                                        cancellationToken
1✔
766
                                ).ConfigureAwait(false);
1✔
767
                        }
768
                }
769
        }
1✔
770

771
        private async Task<IReadOnlyList<JobServerSnapshot>> ReadLiveServersAsync(CancellationToken cancellationToken)
772
        {
773
                var cutoff = _timeProvider.GetUtcNow() - TimeSpan.FromMinutes(2);
1✔
774
                var stale = await _database.SortedSetRangeByScoreAsync(
1✔
775
                        ServersKey,
1✔
776
                        stop: Score(cutoff),
1✔
777
                        exclude: Exclude.Stop
1✔
778
                ).WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
779
                if (stale.Length != 0)
1✔
780
                        _ = await _database.SortedSetRemoveAsync(ServersKey, stale).WaitAsync(cancellationToken).ConfigureAwait(false);
×
781
                var ids = await _database.SortedSetRangeByScoreAsync(
1✔
782
                        ServersKey,
1✔
783
                        start: Score(cutoff)
1✔
784
                ).WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
785
                var tasks = ids
1✔
786
                        .Select(id => _database.HashGetAsync(ServerKey((string)id!), ["last", "active", "max"]))
1✔
787
                        .ToArray();
1✔
788
                _ = await Task.WhenAll(tasks).WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
789
                return
1✔
790
                [
1✔
791
                        .. tasks
1✔
792
                                .Select((task, index) => (Id: (string)ids[index]!, Values: task.Result))
1✔
793
                                .Where(static server => !server.Values[0].IsNullOrEmpty)
1✔
794
                                .Select(static server => new JobServerSnapshot(
1✔
795
                                        server.Id,
1✔
796
                                        FromTicks(server.Values[0]),
1✔
797
                                        ParseInt32(server.Values[1]),
1✔
798
                                        ParseInt32(server.Values[2])
1✔
799
                                )),
1✔
800
                ];
1✔
801
        }
1✔
802

803
        private async Task<IReadOnlyList<RecurringJobSchedule>> ReadAllRecurringAsync(CancellationToken cancellationToken)
804
        {
805
                var names = await _database.SetMembersAsync(RecurringNamesKey)
1✔
806
                        .WaitAsync(cancellationToken)
1✔
807
                        .ConfigureAwait(false);
1✔
808
                return await ReadRecurringAsync(
1✔
809
                        [.. names.Select(static value => (string)value!)],
1✔
810
                        cancellationToken
1✔
811
                ).ConfigureAwait(false);
1✔
812
        }
1✔
813

814
        private async Task<IReadOnlyList<RecurringJobSchedule>> ReadRecurringAsync(
815
                IReadOnlyList<string> names,
816
                CancellationToken cancellationToken
817
        )
818
        {
819
                var tasks = names.Select(name => ReadRecurringAsync(name, cancellationToken).AsTask()).ToArray();
1✔
820
                var schedules = await Task.WhenAll(tasks).WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
821
                return [.. schedules.OfType<RecurringJobSchedule>().OrderBy(schedule => schedule.NextRunAt).ThenBy(schedule => schedule.Name, StringComparer.Ordinal)];
1✔
822
        }
1✔
823

824
        private async ValueTask<RecurringJobSchedule?> ReadRecurringAsync(
825
                string name,
826
                CancellationToken cancellationToken
827
        )
828
        {
829
                var values = await _database.HashGetAsync(RecurringKey(name), RecurringMutableFields)
1✔
830
                        .WaitAsync(cancellationToken)
1✔
831
                        .ConfigureAwait(false);
1✔
832
                if (values[0].IsNull)
1✔
833
                        return null;
×
834
                var schedule = JsonSerializer.Deserialize(
1✔
835
                        (string)values[0]!,
1✔
836
                        RedisJsonSerializerContext.Default.RecurringJobSchedule
1✔
837
                ) ?? throw new ImmediateJobException($"Recurring schedule '{name}' contains invalid data.");
1✔
838
                return schedule with
1✔
839
                {
1✔
840
                        IsPaused = values[1] == "1",
1✔
841
                        NextRunAt = FromTicks(values[2]),
1✔
842
                        LastRunAt = FromNullableTicks(values[3]),
1✔
843
                };
1✔
844
        }
1✔
845

846
        private async Task<IReadOnlyList<JobRecord>> ReadJobsAsync(
847
                IReadOnlyList<string> ids,
848
                CancellationToken cancellationToken
849
        )
850
        {
851
                var tasks = ids.Select(id => ReadJobAsync(id, cancellationToken).AsTask()).ToArray();
1✔
852
                var jobs = await Task.WhenAll(tasks).WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
853
                return [.. jobs.OfType<JobRecord>()];
1✔
854
        }
1✔
855

856
        private async Task<IReadOnlyList<JobExecutionRecord>> ReadExecutionsAsync(
857
                string jobId,
858
                RedisValue[] attempts,
859
                CancellationToken cancellationToken
860
        )
861
        {
862
                if (attempts.Length == 0)
1✔
NEW
863
                        return [];
×
864

865
                var fields = new RedisValue[attempts.Length * ExecutionFieldNames.Length];
1✔
866
                for (var index = 0; index < attempts.Length; index++)
1✔
867
                {
868
                        var executionNumber = ParseInt32(attempts[index]);
1✔
869
                        for (var fieldIndex = 0; fieldIndex < ExecutionFieldNames.Length; fieldIndex++)
1✔
870
                        {
871
                                fields[(index * ExecutionFieldNames.Length) + fieldIndex] =
1✔
872
                                        ExecutionField(executionNumber, ExecutionFieldNames[fieldIndex]);
1✔
873
                        }
874
                }
875

876
                var allValues = await _database.HashGetAsync(ExecutionDataKey(jobId), fields)
1✔
877
                        .WaitAsync(cancellationToken)
1✔
878
                        .ConfigureAwait(false);
1✔
879

880
                var executions = new List<JobExecutionRecord>(attempts.Length);
1✔
881
                for (var index = 0; index < attempts.Length; index++)
1✔
882
                {
883
                        var values = allValues.AsSpan(index * ExecutionFieldNames.Length, ExecutionFieldNames.Length);
1✔
884
                        if (values[0].IsNull)
1✔
885
                                continue;
886
                        executions.Add(new()
1✔
887
                        {
1✔
888
                                JobId = jobId,
1✔
889
                                Attempt = ParseInt32(attempts[index]),
1✔
890
                                State = (JobExecutionState)ParseInt32(values[0]),
1✔
891
                                WorkerId = NullIfEmpty(values[1]),
1✔
892
                                AcquiredAt = FromNullableTicks(values[2]),
1✔
893
                                ExecutionStartedAt = FromNullableTicks(values[3]),
1✔
894
                                CompletedAt = FromNullableTicks(values[4]),
1✔
895
                                ExecutionTraceId = NullIfEmpty(values[5]),
1✔
896
                                ExecutionSpanId = NullIfEmpty(values[6]),
1✔
897
                                Error = NullIfEmpty(values[7]),
1✔
898
                                IsSynthetic = values[8] == "1",
1✔
899
                        });
1✔
900
                }
901

902
                return executions;
1✔
903
        }
1✔
904

905
        private async Task<IReadOnlyList<string>> ReadJobIdsByRankAsync(
906
                long start,
907
                int count,
908
                CancellationToken cancellationToken
909
        )
910
        {
911
                var values = await _database.SortedSetRangeByRankAsync(
1✔
912
                        AllJobsKey,
1✔
913
                        start,
1✔
914
                        start + count - 1,
1✔
915
                        Order.Descending
1✔
916
                ).WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
917
                return [.. values.Select(static value => (string)value!)];
1✔
918
        }
1✔
919

920
        private static bool HasFilters(JobQuery query) =>
921
                query.State is not null ||
1✔
922
                !string.IsNullOrWhiteSpace(query.QueueName) ||
1✔
923
                !string.IsNullOrWhiteSpace(query.JobName) ||
1✔
924
                !string.IsNullOrWhiteSpace(query.Search);
1✔
925

926
        private static bool MatchesQuery(JobRecord job, JobQuery query) =>
927
                (query.State is not { } state || job.State == state) &&
1✔
928
                (string.IsNullOrWhiteSpace(query.QueueName) || string.Equals(job.QueueName, query.QueueName, StringComparison.Ordinal)) &&
1✔
929
                (string.IsNullOrWhiteSpace(query.JobName) || string.Equals(job.JobName, query.JobName, StringComparison.Ordinal)) &&
1✔
930
                (string.IsNullOrWhiteSpace(query.Search) ||
1✔
931
                        job.JobName.Contains(query.Search, StringComparison.OrdinalIgnoreCase));
1✔
932

933
        private async ValueTask<JobRecord?> ReadJobAsync(string id, CancellationToken cancellationToken)
934
        {
935
                var values = await _database.HashGetAsync(JobKey(id), JobMutableFields)
1✔
936
                        .WaitAsync(cancellationToken)
1✔
937
                        .ConfigureAwait(false);
1✔
938
                if (values[0].IsNull)
1✔
939
                        return null;
1✔
940
                var job = JsonSerializer.Deserialize(
1✔
941
                        (string)values[0]!,
1✔
942
                        RedisJsonSerializerContext.Default.JobRecord
1✔
943
                ) ?? throw new ImmediateJobException($"Job '{id}' contains invalid data.");
1✔
944
                return job with
1✔
945
                {
1✔
946
                        State = (JobState)ParseInt32(values[1]),
1✔
947
                        DueAt = FromTicks(values[2]),
1✔
948
                        Attempt = ParseInt32(values[3]),
1✔
949
                        WorkerId = NullIfEmpty(values[4]),
1✔
950
                        LeaseExpiresAt = FromNullableTicks(values[5]),
1✔
951
                        LastError = NullIfEmpty(values[6]),
1✔
952
                        CompletedAt = FromNullableTicks(values[7]),
1✔
953
                        ExecutionTraceId = NullIfEmpty(values[8]),
1✔
954
                        ExecutionSpanId = NullIfEmpty(values[9]),
1✔
955
                        ExecutionStartedAt = FromNullableTicks(values[10]),
1✔
956
                };
1✔
957
        }
1✔
958

959
        private async ValueTask<long> EvaluateInt64Async(
960
                string script,
961
                RedisKey[] keys,
962
                RedisValue[] values,
963
                CancellationToken cancellationToken
964
        )
965
        {
966
                var result = await _database.ScriptEvaluateAsync(script, keys, values)
1✔
967
                        .WaitAsync(cancellationToken)
1✔
968
                        .ConfigureAwait(false);
1✔
969
                return (long)result;
1✔
970
        }
1✔
971

972
        private static RedisValue[] CreateEnqueueArguments(JobRecord job) =>
973
        [
1✔
974
                JsonSerializer.Serialize(job, RedisJsonSerializerContext.Default.JobRecord),
1✔
975
                (int)job.State,
1✔
976
                Ticks(job.DueAt),
1✔
977
                job.Attempt,
1✔
978
                job.WorkerId ?? "",
1✔
979
                NullableTicks(job.LeaseExpiresAt),
1✔
980
                job.LastError ?? "",
1✔
981
                NullableTicks(job.CompletedAt),
1✔
982
                job.ExecutionTraceId ?? "",
1✔
983
                job.ExecutionSpanId ?? "",
1✔
984
                NullableTicks(job.ExecutionStartedAt),
1✔
985
                job.QueueName,
1✔
986
                job.JobName,
1✔
987
                Score(job.CreatedAt),
1✔
988
                job.Id,
1✔
989
                Score(job.DueAt),
1✔
990
                Ticks(job.CreatedAt),
1✔
991
                DueMember(job),
1✔
992
        ];
1✔
993

994
        private static RedisValue[] CreateMaterializeArguments(
995
                RecurringJobSchedule schedule,
996
                JobRecord job,
997
                DateTimeOffset nextRunAt,
998
                DateTimeOffset now
999
        ) =>
1000
        [
1✔
1001
                Ticks(schedule.NextRunAt),
1✔
1002
                job.RecurringKey ?? "",
1✔
1003
                job.Id,
1✔
1004
                JsonSerializer.Serialize(job, RedisJsonSerializerContext.Default.JobRecord),
1✔
1005
                (int)job.State,
1✔
1006
                Ticks(job.DueAt),
1✔
1007
                Score(job.DueAt),
1✔
1008
                job.Attempt,
1✔
1009
                job.WorkerId ?? "",
1✔
1010
                NullableTicks(job.LeaseExpiresAt),
1✔
1011
                job.LastError ?? "",
1✔
1012
                NullableTicks(job.CompletedAt),
1✔
1013
                job.ExecutionTraceId ?? "",
1✔
1014
                job.ExecutionSpanId ?? "",
1✔
1015
                NullableTicks(job.ExecutionStartedAt),
1✔
1016
                job.QueueName,
1✔
1017
                job.JobName,
1✔
1018
                Score(job.CreatedAt),
1✔
1019
                Ticks(nextRunAt),
1✔
1020
                Score(nextRunAt),
1✔
1021
                schedule.Name,
1✔
1022
                Ticks(job.CreatedAt),
1✔
1023
                job.CompletedAt is { } completedAt ? Score(completedAt) : 0,
1✔
1024
                Score(now),
1✔
1025
        ];
1✔
1026

1027
        private static void ValidateQueueJob(JobRecord job)
1028
        {
1029
                ArgumentNullException.ThrowIfNull(job);
1✔
1030
                ArgumentException.ThrowIfNullOrWhiteSpace(job.Id);
1✔
1031
                ArgumentException.ThrowIfNullOrWhiteSpace(job.JobName);
1✔
1032
                ArgumentException.ThrowIfNullOrWhiteSpace(job.QueueName);
1✔
1033
                if (job.BatchId is not null || job.RemainingDependencies != 0 || job.FailedDependencies != 0)
1✔
1034
                {
1035
                        throw new NotSupportedException(
×
1036
                                "Batches & continuations require a graph-capable storage provider (a SQL database)."
×
1037
                        );
×
1038
                }
1039

1040
                if (job.State is not (JobState.Pending or JobState.Scheduled))
1✔
1041
                        throw new ImmediateJobException($"Queue job '{job.Id}' has invalid state '{job.State}'.");
×
1042
        }
1✔
1043

1044
        private static void ValidateMaterializedJob(JobRecord job)
1045
        {
1046
                ArgumentNullException.ThrowIfNull(job);
1✔
1047
                ArgumentException.ThrowIfNullOrWhiteSpace(job.Id);
1✔
1048
                ArgumentException.ThrowIfNullOrWhiteSpace(job.JobName);
1✔
1049
                ArgumentException.ThrowIfNullOrWhiteSpace(job.QueueName);
1✔
1050
                if (job.BatchId is not null || job.RemainingDependencies != 0 || job.FailedDependencies != 0)
1✔
1051
                {
1052
                        throw new NotSupportedException(
×
1053
                                "Batches & continuations require a graph-capable storage provider (a SQL database)."
×
1054
                        );
×
1055
                }
1056

1057
                if (job.State is not (JobState.Pending or JobState.Scheduled or JobState.Cancelled or JobState.Skipped))
1✔
1058
                        throw new ImmediateJobException($"Recurring job '{job.Id}' has invalid state '{job.State}'.");
×
1059
                if (job.CompletedAt is null && (job.State == JobState.Cancelled || job.State == JobState.Skipped))
1✔
1060
                        throw new ImmediateJobException($"Terminal recurring job '{job.Id}' must have a completion time.");
×
1061
        }
1✔
1062

1063
        private static void ValidateRecurring(RecurringJobSchedule schedule)
1064
        {
1065
                ArgumentNullException.ThrowIfNull(schedule);
1✔
1066
                ArgumentException.ThrowIfNullOrWhiteSpace(schedule.Name);
1✔
1067
                ArgumentException.ThrowIfNullOrWhiteSpace(schedule.JobName);
1✔
1068
                ArgumentException.ThrowIfNullOrWhiteSpace(schedule.Cron);
1✔
1069
                ArgumentException.ThrowIfNullOrWhiteSpace(schedule.TimeZone);
1✔
1070
        }
1✔
1071

1072
        private static void ThrowIfNotOwned(long result, string jobId, string workerId)
1073
        {
1074
                if (result <= 0)
1✔
1075
                        throw new ImmediateJobException($"Worker '{workerId}' does not own active job '{jobId}'.");
1✔
1076
        }
1✔
1077

1078
        private RedisKey JobKey(string id) => _root + "job:" + id;
1✔
1079
        private RedisKey ExecutionIndexKey(string id) => _root + "executions:index:" + id;
1✔
1080
        private RedisKey ExecutionDataKey(string id) => _root + "executions:data:" + id;
1✔
1081
        private RedisKey DueKey(string queue) => _root + "due:" + queue;
1✔
1082
        private RedisKey StateKey(JobState state) => string.Create(CultureInfo.InvariantCulture, $"{_root}state:{(int)state}");
1✔
1083
        private RedisKey CompletedKey(JobState state) => string.Create(CultureInfo.InvariantCulture, $"{_root}completed:{(int)state}");
1✔
1084
        private RedisKey RecurringKey(string name) => _root + "recurring:" + name;
1✔
1085
        private RedisKey ServerKey(string workerId) => _root + "server:" + workerId;
1✔
1086
        private RedisKey AllJobsKey => _root + "jobs";
1✔
1087
        private RedisKey LeasesKey => _root + "leases";
1✔
1088
        private RedisKey RecurringNamesKey => _root + "recurring:names";
1✔
1089
        private RedisKey RecurringDueKey => _root + "recurring:due";
1✔
1090
        private RedisKey RecurringDedupeKey => _root + "recurring:dedupe";
1✔
1091
        private RedisKey ServersKey => _root + "servers";
1✔
1092

1093
        private static long Score(DateTimeOffset value) => value.ToUnixTimeMilliseconds();
1✔
1094
        private static string Ticks(DateTimeOffset value) => value.UtcTicks.ToString("D19", CultureInfo.InvariantCulture);
1✔
1095
        private static string DueMember(JobRecord job) => $"{Ticks(job.DueAt)}|{Ticks(job.CreatedAt)}|{job.Id}";
1✔
1096
        private static string RecurringDueMember(DateTimeOffset nextRunAt, string name) => $"{Ticks(nextRunAt)}|{name}";
1✔
1097
        private static string NullableTicks(DateTimeOffset? value) => value is { } actual ? Ticks(actual) : "";
1✔
1098
        private static RedisValue ExecutionField(int executionNumber, string name) =>
1099
                string.Create(CultureInfo.InvariantCulture, $"{executionNumber}:{name}");
1✔
1100
        private static DateTimeOffset FromTicks(RedisValue value) =>
1101
                new(long.Parse((string)value!, NumberStyles.None, CultureInfo.InvariantCulture), TimeSpan.Zero);
1✔
1102
        private static DateTimeOffset? FromNullableTicks(RedisValue value) =>
1103
                value.IsNullOrEmpty ? null : FromTicks(value);
1✔
1104
        private static int ParseInt32(RedisValue value) =>
1105
                int.Parse((string)value!, NumberStyles.Integer, CultureInfo.InvariantCulture);
1✔
1106
        private static string? NullIfEmpty(RedisValue value) => value.IsNullOrEmpty ? null : (string)value!;
1✔
1107
}
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