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

ImmediatePlatform / Immediate.Jobs / 30304771671

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

Pull #33

github

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

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

4326 of 6555 relevant lines covered (66.0%)

2.22 hits per line

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

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

5
namespace Immediate.Jobs.Redis;
6

7
/// <summary>
8
/// Distributed Redis storage for ordinary queue jobs and recurring schedules.
9
/// Batches and continuations require a graph-capable SQL provider.
10
/// </summary>
11
public sealed class RedisJobStorage : IRecurringJobStorage, IDisposable
12
{
13
        private static readonly RedisValue[] JobMutableFields =
1✔
14
        [
1✔
15
                "record",
1✔
16
                "state",
1✔
17
                "due",
1✔
18
                "attempt",
1✔
19
                "worker",
1✔
20
                "lease",
1✔
21
                "error",
1✔
22
                "completed",
1✔
23
                "executionTraceId",
1✔
24
                "executionSpanId",
1✔
25
                "executionStartedAt",
1✔
26
        ];
1✔
27

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

30
        private readonly IConnectionMultiplexer _connection;
31
        private readonly IDatabase _database;
32
        private readonly TimeProvider _timeProvider;
33
        private readonly string _root;
34
        private readonly bool _ownsConnection;
35
        private bool _disposed;
36

37
        /// <summary>Creates storage over an existing Redis connection.</summary>
38
        public RedisJobStorage(
39
                IConnectionMultiplexer connection,
40
                RedisJobStorageOptions? options = null,
41
                TimeProvider? timeProvider = null
42
        ) : this(connection, options ?? new(), timeProvider, ownsConnection: false)
1✔
43
        {
44
        }
1✔
45

46
        internal RedisJobStorage(
1✔
47
                IConnectionMultiplexer connection,
1✔
48
                RedisJobStorageOptions options,
1✔
49
                TimeProvider? timeProvider,
1✔
50
                bool ownsConnection
1✔
51
        )
1✔
52
        {
53
                ArgumentNullException.ThrowIfNull(connection);
1✔
54
                ArgumentNullException.ThrowIfNull(options);
1✔
55
                ArgumentException.ThrowIfNullOrWhiteSpace(options.KeyPrefix);
1✔
56
                if (options.KeyPrefix.IndexOfAny(['{', '}']) >= 0)
1✔
NEW
57
                        throw new ArgumentException("The Redis key prefix cannot contain '{' or '}'.", nameof(options));
×
58

59
                _connection = connection;
1✔
60
                _database = connection.GetDatabase(options.Database);
1✔
61
                _timeProvider = timeProvider ?? TimeProvider.System;
1✔
62
                _root = $"{{{options.KeyPrefix}}}:";
1✔
63
                _ownsConnection = ownsConnection;
1✔
64
        }
1✔
65

66
        /// <inheritdoc />
67
        public async ValueTask InitializeAsync(CancellationToken cancellationToken = default)
68
        {
NEW
69
                _ = await _database.PingAsync().WaitAsync(cancellationToken).ConfigureAwait(false);
×
NEW
70
        }
×
71

72
        /// <inheritdoc />
73
        public async ValueTask EnqueueAsync(JobRecord job, CancellationToken cancellationToken = default)
74
        {
75
                ValidateQueueJob(job);
1✔
76
                var result = await EvaluateInt64Async(
1✔
77
                        RedisScripts.Enqueue,
1✔
78
                        [JobKey(job.Id), AllJobsKey, StateKey(job.State), DueKey(job.QueueName)],
1✔
79
                        CreateEnqueueArguments(job),
1✔
80
                        cancellationToken
1✔
81
                ).ConfigureAwait(false);
1✔
82
                if (result == 0)
1✔
NEW
83
                        throw new ImmediateJobException($"Job '{job.Id}' already exists.");
×
84
        }
1✔
85

86
        /// <inheritdoc />
87
        public async ValueTask<IReadOnlyList<JobRecord>> AcquireDueJobsAsync(
88
                JobAcquisitionRequest request,
89
                CancellationToken cancellationToken = default
90
        )
91
        {
92
                ArgumentNullException.ThrowIfNull(request);
1✔
93
                ArgumentException.ThrowIfNullOrWhiteSpace(request.WorkerId);
1✔
94
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(request.Lease, TimeSpan.Zero);
1✔
95
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(request.BatchSize, 0);
1✔
96
                ArgumentNullException.ThrowIfNull(request.Queues);
1✔
97

98
                var keys = new List<RedisKey>(4 + request.Queues.Count)
1✔
99
                {
1✔
100
                        LeasesKey,
1✔
101
                        StateKey(JobState.Active),
1✔
102
                        StateKey(JobState.Pending),
1✔
103
                        StateKey(JobState.Scheduled),
1✔
104
                };
1✔
105
                var now = _timeProvider.GetUtcNow();
1✔
106
                var leaseExpiresAt = now + request.Lease;
1✔
107
                var values = new List<RedisValue>
1✔
108
                {
1✔
109
                        Score(now),
1✔
110
                        Score(leaseExpiresAt),
1✔
111
                        Ticks(leaseExpiresAt),
1✔
112
                        request.WorkerId,
1✔
113
                        request.BatchSize,
1✔
114
                        request.Queues.Count,
1✔
115
                        _root,
1✔
116
                };
1✔
117
                foreach (var queue in request.Queues)
1✔
118
                {
119
                        ArgumentNullException.ThrowIfNull(queue);
1✔
120
                        ArgumentException.ThrowIfNullOrWhiteSpace(queue.QueueName);
1✔
121
                        keys.Add(DueKey(queue.QueueName));
1✔
122
                        values.Add(queue.QueueName);
1✔
123
                        values.Add(Math.Max(0, queue.Capacity));
1✔
124
                        values.Add(queue.JobCapacities.Count);
1✔
125
                        foreach (var capacity in queue.JobCapacities)
1✔
126
                        {
127
                                values.Add(capacity.Key);
1✔
128
                                values.Add(Math.Max(0, capacity.Value));
1✔
129
                        }
130
                }
131

132
                var result = await _database.ScriptEvaluateAsync(
1✔
133
                        RedisScripts.Acquire,
1✔
134
                        [.. keys],
1✔
135
                        [.. values]
1✔
136
                ).WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
137
                var ids = ((RedisResult[])result!)
1✔
138
                        .Select(static value => (string)value!)
1✔
139
                        .ToArray();
1✔
140
                return await ReadJobsAsync(ids, cancellationToken).ConfigureAwait(false);
1✔
141
        }
1✔
142

143
        /// <inheritdoc />
144
        public async ValueTask SetExecutionTelemetryAsync(
145
                string jobId,
146
                string workerId,
147
                string? traceId,
148
                string? spanId,
149
                DateTimeOffset startedAt,
150
                CancellationToken cancellationToken = default
151
        )
152
        {
153
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
1✔
154
                ArgumentException.ThrowIfNullOrWhiteSpace(workerId);
1✔
155
                var result = await EvaluateInt64Async(
1✔
156
                        RedisScripts.SetTelemetry,
1✔
157
                        [JobKey(jobId)],
1✔
158
                        [workerId, traceId ?? "", spanId ?? "", Ticks(startedAt)],
1✔
159
                        cancellationToken
1✔
160
                ).ConfigureAwait(false);
1✔
161
                ThrowIfNotOwned(result, jobId, workerId);
1✔
162
        }
1✔
163

164
        /// <inheritdoc />
165
        public async ValueTask RenewLeaseAsync(
166
                string jobId,
167
                string workerId,
168
                TimeSpan lease,
169
                CancellationToken cancellationToken = default
170
        )
171
        {
NEW
172
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
×
NEW
173
                ArgumentException.ThrowIfNullOrWhiteSpace(workerId);
×
NEW
174
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lease, TimeSpan.Zero);
×
NEW
175
                var expiresAt = _timeProvider.GetUtcNow() + lease;
×
NEW
176
                var result = await EvaluateInt64Async(
×
NEW
177
                        RedisScripts.RenewLease,
×
NEW
178
                        [JobKey(jobId), LeasesKey],
×
NEW
179
                        [workerId, Ticks(expiresAt), Score(expiresAt), jobId],
×
NEW
180
                        cancellationToken
×
NEW
181
                ).ConfigureAwait(false);
×
NEW
182
                ThrowIfNotOwned(result, jobId, workerId);
×
NEW
183
        }
