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

ImmediatePlatform / Immediate.Jobs / 30668541331

31 Jul 2026 10:00PM UTC coverage: 83.933% (+0.1%) from 83.82%
30668541331

Pull #86

github

web-flow
Merge 1b747547f into c8caa3f4e
Pull Request #86: Add job cancellation APIs and dashboard action

101 of 114 new or added lines in 9 files covered. (88.6%)

5 existing lines in 2 files now uncovered.

8207 of 9778 relevant lines covered (83.93%)

2.7 hits per line

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

91.08
/src/Immediate.Jobs.Shared/InMemoryJobStorage.cs
1
using System.Globalization;
2

3
namespace Immediate.Jobs.Shared;
4

5
// TODO: remove and fix diagnostics
6
#pragma warning disable MA0015 // Specify the parameter name in ArgumentException
7

8
/// <summary>
9
/// A best-effort, non-durable, single-node provider intended for development and tests.
10
/// </summary>
11
/// <param name="timeProvider">The clock used for scheduling, leases, and timestamps.</param>
12
public sealed class InMemoryJobStorage(TimeProvider timeProvider) :
4✔
13
        IRecurringJobStorage,
14
        IJobGraphStorage,
15
        IJobStorageReplica
16
{
17
        private readonly Lock _gate = new();
4✔
18
        private readonly Dictionary<string, JobRecord> _jobs = new(StringComparer.Ordinal);
4✔
19
        private readonly Dictionary<string, SortedDictionary<int, JobExecutionRecord>> _executions = new(StringComparer.Ordinal);
4✔
20
        private readonly Dictionary<string, JobBatchRecord> _batches = new(StringComparer.Ordinal);
4✔
21
        private readonly List<JobContinuationEdge> _edges = [];
4✔
22
        private readonly HashSet<JobContinuationEdge> _settledEdges = [];
4✔
23
        private readonly Dictionary<string, RecurringJobSchedule> _recurring = new(StringComparer.Ordinal);
4✔
24
        private readonly Dictionary<string, JobServerSnapshot> _servers = new(StringComparer.Ordinal);
4✔
25
        private readonly HashSet<string> _recurringKeys = new(StringComparer.Ordinal);
4✔
26
        private readonly Dictionary<(string QueueName, string GroupId), long> _fairQueueLastServed = [];
4✔
27

28
        /// <inheritdoc />
29
        public ValueTask DisposeAsync() => ValueTask.CompletedTask;
4✔
30

31
        /// <inheritdoc />
32
        public ValueTask InitializeAsync(CancellationToken cancellationToken = default)
33
        {
34
                cancellationToken.ThrowIfCancellationRequested();
4✔
35
                return ValueTask.CompletedTask;
4✔
36
        }
37

38
        /// <inheritdoc />
39
        public ValueTask EnqueueAsync(JobRecord job, CancellationToken cancellationToken = default)
40
        {
41
                ArgumentNullException.ThrowIfNull(job);
4✔
42
                cancellationToken.ThrowIfCancellationRequested();
4✔
43
                lock (_gate)
44
                {
45
                        if (!_jobs.TryAdd(job.Id, job))
4✔
46
                                throw new ImmediateJobException($"Job '{job.Id}' already exists.");
×
47
                }
4✔
48

49
                return ValueTask.CompletedTask;
4✔
50
        }
51

52
        /// <inheritdoc />
53
        public async ValueTask<IReadOnlyList<JobContinuationEdge>> GetIncomingEdgesAsync(
54
                IReadOnlyCollection<string> childJobIds,
55
                CancellationToken cancellationToken = default
56
        )
57
        {
58
                ArgumentNullException.ThrowIfNull(childJobIds);
4✔
59
                foreach (var childJobId in childJobIds)
4✔
60
                        ArgumentException.ThrowIfNullOrWhiteSpace(childJobId, paramName: nameof(childJobIds));
4✔
61

62
                cancellationToken.ThrowIfCancellationRequested();
4✔
63
                lock (_gate)
64
                {
65
                        if (childJobIds.Count == 0)
4✔
66
                                return [];
4✔
67

68
                        var distinctIds = new HashSet<string>(StringComparer.Ordinal);
4✔
69
                        foreach (var childJobId in childJobIds)
4✔
70
                                _ = distinctIds.Add(childJobId);
4✔
71

72
                        return [.. _edges.Where(edge => distinctIds.Contains(edge.ChildJobId))];
4✔
73
                }
74
        }
4✔
75

76
        /// <inheritdoc />
77
        public ValueTask EnqueueContinuationAsync(
78
                JobRecord job,
79
                IReadOnlyList<JobContinuationEdge> edges,
80
                CancellationToken cancellationToken = default
81
        )
82
        {
83
                ArgumentNullException.ThrowIfNull(job);
4✔
84
                ArgumentNullException.ThrowIfNull(edges);
4✔
85
                cancellationToken.ThrowIfCancellationRequested();
4✔
86
                lock (_gate)
87
                {
88
                        ValidateNewJob(job);
4✔
89
                        ValidateEdges([job], edges, batchId: null);
4✔
90

91
                        var restoreExistingState = HasTerminalParent(edges) &&
4✔
92
                                (job.State != JobState.AwaitingContinuation || job.RemainingDependencies < edges.Count);
4✔
93
                        _jobs.Add(job.Id, restoreExistingState ? job : NormalizeWaitingJob(job, edges.Count));
4✔
94
                        _edges.AddRange(edges);
4✔
95
                        if (restoreExistingState)
4✔
96
                                MarkTerminalParentEdgesSettled(edges);
×
97
                        else
98
                                EvaluateAlreadyTerminalParents(edges);
4✔
99
                }
4✔
100

101
                return ValueTask.CompletedTask;
4✔
102
        }
103

104
        /// <inheritdoc />
105
        public ValueTask EnqueueBatchAsync(
106
                JobBatchRecord batch,
107
                IReadOnlyList<JobRecord> jobs,
108
                IReadOnlyList<JobContinuationEdge> edges,
109
                CancellationToken cancellationToken = default
110
        )
111
        {
112
                ArgumentNullException.ThrowIfNull(batch);
4✔
113
                ArgumentNullException.ThrowIfNull(jobs);
4✔
114
                ArgumentNullException.ThrowIfNull(edges);
4✔
115
                cancellationToken.ThrowIfCancellationRequested();
4✔
116
                lock (_gate)
117
                {
118
                        ValidateBatch(batch, jobs, edges);
4✔
119

120
                        var restoreExistingState = IsRecoveredBatch(batch, jobs, edges);
4✔
121
                        _batches.Add(batch.Id, batch);
4✔
122
                        var incomingCounts = edges
4✔
123
                                .GroupBy(static edge => edge.ChildJobId, StringComparer.Ordinal)
4✔
124
                                .ToDictionary(static group => group.Key, static group => group.Count(), StringComparer.Ordinal);
4✔
125
                        foreach (var job in jobs)
4✔
126
                        {
127
                                _jobs.Add(
4✔
128
                                        job.Id,
4✔
129
                                        restoreExistingState
4✔
130
                                                ? job
4✔
131
                                                : incomingCounts.TryGetValue(job.Id, out var dependencyCount)
4✔
132
                                                ? NormalizeWaitingJob(job, dependencyCount)
4✔
133
                                                : job with { BatchId = batch.Id, RemainingDependencies = 0, FailedDependencies = 0 }
4✔
134
                                );
4✔
135
                        }
136

137
                        _edges.AddRange(edges);
4✔
138
                        if (restoreExistingState)
4✔
139
                                MarkTerminalParentEdgesSettled(edges);
4✔
140
                        else
141
                                EvaluateAlreadyTerminalParents(edges);
4✔
142
                }
4✔
143

144
                return ValueTask.CompletedTask;
4✔
145
        }
146

147
        /// <inheritdoc />
148
        public async ValueTask<IReadOnlyList<JobRecord>> AcquireDueJobsAsync(
149
                JobAcquisitionRequest request,
150
                CancellationToken cancellationToken = default
151
        )
152
        {
153
                ArgumentNullException.ThrowIfNull(request);
4✔
154
                ArgumentException.ThrowIfNullOrWhiteSpace(request.WorkerId, nameof(request));
4✔
155
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(request.Lease, TimeSpan.Zero, nameof(request));
4✔
156
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(request.BatchSize, 0, nameof(request));
4✔
157

158
                cancellationToken.ThrowIfCancellationRequested();
4✔
159
                var now = timeProvider.GetUtcNow();
4✔
160
                lock (_gate)
161
                {
162
                        foreach (var expired in _jobs.Values.Where(x => x.State == JobState.Active && x.LeaseExpiresAt <= now).ToArray())
4✔
163
                        {
164
                                InterruptExecution(expired);
4✔
165
                                _jobs[expired.Id] = expired with
4✔
166
                                {
4✔
167
                                        State = JobState.Pending,
4✔
168
                                        WorkerId = null,
4✔
169
                                        LeaseExpiresAt = null,
4✔
170
                                };
4✔
171
                        }
172

173
                        if (request.FairQueues is null)
4✔
174
                                return AcquireInExistingOrder(request, now);
4✔
175

176
                        var acquired = new List<JobRecord>(request.BatchSize);
4✔
177
                        foreach (var queue in request.Queues)
4✔
178
                        {
179
                                var queueCapacity = Math.Min(queue.Capacity, request.BatchSize - acquired.Count);
4✔
180
                                if (queueCapacity <= 0)
4✔
181
                                        continue;
182

183
                                var jobCapacities = queue.JobCapacities.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal);
4✔
184
                                if (!HasEligibleGroupedJob(queue.QueueName, jobCapacities, now))
4✔
185
                                {
186
                                        AcquireQueueInExistingOrder(
4✔
187
                                                request,
4✔
188
                                                queue.QueueName,
4✔
189
                                                jobCapacities,
4✔
190
                                                queueCapacity,
4✔
191
                                                now,
4✔
192
                                                acquired
4✔
193
                                        );
4✔
194
                                        continue;
4✔
195
                                }
196

197
                                AcquireQueueFairly(
4✔
198
                                        request,
4✔
199
                                        queue.QueueName,
4✔
200
                                        jobCapacities,
4✔
201
                                        queueCapacity,
4✔
202
                                        now,
4✔
203
                                        acquired
4✔
204
                                );
4✔
205
                        }
206

207
                        return acquired;
4✔
208
                }
209
        }
4✔
210

211
        private List<JobRecord> AcquireInExistingOrder(JobAcquisitionRequest request, DateTimeOffset now)
212
        {
213
                var acquired = new List<JobRecord>(request.BatchSize);
4✔
214
                foreach (var queue in request.Queues)
4✔
215
                {
216
                        var queueCapacity = Math.Min(queue.Capacity, request.BatchSize - acquired.Count);
4✔
217
                        if (queueCapacity <= 0)
4✔
218
                                continue;
219

220
                        var jobCapacities = queue.JobCapacities.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal);
4✔
221
                        AcquireQueueInExistingOrder(
4✔
222
                                request,
4✔
223
                                queue.QueueName,
4✔
224
                                jobCapacities,
4✔
225
                                queueCapacity,
4✔
226
                                now,
4✔
227
                                acquired
4✔
228
                        );
4✔
229
                }
