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

ImmediatePlatform / Immediate.Jobs / 30394116826

28 Jul 2026 07:56PM UTC coverage: 74.382%. First build
30394116826

Pull #46

github

web-flow
Merge a20371fc5 into c324de21a
Pull Request #46: Address PR comments

570 of 684 new or added lines in 10 files covered. (83.33%)

5807 of 7807 relevant lines covered (74.38%)

2.4 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
#pragma warning disable IDE0330 // System.Threading.Lock is unavailable on the lowest target framework.
21
        private readonly object _disposeGate = new();
4✔
22
#pragma warning restore IDE0330
23
        private InMemoryJobStorage _primary;
24
        private bool _initialized;
25
        private Task? _disposeTask;
26
        private int _disposeStarted;
27

28
        /// <summary>Creates a memory-primary store backed by the supplied durable replica.</summary>
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
        public IJobStorage PrimaryStorage => _primary;
4✔
52

53
        /// <summary>The durable write-through replica.</summary>
54
        public IJobStorage DurableStorage { get; }
55

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

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

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

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

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

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

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

129
                return acquired;
4✔
130
        }
3✔
131

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

161
        /// <inheritdoc />
162
        public async ValueTask RenewLeaseAsync(
163
                string jobId,
164
                string workerId,
165
                TimeSpan lease,
166
                CancellationToken cancellationToken = default
167
        )
168
        {
169
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
170
                await DurableStorage.RenewLeaseAsync(jobId, workerId, lease, cancellationToken).ConfigureAwait(false);
×
171
                await _primary.RenewLeaseAsync(jobId, workerId, lease, cancellationToken).ConfigureAwait(false);
×
172
        }
×
173

174
        /// <inheritdoc />
175
        public async ValueTask CompleteAsync(string jobId, string workerId, CancellationToken cancellationToken = default)
176
        {
177
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
4✔
178
                await DurableStorage.CompleteAsync(jobId, workerId, cancellationToken).ConfigureAwait(false);
4✔
179
                await _primary.CompleteAsync(jobId, workerId, cancellationToken).ConfigureAwait(false);
4✔
180
        }
4✔
181

182
        /// <inheritdoc />
183
        public async ValueTask CompleteWithContinuationsAsync(
184
                string jobId,
185
                string workerId,
186
                IReadOnlyList<JobContinuationAddition> additions,
187
                CancellationToken cancellationToken = default
188
        )
189
        {
190
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
191
                await _graphDurableStorage.CompleteWithContinuationsAsync(jobId, workerId, additions, cancellationToken)
×
192
                        .ConfigureAwait(false);
×
193
                await _primary.CompleteWithContinuationsAsync(jobId, workerId, additions, cancellationToken)
×
194
                        .ConfigureAwait(false);
×
195
        }
×
196

197
        /// <inheritdoc />
198
        public async ValueTask AddBatchJobAsync(
199
                string currentJobId,
200
                JobRecord job,
201
                ContinuationOptions options,
202
                CancellationToken cancellationToken = default
203
        )