×
184

185
        /// <inheritdoc />
186
        public async ValueTask CompleteAsync(
187
                string jobId,
188
                string workerId,
189
                CancellationToken cancellationToken = default
190
        )
191
        {
192
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
1✔
193
                ArgumentException.ThrowIfNullOrWhiteSpace(workerId);
1✔
194
                var now = _timeProvider.GetUtcNow();
1✔
195
                var result = await EvaluateInt64Async(
1✔
196
                        RedisScripts.Complete,
1✔
197
                        [
1✔
198
                                JobKey(jobId),
1✔
199
                                LeasesKey,
1✔
200
                                StateKey(JobState.Active),
1✔
201
                                StateKey(JobState.Succeeded),
1✔
202
                                CompletedKey(JobState.Succeeded),
1✔
203
                        ],
1✔
204
                        [workerId, Ticks(now), jobId, Score(now)],
1✔
205
                        cancellationToken
1✔
206
                ).ConfigureAwait(false);
1✔
207
                ThrowIfNotOwned(result, jobId, workerId);
1✔
NEW
208
        }
×
209

210
        /// <inheritdoc />
211
        public async ValueTask FailAsync(
212
                string jobId,
213
                string workerId,
214
                string error,
215
                DateTimeOffset? nextRetryAt,
216
                CancellationToken cancellationToken = default
217
        )
218
        {
NEW
219
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
×
NEW
220
                ArgumentException.ThrowIfNullOrWhiteSpace(workerId);
×
NEW
221
                ArgumentNullException.ThrowIfNull(error);
×
NEW
222
                var now = _timeProvider.GetUtcNow();
×
NEW
223
                var nextTicks = nextRetryAt is { } retryAt ? Ticks(retryAt) : "";
×
NEW
224
                var nextScore = nextRetryAt is { } retryScore ? Score(retryScore) : 0;
×
NEW
225
                var result = await EvaluateInt64Async(
×
NEW
226
                        RedisScripts.Fail,
×
NEW
227
                        [
×
NEW
228
                                JobKey(jobId),
×
NEW
229
                                LeasesKey,
×
NEW
230
                                StateKey(JobState.Active),
×
NEW
231
                                StateKey(JobState.Failed),
×
NEW
232
                                CompletedKey(JobState.Failed),
×
NEW
233
                                StateKey(JobState.Scheduled),
×
NEW
234
                                StateKey(JobState.Pending),
×
NEW
235
                        ],
×
NEW
236
                        [
×
NEW
237
                                workerId,
×
NEW
238
                                jobId,
×
NEW
239
                                nextTicks,
×
NEW
240
                                error,
×
NEW
241
                                Ticks(now),
×
NEW
242
                                Score(now),
×
NEW
243
                                nextScore,
×
NEW
244
                                Score(now),
×
NEW
245
                                _root,
×
NEW
246
                        ],
×
NEW
247
                        cancellationToken
×
NEW
248
                ).ConfigureAwait(false);
×
NEW
249
                ThrowIfNotOwned(result, jobId, workerId);
×
NEW
250
        }