230

231
                return acquired;
4✔
232
        }
233

234
        private void AcquireQueueInExistingOrder(
235
                JobAcquisitionRequest request,
236
                string queueName,
237
                Dictionary<string, int> jobCapacities,
238
                int queueCapacity,
239
                DateTimeOffset now,
240
                List<JobRecord> acquired
241
        )
242
        {
243
                foreach (var candidate in _jobs.Values
4✔
244
                        .Where(job => string.Equals(job.QueueName, queueName, StringComparison.Ordinal) &&
4✔
245
                                jobCapacities.ContainsKey(job.JobName) &&
4✔
246
                                job.State is JobState.Pending or JobState.Scheduled &&
4✔
247
                                job.DueAt <= now)
4✔
248
                        .OrderBy(job => job.DueAt)
4✔
249
                        .ThenBy(job => job.CreatedAt)
4✔
250
                        .ThenBy(job => job.Id, StringComparer.Ordinal))
4✔
251
                {
252
                        if (queueCapacity == 0)
4✔
253
                                break;
4✔
254
                        if (jobCapacities[candidate.JobName] <= 0)
4✔
255
                                continue;
256

257
                        acquired.Add(Acquire(candidate, request, now));
4✔
258
                        jobCapacities[candidate.JobName]--;
4✔
259
                        queueCapacity--;
4✔
260
                }
261
        }
4✔
262

263
        private bool HasEligibleGroupedJob(
264
                string queueName,
265
                Dictionary<string, int> jobCapacities,
266
                DateTimeOffset now
267
        ) =>
268
                _jobs.Values.Any(job => string.Equals(job.QueueName, queueName, StringComparison.Ordinal) &&
4✔
269
                        job.GroupId is not null &&
4✔
270
                        jobCapacities.TryGetValue(job.JobName, out var capacity) &&
4✔
271
                        capacity > 0 &&
4✔
272
                        job.State is JobState.Pending or JobState.Scheduled &&
4✔
273
                        job.DueAt <= now);
4✔
274

275
        private void AcquireQueueFairly(
276
                JobAcquisitionRequest request,
277
                string queueName,
278
                Dictionary<string, int> jobCapacities,
279
                int queueCapacity,
280
                DateTimeOffset now,
281
                List<JobRecord> acquired
282
        )
283
        {
284
                var policy = request.FairQueues!;
4✔
285
                while (queueCapacity > 0)
4✔
286
                {
287
                        var eligible = _jobs.Values
4✔
288
                                .Where(job => string.Equals(job.QueueName, queueName, StringComparison.Ordinal) &&
4✔
289
                                        jobCapacities.TryGetValue(job.JobName, out var capacity) &&
4✔
290
                                        capacity > 0 &&
4✔
291
                                        job.State is JobState.Pending or JobState.Scheduled &&
4✔
292
                                        job.DueAt <= now)
4✔
293
                                .ToArray();
4✔
294
                        if (eligible.Length == 0)
4✔
295
                                break;
296

297
                        var activeCounts = _jobs.Values
4✔
298
                                .Where(job => string.Equals(job.QueueName, queueName, StringComparison.Ordinal) &&
4✔
299
                                        job.GroupId is not null &&
4✔
300
                                        job.State == JobState.Active &&
4✔
301
                                        job.LeaseExpiresAt > now)
4✔
302
                                .GroupBy(static job => job.GroupId!, StringComparer.Ordinal)
4✔
303
                                .ToDictionary(static group => group.Key, static group => group.Count(), StringComparer.Ordinal);
4✔
304
                        var totalActive = _jobs.Values.Count(job => string.Equals(job.QueueName, queueName, StringComparison.Ordinal) &&
4✔
305
                                job.State == JobState.Active &&
4✔
306
                                job.LeaseExpiresAt > now);
4✔
307
                        var groupedHeads = eligible
4✔
308
                                .Where(static job => job.GroupId is not null)
4✔
309
                                .GroupBy(static job => job.GroupId!, StringComparer.Ordinal)
4✔
310
                                .Select(static group => group
4✔
311
                                        .OrderBy(static job => job.DueAt)
4✔
312
                                        .ThenBy(static job => job.CreatedAt)
4✔
313
                                        .ThenBy(static job => job.Id, StringComparer.Ordinal)
4✔
314
                                        .First());
4✔
315
                        var ungroupedHead = eligible
4✔
316
                                .Where(static job => job.GroupId is null)
4✔
317
                                .OrderBy(static job => job.DueAt)
4✔
318
                                .ThenBy(static job => job.CreatedAt)
4✔
319
                                .ThenBy(static job => job.Id, StringComparer.Ordinal)
4✔
320
                                .Take(1);
4✔
321
                        var candidates = groupedHeads.Concat(ungroupedHead);
4✔
322

323
                        var candidate = candidates
4✔
324
                                .OrderBy(job => IsNoisy(job.GroupId, activeCounts, totalActive, policy))
4✔
325
                                .ThenBy(job => GetNoisyInflight(job.GroupId, activeCounts, totalActive, policy))
4✔
326
                                .ThenBy(job => policy.GroupRoundRobin ? GetLastServed(queueName, job.GroupId) : 0)
4✔
327
                                .ThenBy(static job => job.DueAt)
4✔
328
                                .ThenBy(static job => job.CreatedAt)
4✔
329
                                .ThenBy(static job => job.Id, StringComparer.Ordinal)
4✔
330
                                .First();
4✔
331

332
                        var job = Acquire(candidate, request, now);
4✔
333
                        acquired.Add(job);
4✔
334
                        jobCapacities[job.JobName]--;
4✔
335
                        queueCapacity--;
4✔
336
                        if (policy.GroupRoundRobin && job.GroupId is { } groupId)
4✔
337
                                _fairQueueLastServed[(queueName, groupId)] = GetNextSequence(queueName);
4✔
338
                }
339
        }
4✔
340

341
        private static bool IsNoisy(
342
                string? groupId,
343
                Dictionary<string, int> activeCounts,
344
                int totalActive,
345
                FairQueuePolicy policy
346
        ) =>
347
                groupId is not null &&
4✔
348
                totalActive > 0 &&
4✔
349
                activeCounts.TryGetValue(groupId, out var groupActive) &&
4✔
350
                groupActive >= policy.MinInflightForNoisy &&
4✔
351
                (double)groupActive / totalActive > policy.ConcurrencyShareThreshold;
4✔
352

353
        private static int GetNoisyInflight(
354
                string? groupId,
355
                Dictionary<string, int> activeCounts,
356
                int totalActive,
357
                FairQueuePolicy policy
358
        ) =>
359
                IsNoisy(groupId, activeCounts, totalActive, policy) ? activeCounts[groupId!] : 0;
4✔
360

361
        private long GetLastServed(string queueName, string? groupId) =>
362
                groupId is not null && _fairQueueLastServed.TryGetValue((queueName, groupId), out var sequence)
4✔
363
                        ? sequence
4✔
364
                        : 0;
4✔
365

366
        private long GetNextSequence(string queueName) =>
367
                _fairQueueLastServed
4✔
368
                        .Where(pair => string.Equals(pair.Key.QueueName, queueName, StringComparison.Ordinal))
4✔
369
                        .Select(static pair => pair.Value)
4✔
370
                        .DefaultIfEmpty()
4✔
371
                        .Max() + 1;
4✔
372

373
        private JobRecord Acquire(JobRecord candidate, JobAcquisitionRequest request, DateTimeOffset now)
374
        {
375
                MaterializeSyntheticExecution(candidate);
4✔
376
                var job = candidate with
4✔
377
                {
4✔
378
                        State = JobState.Active,
4✔
379
                        Attempt = candidate.Attempt + 1,
4✔
380
                        WorkerId = request.WorkerId,
4✔
381
                        LeaseExpiresAt = now + request.Lease,
4✔
382
                        ExecutionTraceId = null,
4✔
383
                        ExecutionSpanId = null,
4✔
384
                        ExecutionStartedAt = null,
4✔
385
                };
4✔
386
                _jobs[job.Id] = job;
4✔
387
                CreateExecution(job, now);
4✔
388
                MarkBatchStarted(job.BatchId, now);
4✔
389
                return job;
4✔
390
        }
391

392
        /// <inheritdoc />
393
        public async ValueTask<IReadOnlyList<JobRecord>> AcquireJobsAsync(
394
                IReadOnlyCollection<string> jobIds,
395
                string workerId,
396
                TimeSpan lease,
397
                CancellationToken cancellationToken = default
398
        )
399
        {
400
                ArgumentNullException.ThrowIfNull(jobIds);
4✔
401
                ArgumentException.ThrowIfNullOrWhiteSpace(workerId);
4✔
402
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lease, TimeSpan.Zero);
4✔
403
                cancellationToken.ThrowIfCancellationRequested();
4✔
404
                var now = timeProvider.GetUtcNow();
4✔
405
                lock (_gate)
406
                {
407
                        var acquired = new List<JobRecord>(jobIds.Count);
4✔
408
                        foreach (var id in jobIds)
4✔
409
                        {
410
                                if (!_jobs.TryGetValue(id, out var job))
4✔
411
                                        continue;
412
                                if (job.State == JobState.Active && job.LeaseExpiresAt <= now)
4✔
413
                                {
414
                                        InterruptExecution(job);
4✔
415
                                        job = job with { State = JobState.Pending, WorkerId = null, LeaseExpiresAt = null };
4✔
416
                                        _jobs[id] = job;
4✔
417
                                }
418

419
                                if (job.State is not (JobState.Pending or JobState.Scheduled) || job.DueAt > now)
4✔
420
                                        continue;
421

422
                                MaterializeSyntheticExecution(job);
4✔
423
                                job = job with
4✔
424
                                {
4✔
425
                                        State = JobState.Active,
4✔
426
                                        Attempt = job.Attempt + 1,
4✔
427
                                        WorkerId = workerId,
4✔
428
                                        LeaseExpiresAt = now + lease,
4✔
429
                                        ExecutionTraceId = null,
4✔
430
                                        ExecutionSpanId = null,
4✔
431
                                        ExecutionStartedAt = null,
4✔
432
                                };
4✔
433
                                _jobs[id] = job;
4✔
434
                                CreateExecution(job, now);
4✔
435
                                MarkBatchStarted(job.BatchId, now);
4✔
436
                                acquired.Add(job);
4✔
437
                        }
438

439
                        return acquired;
4✔
440
                }
441
        }
4✔
442

443
        /// <inheritdoc />
444
        public ValueTask SetExecutionTelemetryAsync(
445
                string jobId,
446
                int executionNumber,
447
                string workerId,
448
                string? traceId,
449
                string? spanId,
450
                DateTimeOffset startedAt,
451
                CancellationToken cancellationToken = default
452
        )
