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

ImmediatePlatform / Immediate.Jobs / 30658604787

31 Jul 2026 07:17PM UTC coverage: 65.599%. First build
30658604787

Pull #86

github

web-flow
Merge 0b1321e3a into e32a2b769
Pull Request #86: Add job cancellation APIs and dashboard action

103 of 116 new or added lines in 9 files covered. (88.79%)

9157 of 13959 relevant lines covered (65.6%)

1.96 hits per line

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

63.66
/src/Immediate.Jobs.Shared/SingleServerJobStorage.cs
1
namespace Immediate.Jobs.Shared;
2

3
/// <summary>
4
/// A single-server storage topology that executes against an authoritative in-process store while
5
/// synchronously replicating changes to durable storage and restoring them when the process starts.
6
/// </summary>
7
public sealed class SingleServerJobStorage :
8
        IRecurringJobStorage,
9
        IJobGraphStorage,
10
        IAsyncDisposable,
11
        IDisposable
12
{
13
        private const int RecoveryBatchSize = 1000;
14
        private readonly TimeProvider _timeProvider;
15
#pragma warning disable CA2213 // Kept alive so callers already waiting during disposal can safely leave the semaphore.
16
        private readonly SemaphoreSlim _initialization = new(1, 1);
4✔
17
#pragma warning restore CA2213
18
        private readonly IRecurringJobStorage _recurringDurableStorage;
19
        private readonly IJobGraphStorage _graphDurableStorage;
20
        private readonly Lock _disposeGate = new();
4✔
21
        private InMemoryJobStorage _primary;
22
        private bool _initialized;
23
        private Task? _disposeTask;
24
        private int _disposeStarted;
25

26
        /// <summary>Creates a memory-primary store backed by the supplied durable replica.</summary>
27
        /// <param name="durableStorage">The durable write-through replica.</param>
28
        /// <param name="timeProvider">The clock used by the in-process primary store.</param>
29
        /// <remarks>The wrapper takes ownership of <paramref name="durableStorage"/> and disposes it with the primary store.</remarks>
30
        public SingleServerJobStorage(IJobStorage durableStorage, TimeProvider timeProvider)
4✔
31
        {
32
                ArgumentNullException.ThrowIfNull(durableStorage);
4✔
33
                ArgumentNullException.ThrowIfNull(timeProvider);
4✔
34
                if (durableStorage is SingleServerJobStorage)
4✔
35
                        throw new ArgumentException("A single-server store cannot be used as its own durable replica.", nameof(durableStorage));
×
36
                if (durableStorage is not IJobStorageReplica)
4✔
37
                        throw new ArgumentException("Single-server durable storage must implement IJobStorageReplica.", nameof(durableStorage));
×
38
                if (durableStorage is not IRecurringJobStorage recurringDurableStorage)
4✔
39
                        throw new ArgumentException("Single-server durable storage must support recurring jobs.", nameof(durableStorage));
×
40
                if (durableStorage is not IJobGraphStorage graphDurableStorage)
4✔
41
                        throw new ArgumentException("Single-server durable storage must support batches and continuations.", nameof(durableStorage));
×
42

43
                DurableStorage = durableStorage;
4✔
44
                _recurringDurableStorage = recurringDurableStorage;
4✔
45
                _graphDurableStorage = graphDurableStorage;
4✔
46
                _timeProvider = timeProvider;
4✔
47
                _primary = new(timeProvider);
4✔
48
        }
4✔
49

50
        /// <summary>The in-process authoritative store.</summary>
51
        /// <value>The in-process primary storage provider.</value>
52
        public IJobStorage PrimaryStorage => _primary;
4✔
53

54
        /// <summary>The durable write-through replica.</summary>
55
        /// <value>The durable storage provider replicated by this wrapper.</value>
56
        public IJobStorage DurableStorage { get; }
57

58
        /// <inheritdoc />
59
        public ValueTask InitializeAsync(CancellationToken cancellationToken = default) => EnsureInitializedAsync(cancellationToken);
4✔
60

61
        /// <inheritdoc />
62
        public async ValueTask EnqueueAsync(JobRecord job, CancellationToken cancellationToken = default)
63
        {
64
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
4✔
65
                await DurableStorage.EnqueueAsync(job, cancellationToken).ConfigureAwait(false);
4✔
66
                await _primary.EnqueueAsync(job, cancellationToken).ConfigureAwait(false);
4✔
67
        }
4✔
68

69
        /// <inheritdoc />
70
        public async ValueTask<IReadOnlyList<JobContinuationEdge>> GetIncomingEdgesAsync(
71
                IReadOnlyCollection<string> childJobIds,
72
                CancellationToken cancellationToken = default
73
        )
74
        {
75
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
76
                return await _primary.GetIncomingEdgesAsync(childJobIds, cancellationToken).ConfigureAwait(false);
×
77
        }
78

79
        /// <inheritdoc />
80
        public async ValueTask EnqueueContinuationAsync(
81
                JobRecord job,
82
                IReadOnlyList<JobContinuationEdge> edges,
83
                CancellationToken cancellationToken = default
84
        )
85
        {
86
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
87
                await _graphDurableStorage.EnqueueContinuationAsync(job, edges, cancellationToken).ConfigureAwait(false);
×
88
                await _primary.EnqueueContinuationAsync(job, edges, cancellationToken).ConfigureAwait(false);
×
89
        }
×
90

91
        /// <inheritdoc />
92
        public async ValueTask EnqueueBatchAsync(
93
                JobBatchRecord batch,
94
                IReadOnlyList<JobRecord> jobs,
95
                IReadOnlyList<JobContinuationEdge> edges,
96
                CancellationToken cancellationToken = default
97
        )
98
        {
99
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
100
                await _graphDurableStorage.EnqueueBatchAsync(batch, jobs, edges, cancellationToken).ConfigureAwait(false);
×
101
                await _primary.EnqueueBatchAsync(batch, jobs, edges, cancellationToken).ConfigureAwait(false);
×
102
        }
×
103

104
        /// <inheritdoc />
105
        public async ValueTask<IReadOnlyList<JobRecord>> AcquireDueJobsAsync(
106
                JobAcquisitionRequest request,
107
                CancellationToken cancellationToken = default
108
        )
109
        {
110
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
4✔
111
                var acquired = await _primary.AcquireDueJobsAsync(request, cancellationToken).ConfigureAwait(false);
4✔
112
                if (acquired.Count == 0)
4✔
113
                        return acquired;
4✔
114

115
                var replica = (IJobStorageReplica)DurableStorage;
4✔
116
                var replicated = await replica.AcquireJobsAsync(
4✔
117
                        [.. acquired.Select(x => x.Id)],
4✔
118
                        request.WorkerId,
4✔
119
                        request.Lease,
4✔
120
                        cancellationToken
4✔
121
                ).ConfigureAwait(false);
4✔
122
                var replicatedExecutions = replicated.ToDictionary(static job => job.Id, static job => job.Attempt, StringComparer.Ordinal);
4✔
123
                if (acquired.Count != replicated.Count ||
4✔
124
                        acquired.Any(job => !replicatedExecutions.TryGetValue(job.Id, out var attempt) || attempt != job.Attempt))
4✔
125
                {
126
                        throw new ImmediateJobException(
×
127
                                "The durable job replica has drifted from the authoritative in-memory queue. " +
×
128
                                "Single-server mode must not be used by multiple scheduler processes."
×
129
                        );
×
130
                }
131

132
                return acquired;
4✔
133
        }
3✔
134

135
        /// <inheritdoc />
136
        public async ValueTask SetExecutionTelemetryAsync(
137
                string jobId,
138
                int executionNumber,
139
                string workerId,
140
                string? traceId,
141
                string? spanId,
142
                DateTimeOffset startedAt,
143
                CancellationToken cancellationToken = default
144
        )
145
        {
146
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
4✔
147
                await DurableStorage.SetExecutionTelemetryAsync(
4✔
148
                        jobId,
4✔
149
                        executionNumber,
4✔
150
                        workerId,
4✔
151
                        traceId,
4✔
152
                        spanId,
4✔
153
                        startedAt,
4✔
154
                        cancellationToken
4✔
155
                ).ConfigureAwait(false);
4✔
156
                await _primary.SetExecutionTelemetryAsync(
4✔
157
                        jobId,
4✔
158
                        executionNumber,
4✔
159
                        workerId,
4✔
160
                        traceId,
4✔
161
                        spanId,
4✔
162
                        startedAt,
4✔
163
                        cancellationToken
4✔
164
                ).ConfigureAwait(false);
4✔
165
        }
4✔
166

167
        /// <inheritdoc />
168
        public async ValueTask RenewLeaseAsync(
169
                string jobId,
170
                int executionNumber,
171
                string workerId,
172
                TimeSpan lease,
173
                CancellationToken cancellationToken = default
174
        )
175
        {
176
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
177
                await DurableStorage.RenewLeaseAsync(jobId, executionNumber, workerId, lease, cancellationToken).ConfigureAwait(false);
×
178
                await _primary.RenewLeaseAsync(jobId, executionNumber, workerId, lease, cancellationToken).ConfigureAwait(false);
×
179
        }
×
180

181
        /// <inheritdoc />
182
        public async ValueTask CompleteAsync(
183
                string jobId,
184
                int executionNumber,
185
                string workerId,
186
                CancellationToken cancellationToken = default
187
        )
188
        {
189
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
4✔
190
                await DurableStorage.CompleteAsync(jobId, executionNumber, workerId, cancellationToken).ConfigureAwait(false);
4✔
191
                await _primary.CompleteAsync(jobId, executionNumber, workerId, cancellationToken).ConfigureAwait(false);
4✔
192
        }
4✔
193

194
        /// <inheritdoc />
195
        public async ValueTask CompleteWithContinuationsAsync(
196
                string jobId,
197
                int executionNumber,
198
                string workerId,
199
                IReadOnlyList<JobContinuationAddition> additions,
200
                CancellationToken cancellationToken = default
201
        )
202
        {
203
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
204
                await _graphDurableStorage.CompleteWithContinuationsAsync(jobId, executionNumber, workerId, additions, cancellationToken)
×
205
                        .ConfigureAwait(false);
×
206
                await _primary.CompleteWithContinuationsAsync(jobId, executionNumber, workerId, additions, cancellationToken)
×
207
                        .ConfigureAwait(false);
×
208
        }
×
209

210
        /// <inheritdoc />
211
        public async ValueTask AddBatchJobAsync(
212
                string currentJobId,
213
                int executionNumber,
214
                JobRecord job,
215
                ContinuationOptions options,
216
                CancellationToken cancellationToken = default
217
        )
218
        {
219
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
220
                await _graphDurableStorage.AddBatchJobAsync(currentJobId, executionNumber, job, options, cancellationToken).ConfigureAwait(false);
×
221
                await _primary.AddBatchJobAsync(currentJobId, executionNumber, job, options, cancellationToken).ConfigureAwait(false);
×
222
        }
×
223

224
        /// <inheritdoc />
225
        public async ValueTask FailAsync(
226
                string jobId,
227
                int executionNumber,
228
                string workerId,
229
                string error,
230
                DateTimeOffset? nextRetryAt,
231
                CancellationToken cancellationToken = default
232
        )
233
        {
234
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
235
                await DurableStorage.FailAsync(jobId, executionNumber, workerId, error, nextRetryAt, cancellationToken).ConfigureAwait(false);
×
236
                await _primary.FailAsync(jobId, executionNumber, workerId, error, nextRetryAt, cancellationToken).ConfigureAwait(false);
×
237
        }
×
238

239
        /// <inheritdoc />
240
        public async ValueTask UpsertRecurringAsync(RecurringJobSchedule schedule, CancellationToken cancellationToken = default)
241
        {
242
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
4✔
243
                await _recurringDurableStorage.UpsertRecurringAsync(schedule, cancellationToken).ConfigureAwait(false);
4✔
244
                await _primary.UpsertRecurringAsync(schedule, cancellationToken).ConfigureAwait(false);
4✔
245
        }
4✔
246

247
        /// <inheritdoc />
248
        public async ValueTask RemoveObsoleteCodeDefinedRecurringAsync(
249
                IReadOnlyCollection<string> activeScheduleNames,
250
                CancellationToken cancellationToken = default
251
        )
252
        {
253
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
4✔
254
                await _recurringDurableStorage.RemoveObsoleteCodeDefinedRecurringAsync(activeScheduleNames, cancellationToken)
4✔
255
                        .ConfigureAwait(false);
4✔
256
                await _primary.RemoveObsoleteCodeDefinedRecurringAsync(activeScheduleNames, cancellationToken).ConfigureAwait(false);
4✔
257
        }
4✔
258

259
        /// <inheritdoc />
260
        public async ValueTask RemoveRecurringAsync(string name, CancellationToken cancellationToken = default)
261
        {
262
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
263
                await _recurringDurableStorage.RemoveRecurringAsync(name, cancellationToken).ConfigureAwait(false);
×
264
                await _primary.RemoveRecurringAsync(name, cancellationToken).ConfigureAwait(false);
×
265
        }
×
266

267
        /// <inheritdoc />
268
        public async ValueTask PauseRecurringAsync(string name, CancellationToken cancellationToken = default)
269
        {
270
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
271
                await _recurringDurableStorage.PauseRecurringAsync(name, cancellationToken).ConfigureAwait(false);
×
272
                await _primary.PauseRecurringAsync(name, cancellationToken).ConfigureAwait(false);
×
273
        }
×
274

275
        /// <inheritdoc />
276
        public async ValueTask ResumeRecurringAsync(string name, CancellationToken cancellationToken = default)
277
        {
278
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
279
                await _recurringDurableStorage.ResumeRecurringAsync(name, cancellationToken).ConfigureAwait(false);
×
280
                await _primary.ResumeRecurringAsync(name, cancellationToken).ConfigureAwait(false);
×
281
        }
×
282

283
        /// <inheritdoc />
284
        public async ValueTask<IReadOnlyList<RecurringJobSchedule>> GetDueRecurringAsync(
285
                DateTimeOffset now,
286
                int batchSize,
287
                CancellationToken cancellationToken = default
288
        )
289
        {
290
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
291
                return await _primary.GetDueRecurringAsync(now, batchSize, cancellationToken).ConfigureAwait(false);
×
292
        }
293

294
        /// <inheritdoc />
295
        public async ValueTask<bool> MaterializeRecurringAsync(
296
                RecurringJobSchedule schedule,
297
                JobRecord job,
298
                DateTimeOffset nextRunAt,
299
                CancellationToken cancellationToken = default
300
        )
301
        {
302
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
303
                if (!await _recurringDurableStorage.MaterializeRecurringAsync(schedule, job, nextRunAt, cancellationToken).ConfigureAwait(false))
×
304
                        return false;
×
305
                return await _primary.MaterializeRecurringAsync(schedule, job, nextRunAt, cancellationToken).ConfigureAwait(false);
×
306
        }
307

308
        /// <inheritdoc />
309
        public async ValueTask<JobMonitoringSnapshot> GetMonitoringSnapshotAsync(CancellationToken cancellationToken = default)
310
        {
311
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
4✔
312
                return await _primary.GetMonitoringSnapshotAsync(cancellationToken).ConfigureAwait(false);
4✔
313
        }
3✔
314

315
        /// <inheritdoc />
316
        public async ValueTask<IReadOnlyList<JobRecord>> QueryJobsAsync(
317
                JobQuery query,
318
                CancellationToken cancellationToken = default
319
        )
320
        {
321
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
4✔
322
                return await _primary.QueryJobsAsync(query, cancellationToken).ConfigureAwait(false);
4✔
323
        }
3✔
324

325
        /// <inheritdoc />
326
        public async ValueTask<IReadOnlyList<JobExecutionRecord>> QueryJobExecutionsAsync(
327
                JobExecutionQuery query,
328
                CancellationToken cancellationToken = default
329
        )
330
        {
331
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
4✔
332
                // Recovery restores current jobs and related state into the primary, but not retained executions.
333
                return await DurableStorage.QueryJobExecutionsAsync(query, cancellationToken).ConfigureAwait(false);
4✔
334
        }
3✔
335

336
        /// <inheritdoc />
337
        public async ValueTask<BatchStatus?> GetBatchStatusAsync(
338
                string batchId,
339
                CancellationToken cancellationToken = default
340
        )
341
        {
342
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
343
                return await _primary.GetBatchStatusAsync(batchId, cancellationToken).ConfigureAwait(false);
×
344
        }
345

346
        /// <inheritdoc />
347
        public async ValueTask<IReadOnlyList<BatchStatus>> QueryBatchesAsync(
348
                JobBatchQuery query,
349
                CancellationToken cancellationToken = default
350
        )
351
        {
352
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
353
                return await _primary.QueryBatchesAsync(query, cancellationToken).ConfigureAwait(false);
×
354
        }
355

356
        /// <inheritdoc />
357
        public async ValueTask<IReadOnlyList<BatchMemberStatus>> QueryBatchMembersAsync(
358
                string batchId,
359
                BatchMemberQuery query,
360
                CancellationToken cancellationToken = default
361
        )
362
        {
363
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
364
                return await _primary.QueryBatchMembersAsync(batchId, query, cancellationToken).ConfigureAwait(false);
×
365
        }
366

367
        /// <inheritdoc />
368
        public async ValueTask<BatchGraph?> GetBatchGraphAsync(
369
                string batchId,
370
                CancellationToken cancellationToken = default
371
        )
372
        {
373
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
4✔
374
                return await _primary.GetBatchGraphAsync(batchId, cancellationToken).ConfigureAwait(false);
4✔
375
        }
3✔
376

377
        /// <inheritdoc />
378
        public async ValueTask<JobStatus?> GetJobStatusAsync(
379
                string jobId,
380
                CancellationToken cancellationToken = default
381
        )
382
        {
383
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
4✔
384
                return await _primary.GetJobStatusAsync(jobId, cancellationToken).ConfigureAwait(false);
4✔
385
        }
3✔
386

387
        /// <inheritdoc />
388
        public async ValueTask CancelBatchAsync(string batchId, CancellationToken cancellationToken = default)
389
        {
390
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
391
                await _graphDurableStorage.CancelBatchAsync(batchId, cancellationToken).ConfigureAwait(false);
×
392
                await _primary.CancelBatchAsync(batchId, cancellationToken).ConfigureAwait(false);
×
393
        }
×
394

395
        /// <inheritdoc />
396
        public async ValueTask DeleteBatchAsync(string batchId, CancellationToken cancellationToken = default)
397
        {
398
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
399
                await _graphDurableStorage.DeleteBatchAsync(batchId, cancellationToken).ConfigureAwait(false);
×
400
                await _primary.DeleteBatchAsync(batchId, cancellationToken).ConfigureAwait(false);
×
401
        }
×
402

403
        /// <inheritdoc />
404
        public async ValueTask CancelAsync(string jobId, CancellationToken cancellationToken = default)
405
        {
NEW
406
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
NEW
407
                await DurableStorage.CancelAsync(jobId, cancellationToken).ConfigureAwait(false);
×
NEW
408
                await _primary.CancelAsync(jobId, cancellationToken).ConfigureAwait(false);
×
NEW
409
        }
×
410

411
        /// <inheritdoc />
412
        public async ValueTask RetryAsync(string jobId, CancellationToken cancellationToken = default)
413
        {
414
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
415
                await DurableStorage.RetryAsync(jobId, cancellationToken).ConfigureAwait(false);
×
416
                await _primary.RetryAsync(jobId, cancellationToken).ConfigureAwait(false);
×
417
        }
×
418

419
        /// <inheritdoc />
420
        public async ValueTask DeleteAsync(string jobId, CancellationToken cancellationToken = default)
421
        {
422
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
423
                await DurableStorage.DeleteAsync(jobId, cancellationToken).ConfigureAwait(false);
×
424
                await _primary.DeleteAsync(jobId, cancellationToken).ConfigureAwait(false);
×
425
        }
×
426

427
        /// <inheritdoc />
428
        public async ValueTask PurgeJobsAsync(
429
                TimeSpan succeededRetention,
430
                TimeSpan failedRetention,
431
                CancellationToken cancellationToken = default
432
        )
433
        {
434
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
435
                await DurableStorage.PurgeJobsAsync(
×
436
                        succeededRetention,
×
437
                        failedRetention,
×
438
                        cancellationToken
×
439
                ).ConfigureAwait(false);
×
440
                await _primary.PurgeJobsAsync(
×
441
                        succeededRetention,
×
442
                        failedRetention,
×
443
                        cancellationToken
×
444
                ).ConfigureAwait(false);
×
445
        }
×
446

447
        /// <inheritdoc />
448
        public async ValueTask PurgeBatchesAsync(
449
                TimeSpan batchSucceededRetention,
450
                TimeSpan batchFailedRetention,
451
                CancellationToken cancellationToken = default
452
        )
453
        {
454
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
455
                await _graphDurableStorage.PurgeBatchesAsync(
×
456
                        batchSucceededRetention,
×
457
                        batchFailedRetention,
×
458
                        cancellationToken
×
459
                ).ConfigureAwait(false);
×
460
                await _primary.PurgeBatchesAsync(
×
461
                        batchSucceededRetention,
×
462
                        batchFailedRetention,
×
463
                        cancellationToken
×
464
                ).ConfigureAwait(false);
×
465
        }
×
466

467
        /// <inheritdoc />
468
        public async ValueTask HeartbeatAsync(JobServerSnapshot server, CancellationToken cancellationToken = default)
469
        {
470
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
4✔
471
                await _primary.HeartbeatAsync(server, cancellationToken).ConfigureAwait(false);
4✔
472
        }
4✔
473

474
        /// <inheritdoc />
475
        public async ValueTask<bool> IsHealthyAsync(CancellationToken cancellationToken = default)
476
        {
477
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
478
                return await DurableStorage.IsHealthyAsync(cancellationToken).ConfigureAwait(false);
×
479
        }
480

481
        /// <inheritdoc />
482
        public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
4✔
483

484
        /// <inheritdoc />
485
        public ValueTask DisposeAsync()
486
        {
4✔
487
                lock (_disposeGate)
488
                        return new(_disposeTask ??= DisposeCoreAsync());
4✔
489
        }
4✔
490

491
        private async Task DisposeCoreAsync()
492
        {
493
                Volatile.Write(ref _disposeStarted, 1);
4✔
494
                await _initialization.WaitAsync(CancellationToken.None).ConfigureAwait(false);
4✔
495
                try
496
                {
497
                        await _primary.DisposeAsync().ConfigureAwait(false);
4✔
498
                        await DurableStorage.DisposeAsync().ConfigureAwait(false);
4✔
499
                }
4✔
500
                finally
501
                {
502
                        _ = _initialization.Release();
4✔
503
                }
1✔
504
        }
4✔
505

506
        private async ValueTask EnsureInitializedAsync(CancellationToken cancellationToken)
507
        {
508
                ThrowIfDisposed();
4✔
509
                if (Volatile.Read(ref _initialized))
4✔
510
                        return;
4✔
511

512
                await _initialization.WaitAsync(cancellationToken).ConfigureAwait(false);
4✔
513
                InMemoryJobStorage? recoveredPrimary = null;
4✔
514
                try
515
                {
516
                        ThrowIfDisposed();
4✔
517
                        if (Volatile.Read(ref _initialized))
4✔
518
                                return;
519

520
                        await DurableStorage.InitializeAsync(cancellationToken).ConfigureAwait(false);
4✔
521
                        recoveredPrimary = CreatePrimaryStorage();
4✔
522
                        await recoveredPrimary.InitializeAsync(cancellationToken).ConfigureAwait(false);
4✔
523

524
                        var recoveredJobs = new List<JobRecord>();
4✔
525
                        var recoveredIncomingEdges = new Dictionary<string, List<JobContinuationEdge>>(StringComparer.Ordinal);
4✔
526
                        foreach (var state in Enum.GetValues<JobState>())
4✔
527
                        {
528
                                var skip = 0;
4✔
529
                                while (true)
4✔
530
                                {
531
                                        var jobs = await DurableStorage.QueryJobsAsync(
4✔
532
                                                new() { State = state, Skip = skip, Take = RecoveryBatchSize },
4✔
533
                                                cancellationToken
4✔
534
                                        ).ConfigureAwait(false);
4✔
535
                                        recoveredJobs.AddRange(jobs);
4✔
536
                                        var standaloneIds = jobs
4✔
537
                                                .Where(static job => job.BatchId is null)
4✔
538
                                                .Select(static job => job.Id)
4✔
539
                                                .ToArray();
4✔
540
                                        if (standaloneIds.Length != 0)
4✔
541
                                        {
542
                                                var incomingEdges = await _graphDurableStorage.GetIncomingEdgesAsync(
4✔
543
                                                        standaloneIds,
4✔
544
                                                        cancellationToken
4✔
545
                                                ).ConfigureAwait(false);
4✔
546
                                                var requestedIds = standaloneIds.ToHashSet(StringComparer.Ordinal);
4✔
547
                                                foreach (var edge in incomingEdges)
4✔
548
                                                {
549
                                                        if (!requestedIds.Contains(edge.ChildJobId))
4✔
550
                                                        {
551
                                                                throw new ImmediateJobException(
×
552
                                                                        $"Durable storage returned an incoming edge for unrequested job '{edge.ChildJobId}'."
×
553
                                                                );
×
554
                                                        }
555

556
                                                        if (!recoveredIncomingEdges.TryGetValue(edge.ChildJobId, out var edges))
4✔
557
                                                                recoveredIncomingEdges.Add(edge.ChildJobId, edges = []);
4✔
558
                                                        edges.Add(edge);
4✔
559
                                                }
560
                                        }
561

562
                                        if (jobs.Count < RecoveryBatchSize)
4✔
563
                                                break;
564
                                        skip += jobs.Count;
4✔
565
                                }
3✔
566
                        }
567

568
                        var batchIds = recoveredJobs
4✔
569
                                .Where(static job => job.BatchId is not null)
4✔
570
                                .Select(static job => job.BatchId!)
4✔
571
                                .Distinct(StringComparer.Ordinal)
4✔
572
                                .ToArray();
4✔
573
                        var recoveredBatches = new Dictionary<string, RecoveredBatch>(batchIds.Length, StringComparer.Ordinal);
4✔
574
                        foreach (var batchId in batchIds)
4✔
575
                        {
576
                                var status = await _graphDurableStorage.GetBatchStatusAsync(batchId, cancellationToken).ConfigureAwait(false)
4✔
577
                                        ?? throw new ImmediateJobException($"Batch '{batchId}' has members but no durable batch header.");
4✔
578
                                var graph = await _graphDurableStorage.GetBatchGraphAsync(batchId, cancellationToken).ConfigureAwait(false)
4✔
579
                                        ?? throw new ImmediateJobException($"Batch '{batchId}' has members but no durable dependency graph.");
4✔
580
                                recoveredBatches.Add(batchId, new(
4✔
581
                                        new()
4✔
582
                                        {
4✔
583
                                                Id = status.Id,
4✔
584
                                                CreatedAt = status.CreatedAt,
4✔
585
                                                TotalJobs = status.Total,
4✔
586
                                                PendingCount = status.Remaining,
4✔
587
                                                SucceededCount = status.Succeeded,
4✔
588
                                                FailedCount = status.Failed,
4✔
589
                                                CancelledCount = status.Cancelled,
4✔
590
                                                SkippedCount = status.Skipped,
4✔
591
                                                StartedAt = status.StartedAt,
4✔
592
                                                CompletedAt = status.CompletedAt,
4✔
593
                                                State = status.State,
4✔
594
                                        },
4✔
595
                                        [.. recoveredJobs.Where(job => string.Equals(job.BatchId, batchId, StringComparison.Ordinal))],
4✔
596
                                        [.. graph.Edges.Select(ToContinuationEdge)]
4✔
597
                                ));
4✔
598
                        }
3✔
599

600
                        var restoredBatchIds = new HashSet<string>(StringComparer.Ordinal);
4✔
601
                        while (recoveredBatches.Count != 0)
4✔
602
                        {
603
                                var ready = recoveredBatches.Values
4✔
604
                                        .Where(batch => batch.Edges
4✔
605
                                                .Where(static edge => edge.ParentBatchId is not null)
4✔
606
                                                .All(edge => restoredBatchIds.Contains(edge.ParentBatchId!)))
4✔
607
                                        .OrderBy(static batch => batch.Record.CreatedAt)
1✔
608
                                        .ThenBy(static batch => batch.Record.Id, StringComparer.Ordinal)
1✔
609
                                        .ToArray();
4✔
610
                                if (ready.Length == 0)
4✔
611
                                {
612
                                        var unresolved = string.Join(", ", recoveredBatches.Keys.Order(StringComparer.Ordinal));
×
613
                                        throw new ImmediateJobException(
×
614
                                                $"Durable batches have cyclic or missing parent-batch dependencies: {unresolved}."
×
615
                                        );
×
616
                                }
617

618
                                foreach (var batch in ready)
4✔
619
                                {
620
                                        await recoveredPrimary.EnqueueBatchAsync(
4✔
621
                                                batch.Record,
4✔
622
                                                batch.Jobs,
4✔
623
                                                batch.Edges,
4✔
624
                                                cancellationToken
4✔
625
                                        ).ConfigureAwait(false);
4✔
626
                                        _ = recoveredBatches.Remove(batch.Record.Id);
4✔
627
                                        _ = restoredBatchIds.Add(batch.Record.Id);
4✔
628
                                }
3✔
629
                        }
630

631
                        var restoredJobIds = recoveredJobs
4✔
632
                                .Where(static job => job.BatchId is not null)
4✔
633
                                .Select(static job => job.Id)
4✔
634
                                .ToHashSet(StringComparer.Ordinal);
4✔
635
                        var allRecoveredJobIds = recoveredJobs.Select(static job => job.Id).ToHashSet(StringComparer.Ordinal);
4✔
636
                        var standaloneContinuations = new Dictionary<string, JobRecord>(StringComparer.Ordinal);
4✔
637
                        foreach (var job in recoveredJobs.Where(static job => job.BatchId is null))
4✔
638
                        {
639
                                if (!recoveredIncomingEdges.TryGetValue(job.Id, out var incomingEdges))
4✔
640
                                {
641
                                        if (job.State == JobState.AwaitingContinuation || job.RemainingDependencies != 0)
4✔
642
                                        {
643
                                                throw new ImmediateJobException(
×
644
                                                        $"Job '{job.Id}' has continuation dependencies but no durable dependency graph."
×
645
                                                );
×
646
                                        }
647

648
                                        await recoveredPrimary.EnqueueAsync(job, cancellationToken).ConfigureAwait(false);
4✔
649
                                        _ = restoredJobIds.Add(job.Id);
4✔
650
                                }
651
                                else
652
                                {
653
                                        standaloneContinuations.Add(job.Id, job);
4✔
654
                                }
655
                        }
3✔
656

657
                        bool AreContinuationParentsRestored(JobRecord job) => recoveredIncomingEdges[job.Id].All(edge =>
4✔
658
                                (edge.ParentJobId is null || restoredJobIds.Contains(edge.ParentJobId))
4✔
659
                                && (edge.ParentBatchId is null || restoredBatchIds.Contains(edge.ParentBatchId))
4✔
660
                        );
4✔
661

662
                        while (standaloneContinuations.Count != 0)
4✔
663
                        {
664
                                var ready = standaloneContinuations.Values
4✔
665
                                        .Where(AreContinuationParentsRestored)
4✔
666
                                        .OrderBy(static job => job.CreatedAt)
1✔
667
                                        .ThenBy(static job => job.Id, StringComparer.Ordinal)
1✔
668
                                        .ToArray();
4✔
669
                                if (ready.Length == 0)
4✔
670
                                {
671
                                        var missingParents = standaloneContinuations.Values
×
672
                                                .SelectMany(job => recoveredIncomingEdges[job.Id])
×
673
                                                .Where(edge => edge.ParentJobId is { } parentId && !allRecoveredJobIds.Contains(parentId))
×
674
                                                .Select(static edge => edge.ParentJobId!)
×
675
                                                .Distinct(StringComparer.Ordinal)
×
676
                                                .Order(StringComparer.Ordinal)
×
677
                                                .ToArray();
×
678
                                        var missingParentBatches = standaloneContinuations.Values
×
679
                                                .SelectMany(job => recoveredIncomingEdges[job.Id])
×
680
                                                .Where(edge => edge.ParentBatchId is { } parentId && !restoredBatchIds.Contains(parentId))
×
681
                                                .Select(static edge => edge.ParentBatchId!)
×
682
                                                .Distinct(StringComparer.Ordinal)
×
683
                                                .Order(StringComparer.Ordinal)
×
684
                                                .ToArray();
×
685
                                        var unresolved = string.Join(", ", standaloneContinuations.Keys.Order(StringComparer.Ordinal));
×
686
                                        if (missingParents.Length != 0)
×
687
                                        {
688
                                                throw new ImmediateJobException(
×
689
                                                        $"Durable standalone continuations reference missing parent jobs: {string.Join(", ", missingParents)}."
×
690
                                                );
×
691
                                        }
692

693
                                        if (missingParentBatches.Length != 0)
×
694
                                        {
695
                                                throw new ImmediateJobException(
×
696
                                                        $"Durable standalone continuations reference missing parent batches: {string.Join(", ", missingParentBatches)}."
×
697
                                                );
×
698
                                        }
699

700
                                        throw new ImmediateJobException($"Durable standalone continuations contain a cycle: {unresolved}.");
×
701
                                }
702

703
                                foreach (var job in ready)
4✔
704
                                {
705
                                        await recoveredPrimary.EnqueueContinuationAsync(
4✔
706
                                                job,
4✔
707
                                                recoveredIncomingEdges[job.Id],
4✔
708
                                                cancellationToken
4✔
709
                                        ).ConfigureAwait(false);
4✔
710
                                        _ = standaloneContinuations.Remove(job.Id);
4✔
711
                                        _ = restoredJobIds.Add(job.Id);
4✔
712
                                }
3✔
713
                        }
714

715
                        var snapshot = await DurableStorage.GetMonitoringSnapshotAsync(cancellationToken).ConfigureAwait(false);
4✔
716
                        foreach (var schedule in snapshot.Recurring)
4✔
717
                                await recoveredPrimary.UpsertRecurringAsync(schedule, cancellationToken).ConfigureAwait(false);
4✔
718

719
                        var previousPrimary = _primary;
4✔
720
                        _primary = recoveredPrimary;
4✔
721
                        recoveredPrimary = null;
4✔
722
                        await previousPrimary.DisposeAsync().ConfigureAwait(false);
4✔
723
                        Volatile.Write(ref _initialized, true);
4✔
724
                }
3✔
725
                finally
726
                {
727
                        if (recoveredPrimary is not null)
4✔
728
                                await recoveredPrimary.DisposeAsync().ConfigureAwait(false);
×
729
                        _ = _initialization.Release();
4✔
730
                }
731
        }
3✔
732

733
        private void ThrowIfDisposed() =>
734
                ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposeStarted) != 0, this);
4✔
735

736
        private static JobContinuationEdge ToContinuationEdge(BatchGraphEdge edge) => new()
4✔
737
        {
4✔
738
                ChildJobId = edge.ChildJobId,
4✔
739
                ParentJobId = edge.ParentJobId,
4✔
740
                ParentBatchId = edge.ParentBatchId,
4✔
741
                Trigger = edge.Trigger,
4✔
742
        };
4✔
743

744
        private InMemoryJobStorage CreatePrimaryStorage() => new(_timeProvider);
4✔
745

746
        private sealed record RecoveredBatch(
4✔
747
                JobBatchRecord Record,
4✔
748
                IReadOnlyList<JobRecord> Jobs,
4✔
749
                IReadOnlyList<JobContinuationEdge> Edges
4✔
750
        );
4✔
751
}
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