×
251

252
        /// <inheritdoc />
253
        public async ValueTask<JobMonitoringSnapshot> GetMonitoringSnapshotAsync(
254
                CancellationToken cancellationToken = default
255
        )
256
        {
257
                var states = Enum.GetValues<JobState>();
1✔
258
                var countTasks = states
1✔
259
                        .Select(state => _database.SetLengthAsync(StateKey(state)))
1✔
260
                        .ToArray();
1✔
261
                var recurringTask = ReadAllRecurringAsync(cancellationToken);
1✔
262
                var serversTask = ReadLiveServersAsync(cancellationToken);
1✔
263
                _ = await Task.WhenAll(countTasks).WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
264
                var recurring = await recurringTask.ConfigureAwait(false);
1✔
265
                var servers = await serversTask.ConfigureAwait(false);
1✔
266
                var counts = states
1✔
267
                        .Select((state, index) => KeyValuePair.Create(state, countTasks[index].Result))
1✔
268
                        .ToDictionary();
1✔
269
                return new(_timeProvider.GetUtcNow(), counts, recurring, servers)
1✔
270
                {
1✔
271
                        Capabilities = this.GetCapabilities(),
1✔
272
                };
1✔
273
        }
1✔
274

275
        /// <inheritdoc />
276
        public async ValueTask<IReadOnlyList<JobRecord>> QueryJobsAsync(
277
                JobQuery query,
278
                CancellationToken cancellationToken = default
279
        )
280
        {
281
                ArgumentNullException.ThrowIfNull(query);
1✔
282
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
1✔
283
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
1✔
284
                var ids = query.Id is { } id
1✔
285
                        ? [id]
1✔
286
                        : (await _database.SortedSetRangeByRankAsync(
1✔
287
                                AllJobsKey,
1✔
288
                                order: Order.Descending
1✔
289
                        ).WaitAsync(cancellationToken).ConfigureAwait(false))
1✔
290
                                .Select(static value => (string)value!)
1✔
291
                                .ToArray();
1✔
292
                var jobs = await ReadJobsAsync(ids, cancellationToken).ConfigureAwait(false);
1✔
293
                IEnumerable<JobRecord> filtered = jobs;
1✔
294
                if (query.State is { } state)
1✔
295
                        filtered = filtered.Where(job => job.State == state);
1✔
296
                if (!string.IsNullOrWhiteSpace(query.QueueName))
1✔
NEW
297
                        filtered = filtered.Where(job => job.QueueName == query.QueueName);
×
298
                if (!string.IsNullOrWhiteSpace(query.Search))
1✔
299
                        filtered = filtered.Where(job => job.JobName.Contains(query.Search, StringComparison.OrdinalIgnoreCase));
1✔
300
                return [.. filtered.Skip(query.Skip).Take(Math.Min(query.Take, 1000))];
1✔
301
        }
1✔
302

303
        /// <inheritdoc />
304
        public async ValueTask<JobStatus?> GetJobStatusAsync(
305
                string jobId,
306
                CancellationToken cancellationToken = default
307
        )
308
        {
NEW
309
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
×
NEW
310
                var job = await ReadJobAsync(jobId, cancellationToken).ConfigureAwait(false);
×
NEW
311
                return job is null
×
NEW
312
                        ? null
×
NEW
313
                        : new(
×
NEW
314
                                job.Id,
×
NEW
315
                                job.JobName,
×
NEW
316
                                job.QueueName,
×
NEW
317
                                job.State,
×
NEW
318
                                job.Attempt,
×
NEW
319
                                MaxAttempts: 0,
×
NEW
320
                                job.CreatedAt,
×
NEW
321
                                job.DueAt,
×
NEW
322
                                job.CompletedAt,
×
NEW
323
                                job.LastError,
×
NEW
324
                                BatchId: null,
×
NEW
325
                                DependsOn: []
×
NEW
326
                        );
×
NEW
327
        }
×
328

329
        /// <inheritdoc />
330
        public async ValueTask RetryAsync(string jobId, CancellationToken cancellationToken = default)
331
        {
NEW
332
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
×
NEW
333
                var now = _timeProvider.GetUtcNow();
×
NEW
334
                var result = await EvaluateInt64Async(
×
NEW
335
                        RedisScripts.Retry,
×
NEW
336
                        [
×
NEW
337
                                JobKey(jobId),
×
NEW
338
                                StateKey(JobState.Failed),
×
NEW
339
                                StateKey(JobState.Pending),
×
NEW
340
                                CompletedKey(JobState.Failed),
×
NEW
341
                        ],
×
NEW
342
                        [Ticks(now), Score(now), jobId, _root],
×
NEW
343
                        cancellationToken
×
NEW
344
                ).ConfigureAwait(false);
×
NEW
345
                if (result <= 0)
×
NEW
346
                        throw new ImmediateJobException("Only failed jobs can be retried.");
×
NEW
347
        }