453
        {
454
                cancellationToken.ThrowIfCancellationRequested();
4✔
455
                lock (_gate)
456
                {
457
                        var job = GetOwnedActive(jobId, executionNumber, workerId);
4✔
458
                        MaterializeSyntheticExecution(job);
4✔
459
                        _jobs[jobId] = job with
4✔
460
                        {
4✔
461
                                ExecutionTraceId = traceId,
4✔
462
                                ExecutionSpanId = spanId,
4✔
463
                                ExecutionStartedAt = startedAt,
4✔
464
                        };
4✔
465
                        UpdateExecution(jobId, executionNumber, execution => execution with
4✔
466
                        {
4✔
467
                                ExecutionTraceId = traceId,
4✔
468
                                ExecutionSpanId = spanId,
4✔
469
                                ExecutionStartedAt = startedAt,
4✔
470
                        });
4✔
471
                }
4✔
472

473
                return ValueTask.CompletedTask;
4✔
474
        }
475

476
        /// <inheritdoc />
477
        public ValueTask RenewLeaseAsync(
478
                string jobId,
479
                int executionNumber,
480
                string workerId,
481
                TimeSpan lease,
482
                CancellationToken cancellationToken = default
483
        )
484
        {
485
                cancellationToken.ThrowIfCancellationRequested();
4✔
486
                lock (_gate)
487
                {
488
                        var job = GetOwnedActive(jobId, executionNumber, workerId);
4✔
489
                        _jobs[jobId] = job with { LeaseExpiresAt = timeProvider.GetUtcNow() + lease };
×
490
                }
×
491

492
                return ValueTask.CompletedTask;
×
493
        }
494

495
        /// <inheritdoc />
496
        public ValueTask CompleteAsync(
497
                string jobId,
498
                int executionNumber,
499
                string workerId,
500
                CancellationToken cancellationToken = default
501
        ) => CompleteWithContinuationsAsync(jobId, executionNumber, workerId, [], cancellationToken);
4✔
502

503
        /// <inheritdoc />
504
        public ValueTask CompleteWithContinuationsAsync(
505
                string jobId,
506
                int executionNumber,
507
                string workerId,
508
                IReadOnlyList<JobContinuationAddition> additions,
509
                CancellationToken cancellationToken = default
510
        )
511
        {
512
                ArgumentNullException.ThrowIfNull(additions);
4✔
513
                foreach (var addition in additions)
4✔
514
                {
515
                        ArgumentNullException.ThrowIfNull(addition, nameof(additions));
4✔
516
                        ArgumentNullException.ThrowIfNull(addition.Job, nameof(additions));
4✔
517
                }
518

519
                cancellationToken.ThrowIfCancellationRequested();
4✔
520
                lock (_gate)
521
                {
522
                        var current = GetOwnedActive(jobId, executionNumber, workerId);
4✔
523
                        var existingWaiters = GetUnsettledWaiters(jobId);
4✔
524
                        var newJobIds = new HashSet<string>(StringComparer.Ordinal);
4✔
525
                        var dependencyEdges = new List<JobContinuationEdge>(additions.Count);
4✔
526
                        var trackedAdditions = 0;
4✔
527

528
                        foreach (var addition in additions)
4✔
529
                        {
530
                                ValidateNewJob(addition.Job);
4✔
531
                                if (!newJobIds.Add(addition.Job.Id))
4✔
532
                                        throw new ImmediateJobException($"Job '{addition.Job.Id}' occurs more than once in the completion buffer.");
×
533
                                if (addition.Job.State is not (JobState.Pending or JobState.Scheduled))
4✔
534
                                        throw new ImmediateJobException($"Dynamic continuation '{addition.Job.Id}' has invalid state '{addition.Job.State}'.");
×
535
                                if (!Enum.IsDefined(addition.Trigger))
4✔
536
                                        throw new ArgumentOutOfRangeException(nameof(additions), "Unknown continuation trigger.");
×
537

538
                                if (addition.Options == ContinuationOptions.Detached)
4✔
539
                                {
540
                                        if (addition.Job.BatchId is not null)
4✔
541
                                                throw new ImmediateJobException("A detached continuation cannot belong to a batch.");
4✔
542
                                }
543
                                else if (addition.Options is ContinuationOptions.BesideContinuations or ContinuationOptions.BeforeContinuations)
4✔
544
                                {
545
                                        if (current.BatchId is null || !string.Equals(addition.Job.BatchId, current.BatchId, StringComparison.Ordinal))
4✔
546
                                                throw new ImmediateJobException("A batch-tracked continuation must belong to the current job's batch.");
4✔
547
                                        trackedAdditions++;
4✔
548
                                }
549
                                else
550
                                {
551
                                        throw new ArgumentOutOfRangeException(nameof(additions), "Unknown continuation option.");
×
552
                                }
553

554
                                dependencyEdges.Add(new()
4✔
555
                                {
4✔
556
                                        ChildJobId = addition.Job.Id,
4✔
557
                                        ParentJobId = jobId,
4✔
558
                                        Trigger = addition.Trigger,
4✔
559
                                });
4✔
560
                        }
561

562
                        if (dependencyEdges.Count != 0)
4✔
563
                                ValidateEdges([.. additions.Select(static addition => addition.Job)], dependencyEdges, current.BatchId);
4✔
564
                        ValidateSplice(existingWaiters, additions.Count(static addition => addition.Options == ContinuationOptions.BeforeContinuations));
4✔
565

566
                        IncrementBatchMembers(current.BatchId, trackedAdditions);
4✔
567
                        foreach (var addition in additions)
4✔
568
                        {
569
                                _jobs.Add(addition.Job.Id, NormalizeWaitingJob(addition.Job, dependencyCount: 1));
4✔
570
                                if (addition.Options == ContinuationOptions.BeforeContinuations)
4✔
571
                                        SpliceBeforeWaiters(addition.Job.Id, existingWaiters);
4✔
572
                        }
573

574
                        _edges.AddRange(dependencyEdges);
4✔
575
                        var completedAt = timeProvider.GetUtcNow();
4✔
576
                        CompleteExecution(current, JobExecutionState.Succeeded, completedAt);
4✔
577
                        TransitionToTerminal(jobId, JobState.Succeeded, error: null, completedAt);
4✔
578
                }
4✔
579

580
                return ValueTask.CompletedTask;
4✔
581
        }
582

583
        /// <inheritdoc />
584
        public ValueTask AddBatchJobAsync(
585
                string currentJobId,
586
                int executionNumber,
587
                JobRecord job,
588
                ContinuationOptions options,
589
                CancellationToken cancellationToken = default
590
        )
591
        {
592
                ArgumentNullException.ThrowIfNull(job);
4✔
593
                cancellationToken.ThrowIfCancellationRequested();
4✔
594
                lock (_gate)
595
                {
596
                        if (!_jobs.TryGetValue(currentJobId, out var current) || current.State != JobState.Active)
4✔
597
                                throw new ImmediateJobException($"Job '{currentJobId}' is not currently active.");
×
598
                        if (current.Attempt != executionNumber)
4✔
599
                        {
600
                                throw new ImmediateJobException(string.Create(
4✔
601
                                        CultureInfo.InvariantCulture,
4✔
602
                                        $"Execution {executionNumber} cannot add a batch job for '{currentJobId}'; the active execution is {current.Attempt}."
4✔
603
                                ));
4✔
604
                        }
605

606
                        if (current.BatchId is null || !_batches.ContainsKey(current.BatchId))
4✔
607
                                throw new ImmediateJobException("The current job does not belong to a batch.");
×
608
                        if (options == ContinuationOptions.Detached)
4✔
609
                                throw new ImmediateJobException("AddToBatchAsync(JobDetails, ...) cannot create detached work.");
×
610
                        if (options is not (ContinuationOptions.BesideContinuations or ContinuationOptions.BeforeContinuations))
4✔
611
                                throw new ArgumentOutOfRangeException(nameof(options));
×
612
                        ValidateNewJob(job);
4✔
613
                        if (!string.Equals(job.BatchId, current.BatchId, StringComparison.Ordinal))
4✔
614
                                throw new ImmediateJobException("The new job must belong to the current job's batch.");
×
615
                        if (job.State is JobState.Active or JobState.AwaitingContinuation || IsTerminal(job.State))
4✔
616
                                throw new ImmediateJobException($"Concurrent batch member '{job.Id}' has invalid state '{job.State}'.");
×
617

618
                        var existingWaiters = options == ContinuationOptions.BeforeContinuations
4✔
619
                                ? GetUnsettledWaiters(currentJobId)
4✔
620
                                : [];
4✔
621
                        ValidateSplice(existingWaiters, options == ContinuationOptions.BeforeContinuations ? 1 : 0);
4✔
622
                        IncrementBatchMembers(current.BatchId, 1);
4✔
623
                        _jobs.Add(job.Id, job with { RemainingDependencies = 0 });
4✔
624
                        if (options == ContinuationOptions.BeforeContinuations)
4✔
625
                                SpliceBeforeWaiters(job.Id, existingWaiters);
4✔
626
                }
4✔
627

628
                return ValueTask.CompletedTask;
4✔
629
        }
630

631
        /// <inheritdoc />
632
        public ValueTask FailAsync(
633
                string jobId,
634
                int executionNumber,
635
                string workerId,
636
                string error,
637
                DateTimeOffset? nextRetryAt,
638
                CancellationToken cancellationToken = default
639
        )
640
        {
641
                cancellationToken.ThrowIfCancellationRequested();
4✔
642
                lock (_gate)
643
                {
644
                        var job = GetOwnedActive(jobId, executionNumber, workerId);
4✔
645
                        var completedAt = timeProvider.GetUtcNow();
4✔
646
                        CompleteExecution(job, JobExecutionState.Failed, completedAt, error);
4✔
647
                        if (nextRetryAt.HasValue)
4✔
648
                        {
649
                                var now = timeProvider.GetUtcNow();
4✔
650
                                _jobs[jobId] = job with
4✔
651
                                {
4✔
652
                                        State = nextRetryAt <= now ? JobState.Pending : JobState.Scheduled,
4✔
653
                                        DueAt = nextRetryAt.Value,
4✔
654
                                        WorkerId = null,
4✔
655
                                        LeaseExpiresAt = null,
4✔
656
                                        LastError = error,
4✔
657
                                        CompletedAt = null,
4✔
658
                                };
4✔
659
                        }
660
                        else
661
                        {
662
                                TransitionToTerminal(jobId, JobState.Failed, error, completedAt);
4✔
663
                        }
664
                }
4✔
665

666
                return ValueTask.CompletedTask;
4✔
667
        }
668

669
        /// <inheritdoc />
670
        public ValueTask UpsertRecurringAsync(RecurringJobSchedule schedule, CancellationToken cancellationToken = default)
671
        {
672
                ArgumentNullException.ThrowIfNull(schedule);
4✔
673
                cancellationToken.ThrowIfCancellationRequested();
4✔
674
                lock (_gate)
675
                {
676
                        if (_recurring.TryGetValue(schedule.Name, out var current))
4✔
677
                        {
678
                                if (current.IsCodeDefined && !schedule.IsCodeDefined)
4✔
679
                                        throw new ImmediateJobException("Code-defined recurring schedules cannot be replaced by dynamic schedules.");
4✔
680

681
                                schedule = schedule with { IsPaused = current.IsPaused, LastRunAt = current.LastRunAt };
4✔
682
                        }
683

684
                        _recurring[schedule.Name] = schedule;
4✔
685
                }
4✔
686

687
                return ValueTask.CompletedTask;
4✔
688
        }
689

690
        /// <inheritdoc />