204
        {
205
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
206
                await _graphDurableStorage.AddBatchJobAsync(currentJobId, job, options, cancellationToken).ConfigureAwait(false);
×
207
                await _primary.AddBatchJobAsync(currentJobId, job, options, cancellationToken).ConfigureAwait(false);
×
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
        {
219
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
220
                await DurableStorage.FailAsync(jobId, workerId, error, nextRetryAt, cancellationToken).ConfigureAwait(false);
×
221
                await _primary.FailAsync(jobId, workerId, error, nextRetryAt, cancellationToken).ConfigureAwait(false);
×
222
        }
×
223

224
        /// <inheritdoc />
225
        public async ValueTask UpsertRecurringAsync(RecurringJobSchedule schedule, CancellationToken cancellationToken = default)
226
        {
227
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
4✔
228
                await _recurringDurableStorage.UpsertRecurringAsync(schedule, cancellationToken).ConfigureAwait(false);
4✔
229
                await _primary.UpsertRecurringAsync(schedule, cancellationToken).ConfigureAwait(false);
4✔
230
        }
4✔
231

232
        /// <inheritdoc />
233
        public async ValueTask RemoveObsoleteCodeDefinedRecurringAsync(
234
                IReadOnlyCollection<string> activeScheduleNames,
235
                CancellationToken cancellationToken = default
236
        )
237
        {
238
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
4✔
239
                await _recurringDurableStorage.RemoveObsoleteCodeDefinedRecurringAsync(activeScheduleNames, cancellationToken)
4✔
240
                        .ConfigureAwait(false);
4✔
241
                await _primary.RemoveObsoleteCodeDefinedRecurringAsync(activeScheduleNames, cancellationToken).ConfigureAwait(false);
4✔
242
        }
4✔
243

244
        /// <inheritdoc />
245
        public async ValueTask RemoveRecurringAsync(string name, CancellationToken cancellationToken = default)
246
        {
247
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
248
                await _recurringDurableStorage.RemoveRecurringAsync(name, cancellationToken).ConfigureAwait(false);
×
249
                await _primary.RemoveRecurringAsync(name, cancellationToken).ConfigureAwait(false);
×
250
        }
×
251

252
        /// <inheritdoc />
253
        public async ValueTask PauseRecurringAsync(string name, CancellationToken cancellationToken = default)
254
        {
255
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
256
                await _recurringDurableStorage.PauseRecurringAsync(name, cancellationToken).ConfigureAwait(false);
×
257
                await _primary.PauseRecurringAsync(name, cancellationToken).ConfigureAwait(false);
×
258
        }
×
259

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

268
        /// <inheritdoc />
269
        public async ValueTask<IReadOnlyList<RecurringJobSchedule>> GetDueRecurringAsync(
270
                DateTimeOffset now,
271
                int batchSize,
272
                CancellationToken cancellationToken = default
273
        )
274
        {
275
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
276
                return await _primary.GetDueRecurringAsync(now, batchSize, cancellationToken).ConfigureAwait(false);
×
277
        }
278

279
        /// <inheritdoc />
280
        public async ValueTask<bool> MaterializeRecurringAsync(
281
                RecurringJobSchedule schedule,
282
                JobRecord job,
283
                DateTimeOffset nextRunAt,
284
                CancellationToken cancellationToken = default
285
        )
286
        {
287
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
288
                if (!await _recurringDurableStorage.MaterializeRecurringAsync(schedule, job, nextRunAt, cancellationToken).ConfigureAwait(false))
×
289
                        return false;
×
290
                return await _primary.MaterializeRecurringAsync(schedule, job, nextRunAt, cancellationToken).ConfigureAwait(false);
×
291
        }
292

293
        /// <inheritdoc />
294
        public async ValueTask<JobMonitoringSnapshot> GetMonitoringSnapshotAsync(CancellationToken cancellationToken = default)
295
        {
296
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
4✔
297
                return await _primary.GetMonitoringSnapshotAsync(cancellationToken).ConfigureAwait(false);
4✔
298
        }
3✔
299

300
        /// <inheritdoc />
301
        public async ValueTask<IReadOnlyList<JobRecord>> QueryJobsAsync(
302
                JobQuery query,
303
                CancellationToken cancellationToken = default
304
        )
305
        {
306
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
4✔
307
                return await _primary.QueryJobsAsync(query, cancellationToken).ConfigureAwait(false);
4✔
308
        }
3✔
309

310
        /// <inheritdoc />
311
        public async ValueTask<BatchStatus?> GetBatchStatusAsync(
312
                string batchId,
313
                CancellationToken cancellationToken = default
314
        )
315
        {
316
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
317
                return await _primary.GetBatchStatusAsync(batchId, cancellationToken).ConfigureAwait(false);
×
318
        }
319

320
        /// <inheritdoc />
321
        public async ValueTask<IReadOnlyList<BatchStatus>> QueryBatchesAsync(
322
                JobBatchQuery query,
323
                CancellationToken cancellationToken = default
324
        )
325
        {
326
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
327
                return await _primary.QueryBatchesAsync(query, cancellationToken).ConfigureAwait(false);
×
328
        }
329

330
        /// <inheritdoc />
331
        public async ValueTask<IReadOnlyList<BatchMemberStatus>> QueryBatchMembersAsync(
332
                string batchId,
333
                BatchMemberQuery query,
334
                CancellationToken cancellationToken = default
335
        )
336
        {
337
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
338
                return await _primary.QueryBatchMembersAsync(batchId, query, cancellationToken).ConfigureAwait(false);
×
339
        }
340

341
        /// <inheritdoc />
342
        public async ValueTask<BatchGraph?> GetBatchGraphAsync(
343
                string batchId,
344
                CancellationToken cancellationToken = default
345
        )
346
        {
347
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
4✔
348
                return await _primary.GetBatchGraphAsync(batchId, cancellationToken).ConfigureAwait(false);
4✔
349
        }
3✔
350

351
        /// <inheritdoc />
352
        public async ValueTask<JobStatus?> GetJobStatusAsync(
353
                string jobId,
354
                CancellationToken cancellationToken = default
355
        )
356
        {
357
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
4✔
358
                return await _primary.GetJobStatusAsync(jobId, cancellationToken).ConfigureAwait(false);
4✔
359
        }
3✔
360

361
        /// <inheritdoc />
362
        public async ValueTask CancelBatchAsync(string batchId, CancellationToken cancellationToken = default)
363
        {
364
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
365
                await _graphDurableStorage.CancelBatchAsync(batchId, cancellationToken).ConfigureAwait(false);
×
366
                await _primary.CancelBatchAsync(batchId, cancellationToken).ConfigureAwait(false);
×
367
        }
×
368

369
        /// <inheritdoc />
370
        public async ValueTask DeleteBatchAsync(string batchId, CancellationToken cancellationToken = default)
371
        {
372
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
373
                await _graphDurableStorage.DeleteBatchAsync(batchId, cancellationToken).ConfigureAwait(false);
×
374
                await _primary.DeleteBatchAsync(batchId, cancellationToken).ConfigureAwait(false);
×
375
        }
×
376

377
        /// <inheritdoc />
378
        public async ValueTask RetryAsync(string jobId, CancellationToken cancellationToken = default)
379
        {
380
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
381
                await DurableStorage.RetryAsync(jobId, cancellationToken).ConfigureAwait(false);
×
382
                await _primary.RetryAsync(jobId, cancellationToken).ConfigureAwait(false);
×
383
        }
×
384

385
        /// <inheritdoc />
386
        public async ValueTask DeleteAsync(string jobId, CancellationToken cancellationToken = default)
387
        {
388
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
389
                await DurableStorage.DeleteAsync(jobId, cancellationToken).ConfigureAwait(false);
×
390
                await _primary.DeleteAsync(jobId, cancellationToken).ConfigureAwait(false);
×
391
        }
×
392

393
        /// <inheritdoc />
394
        public async ValueTask PurgeJobsAsync(
395
                TimeSpan succeededRetention,
396
                TimeSpan failedRetention,
397
                CancellationToken cancellationToken = default
398
        )
399
        {
400
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
401
                await DurableStorage.PurgeJobsAsync(
×
402
                        succeededRetention,
×
403
                        failedRetention,
×
404
                        cancellationToken
×
405
                ).ConfigureAwait(false);
×
406
                await _primary.PurgeJobsAsync(
×
407
                        succeededRetention,
×
408
                        failedRetention,
×
409
                        cancellationToken
×
410
                ).ConfigureAwait(false);
×
411
        }
×
412

413
        /// <inheritdoc />
414
        public async ValueTask PurgeBatchesAsync(
415
                TimeSpan batchSucceededRetention,
416
                TimeSpan batchFailedRetention,
417
                CancellationToken cancellationToken = default
418
        )
419
        {
420
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
421
                await _graphDurableStorage.PurgeBatchesAsync(
×
422
                        batchSucceededRetention,
×
423
                        batchFailedRetention,
×
424
                        cancellationToken
×
425
                ).ConfigureAwait(false);
×
426
                await _primary.PurgeBatchesAsync(
×
427
                        batchSucceededRetention,
×
428
                        batchFailedRetention,
×
429
                        cancellationToken
×
430
                ).ConfigureAwait(false);
×
431
        }
×
432

433
        /// <inheritdoc />
434
        public async ValueTask HeartbeatAsync(JobServerSnapshot server, CancellationToken cancellationToken = default)
435
        {
436
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
4✔
437
                await _primary.HeartbeatAsync(server, cancellationToken).ConfigureAwait(false);
4✔
438
        }
4✔
439

440
        /// <inheritdoc />
441
        public async ValueTask<bool> IsHealthyAsync(CancellationToken cancellationToken = default)
442
        {
443
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
444
                return await DurableStorage.IsHealthyAsync(cancellationToken).ConfigureAwait(false);
×
445
        }
446

447
        /// <inheritdoc />
448
        public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
4✔
449

450
        /// <inheritdoc />
451
        public ValueTask DisposeAsync()
452
        {
453
                lock (_disposeGate)
4✔
454
                        return new(_disposeTask ??= DisposeCoreAsync());
4✔
455
        }
4✔
456

457
        private async Task DisposeCoreAsync()
458
        {
459
                Volatile.Write(ref _disposeStarted, 1);
4✔
460
                await _initialization.WaitAsync(CancellationToken.None).ConfigureAwait(false);
4✔
461
                try
462
                {
463
                        await _primary.DisposeAsync().ConfigureAwait(false);
4✔
464
                        await DurableStorage.DisposeAsync().ConfigureAwait(false);
4✔
465
                }
4✔
466
                finally
467
                {
468
                        _ = _initialization.Release();
4✔
469
                }
1✔
470
        }
4✔
471

472
        private async ValueTask EnsureInitializedAsync(CancellationToken cancellationToken)
473
        {
474
                ThrowIfDisposed();
4✔
475
                if (Volatile.Read(ref _initialized))
4✔
476
                        return;
4✔
477

478
                await _initialization.WaitAsync(cancellationToken).ConfigureAwait(false);
4✔
479
                InMemoryJobStorage? recoveredPrimary = null;
4✔
480
                try
481
                {
482
                        ThrowIfDisposed();
4✔
483
                        if (Volatile.Read(ref _initialized))
4✔
484
                                return;
485

486
                        await DurableStorage.InitializeAsync(cancellationToken).ConfigureAwait(false);
4✔
487
                        recoveredPrimary = CreatePrimaryStorage();
4✔
488
                        await recoveredPrimary.InitializeAsync(cancellationToken).ConfigureAwait(false);
4✔
489

490
                        var recoveredJobs = new List<JobRecord>();
4✔
491
                        var recoveredIncomingEdges = new Dictionary<string, List<JobContinuationEdge>>(StringComparer.Ordinal);
4✔
492
                        foreach (var state in Enum.GetValues<JobState>())
4✔
493
                        {
494
                                var skip = 0;
4✔
495
                                while (true)
4✔
496
                                {
497
                                        var jobs = await DurableStorage.QueryJobsAsync(
4✔
498
                                                new() { State = state, Skip = skip, Take = RecoveryBatchSize },
4✔
499
                                                cancellationToken
4✔
500
                                        ).ConfigureAwait(false);
4✔
501
                                        recoveredJobs.AddRange(jobs);
4✔
502
                                        var standaloneIds = jobs
4✔
503
                                                .Where(static job => job.BatchId is null)
4✔
504
                                                .Select(static job => job.Id)
4✔
505
                                                .ToArray();
4✔
506
                                        if (standaloneIds.Length != 0)
4✔
507
                                        {
508
                                                var incomingEdges = await _graphDurableStorage.GetIncomingEdgesAsync(
4✔
509
                                                        standaloneIds,
4✔
510
                                                        cancellationToken
4✔
511
                                                ).ConfigureAwait(false);
4✔
512
                                                var requestedIds = standaloneIds.ToHashSet(StringComparer.Ordinal);
4✔
513
                                                foreach (var edge in incomingEdges)
4✔
514
                                                {
515
                                                        if (!requestedIds.Contains(edge.ChildJobId))
4✔
516
                                                        {
NEW
517
                                                                throw new ImmediateJobException(
×
NEW
518
                                                                        $"Durable storage returned an incoming edge for unrequested job '{edge.ChildJobId}'."
×
NEW
519
                                                                );
×
520
                                                        }
521

522
                                                        if (!recoveredIncomingEdges.TryGetValue(edge.ChildJobId, out var edges))
4✔
523
                                                                recoveredIncomingEdges.Add(edge.ChildJobId, edges = []);
4✔
524
                                                        edges.Add(edge);
4✔
525
                                                }
526
                                        }
527

528
                                        if (jobs.Count < RecoveryBatchSize)
4✔
529
                                                break;
530
                                        skip += jobs.Count;
4✔
531
                                }
3✔
532
                        }
533

534
                        var batchIds = recoveredJobs
4✔
535
                                .Where(static job => job.BatchId is not null)
4✔
536
                                .Select(static job => job.BatchId!)
4✔
537
                                .Distinct(StringComparer.Ordinal)
4✔
538
                                .ToArray();
4✔
539
                        var recoveredBatches = new Dictionary<string, RecoveredBatch>(batchIds.Length, StringComparer.Ordinal);
4✔
540
                        foreach (var batchId in batchIds)
4✔
541
                        {
542
                                var status = await _graphDurableStorage.GetBatchStatusAsync(batchId, cancellationToken).ConfigureAwait(false)
4✔
543
                                        ?? throw new ImmediateJobException($"Batch '{batchId}' has members but no durable batch header.");
4✔
544
                                var graph = await _graphDurableStorage.GetBatchGraphAsync(batchId, cancellationToken).ConfigureAwait(false)
4✔
545
                                        ?? throw new ImmediateJobException($"Batch '{batchId}' has members but no durable dependency graph.");
4✔
546
                                recoveredBatches.Add(batchId, new(
4✔
547
                                        new()
4✔
548
                                        {
4✔
549
                                                Id = status.Id,
4✔
550
                                                CreatedAt = status.CreatedAt,
4✔
551
                                                TotalJobs = status.Total,
4✔
552
                                                PendingCount = status.Remaining,
4✔
553
                                                SucceededCount = status.Succeeded,
4✔
554
                                                FailedCount = status.Failed,
4✔
555
                                                CancelledCount = status.Cancelled,
4✔
556
                                                StartedAt = status.StartedAt,
4✔
557
                                                CompletedAt = status.CompletedAt,
4✔
558
                                                State = status.State,
4✔
559
                                        },
4✔
560
                                        [.. recoveredJobs.Where(job => job.BatchId == batchId)],
4✔
561
                                        [.. graph.Edges.Select(ToContinuationEdge)]
4✔
562
                                ));
4✔
563
                        }
3✔
564

565
                        var restoredBatchIds = new HashSet<string>(StringComparer.Ordinal);
4✔
566
                        while (recoveredBatches.Count != 0)
4✔
567
                        {
568
                                var ready = recoveredBatches.Values
4✔
569
                                        .Where(batch => batch.Edges
4✔
570
                                                .Where(static edge => edge.ParentBatchId is not null)
4✔
571
                                                .All(edge => restoredBatchIds.Contains(edge.ParentBatchId!)))
4✔
572
                                        .OrderBy(static batch => batch.Record.CreatedAt)
1✔
573
                                        .ThenBy(static batch => batch.Record.Id, StringComparer.Ordinal)
1✔
574
                                        .ToArray();
4✔
575
                                if (ready.Length == 0)
4✔
576
                                {
577
                                        var unresolved = string.Join(", ", recoveredBatches.Keys.Order(StringComparer.Ordinal));
×
578
                                        throw new ImmediateJobException(
×
579
                                                $"Durable batches have cyclic or missing parent-batch dependencies: {unresolved}."
×
580
                                        );
×
581
                                }
582

583
                                foreach (var batch in ready)
4✔
584
                                {
585
                                        await recoveredPrimary.EnqueueBatchAsync(
4✔
586
                                                batch.Record,
4✔
587
                                                batch.Jobs,
4✔
588
                                                batch.Edges,
4✔
589
                                                cancellationToken
4✔
590
                                        ).ConfigureAwait(false);
4✔
591
                                        _ = recoveredBatches.Remove(batch.Record.Id);
4✔
592
                                        _ = restoredBatchIds.Add(batch.Record.Id);
4✔
593
                                }
3✔
594
                        }
595

596
                        var restoredJobIds = recoveredJobs
4✔
597
                                .Where(static job => job.BatchId is not null)
4✔
598
                                .Select(static job => job.Id)
4✔
599
                                .ToHashSet(StringComparer.Ordinal);
4✔
600
                        var allRecoveredJobIds = recoveredJobs.Select(static job => job.Id).ToHashSet(StringComparer.Ordinal);
4✔
601
                        var standaloneContinuations = new Dictionary<string, JobRecord>(StringComparer.Ordinal);
4✔
602
                        foreach (var job in recoveredJobs.Where(static job => job.BatchId is null))
4✔
603
                        {
604
                                if (!recoveredIncomingEdges.TryGetValue(job.Id, out var incomingEdges))
4✔
605
                                {
606
                                        if (job.State == JobState.AwaitingContinuation || job.RemainingDependencies != 0)
4✔
607
                                        {
NEW
608
                                                throw new ImmediateJobException(
×
NEW
609
                                                        $"Job '{job.Id}' has continuation dependencies but no durable dependency graph."
×
NEW
610
                                                );
×
611
                                        }
612

613
                                        await recoveredPrimary.EnqueueAsync(job, cancellationToken).ConfigureAwait(false);
4✔
614
                                        _ = restoredJobIds.Add(job.Id);
4✔
615
                                }
616
                                else
617
                                        standaloneContinuations.Add(job.Id, job);
4✔
618
                        }
3✔
619

620
                        bool AreContinuationParentsRestored(JobRecord job) => recoveredIncomingEdges[job.Id].All(edge =>
4✔
621
                                (edge.ParentJobId is null || restoredJobIds.Contains(edge.ParentJobId))
4✔
622
                                && (edge.ParentBatchId is null || restoredBatchIds.Contains(edge.ParentBatchId))
4✔
623
                        );
4✔
624

625
                        while (standaloneContinuations.Count != 0)
4✔
626
                        {
627
                                var ready = standaloneContinuations.Values
4✔
628
                                        .Where(AreContinuationParentsRestored)
4✔
629
                                        .OrderBy(static job => job.CreatedAt)
1✔
630
                                        .ThenBy(static job => job.Id, StringComparer.Ordinal)
1✔
631
                                        .ToArray();
4✔
632
                                if (ready.Length == 0)
4✔
633
                                {
NEW
634
                                        var missingParents = standaloneContinuations.Values
×
NEW
635
                                                .SelectMany(job => recoveredIncomingEdges[job.Id])
×
NEW
636
                                                .Where(edge => edge.ParentJobId is { } parentId && !allRecoveredJobIds.Contains(parentId))
×
NEW
637
                                                .Select(static edge => edge.ParentJobId!)
×
NEW
638
                                                .Distinct(StringComparer.Ordinal)
×
NEW
639
                                                .Order(StringComparer.Ordinal)
×
NEW
640
                                                .ToArray();
×
NEW
641
                                        var missingParentBatches = standaloneContinuations.Values
×
NEW
642
                                                .SelectMany(job => recoveredIncomingEdges[job.Id])
×
NEW
643
                                                .Where(edge => edge.ParentBatchId is { } parentId && !restoredBatchIds.Contains(parentId))
×
NEW
644
                                                .Select(static edge => edge.ParentBatchId!)
×
NEW
645
                                                .Distinct(StringComparer.Ordinal)
×
NEW
646
                                                .Order(StringComparer.Ordinal)
×
NEW
647
                                                .ToArray();
×
NEW
648
                                        var unresolved = string.Join(", ", standaloneContinuations.Keys.Order(StringComparer.Ordinal));
×
NEW
649
                                        if (missingParents.Length != 0)
×
650
                                        {
NEW
651
                                                throw new ImmediateJobException(
×
NEW
652
                                                        $"Durable standalone continuations reference missing parent jobs: {string.Join(", ", missingParents)}."
×
NEW
653
                                                );
×
654
                                        }
655

NEW
656
                                        if (missingParentBatches.Length != 0)
×
657
                                        {
NEW
658
                                                throw new ImmediateJobException(
×
NEW
659
                                                        $"Durable standalone continuations reference missing parent batches: {string.Join(", ", missingParentBatches)}."
×
NEW
660
                                                );
×
661
                                        }
662

NEW
663
                                        throw new ImmediateJobException($"Durable standalone continuations contain a cycle: {unresolved}.");
×
664
                                }
665

666
                                foreach (var job in ready)
4✔
667
                                {
668
                                        await recoveredPrimary.EnqueueContinuationAsync(
4✔
669
                                                job,
4✔
670
                                                recoveredIncomingEdges[job.Id],
4✔
671
                                                cancellationToken
4✔
672
                                        ).ConfigureAwait(false);
4✔
673
                                        _ = standaloneContinuations.Remove(job.Id);
4✔
674
                                        _ = restoredJobIds.Add(job.Id);
4✔
675
                                }
3✔
676
                        }
677

678
                        var snapshot = await DurableStorage.GetMonitoringSnapshotAsync(cancellationToken).ConfigureAwait(false);
4✔
679
                        foreach (var schedule in snapshot.Recurring)
4✔
680
                                await recoveredPrimary.UpsertRecurringAsync(schedule, cancellationToken).ConfigureAwait(false);
4✔
681

682
                        var previousPrimary = _primary;
4✔
683
                        _primary = recoveredPrimary;
4✔
684
                        recoveredPrimary = null;
4✔
685
                        await previousPrimary.DisposeAsync().ConfigureAwait(false);
4✔
686
                        Volatile.Write(ref _initialized, true);
4✔
687
                }
3✔
688
                finally
689
                {
690
                        if (recoveredPrimary is not null)
4✔
691
                                await recoveredPrimary.DisposeAsync().ConfigureAwait(false);
×
692
                        _ = _initialization.Release();
4✔
693
                }
694
        }
3✔
695

696
        private void ThrowIfDisposed() =>
697
                ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposeStarted) != 0, this);
4✔
698

699
        private static JobContinuationEdge ToContinuationEdge(BatchGraphEdge edge) => new()
4✔
700
        {
4✔
701
                ChildJobId = edge.ChildJobId,
4✔
702
                ParentJobId = edge.ParentJobId,
4✔
703
                ParentBatchId = edge.ParentBatchId,
4✔
704
                Trigger = edge.Trigger,
4✔
705
        };
4✔
706

707
        private InMemoryJobStorage CreatePrimaryStorage() => new(_timeProvider);
4✔
708

709
        private sealed record RecoveredBatch(
4✔
710
                JobBatchRecord Record,
4✔
711
                IReadOnlyList<JobRecord> Jobs,
4✔
712
                IReadOnlyList<JobContinuationEdge> Edges
4✔
713
        );
4✔
714
}
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