×
348

349
        /// <inheritdoc />
350
        public async ValueTask DeleteAsync(string jobId, CancellationToken cancellationToken = default)
351
        {
NEW
352
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
×
NEW
353
                var result = await EvaluateInt64Async(
×
NEW
354
                        RedisScripts.Delete,
×
NEW
355
                        [JobKey(jobId), AllJobsKey],
×
NEW
356
                        [jobId, _root],
×
NEW
357
                        cancellationToken
×
NEW
358
                ).ConfigureAwait(false);
×
NEW
359
                if (result < 0)
×
NEW
360
                        throw new ImmediateJobException("Only terminal jobs can be deleted.");
×
NEW
361
        }
×
362

363
        /// <inheritdoc />
364
        public async ValueTask PurgeJobsAsync(
365
                TimeSpan succeededRetention,
366
                TimeSpan failedRetention,
367
                CancellationToken cancellationToken = default
368
        )
369
        {
NEW
370
                ArgumentOutOfRangeException.ThrowIfLessThan(succeededRetention, TimeSpan.Zero);
×
NEW
371
                ArgumentOutOfRangeException.ThrowIfLessThan(failedRetention, TimeSpan.Zero);
×
NEW
372
                var now = _timeProvider.GetUtcNow();
×
NEW
373
                await PurgeStateAsync(JobState.Succeeded, now - succeededRetention, cancellationToken).ConfigureAwait(false);
×
NEW
374
                await PurgeStateAsync(JobState.Failed, now - failedRetention, cancellationToken).ConfigureAwait(false);
×
NEW
375
                await PurgeStateAsync(JobState.Cancelled, now - failedRetention, cancellationToken).ConfigureAwait(false);
×
NEW
376
        }
×
377

378
        /// <inheritdoc />
379
        public async ValueTask HeartbeatAsync(
380
                JobServerSnapshot server,
381
                CancellationToken cancellationToken = default
382
        )
383
        {
NEW
384
                ArgumentNullException.ThrowIfNull(server);
×
NEW
385
                ArgumentException.ThrowIfNullOrWhiteSpace(server.WorkerId);
×
NEW
386
                _ = await EvaluateInt64Async(
×
NEW
387
                        RedisScripts.Heartbeat,
×
NEW
388
                        [ServerKey(server.WorkerId), ServersKey],
×
NEW
389
                        [
×
NEW
390
                                Ticks(server.LastHeartbeat),
×
NEW
391
                                server.ActiveWorkers,
×
NEW
392
                                server.MaxWorkers,
×
NEW
393
                                Score(server.LastHeartbeat),
×
NEW
394
                                server.WorkerId,
×
NEW
395
                        ],
×
NEW
396
                        cancellationToken
×
NEW
397
                ).ConfigureAwait(false);
×
NEW
398
        }
×
399

400
        /// <inheritdoc />
401
        public async ValueTask<bool> IsHealthyAsync(CancellationToken cancellationToken = default)
402
        {
403
                try
404
                {
NEW
405
                        _ = await _database.PingAsync().WaitAsync(cancellationToken).ConfigureAwait(false);
×
NEW
406
                        return true;
×
407
                }
NEW
408
                catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
×
409
                {
NEW
410
                        throw;
×
411
                }
NEW
412
                catch (RedisException)
×
413
                {
NEW
414
                        return false;
×
415
                }
NEW
416
        }
×
417

418
        /// <inheritdoc />
419
        public async ValueTask UpsertRecurringAsync(
420
                RecurringJobSchedule schedule,
421
                CancellationToken cancellationToken = default
422
        )
423
        {
424
                ValidateRecurring(schedule);
1✔
425
                var result = await EvaluateInt64Async(
1✔
426
                        RedisScripts.UpsertRecurring,
1✔
427
                        [RecurringKey(schedule.Name), RecurringNamesKey, RecurringDueKey],
1✔
428
                        [
1✔
429
                                JsonSerializer.Serialize(schedule, RedisJsonSerializerContext.Default.RecurringJobSchedule),
1✔
430
                                schedule.IsCodeDefined ? 1 : 0,
1✔
431
                                schedule.IsPaused ? 1 : 0,
1✔
432
                                Ticks(schedule.NextRunAt),
1✔
433
                                NullableTicks(schedule.LastRunAt),
1✔
434
                                schedule.Name,
1✔
435
                                Score(schedule.NextRunAt),
1✔
436
                                RecurringDueMember(schedule.NextRunAt, schedule.Name),
1✔
437
                        ],
1✔
438
                        cancellationToken
1✔
439
                ).ConfigureAwait(false);
1✔
440
                if (result < 0)
1✔
NEW
441
                        throw new ImmediateJobException("Code-defined recurring schedules cannot be replaced by dynamic schedules.");
×
442
        }
1✔
443

444
        /// <inheritdoc />
445
        public async ValueTask RemoveObsoleteCodeDefinedRecurringAsync(
446
                IReadOnlyCollection<string> activeScheduleNames,
447
                CancellationToken cancellationToken = default
448
        )