691
        public ValueTask RemoveObsoleteCodeDefinedRecurringAsync(
692
                IReadOnlyCollection<string> activeScheduleNames,
693
                CancellationToken cancellationToken = default
694
        )
695
        {
696
                ArgumentNullException.ThrowIfNull(activeScheduleNames);
4✔
697
                cancellationToken.ThrowIfCancellationRequested();
4✔
698
                var activeNames = activeScheduleNames.ToHashSet(StringComparer.Ordinal);
4✔
699
                lock (_gate)
700
                {
701
                        var obsoleteNames = _recurring
4✔
702
                                .Where(schedule => schedule.Value.IsCodeDefined && !activeNames.Contains(schedule.Key))
4✔
703
                                .Select(static schedule => schedule.Key)
4✔
704
                                .ToArray();
4✔
705
                        foreach (var name in obsoleteNames)
4✔
706
                                _ = _recurring.Remove(name);
4✔
707
                }
4✔
708

709
                return ValueTask.CompletedTask;
4✔
710
        }
711

712
        /// <inheritdoc />
713
        public ValueTask RemoveRecurringAsync(string name, CancellationToken cancellationToken = default)
714
        {
715
                cancellationToken.ThrowIfCancellationRequested();
4✔
716
                lock (_gate)
717
                {
718
                        if (_recurring.TryGetValue(name, out var schedule) && schedule.IsCodeDefined)
4✔
719
                                throw new ImmediateJobException("Code-defined recurring schedules cannot be deleted.");
×
720

721
                        _ = _recurring.Remove(name);
4✔
722
                }
4✔
723

724
                return ValueTask.CompletedTask;
4✔
725
        }
726

727
        /// <inheritdoc />
728
        public ValueTask PauseRecurringAsync(string name, CancellationToken cancellationToken = default) =>
729
                SetRecurringPausedAsync(name, isPaused: true, cancellationToken);
4✔
730

731
        /// <inheritdoc />
732
        public ValueTask ResumeRecurringAsync(string name, CancellationToken cancellationToken = default) =>
733
                SetRecurringPausedAsync(name, isPaused: false, cancellationToken);
4✔
734

735
        /// <inheritdoc />
736
        public async ValueTask<IReadOnlyList<RecurringJobSchedule>> GetDueRecurringAsync(
737
                DateTimeOffset now,
738
                int batchSize,
739
                CancellationToken cancellationToken = default
740
        )
741
        {
742
                cancellationToken.ThrowIfCancellationRequested();
4✔
743
                lock (_gate)
744
                {
745
                        return
4✔
746
                        [
4✔
747
                                .. _recurring.Values.Where(x => !x.IsPaused && x.NextRunAt <= now).OrderBy(x => x.NextRunAt).Take(batchSize),
4✔
748
                        ];
4✔
749
                }
750
        }
4✔
751

752
        /// <inheritdoc />
753
        public async ValueTask<bool> MaterializeRecurringAsync(
754
                RecurringJobSchedule schedule,
755
                JobRecord job,
756
                DateTimeOffset nextRunAt,
757
                CancellationToken cancellationToken = default
758
        )
759
        {
760
                ArgumentNullException.ThrowIfNull(schedule);
4✔
761
                ArgumentNullException.ThrowIfNull(job);
4✔
762
                cancellationToken.ThrowIfCancellationRequested();
4✔
763
                lock (_gate)
764
                {
765
                        if (!_recurring.TryGetValue(schedule.Name, out var current) || current.NextRunAt != schedule.NextRunAt)
4✔
766
                                return false;
×
767

768
                        var inserted = job.RecurringKey is null || _recurringKeys.Add(job.RecurringKey);
4✔
769
                        if (inserted)
4✔
770
                                _jobs[job.Id] = job;
4✔
771
                        _recurring[schedule.Name] = current with { LastRunAt = schedule.NextRunAt, NextRunAt = nextRunAt };
4✔
772
                        return inserted;
4✔
773
                }
774
        }
4✔
775

776
        /// <inheritdoc />
777
        public async ValueTask<JobMonitoringSnapshot> GetMonitoringSnapshotAsync(CancellationToken cancellationToken = default)
778
        {
779
                cancellationToken.ThrowIfCancellationRequested();
4✔
780
                lock (_gate)
781
                {
782
                        var counts = Enum.GetValues<JobState>().ToDictionary(state => state, state => _jobs.Values.LongCount(x => x.State == state));
4✔
783
                        var cutoff = timeProvider.GetUtcNow() - TimeSpan.FromMinutes(2);
4✔
784
                        IReadOnlyList<JobServerSnapshot> servers = [.. _servers.Values.Where(x => x.LastHeartbeat >= cutoff)];
4✔
785
                        return new(timeProvider.GetUtcNow(), counts, [.. _recurring.Values], servers)
4✔
786
                        {
4✔
787
                                Capabilities = this.GetCapabilities(),
4✔
788
                        };
4✔
789
                }
790
        }
4✔
791

792
        /// <inheritdoc />
793
        public async ValueTask<IReadOnlyList<JobRecord>> QueryJobsAsync(JobQuery query, CancellationToken cancellationToken = default)
794
        {
795
                ArgumentNullException.ThrowIfNull(query);
4✔
796
                cancellationToken.ThrowIfCancellationRequested();
4✔
797
                lock (_gate)
798
                {
799
                        var jobs = _jobs.Values.AsEnumerable();
4✔
800
                        if (query.Id is { } id)
4✔
801
                                jobs = jobs.Where(x => string.Equals(x.Id, id, StringComparison.Ordinal));
4✔
802
                        if (query.State is { } state)
4✔
803
                                jobs = jobs.Where(x => x.State == state);
4✔
804
                        if (!string.IsNullOrWhiteSpace(query.QueueName))
4✔
805
                                jobs = jobs.Where(x => string.Equals(x.QueueName, query.QueueName, StringComparison.Ordinal));
4✔
806
                        if (!string.IsNullOrWhiteSpace(query.JobName))
4✔
807
                                jobs = jobs.Where(x => string.Equals(x.JobName, query.JobName, StringComparison.Ordinal));
4✔
808

809
                        if (!string.IsNullOrWhiteSpace(query.Search))
4✔
810
                                jobs = jobs.Where(x => x.JobName.Contains(query.Search, StringComparison.OrdinalIgnoreCase));
×
811

812
                        return
4✔
813
                        [
4✔
814
                                .. jobs.OrderByDescending(x => x.CreatedAt)
4✔
815
                                        .ThenBy(x => x.Id, StringComparer.Ordinal)
4✔
816
                                        .Skip(Math.Max(0, query.Skip))
4✔
817
                                        .Take(Math.Clamp(query.Take, 1, 1000)),
4✔
818
                        ];
4✔
819
                }
820
        }
4✔
821

822
        /// <inheritdoc />
823
        public async ValueTask<IReadOnlyList<JobExecutionRecord>> QueryJobExecutionsAsync(
824
                JobExecutionQuery query,
825
                CancellationToken cancellationToken = default
826
        )
827
        {
828
                JobExecutionRecords.ValidateQuery(query);
4✔
829
                cancellationToken.ThrowIfCancellationRequested();
4✔
830
                lock (_gate)
831
                {
832
                        if (!_jobs.TryGetValue(query.JobId, out var job))
4✔
833
                                return [];
×
834

835
                        var executions = _executions.TryGetValue(query.JobId, out var persisted)
4✔
836
                                ? persisted.Values.AsEnumerable()
4✔
837
                                : [];
4✔
838
                        var synthetic = JobExecutionRecords.CreateSynthetic(job);
4✔
839
                        if (synthetic is not null && (persisted is null || !persisted.ContainsKey(synthetic.Attempt)))
4✔
840
                                executions = executions.Append(synthetic);
4✔
841
                        if (query.Attempt is { } attempt)
4✔
842
                                executions = executions.Where(execution => execution.Attempt == attempt);
4✔
843

844
                        return [.. executions
4✔
845
                                .OrderByDescending(static execution => execution.Attempt)
4✔
846
                                .Skip(query.Skip)
4✔
847
                                .Take(Math.Min(query.Take, 1000))];
4✔
848
                }
849
        }
4✔
850

851
        /// <inheritdoc />
852
        public async ValueTask<BatchStatus?> GetBatchStatusAsync(
853
                string batchId,
854
                CancellationToken cancellationToken = default
855
        )
856
        {
857
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
858
                cancellationToken.ThrowIfCancellationRequested();
4✔
859
                lock (_gate)
860
                {
861
                        if (!_batches.TryGetValue(batchId, out var batch))
4✔
862
                                return null;
4✔
863

864
                        return ToStatus(batch);
4✔
865
                }
866
        }
4✔
867

868
        /// <inheritdoc />
869
        public async ValueTask<IReadOnlyList<BatchStatus>> QueryBatchesAsync(
870
                JobBatchQuery query,
871
                CancellationToken cancellationToken = default
872
        )
873
        {
874
                ArgumentNullException.ThrowIfNull(query);
4✔
875
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip, nameof(query));
4✔
876
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0, nameof(query));
4✔
877

878
                cancellationToken.ThrowIfCancellationRequested();
4✔
879
                lock (_gate)
880
                {
881
                        var batches = _batches.Values.AsEnumerable();
4✔
882
                        if (query.State is { } state)
4✔
883
                                batches = batches.Where(batch => batch.State == state);
×
884
                        return
4✔
885
                        [
4✔
886
                                .. batches.OrderByDescending(static batch => batch.CreatedAt)
4✔
887
                                        .ThenBy(static batch => batch.Id, StringComparer.Ordinal)
4✔
888
                                        .Skip(query.Skip)
4✔
889
                                        .Take(query.Take)
4✔
890
                                        .Select(ToStatus),
4✔
891
                        ];
4✔
892
                }
893
        }
4✔
894

895
        /// <inheritdoc />
896
        public async ValueTask<IReadOnlyList<BatchMemberStatus>> QueryBatchMembersAsync(
897
                string batchId,
898
                BatchMemberQuery query,
899
                CancellationToken cancellationToken = default
900
        )
901
        {
902
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
903
                ArgumentNullException.ThrowIfNull(query);
4✔
904
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip, nameof(query));
4✔
905
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0, nameof(query));
4✔
906

907
                cancellationToken.ThrowIfCancellationRequested();
4✔
908
                lock (_gate)
909
                {
910
                        if (!_batches.ContainsKey(batchId))
4✔
911
                                return [];
×
912

913
                        var members = _jobs.Values.Where(job => string.Equals(job.BatchId, batchId, StringComparison.Ordinal));
4✔
914
                        if (query.State is { } state)
4✔
915
                                members = members.Where(job => job.State == state);
4✔
916

917
                        return
4✔
918
                        [
4✔
919
                                .. members
4✔
920
                                        .OrderBy(job => job.CreatedAt)
4✔
921
                                        .ThenBy(job => job.Id, StringComparer.Ordinal)
4✔
922
                                        .Skip(query.Skip)
4✔
923
                                        .Take(query.Take)
4✔
924
                                        .Select(static job => new BatchMemberStatus(
4✔
925
                                                job.Id,
4✔
926
                                                job.JobName,
4✔
927
                                                job.QueueName,
4✔
928
                                                job.State,
4✔
929
                                                job.Attempt,
4✔
930
                                                job.CreatedAt,
4✔
931
                                                job.CompletedAt,
4✔
932
                                                job.LastError
4✔
933
                                        )),
4✔
934
                        ];
4✔
935
                }
