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

ImmediatePlatform / Immediate.Jobs / 30302568744

27 Jul 2026 08:26PM UTC coverage: 66.69%. First build
30302568744

Pull #26

github

web-flow
Merge 4a231468a into eac5c1044
Pull Request #26: Add batches and continuation workflows

1239 of 1810 new or added lines in 17 files covered. (68.45%)

2855 of 4281 relevant lines covered (66.69%)

2.66 hits per line

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

55.72
/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 : IJobStorage, IAsyncDisposable, IDisposable
8
{
9
        private const int RecoveryBatchSize = 1000;
10
        private readonly TimeProvider _timeProvider;
11
        private readonly SemaphoreSlim _initialization = new(1, 1);
4✔
12
        private InMemoryJobStorage _primary;
13
        private bool _initialized;
14
        private bool _disposed;
15

16
        /// <summary>Creates a memory-primary store backed by the supplied durable replica.</summary>
17
        public SingleServerJobStorage(IJobStorage durableStorage, TimeProvider timeProvider)
4✔
18
        {
19
                ArgumentNullException.ThrowIfNull(durableStorage);
4✔
20
                ArgumentNullException.ThrowIfNull(timeProvider);
4✔
21
                if (durableStorage is SingleServerJobStorage)
4✔
22
                        throw new ArgumentException("A single-server store cannot be used as its own durable replica.", nameof(durableStorage));
×
23
                if (durableStorage is not IJobStorageReplica)
4✔
24
                        throw new ArgumentException("Single-server durable storage must implement IJobStorageReplica.", nameof(durableStorage));
×
25

26
                DurableStorage = durableStorage;
4✔
27
                _timeProvider = timeProvider;
4✔
28
                _primary = new(timeProvider);
4✔
29
        }
4✔
30

31
        /// <summary>The in-process authoritative store.</summary>
32
        public IJobStorage PrimaryStorage => _primary;
4✔
33

34
        /// <summary>The durable write-through replica.</summary>
35
        public IJobStorage DurableStorage { get; }
36

37
        /// <inheritdoc />
38
        public ValueTask InitializeAsync(CancellationToken cancellationToken = default) => EnsureInitializedAsync(cancellationToken);
4✔
39

40
        /// <inheritdoc />
41
        public async ValueTask EnqueueAsync(JobRecord job, CancellationToken cancellationToken = default)
42
        {
43
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
4✔
44
                await DurableStorage.EnqueueAsync(job, cancellationToken).ConfigureAwait(false);
4✔
45
                await _primary.EnqueueAsync(job, cancellationToken).ConfigureAwait(false);
4✔
46
        }
4✔
47

48
        /// <inheritdoc />
49
        public async ValueTask EnqueueContinuationAsync(
50
                JobRecord job,
51
                IReadOnlyList<JobContinuationEdge> edges,
52
                CancellationToken cancellationToken = default
53
        )
54
        {
NEW
55
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
NEW
56
                await DurableStorage.EnqueueContinuationAsync(job, edges, cancellationToken).ConfigureAwait(false);
×
NEW
57
                await _primary.EnqueueContinuationAsync(job, edges, cancellationToken).ConfigureAwait(false);
×
NEW
58
        }
×
59

60
        /// <inheritdoc />
61
        public async ValueTask EnqueueBatchAsync(
62
                JobBatchRecord batch,
63
                IReadOnlyList<JobRecord> jobs,
64
                IReadOnlyList<JobContinuationEdge> edges,
65
                CancellationToken cancellationToken = default
66
        )
67
        {
NEW
68
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
NEW
69
                await DurableStorage.EnqueueBatchAsync(batch, jobs, edges, cancellationToken).ConfigureAwait(false);
×
NEW
70
                await _primary.EnqueueBatchAsync(batch, jobs, edges, cancellationToken).ConfigureAwait(false);
×
NEW
71
        }
×
72

73
        /// <inheritdoc />
74
        public async ValueTask<IReadOnlyList<JobRecord>> AcquireDueJobsAsync(
75
                JobAcquisitionRequest request,
76
                CancellationToken cancellationToken = default
77
        )
78
        {
79
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
4✔
80
                var acquired = await _primary.AcquireDueJobsAsync(request, cancellationToken).ConfigureAwait(false);
4✔
81
                if (acquired.Count == 0)
4✔
82
                        return acquired;
4✔
83

84
                var replica = (IJobStorageReplica)DurableStorage;
4✔
85
                var replicated = await replica.AcquireJobsAsync(
4✔
86
                        [.. acquired.Select(x => x.Id)],
4✔
87
                        request.WorkerId,
4✔
88
                        request.Lease,
4✔
89
                        cancellationToken
4✔
90
                ).ConfigureAwait(false);
4✔
91
                if (acquired.Count != replicated.Count ||
4✔
92
                        !acquired.Select(x => x.Id).ToHashSet(StringComparer.Ordinal).SetEquals(replicated.Select(x => x.Id)))
4✔
93
                {
94
                        throw new InvalidOperationException(
×
95
                                "The durable job replica has drifted from the authoritative in-memory queue. " +
×
96
                                "Single-server mode must not be used by multiple scheduler processes."
×
97
                        );
×
98
                }
99

100
                return acquired;
4✔
101
        }
3✔
102

103
        /// <inheritdoc />
104
        public async ValueTask RenewLeaseAsync(
105
                string jobId,
106
                string workerId,
107
                TimeSpan lease,
108
                CancellationToken cancellationToken = default
109
        )
110
        {
111
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
112
                await DurableStorage.RenewLeaseAsync(jobId, workerId, lease, cancellationToken).ConfigureAwait(false);
×
113
                await _primary.RenewLeaseAsync(jobId, workerId, lease, cancellationToken).ConfigureAwait(false);
×
114
        }
×
115

116
        /// <inheritdoc />
117
        public async ValueTask CompleteAsync(string jobId, string workerId, CancellationToken cancellationToken = default)
118
        {
119
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
120
                await DurableStorage.CompleteAsync(jobId, workerId, cancellationToken).ConfigureAwait(false);
×
121
                await _primary.CompleteAsync(jobId, workerId, cancellationToken).ConfigureAwait(false);
×
122
        }
×
123

124
        /// <inheritdoc />
125
        public async ValueTask CompleteWithContinuationsAsync(
126
                string jobId,
127
                string workerId,
128
                IReadOnlyList<JobContinuationAddition> additions,
129
                CancellationToken cancellationToken = default
130
        )
131
        {
NEW
132
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
NEW
133
                await DurableStorage.CompleteWithContinuationsAsync(jobId, workerId, additions, cancellationToken)
×
NEW
134
                        .ConfigureAwait(false);
×
NEW
135
                await _primary.CompleteWithContinuationsAsync(jobId, workerId, additions, cancellationToken)
×
NEW
136
                        .ConfigureAwait(false);
×
NEW
137
        }
×
138

139
        /// <inheritdoc />
140
        public async ValueTask AddBatchJobAsync(
141
                string currentJobId,
142
                JobRecord job,
143
                ContinuationOptions options,
144
                CancellationToken cancellationToken = default
145
        )
146
        {
NEW
147
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
NEW
148
                await DurableStorage.AddBatchJobAsync(currentJobId, job, options, cancellationToken).ConfigureAwait(false);
×
NEW
149
                await _primary.AddBatchJobAsync(currentJobId, job, options, cancellationToken).ConfigureAwait(false);
×
NEW
150
        }
×
151

152
        /// <inheritdoc />
153
        public async ValueTask FailAsync(
154
                string jobId,
155
                string workerId,
156
                string error,
157
                DateTimeOffset? nextRetryAt,
158
                CancellationToken cancellationToken = default
159
        )
160
        {
161
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
162
                await DurableStorage.FailAsync(jobId, workerId, error, nextRetryAt, cancellationToken).ConfigureAwait(false);
×
163
                await _primary.FailAsync(jobId, workerId, error, nextRetryAt, cancellationToken).ConfigureAwait(false);
×
164
        }
×
165

166
        /// <inheritdoc />
167
        public async ValueTask UpsertRecurringAsync(RecurringJobSchedule schedule, CancellationToken cancellationToken = default)
168
        {
169
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
4✔
170
                await DurableStorage.UpsertRecurringAsync(schedule, cancellationToken).ConfigureAwait(false);
4✔
171
                await _primary.UpsertRecurringAsync(schedule, cancellationToken).ConfigureAwait(false);
4✔
172
        }
4✔
173

174
        /// <inheritdoc />
175
        public async ValueTask RemoveObsoleteCodeDefinedRecurringAsync(
176
                IReadOnlyCollection<string> activeScheduleNames,
177
                CancellationToken cancellationToken = default
178
        )
179
        {
180
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
4✔
181
                await DurableStorage.RemoveObsoleteCodeDefinedRecurringAsync(activeScheduleNames, cancellationToken)
4✔
182
                        .ConfigureAwait(false);
4✔
183
                await _primary.RemoveObsoleteCodeDefinedRecurringAsync(activeScheduleNames, cancellationToken).ConfigureAwait(false);
4✔
184
        }
4✔
185

186
        /// <inheritdoc />
187
        public async ValueTask RemoveRecurringAsync(string name, CancellationToken cancellationToken = default)
188
        {
189
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
190
                await DurableStorage.RemoveRecurringAsync(name, cancellationToken).ConfigureAwait(false);
×
191
                await _primary.RemoveRecurringAsync(name, cancellationToken).ConfigureAwait(false);
×
192
        }
×
193

194
        /// <inheritdoc />
195
        public async ValueTask PauseRecurringAsync(string name, CancellationToken cancellationToken = default)
196
        {
197
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
198
                await DurableStorage.PauseRecurringAsync(name, cancellationToken).ConfigureAwait(false);
×
199
                await _primary.PauseRecurringAsync(name, cancellationToken).ConfigureAwait(false);
×
200
        }
×
201

202
        /// <inheritdoc />
203
        public async ValueTask ResumeRecurringAsync(string name, CancellationToken cancellationToken = default)
204
        {
205
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
206
                await DurableStorage.ResumeRecurringAsync(name, cancellationToken).ConfigureAwait(false);
×
207
                await _primary.ResumeRecurringAsync(name, cancellationToken).ConfigureAwait(false);
×
208
        }
×
209

210
        /// <inheritdoc />
211
        public async ValueTask<IReadOnlyList<RecurringJobSchedule>> GetDueRecurringAsync(
212
                DateTimeOffset now,
213
                int batchSize,
214
                CancellationToken cancellationToken = default
215
        )
216
        {
217
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
218
                return await _primary.GetDueRecurringAsync(now, batchSize, cancellationToken).ConfigureAwait(false);
×
219
        }
220

221
        /// <inheritdoc />
222
        public async ValueTask<bool> MaterializeRecurringAsync(
223
                RecurringJobSchedule schedule,
224
                JobRecord job,
225
                DateTimeOffset nextRunAt,
226
                CancellationToken cancellationToken = default
227
        )
228
        {
229
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
230
                if (!await DurableStorage.MaterializeRecurringAsync(schedule, job, nextRunAt, cancellationToken).ConfigureAwait(false))
×
231
                        return false;
×
232
                return await _primary.MaterializeRecurringAsync(schedule, job, nextRunAt, cancellationToken).ConfigureAwait(false);
×
233
        }
234

235
        /// <inheritdoc />
236
        public async ValueTask<JobMonitoringSnapshot> GetMonitoringSnapshotAsync(CancellationToken cancellationToken = default)
237
        {
238
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
4✔
239
                return await _primary.GetMonitoringSnapshotAsync(cancellationToken).ConfigureAwait(false);
4✔
240
        }
3✔
241

242
        /// <inheritdoc />
243
        public async ValueTask<IReadOnlyList<JobRecord>> QueryJobsAsync(
244
                JobQuery query,
245
                CancellationToken cancellationToken = default
246
        )
247
        {
248
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
4✔
249
                return await _primary.QueryJobsAsync(query, cancellationToken).ConfigureAwait(false);
4✔
250
        }
3✔
251

252
        /// <inheritdoc />
253
        public async ValueTask<BatchStatus?> GetBatchStatusAsync(
254
                string batchId,
255
                CancellationToken cancellationToken = default
256
        )
257
        {
NEW
258
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
NEW
259
                return await _primary.GetBatchStatusAsync(batchId, cancellationToken).ConfigureAwait(false);
×
260
        }
261

262
        /// <inheritdoc />
263
        public async ValueTask<IReadOnlyList<BatchStatus>> QueryBatchesAsync(
264
                JobBatchQuery query,
265
                CancellationToken cancellationToken = default
266
        )
267
        {
NEW
268
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
NEW
269
                return await _primary.QueryBatchesAsync(query, cancellationToken).ConfigureAwait(false);
×
270
        }
271

272
        /// <inheritdoc />
273
        public async ValueTask<IReadOnlyList<BatchMemberStatus>> QueryBatchMembersAsync(
274
                string batchId,
275
                BatchMemberQuery query,
276
                CancellationToken cancellationToken = default
277
        )
278
        {
NEW
279
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
NEW
280
                return await _primary.QueryBatchMembersAsync(batchId, query, cancellationToken).ConfigureAwait(false);
×
281
        }
282

283
        /// <inheritdoc />
284
        public async ValueTask<BatchGraph?> GetBatchGraphAsync(
285
                string batchId,
286
                CancellationToken cancellationToken = default
287
        )
288
        {
289
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
4✔
290
                return await _primary.GetBatchGraphAsync(batchId, cancellationToken).ConfigureAwait(false);
4✔
291
        }
3✔
292

293
        /// <inheritdoc />
294
        public async ValueTask<JobStatus?> GetJobStatusAsync(
295
                string jobId,
296
                CancellationToken cancellationToken = default
297
        )
298
        {
NEW
299
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
NEW
300
                return await _primary.GetJobStatusAsync(jobId, cancellationToken).ConfigureAwait(false);
×
301
        }
302

303
        /// <inheritdoc />
304
        public async ValueTask CancelBatchAsync(string batchId, CancellationToken cancellationToken = default)
305
        {
NEW
306
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
NEW
307
                await DurableStorage.CancelBatchAsync(batchId, cancellationToken).ConfigureAwait(false);
×
NEW
308
                await _primary.CancelBatchAsync(batchId, cancellationToken).ConfigureAwait(false);
×
NEW
309
        }
×
310

311
        /// <inheritdoc />
312
        public async ValueTask DeleteBatchAsync(string batchId, CancellationToken cancellationToken = default)
313
        {
NEW
314
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
NEW
315
                await DurableStorage.DeleteBatchAsync(batchId, cancellationToken).ConfigureAwait(false);
×
NEW
316
                await _primary.DeleteBatchAsync(batchId, cancellationToken).ConfigureAwait(false);
×
NEW
317
        }
×
318

319
        /// <inheritdoc />
320
        public async ValueTask RetryAsync(string jobId, CancellationToken cancellationToken = default)
321
        {
322
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
323
                await DurableStorage.RetryAsync(jobId, cancellationToken).ConfigureAwait(false);
×
324
                await _primary.RetryAsync(jobId, cancellationToken).ConfigureAwait(false);
×
325
        }
×
326

327
        /// <inheritdoc />
328
        public async ValueTask DeleteAsync(string jobId, CancellationToken cancellationToken = default)
329
        {
330
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
331
                await DurableStorage.DeleteAsync(jobId, cancellationToken).ConfigureAwait(false);
×
332
                await _primary.DeleteAsync(jobId, cancellationToken).ConfigureAwait(false);
×
333
        }
×
334

335
        /// <inheritdoc />
336
        public async ValueTask PurgeAsync(
337
                TimeSpan succeededRetention,
338
                TimeSpan failedRetention,
339
                TimeSpan batchSucceededRetention,
340
                TimeSpan batchFailedRetention,
341
                CancellationToken cancellationToken = default
342
        )
343
        {
344
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
NEW
345
                await DurableStorage.PurgeAsync(
×
NEW
346
                        succeededRetention,
×
NEW
347
                        failedRetention,
×
NEW
348
                        batchSucceededRetention,
×
NEW
349
                        batchFailedRetention,
×
NEW
350
                        cancellationToken
×
NEW
351
                ).ConfigureAwait(false);
×
NEW
352
                await _primary.PurgeAsync(
×
NEW
353
                        succeededRetention,
×
NEW
354
                        failedRetention,
×
NEW
355
                        batchSucceededRetention,
×
NEW
356
                        batchFailedRetention,
×
NEW
357
                        cancellationToken
×
NEW
358
                ).ConfigureAwait(false);
×
359
        }
×
360

361
        /// <inheritdoc />
362
        public async ValueTask HeartbeatAsync(JobServerSnapshot server, CancellationToken cancellationToken = default)
363
        {
364
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
4✔
365
                await _primary.HeartbeatAsync(server, cancellationToken).ConfigureAwait(false);
4✔
366
        }
4✔
367

368
        /// <inheritdoc />
369
        public async ValueTask<bool> IsHealthyAsync(CancellationToken cancellationToken = default)
370
        {
371
                await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
×
372
                return await DurableStorage.IsHealthyAsync(cancellationToken).ConfigureAwait(false);
×
373
        }
374

375
        /// <inheritdoc />
376
        public void Dispose()
377
        {
378
                if (_disposed)
4✔
379
                        return;
×
380
                _disposed = true;
4✔
381
                if (DurableStorage is IDisposable disposable)
4✔
382
                        disposable.Dispose();
×
383
                else if (DurableStorage is IAsyncDisposable asyncDisposable)
4✔
384
                        asyncDisposable.DisposeAsync().AsTask().GetAwaiter().GetResult();
×
385
                _initialization.Dispose();
4✔
386
        }
4✔
387

388
        /// <inheritdoc />
389
        public async ValueTask DisposeAsync()
390
        {
391
                if (_disposed)
×
392
                        return;
×
393
                _disposed = true;
×
394
                if (DurableStorage is IAsyncDisposable asyncDisposable)
×
395
                        await asyncDisposable.DisposeAsync().ConfigureAwait(false);
×
396
                else if (DurableStorage is IDisposable disposable)
×
397
                        disposable.Dispose();
×
398
                _initialization.Dispose();
×
399
        }
×
400

401
        private async ValueTask EnsureInitializedAsync(CancellationToken cancellationToken)
402
        {
403
                ObjectDisposedException.ThrowIf(_disposed, this);
4✔
404
                if (Volatile.Read(ref _initialized))
4✔
405
                        return;
4✔
406

407
                await _initialization.WaitAsync(cancellationToken).ConfigureAwait(false);
4✔
408
                try
409
                {
410
                        if (Volatile.Read(ref _initialized))
4✔
411
                                return;
×
412

413
                        await DurableStorage.InitializeAsync(cancellationToken).ConfigureAwait(false);
4✔
414
                        var recoveredPrimary = new InMemoryJobStorage(_timeProvider);
4✔
415
                        await recoveredPrimary.InitializeAsync(cancellationToken).ConfigureAwait(false);
4✔
416

417
                        var recoveredJobs = new List<JobRecord>();
4✔
418
                        foreach (var state in Enum.GetValues<JobState>())
4✔
419
                        {
420
                                var skip = 0;
4✔
421
                                while (true)
×
422
                                {
423
                                        var jobs = await DurableStorage.QueryJobsAsync(
4✔
424
                                                new() { State = state, Skip = skip, Take = RecoveryBatchSize },
4✔
425
                                                cancellationToken
4✔
426
                                        ).ConfigureAwait(false);
4✔
427
                                        recoveredJobs.AddRange(jobs);
4✔
428
                                        if (jobs.Count < RecoveryBatchSize)
4✔
429
                                                break;
430
                                        skip += jobs.Count;
×
431
                                }
432
                        }
433

434
                        var batchIds = recoveredJobs
4✔
435
                                .Where(static job => job.BatchId is not null)
4✔
436
                                .Select(static job => job.BatchId!)
4✔
437
                                .Distinct(StringComparer.Ordinal)
4✔
438
                                .ToArray();
4✔
439
                        var recoveredBatches = new Dictionary<string, RecoveredBatch>(batchIds.Length, StringComparer.Ordinal);
4✔
440
                        foreach (var batchId in batchIds)
4✔
441
                        {
442
                                var status = await DurableStorage.GetBatchStatusAsync(batchId, cancellationToken).ConfigureAwait(false)
4✔
443
                                        ?? throw new InvalidOperationException($"Batch '{batchId}' has members but no durable batch header.");
4✔
444
                                var graph = await DurableStorage.GetBatchGraphAsync(batchId, cancellationToken).ConfigureAwait(false)
4✔
445
                                        ?? throw new InvalidOperationException($"Batch '{batchId}' has members but no durable dependency graph.");
4✔
446
                                recoveredBatches.Add(batchId, new(
4✔
447
                                        new()
4✔
448
                                        {
4✔
449
                                                Id = status.Id,
4✔
450
                                                CreatedAt = status.CreatedAt,
4✔
451
                                                TotalJobs = status.Total,
4✔
452
                                                PendingCount = status.Remaining,
4✔
453
                                                SucceededCount = status.Succeeded,
4✔
454
                                                FailedCount = status.Failed,
4✔
455
                                                CancelledCount = status.Cancelled,
4✔
456
                                                StartedAt = status.StartedAt,
4✔
457
                                                CompletedAt = status.CompletedAt,
4✔
458
                                                State = status.State,
4✔
459
                                        },
4✔
460
                                        [.. recoveredJobs.Where(job => job.BatchId == batchId)],
4✔
461
                                        [.. graph.Edges.Select(ToContinuationEdge)]
4✔
462
                                ));
4✔
463
                        }
3✔
464

465
                        var restoredBatchIds = new HashSet<string>(StringComparer.Ordinal);
4✔
466
                        while (recoveredBatches.Count != 0)
4✔
467
                        {
468
                                var ready = recoveredBatches.Values
4✔
469
                                        .Where(batch => batch.Edges
4✔
470
                                                .Where(static edge => edge.ParentBatchId is not null)
4✔
471
                                                .All(edge => restoredBatchIds.Contains(edge.ParentBatchId!)))
4✔
472
                                        .OrderBy(static batch => batch.Record.CreatedAt)
1✔
473
                                        .ThenBy(static batch => batch.Record.Id, StringComparer.Ordinal)
1✔
474
                                        .ToArray();
4✔
475
                                if (ready.Length == 0)
4✔
476
                                {
NEW
477
                                        var unresolved = string.Join(", ", recoveredBatches.Keys.Order(StringComparer.Ordinal));
×
NEW
478
                                        throw new InvalidOperationException(
×
NEW
479
                                                $"Durable batches have cyclic or missing parent-batch dependencies: {unresolved}."
×
NEW
480
                                        );
×
481
                                }
482

483
                                foreach (var batch in ready)
4✔
484
                                {
485
                                        await recoveredPrimary.EnqueueBatchAsync(
4✔
486
                                                batch.Record,
4✔
487
                                                batch.Jobs,
4✔
488
                                                batch.Edges,
4✔
489
                                                cancellationToken
4✔
490
                                        ).ConfigureAwait(false);
4✔
491
                                        _ = recoveredBatches.Remove(batch.Record.Id);
4✔
492
                                        _ = restoredBatchIds.Add(batch.Record.Id);
4✔
493
                                }
3✔
494
                        }
495

496
                        foreach (var job in recoveredJobs.Where(static job => job.BatchId is null))
4✔
497
                        {
498
                                var status = await DurableStorage.GetJobStatusAsync(job.Id, cancellationToken).ConfigureAwait(false)
4✔
499
                                        ?? throw new InvalidOperationException($"Job '{job.Id}' was queried but has no durable status.");
4✔
500
                                if (status.DependsOn.Count == 0)
4✔
501
                                        await recoveredPrimary.EnqueueAsync(job, cancellationToken).ConfigureAwait(false);
4✔
502
                                else
NEW
503
                                        await recoveredPrimary.EnqueueContinuationAsync(
×
NEW
504
                                                job,
×
NEW
505
                                                [.. status.DependsOn.Select(ToContinuationEdge)],
×
NEW
506
                                                cancellationToken
×
NEW
507
                                        ).ConfigureAwait(false);
×
508
                        }
3✔
509

510
                        var snapshot = await DurableStorage.GetMonitoringSnapshotAsync(cancellationToken).ConfigureAwait(false);
4✔
511
                        foreach (var schedule in snapshot.Recurring)
4✔
512
                                await recoveredPrimary.UpsertRecurringAsync(schedule, cancellationToken).ConfigureAwait(false);
4✔
513

514
                        _primary = recoveredPrimary;
4✔
515
                        Volatile.Write(ref _initialized, true);
4✔
516
                }
4✔
517
                finally
518
                {
519
                        _ = _initialization.Release();
4✔
520
                }
1✔
521
        }
4✔
522

523
        private static JobContinuationEdge ToContinuationEdge(BatchGraphEdge edge) => new()
4✔
524
        {
4✔
525
                ChildJobId = edge.ChildJobId,
4✔
526
                ParentJobId = edge.ParentJobId,
4✔
527
                ParentBatchId = edge.ParentBatchId,
4✔
528
                Trigger = edge.Trigger,
4✔
529
        };
4✔
530

531
        private sealed record RecoveredBatch(
4✔
532
                JobBatchRecord Record,
4✔
533
                IReadOnlyList<JobRecord> Jobs,
4✔
534
                IReadOnlyList<JobContinuationEdge> Edges
4✔
535
        );
4✔
536
}
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