449
        {
NEW
450
                ArgumentNullException.ThrowIfNull(activeScheduleNames);
×
NEW
451
                var values = new RedisValue[activeScheduleNames.Count + 1];
×
NEW
452
                values[0] = _root;
×
NEW
453
                var index = 1;
×
NEW
454
                foreach (var name in activeScheduleNames)
×
NEW
455
                        values[index++] = name;
×
NEW
456
                _ = await EvaluateInt64Async(
×
NEW
457
                        RedisScripts.RemoveObsoleteRecurring,
×
NEW
458
                        [RecurringNamesKey, RecurringDueKey],
×
NEW
459
                        values,
×
NEW
460
                        cancellationToken
×
NEW
461
                ).ConfigureAwait(false);
×
NEW
462
        }
×
463

464
        /// <inheritdoc />
465
        public async ValueTask RemoveRecurringAsync(string name, CancellationToken cancellationToken = default)
466
        {
NEW
467
                ArgumentException.ThrowIfNullOrWhiteSpace(name);
×
NEW
468
                var result = await EvaluateInt64Async(
×
NEW
469
                        RedisScripts.RemoveRecurring,
×
NEW
470
                        [RecurringKey(name), RecurringNamesKey, RecurringDueKey],
×
NEW
471
                        [name],
×
NEW
472
                        cancellationToken
×
NEW
473
                ).ConfigureAwait(false);
×
NEW
474
                if (result < 0)
×
NEW
475
                        throw new ImmediateJobException("Code-defined recurring schedules cannot be deleted.");
×
NEW
476
        }
×
477

478
        /// <inheritdoc />
479
        public ValueTask PauseRecurringAsync(string name, CancellationToken cancellationToken = default) =>
NEW
480
                SetRecurringPausedAsync(name, isPaused: true, cancellationToken);
×
481

482
        /// <inheritdoc />
483
        public ValueTask ResumeRecurringAsync(string name, CancellationToken cancellationToken = default) =>
NEW
484
                SetRecurringPausedAsync(name, isPaused: false, cancellationToken);
×
485

486
        /// <inheritdoc />
487
        public async ValueTask<IReadOnlyList<RecurringJobSchedule>> GetDueRecurringAsync(
488
                DateTimeOffset now,
489
                int batchSize,
490
                CancellationToken cancellationToken = default
491
        )
492
        {
NEW
493
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(batchSize, 0);
×
NEW
494
                var values = await _database.SortedSetRangeByScoreAsync(
×
NEW
495
                        RecurringDueKey,
×
NEW
496
                        stop: Score(now),
×
NEW
497
                        take: batchSize
×
NEW
498
                ).WaitAsync(cancellationToken).ConfigureAwait(false);
×
NEW
499
                return await ReadRecurringAsync(
×
NEW
500
                        [.. values.Select(static value => ((string)value!)[20..])],
×
NEW
501
                        cancellationToken
×
NEW
502
                ).ConfigureAwait(false);
×
NEW
503
        }
×
504

505
        /// <inheritdoc />
506
        public async ValueTask<bool> MaterializeRecurringAsync(
507
                RecurringJobSchedule schedule,
508
                JobRecord job,
509
                DateTimeOffset nextRunAt,
510
                CancellationToken cancellationToken = default
511
        )
512
        {
513
                ValidateRecurring(schedule);
1✔
514
                ValidateMaterializedJob(job);
1✔
515
                var jobArguments = CreateMaterializeArguments(schedule, job, nextRunAt);
1✔
516
                var result = await EvaluateInt64Async(
1✔
517
                        RedisScripts.MaterializeRecurring,
1✔
518
                        [
1✔
519
                                RecurringKey(schedule.Name),
1✔
520
                                RecurringDedupeKey,
1✔
521
                                JobKey(job.Id),
1✔
522
                                AllJobsKey,
1✔
523
                                StateKey(job.State),
1✔
524
                                DueKey(job.QueueName),
1✔
525
                                RecurringDueKey,
1✔
526
                                CompletedKey(JobState.Cancelled),
1✔
527
                        ],
1✔
528
                        jobArguments,
1✔
529
                        cancellationToken
1✔
530
                ).ConfigureAwait(false);
1✔
531
                if (result < 0)
1✔
NEW
532
                        throw new ImmediateJobException($"Job '{job.Id}' already exists.");
×
533
                return result == 1;
1✔
534
        }
1✔
535

536
        /// <inheritdoc />
537
        public void Dispose()
538
        {
539
                if (_disposed)
1✔
NEW
540
                        return;
×
541
                _disposed = true;
1✔
542
                if (_ownsConnection)
1✔
NEW
543
                        _connection.Dispose();
×
544
        }
1✔
545

546
        private async ValueTask SetRecurringPausedAsync(
547
                string name,
548
                bool isPaused,
549
                CancellationToken cancellationToken
550
        )
551
        {
NEW
552
                ArgumentException.ThrowIfNullOrWhiteSpace(name);
×
NEW
553
                var schedule = await ReadRecurringAsync(name, cancellationToken).ConfigureAwait(false)
×
NEW
554
                        ?? throw new KeyNotFoundException($"Recurring schedule '{name}' was not found.");
×
NEW
555
                var result = await EvaluateInt64Async(
×
NEW
556
                        RedisScripts.SetRecurringPaused,
×
NEW
557
                        [RecurringKey(name), RecurringDueKey],
×
NEW
558
                        [
×
NEW
559
                                isPaused ? 1 : 0,
×
NEW
560
                                name,
×
NEW
561
                                Score(schedule.NextRunAt),
×
NEW
562
                                RecurringDueMember(schedule.NextRunAt, name),
×
NEW
563
                        ],
×
NEW
564
                        cancellationToken
×
NEW
565
                ).ConfigureAwait(false);
×
NEW
566
                if (result == 0)
×
NEW
567
                        throw new KeyNotFoundException($"Recurring schedule '{name}' was not found.");
×
NEW
568
        }