936
        }
4✔
937

938
        /// <inheritdoc />
939
        public async ValueTask<BatchGraph?> GetBatchGraphAsync(
940
                string batchId,
941
                CancellationToken cancellationToken = default
942
        )
943
        {
944
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
945
                cancellationToken.ThrowIfCancellationRequested();
4✔
946
                lock (_gate)
947
                {
948
                        if (!_batches.ContainsKey(batchId))
4✔
949
                                return null;
×
950

951
                        var members = _jobs.Values
4✔
952
                                .Where(job => string.Equals(job.BatchId, batchId, StringComparison.Ordinal))
4✔
953
                                .OrderBy(job => job.CreatedAt)
4✔
954
                                .ThenBy(job => job.Id, StringComparer.Ordinal)
4✔
955
                                .ToArray();
4✔
956
                        var memberIds = members.Select(static job => job.Id).ToHashSet(StringComparer.Ordinal);
4✔
957
                        return new(
4✔
958
                                batchId,
4✔
959
                                [.. members.Select(static job => new BatchGraphNode(job.Id, job.JobName, job.State))],
4✔
960
                                [.. _edges.Where(edge => memberIds.Contains(edge.ChildJobId)).Select(ToGraphEdge)]
4✔
961
                        );
4✔
962
                }
963
        }
4✔
964

965
        /// <inheritdoc />
966
        public async ValueTask<JobStatus?> GetJobStatusAsync(
967
                string jobId,
968
                CancellationToken cancellationToken = default
969
        )
970
        {
971
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
4✔
972
                cancellationToken.ThrowIfCancellationRequested();
4✔
973
                lock (_gate)
974
                {
975
                        if (!_jobs.TryGetValue(jobId, out var job))
4✔
976
                                return null;
×
977

978
                        return new(
4✔
979
                                job.Id,
4✔
980
                                job.JobName,
4✔
981
                                job.QueueName,
4✔
982
                                job.State,
4✔
983
                                job.Attempt,
4✔
984
                                MaxAttempts: null,
4✔
985
                                job.CreatedAt,
4✔
986
                                job.DueAt,
4✔
987
                                job.CompletedAt,
4✔
988
                                job.LastError,
4✔
989
                                job.BatchId,
4✔
990
                                [.. _edges.Where(edge => string.Equals(edge.ChildJobId, jobId, StringComparison.Ordinal)).Select(ToGraphEdge)]
4✔
991
                        );
4✔
992
                }
993
        }
4✔
994

995
        /// <inheritdoc />
996
        public ValueTask CancelBatchAsync(string batchId, CancellationToken cancellationToken = default)
997
        {
998
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
999
                cancellationToken.ThrowIfCancellationRequested();
4✔
1000
                lock (_gate)
1001
                {
1002
                        if (!_batches.TryGetValue(batchId, out var batch))
4✔
1003
                                throw new KeyNotFoundException($"Batch '{batchId}' was not found.");
4✔
1004
                        if (batch.State != BatchState.Executing)
4✔
1005
                                throw new ImmediateJobException("Only an executing batch can be cancelled.");
×
1006

1007
                        var now = timeProvider.GetUtcNow();
4✔
1008
                        var jobIds = _jobs.Values
4✔
1009
                                .Where(job => string.Equals(job.BatchId, batchId, StringComparison.Ordinal) && !IsTerminal(job.State))
4✔
1010
                                .Select(static job => job.Id)
4✔
1011
                                .ToArray();
4✔
1012
                        foreach (var jobId in jobIds)
4✔
1013
                        {
1014
                                if (_jobs.TryGetValue(jobId, out var job) && job.State == JobState.Active)
4✔
1015
                                        CompleteExecution(job, JobExecutionState.Cancelled, now);
×
1016
                                TransitionToTerminal(jobId, JobState.Cancelled, error: null, now, propagateContinuations: false);
4✔
1017
                        }
1018

1019
                        foreach (var jobId in jobIds)
4✔
1020
                                ProcessTerminalJob(jobId);
4✔
1021
                }
4✔
1022

1023
                return ValueTask.CompletedTask;
4✔
1024
        }
1025

1026
        /// <inheritdoc />
1027
        public ValueTask DeleteBatchAsync(string batchId, CancellationToken cancellationToken = default)
1028
        {
1029
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
1030
                cancellationToken.ThrowIfCancellationRequested();
4✔
1031
                lock (_gate)
1032
                {
1033
                        if (!_batches.TryGetValue(batchId, out var batch))
4✔
1034
                                throw new KeyNotFoundException($"Batch '{batchId}' was not found.");
4✔
1035
                        if (batch.State == BatchState.Executing)
4✔
1036
                                throw new ImmediateJobException("Only a terminal batch can be deleted.");
×
1037

1038
                        var jobIds = _jobs.Values
4✔
1039
                                .Where(job => string.Equals(job.BatchId, batchId, StringComparison.Ordinal))
4✔
1040
                                .Select(static job => job.Id)
4✔
1041
                                .ToHashSet(StringComparer.Ordinal);
4✔
1042
                        foreach (var jobId in jobIds)
4✔
1043
                        {
1044
                                _ = _jobs.Remove(jobId);
4✔
1045
                                _ = _executions.Remove(jobId);
4✔
1046
                        }
1047

1048
                        _ = _batches.Remove(batchId);
4✔
1049
                        RemoveEdgesForJobs(jobIds, [batchId]);
4✔
1050
                }
4✔
1051

1052
                return ValueTask.CompletedTask;
4✔
1053
        }
1054

1055
        /// <inheritdoc />
1056
        public ValueTask CancelAsync(string jobId, CancellationToken cancellationToken = default)
1057
        {
1058
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
4✔
1059
                cancellationToken.ThrowIfCancellationRequested();
4✔
1060
                lock (_gate)
1061
                {
1062
                        if (!_jobs.TryGetValue(jobId, out var job))
4✔
1063
                                throw new KeyNotFoundException($"Job '{jobId}' was not found.");
4✔
1064
                        if (IsTerminal(job.State))
4✔
1065
                                throw new ImmediateJobException("Only a non-terminal job can be cancelled.");
4✔
1066

1067
                        var now = timeProvider.GetUtcNow();
4✔
1068
                        if (job.State == JobState.Active)
4✔
1069
                                CompleteExecution(job, JobExecutionState.Cancelled, now);
4✔
1070
                        TransitionToTerminal(jobId, JobState.Cancelled, error: null, now);
4✔
1071
                }
4✔
1072

1073
                return ValueTask.CompletedTask;
4✔
1074
        }
1075

1076
        /// <inheritdoc />
1077
        public ValueTask RetryAsync(string jobId, CancellationToken cancellationToken = default)
1078
        {
1079
                cancellationToken.ThrowIfCancellationRequested();
4✔
1080
                lock (_gate)
1081
                {
1082
                        if (!_jobs.TryGetValue(jobId, out var job))
4✔
1083
                                throw new KeyNotFoundException($"Job '{jobId}' was not found.");
4✔
1084
                        var wasFailed = job.State == JobState.Failed;
4✔
1085
                        if (!wasFailed && job.State != JobState.Scheduled)
4✔
1086
                                throw new ImmediateJobException("Only failed or scheduled jobs can be retried.");
×
1087

1088
                        MaterializeSyntheticExecution(job);
4✔
1089
                        _jobs[jobId] = job with
4✔
1090
                        {
4✔
1091
                                State = JobState.Pending,
4✔
1092
                                DueAt = timeProvider.GetUtcNow(),
4✔
1093
                                CompletedAt = wasFailed ? null : job.CompletedAt,
4✔
1094
                                LastError = wasFailed ? null : job.LastError,
4✔
1095
                        };
4✔
1096
                        if (wasFailed && job.BatchId is { } batchId && _batches.TryGetValue(batchId, out var batch))
4✔
1097
                        {
1098
                                _batches[batchId] = batch with
×
1099
                                {
×
1100
                                        State = BatchState.Executing,
×
1101
                                        PendingCount = batch.PendingCount + 1,
×
1102
                                        FailedCount = Math.Max(0, batch.FailedCount - 1),
×
1103
                                        CompletedAt = null,
×
1104
                                };
×
1105
                        }
1106
                }
4✔
1107

1108
                return ValueTask.CompletedTask;
4✔
1109
        }
1110

1111
        /// <inheritdoc />
1112
        public ValueTask DeleteAsync(string jobId, CancellationToken cancellationToken = default)
1113
        {
UNCOV
1114
                cancellationToken.ThrowIfCancellationRequested();
×
1115
                lock (_gate)
1116
                {
UNCOV
1117
                        if (!_jobs.TryGetValue(jobId, out var job))
×
UNCOV
1118
                                throw new KeyNotFoundException($"Job '{jobId}' was not found.");
×
1119
                        if (!IsTerminal(job.State))
×
1120
                                throw new ImmediateJobException("Only terminal jobs can be deleted.");
×
1121
                        if (job.BatchId is not null)
×
1122
                                throw new ImmediateJobException("Batch members cannot be deleted individually.");
×
1123

1124
                        _ = _jobs.Remove(jobId);
×
1125
                        _ = _executions.Remove(jobId);
×
1126
                        RemoveEdgesForJobs(new(StringComparer.Ordinal) { jobId });
×
1127
                }
×
1128

1129
                return ValueTask.CompletedTask;
×
1130
        }
1131

1132
        /// <inheritdoc />
1133
        public ValueTask PurgeJobsAsync(
1134
                TimeSpan succeededRetention,
1135
                TimeSpan failedRetention,
1136
                CancellationToken cancellationToken = default
1137
        )
1138
        {
1139
                cancellationToken.ThrowIfCancellationRequested();
4✔
1140
                var now = timeProvider.GetUtcNow();
4✔
1141
                lock (_gate)
1142
                {
1143
                        var standaloneJobIds = _jobs.Values
4✔
1144
                                .Where(static job => job.BatchId is null)
4✔
1145
                                .Where(
4✔
1146
                                        x =>
4✔
1147
                                                x.CompletedAt is { } completed
4✔
1148
                                                && (
4✔
1149
                                                        (x.State is JobState.Succeeded && completed < now - succeededRetention)
4✔
1150
                                                        || (x.State is JobState.Failed or JobState.Cancelled or JobState.Skipped && completed < now - failedRetention)
4✔
1151
                                                )
4✔
1152
                                )
4✔
1153
                                .Select(static job => job.Id)
×
1154
                                .ToHashSet(StringComparer.Ordinal);
4✔
1155

1156
                        foreach (var id in standaloneJobIds)
4✔
1157
                        {
1158
                                _ = _jobs.Remove(id);
×
1159
                                _ = _executions.Remove(id);
×
1160
                        }
1161

1162
                        RemoveEdgesForJobs(standaloneJobIds);
4✔
1163
                }
4✔
1164

1165
                return ValueTask.CompletedTask;
4✔
1166
        }
1167

1168
        /// <inheritdoc />
1169
        public ValueTask PurgeBatchesAsync(
1170
                TimeSpan batchSucceededRetention,
1171
                TimeSpan batchFailedRetention,
1172
                CancellationToken cancellationToken = default
1173
        )
1174
        {
1175
                cancellationToken.ThrowIfCancellationRequested();
4✔
1176
                var now = timeProvider.GetUtcNow();
4✔
1177
                lock (_gate)
1178
                {
1179
                        var batchIds = _batches.Values
4✔
1180
                                .Where(
4✔
1181
                                        batch =>
4✔
1182
                                                batch.CompletedAt is { } completed
×
1183
                                                && (
×
1184
                                                        (batch.State is BatchState.Succeeded && completed < now - batchSucceededRetention)
×
1185
                                                        || (batch.State is BatchState.Failed or BatchState.Cancelled && completed < now - batchFailedRetention)
×
1186
                                                )
×
1187
                                )
4✔
1188
                                .Select(static batch => batch.Id)
×
1189
                                .ToHashSet(StringComparer.Ordinal);
4✔
1190

1191
                        var batchJobIds = _jobs.Values
4✔
1192
                                .Where(job => job.BatchId is { } batchId && batchIds.Contains(batchId))
4✔
1193
                                .Select(static job => job.Id)
×
1194
                                .ToHashSet(StringComparer.Ordinal);
4✔
1195
                        foreach (var id in batchJobIds)
4✔
1196
                        {
1197
                                _ = _jobs.Remove(id);
×
1198
                                _ = _executions.Remove(id);
×
1199
                        }
1200

1201
                        foreach (var batchId in batchIds)
4✔
1202
                                _ = _batches.Remove(batchId);
×
1203
                        RemoveEdgesForJobs(batchJobIds, batchIds);
4✔
1204
                }
4✔
1205

1206
                return ValueTask.CompletedTask;
4✔
1207
        }
1208

1209
        /// <inheritdoc />
1210
        public ValueTask HeartbeatAsync(JobServerSnapshot server, CancellationToken cancellationToken = default)
1211
        {
1212
                ArgumentNullException.ThrowIfNull(server);
4✔
1213
                cancellationToken.ThrowIfCancellationRequested();
4✔
1214
                lock (_gate)
1215
                        _servers[server.WorkerId] = server;
4✔
1216
                return ValueTask.CompletedTask;
4✔
1217
        }
1218

1219
        /// <inheritdoc />
1220
        public async ValueTask<bool> IsHealthyAsync(CancellationToken cancellationToken = default)
1221
        {
1222
                cancellationToken.ThrowIfCancellationRequested();
×
1223
                return true;
×
1224
        }
1225

1226
        private void ValidateNewJob(JobRecord job)
1227
        {
1228
                ArgumentException.ThrowIfNullOrWhiteSpace(job.Id);
4✔
1229
                ArgumentException.ThrowIfNullOrWhiteSpace(job.JobName);
4✔
1230
                if (_jobs.ContainsKey(job.Id))
4✔
1231
                        throw new ImmediateJobException($"Job '{job.Id}' already exists.");
×
1232
        }
4✔
1233

1234
        private void ValidateBatch(
1235
                JobBatchRecord batch,
1236
                IReadOnlyList<JobRecord> jobs,
1237
                IReadOnlyList<JobContinuationEdge> edges
1238
        )
1239
        {
1240
                ArgumentException.ThrowIfNullOrWhiteSpace(batch.Id);
4✔
1241
                if (_batches.ContainsKey(batch.Id))
4✔
1242
                        throw new ImmediateJobException($"Batch '{batch.Id}' already exists.");
×
1243
                if (jobs.Count == 0)
4✔
1244
                        throw new ImmediateJobException("An atomic batch cannot be empty.");
×
1245

1246
                var succeeded = jobs.Count(static job => job.State == JobState.Succeeded);
4✔
1247
                var failed = jobs.Count(static job => job.State == JobState.Failed);
4✔
1248
                var cancelled = jobs.Count(static job => job.State == JobState.Cancelled);
4✔
1249
                var skipped = jobs.Count(static job => job.State == JobState.Skipped);
4✔
1250
                var pending = jobs.Count - succeeded - failed - cancelled - skipped;
4✔
1251

1252
                var expectedState = true switch
1253
                {
1254
                        _ when pending != 0 => BatchState.Executing,
4✔
1255
                        _ when failed != 0 => BatchState.Failed,
4✔
1256
                        _ when cancelled != 0 => BatchState.Cancelled,
4✔
1257
                        _ => BatchState.Succeeded,
4✔
1258
                };
1259

1260
                if (
4✔
1261
                        batch.TotalJobs != jobs.Count ||
4✔
1262
                        batch.PendingCount != pending ||
4✔
1263
                        batch.SucceededCount != succeeded ||
4✔
1264
                        batch.FailedCount != failed ||
4✔
1265
                        batch.CancelledCount != cancelled ||
4✔
1266
                        batch.SkippedCount != skipped ||
4✔
1267
                        batch.State != expectedState ||
4✔
1268
                        ((pending == 0) != (batch.CompletedAt is not null))
4✔
1269
                )
4✔
1270
                {
1271
                        throw new ImmediateJobException("A batch header does not match its members or aggregate state.");
×
1272
                }
1273

1274
                var jobIds = new HashSet<string>(StringComparer.Ordinal);
4✔
1275
                foreach (var job in jobs)
4✔
1276
                {
1277
                        ValidateNewJob(job);
4✔
1278
                        if (!jobIds.Add(job.Id))
4✔
1279
                                throw new ImmediateJobException($"Job '{job.Id}' occurs more than once in the batch.");
×
1280
                        if (!string.Equals(job.BatchId, batch.Id, StringComparison.Ordinal))
4✔
1281
                                throw new ImmediateJobException($"Job '{job.Id}' does not belong to batch '{batch.Id}'.");
×
1282
                }
1283

1284
                ValidateEdges(jobs, edges, batch.Id);
4✔
1285
        }
4✔
1286

1287
        private void ValidateEdges(
1288
                IReadOnlyList<JobRecord> newJobs,
1289
                IReadOnlyList<JobContinuationEdge> edges,
1290
                string? batchId
1291
        )
1292
        {
1293
                if (batchId is null && edges.Count == 0)
4✔
1294
                        throw new ImmediateJobException("A continuation must have at least one parent.");
×
1295

1296
                var newJobIds = newJobs.Select(static job => job.Id).ToHashSet(StringComparer.Ordinal);
4✔
1297
                var logicalEdges = new HashSet<(string Child, string ParentKind, string Parent)>(
4✔
1298
                        EqualityComparer<(string Child, string ParentKind, string Parent)>.Default
4✔
1299
                );
4✔
1300
                var outgoing = newJobIds.ToDictionary(static id => id, static _ => new List<string>(), StringComparer.Ordinal);
4✔
1301
                var incoming = newJobIds.ToDictionary(static id => id, static _ => 0, StringComparer.Ordinal);
4✔
1302
                foreach (var edge in edges)
4✔
1303
                {
1304
                        ArgumentNullException.ThrowIfNull(edge);
4✔
1305
                        ArgumentException.ThrowIfNullOrWhiteSpace(edge.ChildJobId);
4✔
1306
                        if (!Enum.IsDefined(edge.Trigger))
4✔
1307
                                throw new ArgumentOutOfRangeException(nameof(edges), "Unknown continuation trigger.");
×
1308
                        if (!newJobIds.Contains(edge.ChildJobId))
4✔
1309
                                throw new ImmediateJobException($"Continuation child '{edge.ChildJobId}' is not part of the atomic insert.");
×
1310

1311
                        var hasJobParent = !string.IsNullOrWhiteSpace(edge.ParentJobId);
4✔
1312
                        var hasBatchParent = !string.IsNullOrWhiteSpace(edge.ParentBatchId);
4✔
1313
                        if (hasJobParent == hasBatchParent)
4✔
1314
                                throw new ImmediateJobException("A continuation edge must have exactly one job or batch parent.");
×
1315

1316
                        if (hasJobParent)
4✔
1317
                        {
1318
                                var parentId = edge.ParentJobId!;
4✔
1319
                                if (string.Equals(parentId, edge.ChildJobId, StringComparison.Ordinal))
4✔
1320
                                        throw new ImmediateJobException($"Continuation job '{edge.ChildJobId}' cannot depend on itself.");
×
1321
                                if (!newJobIds.Contains(parentId) && !_jobs.ContainsKey(parentId))
4✔
1322
                                        throw new KeyNotFoundException($"Continuation parent job '{parentId}' was not found.");
4✔
1323
                                if (!logicalEdges.Add((edge.ChildJobId, "job", parentId)))
4✔
1324
                                        throw new ImmediateJobException($"Duplicate continuation edge '{parentId}' -> '{edge.ChildJobId}'.");
×
1325

1326
                                if (newJobIds.Contains(parentId))
4✔
1327
                                {
1328
                                        outgoing[parentId].Add(edge.ChildJobId);
4✔
1329
                                        incoming[edge.ChildJobId]++;
4✔
1330
                                }
1331
                        }
1332
                        else
1333
                        {
1334
                                var parentId = edge.ParentBatchId!;
4✔
1335
                                if (!_batches.ContainsKey(parentId))
4✔
1336
                                        throw new KeyNotFoundException($"Continuation parent batch '{parentId}' was not found.");
×
1337
                                if (!logicalEdges.Add((edge.ChildJobId, "batch", parentId)))
4✔
1338
                                        throw new ImmediateJobException($"Duplicate continuation edge from batch '{parentId}' to '{edge.ChildJobId}'.");
×
1339
                        }
1340
                }
1341

1342
                var ready = new Queue<string>(incoming.Where(static pair => pair.Value == 0).Select(static pair => pair.Key));
4✔
1343
                var visited = 0;
4✔
1344
                while (ready.TryDequeue(out var parentId))
4✔
1345
                {
1346
                        visited++;
4✔
1347
                        foreach (var childId in outgoing[parentId])
4✔
1348
                        {
1349
                                if (--incoming[childId] == 0)
4✔
1350
                                        ready.Enqueue(childId);
4✔
1351
                        }
1352
                }
1353

1354
                if (visited != newJobIds.Count)
4✔
1355
                        throw new ImmediateJobException("The continuation graph contains a dependency cycle.");
×
1356
        }
4✔
1357

1358
        private static JobRecord NormalizeWaitingJob(JobRecord job, int dependencyCount) => job with
4✔
1359
        {
4✔
1360
                State = dependencyCount == 0 ? job.State : JobState.AwaitingContinuation,
4✔
1361
                RemainingDependencies = dependencyCount,
4✔
1362
                FailedDependencies = 0,
4✔
1363
                WorkerId = null,
4✔
1364
                LeaseExpiresAt = null,
4✔
1365
                CompletedAt = null,
4✔
1366
        };
4✔
1367

1368
        private bool IsRecoveredBatch(
1369
                JobBatchRecord batch,
1370
                IReadOnlyList<JobRecord> jobs,
1371
                IReadOnlyList<JobContinuationEdge> edges
1372
        )