×
569

570
        private async Task PurgeStateAsync(
571
                JobState state,
572
                DateTimeOffset cutoff,
573
                CancellationToken cancellationToken
574
        )
575
        {
NEW
576
                while (true)
×
577
                {
NEW
578
                        var ids = await _database.SortedSetRangeByScoreAsync(
×
NEW
579
                                CompletedKey(state),
×
NEW
580
                                stop: Score(cutoff),
×
NEW
581
                                exclude: Exclude.Stop,
×
NEW
582
                                take: 256
×
NEW
583
                        ).WaitAsync(cancellationToken).ConfigureAwait(false);
×
NEW
584
                        if (ids.Length == 0)
×
NEW
585
                                return;
×
NEW
586
                        foreach (var value in ids)
×
587
                        {
NEW
588
                                var id = (string)value!;
×
NEW
589
                                _ = await EvaluateInt64Async(
×
NEW
590
                                        RedisScripts.Purge,
×
NEW
591
                                        [JobKey(id), CompletedKey(state), AllJobsKey, StateKey(state)],
×
NEW
592
                                        [id, (int)state],
×
NEW
593
                                        cancellationToken
×
NEW
594
                                ).ConfigureAwait(false);
×
595
                        }
596
                }
NEW
597
        }
×
598

599
        private async Task<IReadOnlyList<JobServerSnapshot>> ReadLiveServersAsync(CancellationToken cancellationToken)
600
        {
601
                var cutoff = _timeProvider.GetUtcNow() - TimeSpan.FromMinutes(2);
1✔
602
                var stale = await _database.SortedSetRangeByScoreAsync(
1✔
603
                        ServersKey,
1✔
604
                        stop: Score(cutoff),
1✔
605
                        exclude: Exclude.Stop
1✔
606
                ).WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
607
                if (stale.Length != 0)
1✔
NEW
608
                        _ = await _database.SortedSetRemoveAsync(ServersKey, stale).WaitAsync(cancellationToken).ConfigureAwait(false);
×
609
                var ids = await _database.SortedSetRangeByScoreAsync(
1✔
610
                        ServersKey,
1✔
611
                        start: Score(cutoff)
1✔
612
                ).WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
613
                var tasks = ids
1✔
NEW
614
                        .Select(id => _database.HashGetAsync(ServerKey((string)id!), ["last", "active", "max"]))
×
615
                        .ToArray();
1✔
616
                _ = await Task.WhenAll(tasks).WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
617
                return
1✔
618
                [
1✔
NEW
619
                        .. tasks.Select((task, index) => new JobServerSnapshot(
×
NEW
620
                                (string)ids[index]!,
×
NEW
621
                                FromTicks(task.Result[0]),
×
NEW
622
                                ParseInt32(task.Result[1]),
×
NEW
623
                                ParseInt32(task.Result[2])
×
NEW
624
                        )),
×
625
                ];
1✔
626
        }
1✔
627

628
        private async Task<IReadOnlyList<RecurringJobSchedule>> ReadAllRecurringAsync(CancellationToken cancellationToken)
629
        {
630
                var names = await _database.SetMembersAsync(RecurringNamesKey)
1✔
631
                        .WaitAsync(cancellationToken)
1✔
632
                        .ConfigureAwait(false);
1✔
633
                return await ReadRecurringAsync(
1✔
634
                        [.. names.Select(static value => (string)value!)],
1✔
635
                        cancellationToken
1✔
636
                ).ConfigureAwait(false);
1✔
637
        }
1✔
638

639
        private async Task<IReadOnlyList<RecurringJobSchedule>> ReadRecurringAsync(
640
                IReadOnlyList<string> names,
641
                CancellationToken cancellationToken
642
        )
643
        {
644
                var tasks = names.Select(name => ReadRecurringAsync(name, cancellationToken).AsTask()).ToArray();
1✔
645
                var schedules = await Task.WhenAll(tasks).WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
646
                return [.. schedules.OfType<RecurringJobSchedule>().OrderBy(schedule => schedule.NextRunAt).ThenBy(schedule => schedule.Name)];
1✔
647
        }
1✔
648

649
        private async ValueTask<RecurringJobSchedule?> ReadRecurringAsync(
650
                string name,
651
                CancellationToken cancellationToken
652
        )
653
        {
654
                var values = await _database.HashGetAsync(RecurringKey(name), RecurringMutableFields)
1✔
655
                        .WaitAsync(cancellationToken)
1✔
656
                        .ConfigureAwait(false);
1✔
657
                if (values[0].IsNull)
1✔
NEW
658
                        return null;
×
659
                var schedule = JsonSerializer.Deserialize(
1✔
660
                        (string)values[0]!,
1✔
661
                        RedisJsonSerializerContext.Default.RecurringJobSchedule
1✔
662
                ) ?? throw new ImmediateJobException($"Recurring schedule '{name}' contains invalid data.");
1✔
663
                return schedule with
1✔
664
                {
1✔
665
                        IsPaused = values[1] == "1",
1✔
666
                        NextRunAt = FromTicks(values[2]),
1✔
667
                        LastRunAt = FromNullableTicks(values[3]),
1✔
668
                };
1✔
669
        }
1✔
670

671
        private async Task<IReadOnlyList<JobRecord>> ReadJobsAsync(
672
                IReadOnlyList<string> ids,
673
                CancellationToken cancellationToken
674
        )
675
        {
676
                var tasks = ids.Select(id => ReadJobAsync(id, cancellationToken).AsTask()).ToArray();
1✔
677
                var jobs = await Task.WhenAll(tasks).WaitAsync(cancellationToken).ConfigureAwait(false);
1✔
678
                return [.. jobs.OfType<JobRecord>()];
1✔
679
        }
1✔
680

681
        private async ValueTask<JobRecord?> ReadJobAsync(string id, CancellationToken cancellationToken)
682
        {
683
                var values = await _database.HashGetAsync(JobKey(id), JobMutableFields)
1✔
684
                        .WaitAsync(cancellationToken)
1✔
685
                        .ConfigureAwait(false);
1✔
686
                if (values[0].IsNull)
1✔
NEW
687
                        return null;
×
688
                var job = JsonSerializer.Deserialize(
1✔
689
                        (string)values[0]!,
1✔
690
                        RedisJsonSerializerContext.Default.JobRecord
1✔
691
                ) ?? throw new ImmediateJobException($"Job '{id}' contains invalid data.");
1✔
692
                return job with
1✔
693
                {
1✔
694
                        State = (JobState)ParseInt32(values[1]),
1✔
695
                        DueAt = FromTicks(values[2]),
1✔
696
                        Attempt = ParseInt32(values[3]),
1✔
697
                        WorkerId = NullIfEmpty(values[4]),
1✔
698
                        LeaseExpiresAt = FromNullableTicks(values[5]),
1✔
699
                        LastError = NullIfEmpty(values[6]),
1✔
700
                        CompletedAt = FromNullableTicks(values[7]),
1✔
701
                        ExecutionTraceId = NullIfEmpty(values[8]),
1✔
702
                        ExecutionSpanId = NullIfEmpty(values[9]),
1✔
703
                        ExecutionStartedAt = FromNullableTicks(values[10]),
1✔
704
                };
1✔
705
        }
1✔
706

707
        private async ValueTask<long> EvaluateInt64Async(
708
                string script,
709
                RedisKey[] keys,
710
                RedisValue[] values,
711
                CancellationToken cancellationToken
712
        )
713
        {
714
                var result = await _database.ScriptEvaluateAsync(script, keys, values)
1✔
715
                        .WaitAsync(cancellationToken)
1✔
716
                        .ConfigureAwait(false);
1✔
717
                return (long)result;
1✔
718
        }
1✔
719

720
        private static RedisValue[] CreateEnqueueArguments(JobRecord job) =>
721
        [
1✔
722
                JsonSerializer.Serialize(job, RedisJsonSerializerContext.Default.JobRecord),
1✔
723
                (int)job.State,
1✔
724
                Ticks(job.DueAt),
1✔
725
                job.Attempt,
1✔
726
                job.WorkerId ?? "",
1✔
727
                NullableTicks(job.LeaseExpiresAt),
1✔
728
                job.LastError ?? "",
1✔
729
                NullableTicks(job.CompletedAt),
1✔
730
                job.ExecutionTraceId ?? "",
1✔
731
                job.ExecutionSpanId ?? "",
1✔
732
                NullableTicks(job.ExecutionStartedAt),
1✔
733
                job.QueueName,
1✔
734
                job.JobName,
1✔
735
                Score(job.CreatedAt),
1✔
736
                job.Id,
1✔
737
                Score(job.DueAt),
1✔
738
                Ticks(job.CreatedAt),
1✔
739
                DueMember(job),
1✔
740
        ];
1✔
741

742
        private static RedisValue[] CreateMaterializeArguments(
743
                RecurringJobSchedule schedule,
744
                JobRecord job,
745
                DateTimeOffset nextRunAt
746
        ) =>
747
        [
1✔
748
                Ticks(schedule.NextRunAt),
1✔
749
                job.RecurringKey ?? "",
1✔
750
                job.Id,
1✔
751
                JsonSerializer.Serialize(job, RedisJsonSerializerContext.Default.JobRecord),
1✔
752
                (int)job.State,
1✔
753
                Ticks(job.DueAt),
1✔
754
                Score(job.DueAt),
1✔
755
                job.Attempt,
1✔
756
                job.WorkerId ?? "",
1✔
757
                NullableTicks(job.LeaseExpiresAt),
1✔
758
                job.LastError ?? "",
1✔
759
                NullableTicks(job.CompletedAt),
1✔
760
                job.ExecutionTraceId ?? "",
1✔
761
                job.ExecutionSpanId ?? "",
1✔
762
                NullableTicks(job.ExecutionStartedAt),
1✔
763
                job.QueueName,
1✔
764
                job.JobName,
1✔
765
                Score(job.CreatedAt),
1✔
766
                Ticks(nextRunAt),
1✔
767
                Score(nextRunAt),
1✔
768
                schedule.Name,
1✔
769
                Ticks(job.CreatedAt),
1✔
770
                job.CompletedAt is { } completedAt ? Score(completedAt) : 0,
1✔
771
        ];
1✔
772

773
        private static void ValidateQueueJob(JobRecord job)