1373
        {
1374
                if (batch.StartedAt is not null ||
4✔
1375
                        batch.CompletedAt is not null ||
4✔
1376
                        batch.SucceededCount != 0 ||
4✔
1377
                        batch.FailedCount != 0 ||
4✔
1378
                        batch.CancelledCount != 0 ||
4✔
1379
                        batch.SkippedCount != 0 ||
4✔
1380
                        jobs.Any(static job => job.State is JobState.Active or JobState.Succeeded or JobState.Failed or JobState.Cancelled or JobState.Skipped))
4✔
1381
                {
1382
                        return true;
4✔
1383
                }
1384

1385
                if (!HasTerminalParent(edges))
4✔
1386
                        return false;
4✔
1387

1388
                var incomingCounts = edges
4✔
1389
                        .GroupBy(static edge => edge.ChildJobId, StringComparer.Ordinal)
4✔
1390
                        .ToDictionary(static group => group.Key, static group => group.Count(), StringComparer.Ordinal);
4✔
1391
                return jobs.Any(job => incomingCounts.TryGetValue(job.Id, out var incoming) &&
4✔
1392
                        (job.State != JobState.AwaitingContinuation || job.RemainingDependencies < incoming));
4✔
1393
        }
1394

1395
        private bool HasTerminalParent(IEnumerable<JobContinuationEdge> edges) =>
1396
                edges.Any(IsTerminal);
4✔
1397

1398
        private void MarkTerminalParentEdgesSettled(IEnumerable<JobContinuationEdge> edges)
1399
        {
1400
                foreach (var edge in edges)
4✔
1401
                {
1402
                        if (IsTerminal(edge))
4✔
1403
                        {
1404
                                _ = _settledEdges.Add(edge);
4✔
1405
                        }
1406
                }
1407
        }
4✔
1408

1409
        private bool IsTerminal(JobContinuationEdge edge)
1410
        {
1411
                return (
4✔
1412
                        edge.ParentJobId is { } parentJobId
4✔
1413
                        && _jobs.TryGetValue(parentJobId, out var parentJob)
4✔
1414
                        && IsTerminal(parentJob.State)
4✔
1415
                ) || (
4✔
1416
                        edge.ParentBatchId is { } parentBatchId
4✔
1417
                        && _batches.TryGetValue(parentBatchId, out var parentBatch)
4✔
1418
                        && IsTerminal(parentBatch.State)
4✔
1419
                );
4✔
1420
        }
1421

1422
        private void EvaluateAlreadyTerminalParents(IReadOnlyList<JobContinuationEdge> edges)
1423
        {
1424
                foreach (var parentId in edges
4✔
1425
                        .Where(static edge => edge.ParentJobId is not null)
4✔
1426
                        .Select(static edge => edge.ParentJobId!)
4✔
1427
                        .Distinct(StringComparer.Ordinal)
4✔
1428
                        .Where(parentId => _jobs.TryGetValue(parentId, out var parent) && IsTerminal(parent.State))
4✔
1429
                        .ToArray())
4✔
1430
                {
1431
                        ProcessTerminalJob(parentId);
4✔
1432
                }
1433

1434
                foreach (var parentId in edges
4✔
1435
                        .Where(static edge => edge.ParentBatchId is not null)
4✔
1436
                        .Select(static edge => edge.ParentBatchId!)
4✔
1437
                        .Distinct(StringComparer.Ordinal)
4✔
1438
                        .Where(parentId => _batches.TryGetValue(parentId, out var parent) && IsTerminal(parent.State))
4✔
1439
                        .ToArray())
4✔
1440
                {
1441
                        ProcessTerminalBatch(parentId);
4✔
1442
                }
1443
        }
4✔
1444

1445
        private void TransitionToTerminal(
1446
                string jobId,
1447
                JobState terminalState,
1448
                string? error,
1449
                DateTimeOffset completedAt,
1450
                bool propagateContinuations = true
1451
        )
1452
        {
1453
                if (!IsTerminal(terminalState))
4✔
1454
                        throw new ArgumentOutOfRangeException(nameof(terminalState));
×
1455
                if (!_jobs.TryGetValue(jobId, out var job) || IsTerminal(job.State))
4✔
1456
                        return;
×
1457

1458
                _jobs[jobId] = job with
4✔
1459
                {
4✔
1460
                        State = terminalState,
4✔
1461
                        WorkerId = null,
4✔
1462
                        LeaseExpiresAt = null,
4✔
1463
                        LastError = error,
4✔
1464
                        CompletedAt = completedAt,
4✔
1465
                };
4✔
1466
                UpdateBatchAfterTerminal(job.BatchId, terminalState, completedAt);
4✔
1467
                if (propagateContinuations)
4✔
1468
                        ProcessTerminalJob(jobId);
4✔
1469
                RemoveFairQueueCursorWhenBacklogClears(job.QueueName, job.GroupId);
4✔
1470
        }
4✔
1471

1472
        private void RemoveFairQueueCursorWhenBacklogClears(string queueName, string? groupId)
1473
        {
1474
                if (groupId is null ||
4✔
1475
                        _jobs.Values.Any(job =>
4✔
1476
                                string.Equals(job.QueueName, queueName, StringComparison.Ordinal) &&
4✔
1477
                                string.Equals(job.GroupId, groupId, StringComparison.Ordinal) &&
4✔
1478
                                job.State is JobState.Pending or JobState.Scheduled or JobState.Active))
4✔
1479
                {
1480
                        return;
4✔
1481
                }
1482

1483
                _ = _fairQueueLastServed.Remove((queueName, groupId));
4✔
1484
        }
4✔
1485

1486
        private void UpdateBatchAfterTerminal(string? batchId, JobState state, DateTimeOffset completedAt)
1487
        {
1488
                if (batchId is null || !_batches.TryGetValue(batchId, out var batch))
4✔
1489
                        return;
4✔
1490

1491
                var pending = Math.Max(0, batch.PendingCount - 1);
4✔
1492
                batch = batch with
4✔
1493
                {
4✔
1494
                        PendingCount = pending,
4✔
1495
                        SucceededCount = batch.SucceededCount + (state == JobState.Succeeded ? 1 : 0),
4✔
1496
                        FailedCount = batch.FailedCount + (state == JobState.Failed ? 1 : 0),
4✔
1497
                        CancelledCount = batch.CancelledCount + (state == JobState.Cancelled ? 1 : 0),
4✔
1498
                        SkippedCount = batch.SkippedCount + (state == JobState.Skipped ? 1 : 0),
4✔
1499
                };
4✔
1500
                if (pending == 0)
4✔
1501
                {
1502
                        batch = batch with
4✔
1503
                        {
4✔
1504
                                State = batch.FailedCount != 0
4✔
1505
                                        ? BatchState.Failed
4✔
1506
                                        : batch.CancelledCount != 0 ? BatchState.Cancelled : BatchState.Succeeded,
4✔
1507
                                CompletedAt = completedAt,
4✔
1508
                        };
4✔
1509
                }
1510

1511
                _batches[batchId] = batch;
4✔
1512
                if (pending == 0)
4✔
1513
                        ProcessTerminalBatch(batchId);
4✔
1514
        }
4✔
1515

1516
        private void ProcessTerminalJob(string parentJobId)
1517
        {
1518
                if (!_jobs.TryGetValue(parentJobId, out var parent) || !IsTerminal(parent.State))
4✔
1519
                        return;
×
1520

1521
                foreach (var edge in _edges
4✔
1522
                        .Where(edge => string.Equals(edge.ParentJobId, parentJobId, StringComparison.Ordinal) && !_settledEdges.Contains(edge))
4✔
1523
                        .ToArray())
4✔
1524
                {
1525
                        _ = _settledEdges.Add(edge);
4✔
1526
                        SettleEdge(edge, parentFailed: parent.State == JobState.Failed);
4✔
1527
                }
1528
        }
4✔
1529

1530
        private void ProcessTerminalBatch(string parentBatchId)
1531
        {
1532
                if (!_batches.TryGetValue(parentBatchId, out var parent) || !IsTerminal(parent.State))
4✔
1533
                        return;
×
1534

1535
                foreach (var edge in _edges
4✔
1536
                        .Where(edge => string.Equals(edge.ParentBatchId, parentBatchId, StringComparison.Ordinal) && !_settledEdges.Contains(edge))
4✔
1537
                        .ToArray())
4✔
1538
                {
1539
                        _ = _settledEdges.Add(edge);
4✔
1540
                        SettleEdge(edge, parentFailed: parent.State == BatchState.Failed);
4✔
1541
                }
1542
        }
4✔
1543

1544
        private void SettleEdge(JobContinuationEdge edge, bool parentFailed)
1545
        {
1546
                if (!_jobs.TryGetValue(edge.ChildJobId, out var child) || IsTerminal(child.State))
4✔
1547
                        return;
4✔
1548

1549
                var remaining = Math.Max(0, child.RemainingDependencies - 1);
4✔
1550
                if (remaining != 0)
4✔
1551
                {
1552
                        _jobs[child.Id] = child with
4✔
1553
                        {
4✔
1554
                                RemainingDependencies = remaining,
4✔
1555
                                FailedDependencies = child.FailedDependencies + (parentFailed ? 1 : 0),
4✔
1556
                        };
4✔
1557
                        return;
4✔
1558
                }
1559

1560
                var (triggersSatisfied, failedDependencies) = EvaluateIncomingTriggers(child.Id);
4✔
1561
                _jobs[child.Id] = child with
4✔
1562
                {
4✔
1563
                        State = triggersSatisfied && child.State == JobState.AwaitingContinuation
4✔
1564
                                ? GetReadyState(child)
4✔
1565
                                : child.State,
4✔
1566
                        RemainingDependencies = 0,
4✔
1567
                        FailedDependencies = failedDependencies,
4✔
1568
                };
4✔
1569
                if (!triggersSatisfied)
4✔
1570
                        TransitionToTerminal(child.Id, JobState.Skipped, error: null, timeProvider.GetUtcNow());
4✔
1571
        }
4✔
1572

1573
        private (bool Satisfied, int FailedDependencies) EvaluateIncomingTriggers(string childJobId)
1574
        {
1575
                var allTerminal = true;
4✔
1576
                var requiresFailure = false;
4✔
1577
                var successViolated = false;
4✔
1578
                var failedDependencies = 0;
4✔
1579
                foreach (var edge in _edges.Where(edge => string.Equals(edge.ChildJobId, childJobId, StringComparison.Ordinal)))
4✔
1580
                {
1581
                        var parentTerminal = false;
4✔
1582
                        var parentSucceeded = false;
4✔
1583
                        var parentFailed = false;
4✔
1584
                        if (edge.ParentJobId is { } parentJobId && _jobs.TryGetValue(parentJobId, out var parentJob))
4✔
1585
                        {
1586
                                parentTerminal = IsTerminal(parentJob.State);
4✔
1587
                                parentSucceeded = parentJob.State == JobState.Succeeded;
4✔
1588
                                parentFailed = parentJob.State == JobState.Failed;
4✔
1589
                        }
1590
                        else if (edge.ParentBatchId is { } parentBatchId && _batches.TryGetValue(parentBatchId, out var parentBatch))
4✔
1591
                        {
1592
                                parentTerminal = IsTerminal(parentBatch.State);
4✔
1593
                                parentSucceeded = parentBatch.State == BatchState.Succeeded;
4✔
1594
                                parentFailed = parentBatch.State == BatchState.Failed;
4✔
1595
                        }
1596

1597
                        allTerminal &= parentTerminal;
4✔
1598
                        failedDependencies += parentFailed ? 1 : 0;
4✔
1599
                        requiresFailure |= edge.Trigger == ContinuationTrigger.Failure;
4✔
1600
                        successViolated |= edge.Trigger == ContinuationTrigger.Success && !parentSucceeded;
4✔
1601
                }
1602

1603
                return (
4✔
1604
                        allTerminal && !successViolated && (!requiresFailure || failedDependencies != 0),
4✔
1605
                        failedDependencies
4✔
1606
                );
4✔
1607
        }
1608

1609
        private JobState GetReadyState(JobRecord job) =>
1610
                job.DueAt <= timeProvider.GetUtcNow() ? JobState.Pending : JobState.Scheduled;
4✔
1611

1612
        private JobContinuationEdge[] GetUnsettledWaiters(string parentJobId) =>
1613
        [
4✔
1614
                .. _edges.Where(edge => string.Equals(edge.ParentJobId, parentJobId, StringComparison.Ordinal) &&
4✔
1615
                        !_settledEdges.Contains(edge) &&
4✔
1616
                        _jobs.TryGetValue(edge.ChildJobId, out var child) &&
4✔
1617
                        !IsTerminal(child.State)),
4✔
1618
        ];
4✔
1619

1620
        private void SpliceBeforeWaiters(
1621
                string newParentJobId,
1622
                IReadOnlyList<JobContinuationEdge> existingWaiters
1623
        )
1624
        {
1625
                foreach (var existingEdge in existingWaiters)
4✔
1626
                {
1627
                        if (!_jobs.TryGetValue(existingEdge.ChildJobId, out var child) || IsTerminal(child.State))
4✔
1628
                                continue;
1629

1630
                        _jobs[child.Id] = child with
4✔
1631
                        {
4✔
1632
                                State = JobState.AwaitingContinuation,
4✔
1633
                                RemainingDependencies = child.RemainingDependencies + 1,
4✔
1634
                        };
4✔
1635
                        _edges.Add(new()
4✔
1636
                        {
4✔
1637
                                ChildJobId = child.Id,
4✔
1638
                                ParentJobId = newParentJobId,
4✔
1639
                                Trigger = existingEdge.Trigger,
4✔
1640
                        });
4✔
1641
                }
1642
        }
4✔
1643

1644
        private void ValidateSplice(IReadOnlyList<JobContinuationEdge> existingWaiters, int additions)
1645
        {
1646
                if (additions == 0)
4✔
1647
                        return;
4✔
1648
                foreach (var existingEdge in existingWaiters)
4✔
1649
                {
1650
                        if (_jobs.TryGetValue(existingEdge.ChildJobId, out var child) &&
4✔
1651
                                child.RemainingDependencies > int.MaxValue - additions)
4✔
1652
                        {
1653
                                throw new ImmediateJobException($"Continuation dependency count overflow for job '{child.Id}'.");
×
1654
                        }
1655
                }
1656
        }
4✔
1657

1658
        private void IncrementBatchMembers(string? batchId, int count)
1659
        {
1660
                if (count == 0)
4✔
1661
                        return;
4✔
1662
                if (batchId is null || !_batches.TryGetValue(batchId, out var batch))
4✔
1663
                        throw new ImmediateJobException("The current job's batch was not found.");
×
1664
                if (batch.TotalJobs > int.MaxValue - count || batch.PendingCount > int.MaxValue - count)
4✔
1665
                        throw new ImmediateJobException($"Batch '{batchId}' member count overflow.");
×
1666

1667
                _batches[batchId] = batch with
4✔
1668
                {
4✔
1669
                        TotalJobs = batch.TotalJobs + count,
4✔
1670
                        PendingCount = batch.PendingCount + count,
4✔
1671
                };
4✔
1672
        }
4✔
1673

1674
        private void MarkBatchStarted(string? batchId, DateTimeOffset startedAt)
1675
        {
1676
                if (batchId is not null && _batches.TryGetValue(batchId, out var batch) && batch.StartedAt is null)
4✔
1677
                        _batches[batchId] = batch with { StartedAt = startedAt };
4✔
1678
        }
4✔
1679

1680
        private static bool IsTerminal(JobState state) =>
1681
                state is JobState.Succeeded or JobState.Failed or JobState.Cancelled or JobState.Skipped;
4✔
1682

1683
        private static bool IsTerminal(BatchState state) => state is not BatchState.Executing;
4✔
1684

1685
        private static BatchStatus ToStatus(JobBatchRecord batch) => new(
4✔
1686
                batch.Id,
4✔
1687
                batch.State,
4✔
1688
                batch.TotalJobs,
4✔
1689
                batch.SucceededCount,
4✔
1690
                batch.FailedCount,
4✔
1691
                batch.CancelledCount,
4✔
1692
                batch.SkippedCount,
4✔
1693
                batch.PendingCount,
4✔
1694
                batch.CreatedAt,
4✔
1695
                batch.StartedAt,
4✔
1696
                batch.CompletedAt,
4✔
1697
                BatchStatus.CalculateFractionSettled(batch.TotalJobs, batch.PendingCount)
4✔
1698
        );
4✔
1699

1700
        private static BatchGraphEdge ToGraphEdge(JobContinuationEdge edge) => new(
4✔
1701
                edge.ChildJobId,
4✔
1702
                edge.ParentJobId,
4✔
1703
                edge.ParentBatchId,
4✔
1704
                edge.Trigger
4✔
1705
        );
4✔
1706

1707
        private void RemoveEdgesForJobs(
1708
                HashSet<string> jobIds,
1709
                HashSet<string>? batchIds = null
1710
        )
1711
        {
1712
                if (jobIds.Count == 0 && (batchIds is null || batchIds.Count == 0))
4✔
1713
                        return;
4✔
1714

1715
                var batches = batchIds ?? [];
4✔
1716
                for (var index = _edges.Count - 1; index >= 0; index--)
4✔
1717
                {
1718
                        var edge = _edges[index];
4✔
1719
                        if (!jobIds.Contains(edge.ChildJobId) &&
4✔
1720
                                (edge.ParentJobId is null || !jobIds.Contains(edge.ParentJobId)) &&
4✔
1721
                                (edge.ParentBatchId is null || !batches.Contains(edge.ParentBatchId)))
4✔
1722
                        {
1723
                                continue;
1724
                        }
1725

1726
                        _edges.RemoveAt(index);
4✔
1727
                        _ = _settledEdges.Remove(edge);
4✔
1728
                }
1729
        }
4✔
1730

1731
        private void CreateExecution(JobRecord job, DateTimeOffset acquiredAt)
1732
        {
1733
                if (!_executions.TryGetValue(job.Id, out var executions))
4✔
1734
                        _executions.Add(job.Id, executions = []);
4✔
1735
                if (!executions.TryAdd(job.Attempt, new()
4✔
1736
                {
4✔
1737
                        JobId = job.Id,
4✔
1738
                        Attempt = job.Attempt,
4✔
1739
                        State = JobExecutionState.Active,
4✔
1740
                        WorkerId = job.WorkerId,
4✔
1741
                        AcquiredAt = acquiredAt,
4✔
1742
                }))
4✔
1743
                {
1744
                        throw new ImmediateJobException(string.Create(
×
1745
                                CultureInfo.InvariantCulture,
×
1746
                                $"Execution {job.Attempt} for job '{job.Id}' already exists."
×
1747
                        ));
×
1748
                }
1749
        }
4✔
1750

1751
        private void MaterializeSyntheticExecution(JobRecord job)
1752
        {
1753
                var synthetic = JobExecutionRecords.CreateSynthetic(job);
4✔
1754
                if (synthetic is null)
4✔
1755
                        return;
4✔
1756
                if (!_executions.TryGetValue(job.Id, out var executions))
4✔
1757
                        _executions.Add(job.Id, executions = []);
4✔
1758
                _ = executions.TryAdd(synthetic.Attempt, synthetic);
4✔
1759
        }
4✔
1760

1761
        private void InterruptExecution(JobRecord job)
1762
        {
1763
                CompleteExecution(job, JobExecutionState.Interrupted, job.LeaseExpiresAt ?? timeProvider.GetUtcNow());
4✔
1764
        }
4✔
1765

1766
        private void CompleteExecution(
1767
                JobRecord job,
1768
                JobExecutionState state,
1769
                DateTimeOffset completedAt,
1770
                string? error = null
1771
        )
1772
        {
1773
                if (job.Attempt <= 0)
4✔
1774
                        return;
4✔
1775

1776
                MaterializeSyntheticExecution(job);
4✔
1777
                UpdateExecution(job.Id, job.Attempt, execution => execution with
4✔
1778
                {
4✔
1779
                        State = state,
4✔
1780
                        CompletedAt = completedAt,
4✔
1781
                        Error = error,
4✔
1782
                });
4✔
1783
        }
4✔
1784

1785
        private void UpdateExecution(
1786
                string jobId,
1787
                int executionNumber,
1788
                Func<JobExecutionRecord, JobExecutionRecord> update
1789
        )
1790
        {
1791
                if (!_executions.TryGetValue(jobId, out var executions) || !executions.TryGetValue(executionNumber, out var execution))
4✔
1792
                {
1793
                        throw new ImmediateJobException(string.Create(
×
1794
                                CultureInfo.InvariantCulture,
×
1795
                                $"Execution {executionNumber} for job '{jobId}' was not found."
×
1796
                        ));
×
1797
                }
1798

1799
                executions[executionNumber] = update(execution);
4✔
1800
        }
4✔
1801

1802
        private JobRecord GetOwnedActive(string jobId, int executionNumber, string workerId)
1803
        {
1804
                if (!_jobs.TryGetValue(jobId, out var job) || job.State != JobState.Active)
4✔
1805
                {
1806
                        throw new ImmediateJobException($"Worker '{workerId}' does not own active job '{jobId}'.");
4✔
1807
                }
1808

1809
                if (job.Attempt != executionNumber)
4✔
1810
                {
1811
                        throw new ImmediateJobException(string.Create(
4✔
1812
                                CultureInfo.InvariantCulture,
4✔
1813
                                $"Execution {executionNumber} does not own active job '{jobId}'; the active execution is {job.Attempt}."
4✔
1814
                        ));
4✔
1815
                }
1816

1817
                if (!string.Equals(job.WorkerId, workerId, StringComparison.Ordinal))
4✔
1818
                        throw new ImmediateJobException($"Worker '{workerId}' does not own active job '{jobId}'.");
×
1819

1820
                return job;
4✔
1821
        }
1822

1823
        private ValueTask SetRecurringPausedAsync(string name, bool isPaused, CancellationToken cancellationToken)
1824
        {
1825
                cancellationToken.ThrowIfCancellationRequested();
4✔
1826
                lock (_gate)
1827
                {
1828
                        if (!_recurring.TryGetValue(name, out var schedule))
4✔
1829
                                throw new KeyNotFoundException($"Recurring schedule '{name}' was not found.");
4✔
1830

1831
                        _recurring[name] = schedule with { IsPaused = isPaused };
×
1832
                }
×
1833

1834
                return ValueTask.CompletedTask;
×
1835
        }
1836
}
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