774
        {
775
                ArgumentNullException.ThrowIfNull(job);
1✔
776
                ArgumentException.ThrowIfNullOrWhiteSpace(job.Id);
1✔
777
                ArgumentException.ThrowIfNullOrWhiteSpace(job.JobName);
1✔
778
                ArgumentException.ThrowIfNullOrWhiteSpace(job.QueueName);
1✔
779
                if (job.BatchId is not null || job.RemainingDependencies != 0 || job.FailedDependencies != 0)
1✔
780
                {
NEW
781
                        throw new NotSupportedException(
×
NEW
782
                                "Batches & continuations require a graph-capable storage provider (a SQL database)."
×
NEW
783
                        );
×
784
                }
785

786
                if (job.State is not (JobState.Pending or JobState.Scheduled))
1✔
NEW
787
                        throw new ImmediateJobException($"Queue job '{job.Id}' has invalid state '{job.State}'.");
×
788
        }
1✔
789

790
        private static void ValidateMaterializedJob(JobRecord job)
791
        {
792
                ArgumentNullException.ThrowIfNull(job);
1✔
793
                ArgumentException.ThrowIfNullOrWhiteSpace(job.Id);
1✔
794
                ArgumentException.ThrowIfNullOrWhiteSpace(job.JobName);
1✔
795
                ArgumentException.ThrowIfNullOrWhiteSpace(job.QueueName);
1✔
796
                if (job.BatchId is not null || job.RemainingDependencies != 0 || job.FailedDependencies != 0)
1✔
797
                {
NEW
798
                        throw new NotSupportedException(
×
NEW
799
                                "Batches & continuations require a graph-capable storage provider (a SQL database)."
×
NEW
800
                        );
×
801
                }
802

803
                if (job.State is not (JobState.Pending or JobState.Scheduled or JobState.Cancelled))
1✔
NEW
804
                        throw new ImmediateJobException($"Recurring job '{job.Id}' has invalid state '{job.State}'.");
×
805
                if (job.State == JobState.Cancelled && job.CompletedAt is null)
1✔
NEW
806
                        throw new ImmediateJobException($"Cancelled recurring job '{job.Id}' must have a completion time.");
×
807
        }
1✔
808

809
        private static void ValidateRecurring(RecurringJobSchedule schedule)
810
        {
811
                ArgumentNullException.ThrowIfNull(schedule);
1✔
812
                ArgumentException.ThrowIfNullOrWhiteSpace(schedule.Name);
1✔
813
                ArgumentException.ThrowIfNullOrWhiteSpace(schedule.JobName);
1✔
814
                ArgumentException.ThrowIfNullOrWhiteSpace(schedule.Cron);
1✔
815
                ArgumentException.ThrowIfNullOrWhiteSpace(schedule.TimeZone);
1✔
816
        }
1✔
817

818
        private static void ThrowIfNotOwned(long result, string jobId, string workerId)
819
        {
820
                if (result <= 0)
1✔
821
                        throw new ImmediateJobException($"Worker '{workerId}' does not own active job '{jobId}'.");
1✔
822
        }
1✔
823

824
        private RedisKey JobKey(string id) => _root + "job:" + id;
1✔
825
        private RedisKey DueKey(string queue) => _root + "due:" + queue;
1✔
826
        private RedisKey StateKey(JobState state) => _root + "state:" + (int)state;
1✔
827
        private RedisKey CompletedKey(JobState state) => _root + "completed:" + (int)state;
1✔
828
        private RedisKey RecurringKey(string name) => _root + "recurring:" + name;
1✔
NEW
829
        private RedisKey ServerKey(string workerId) => _root + "server:" + workerId;
×
830
        private RedisKey AllJobsKey => _root + "jobs";
1✔
831
        private RedisKey LeasesKey => _root + "leases";
1✔
832
        private RedisKey RecurringNamesKey => _root + "recurring:names";
1✔
833
        private RedisKey RecurringDueKey => _root + "recurring:due";
1✔
834
        private RedisKey RecurringDedupeKey => _root + "recurring:dedupe";
1✔
835
        private RedisKey ServersKey => _root + "servers";
1✔
836

837
        private static long Score(DateTimeOffset value) => value.ToUnixTimeMilliseconds();
1✔
838
        private static string Ticks(DateTimeOffset value) => value.UtcTicks.ToString("D19", CultureInfo.InvariantCulture);
1✔
839
        private static string DueMember(JobRecord job) => $"{Ticks(job.DueAt)}|{Ticks(job.CreatedAt)}|{job.Id}";
1✔
840
        private static string RecurringDueMember(DateTimeOffset nextRunAt, string name) => $"{Ticks(nextRunAt)}|{name}";
1✔
841
        private static string NullableTicks(DateTimeOffset? value) => value is { } actual ? Ticks(actual) : "";
1✔
842
        private static DateTimeOffset FromTicks(RedisValue value) =>
843
                new(long.Parse((string)value!, NumberStyles.None, CultureInfo.InvariantCulture), TimeSpan.Zero);
1✔
844
        private static DateTimeOffset? FromNullableTicks(RedisValue value) =>
845
                value.IsNullOrEmpty ? null : FromTicks(value);
1✔
846
        private static int ParseInt32(RedisValue value) =>
847
                int.Parse((string)value!, NumberStyles.Integer, CultureInfo.InvariantCulture);
1✔
848
        private static string? NullIfEmpty(RedisValue value) => value.IsNullOrEmpty ? null : (string)value!;
1✔
849
}
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