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

ImmediatePlatform / Immediate.Jobs / 30631770050

31 Jul 2026 12:45PM UTC coverage: 83.079% (+0.8%) from 82.321%
30631770050

Pull #81

github

web-flow
Merge 3e7b86efb into be7540808
Pull Request #81: Add a durable skipped job state

125 of 133 new or added lines in 10 files covered. (93.98%)

5 existing lines in 3 files now uncovered.

7473 of 8995 relevant lines covered (83.08%)

2.75 hits per line

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

90.39
/src/Immediate.Jobs.Shared/InMemoryJobStorage.cs
1
namespace Immediate.Jobs.Shared;
2

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

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

25
        /// <inheritdoc />
26
        public ValueTask DisposeAsync() => ValueTask.CompletedTask;
4✔
27

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

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

46
                return ValueTask.CompletedTask;
4✔
47
        }
48

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

59
                cancellationToken.ThrowIfCancellationRequested();
4✔
60
                lock (_gate)
61
                {
62
                        if (childJobIds.Count == 0)
4✔
63
                                return [];
4✔
64

65
                        var distinctIds = new HashSet<string>(StringComparer.Ordinal);
4✔
66
                        foreach (var childJobId in childJobIds)
4✔
67
                                _ = distinctIds.Add(childJobId);
4✔
68

69
                        return [.. _edges.Where(edge => distinctIds.Contains(edge.ChildJobId))];
4✔
70
                }
71
        }
4✔
72

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

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

98
                return ValueTask.CompletedTask;
4✔
99
        }
100

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

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

134
                        _edges.AddRange(edges);
4✔
135
                        if (restoreExistingState)
4✔
136
                                MarkTerminalParentEdgesSettled(edges);
4✔
137
                        else
138
                                EvaluateAlreadyTerminalParents(edges);
4✔
139
                }
4✔
140

141
                return ValueTask.CompletedTask;
4✔
142
        }
143

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

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

169
                        if (request.FairQueues is null)
4✔
170
                                return AcquireInExistingOrder(request, now);
4✔
171

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

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

193
                                AcquireQueueFairly(
4✔
194
                                        request,
4✔
195
                                        queue.QueueName,
4✔
196
                                        jobCapacities,
4✔
197
                                        queueCapacity,
4✔
198
                                        now,
4✔
199
                                        acquired
4✔
200
                                );
4✔
201
                        }
202

203
                        return acquired;
4✔
204
                }
205
        }
4✔
206

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

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

227
                return acquired;
4✔
228
        }
229

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

253
                        acquired.Add(Acquire(candidate, request, now));
4✔
254
                        jobCapacities[candidate.JobName]--;
4✔
255
                        queueCapacity--;
4✔
256
                }
257
        }
4✔
258

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

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

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

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

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

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

349
        private static int GetNoisyInflight(
350
                string? groupId,
351
                Dictionary<string, int> activeCounts,
352
                int totalActive,
353
                FairQueuePolicy policy
354
        ) =>
355
                IsNoisy(groupId, activeCounts, totalActive, policy) ? activeCounts[groupId!] : 0;
4✔
356

357
        private long GetLastServed(string queueName, string? groupId) =>
358
                groupId is not null && _fairQueueLastServed.TryGetValue((queueName, groupId), out var sequence)
4✔
359
                        ? sequence
4✔
360
                        : 0;
4✔
361

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

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

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

412
                                if (job.State is not (JobState.Pending or JobState.Scheduled) || job.DueAt > now)
4✔
413
                                        continue;
414

415
                                job = job with
4✔
416
                                {
4✔
417
                                        State = JobState.Active,
4✔
418
                                        Attempt = job.Attempt + 1,
4✔
419
                                        WorkerId = workerId,
4✔
420
                                        LeaseExpiresAt = now + lease,
4✔
421
                                        ExecutionTraceId = null,
4✔
422
                                        ExecutionSpanId = null,
4✔
423
                                        ExecutionStartedAt = null,
4✔
424
                                };
4✔
425
                                _jobs[id] = job;
4✔
426
                                MarkBatchStarted(job.BatchId, now);
4✔
427
                                acquired.Add(job);
4✔
428
                        }
429

430
                        return acquired;
4✔
431
                }
432
        }
4✔
433

434
        /// <inheritdoc />
435
        public ValueTask SetExecutionTelemetryAsync(
436
                string jobId,
437
                string workerId,
438
                string? traceId,
439
                string? spanId,
440
                DateTimeOffset startedAt,
441
                CancellationToken cancellationToken = default
442
        )
443
        {
444
                cancellationToken.ThrowIfCancellationRequested();
4✔
445
                lock (_gate)
446
                {
447
                        var job = GetOwnedActive(jobId, workerId);
4✔
448
                        _jobs[jobId] = job with
4✔
449
                        {
4✔
450
                                ExecutionTraceId = traceId,
4✔
451
                                ExecutionSpanId = spanId,
4✔
452
                                ExecutionStartedAt = startedAt,
4✔
453
                        };
4✔
454
                }
4✔
455

456
                return ValueTask.CompletedTask;
4✔
457
        }
458

459
        /// <inheritdoc />
460
        public ValueTask RenewLeaseAsync(string jobId, string workerId, TimeSpan lease, CancellationToken cancellationToken = default)
461
        {
462
                cancellationToken.ThrowIfCancellationRequested();
×
463
                lock (_gate)
464
                {
465
                        var job = GetOwnedActive(jobId, workerId);
×
466
                        _jobs[jobId] = job with { LeaseExpiresAt = timeProvider.GetUtcNow() + lease };
×
467
                }
×
468

469
                return ValueTask.CompletedTask;
×
470
        }
471

472
        /// <inheritdoc />
473
        public ValueTask CompleteAsync(string jobId, string workerId, CancellationToken cancellationToken = default)
474
                => CompleteWithContinuationsAsync(jobId, workerId, [], cancellationToken);
4✔
475

476
        /// <inheritdoc />
477
        public ValueTask CompleteWithContinuationsAsync(
478
                string jobId,
479
                string workerId,
480
                IReadOnlyList<JobContinuationAddition> additions,
481
                CancellationToken cancellationToken = default
482
        )
483
        {
484
                ArgumentNullException.ThrowIfNull(additions);
4✔
485
                foreach (var addition in additions)
4✔
486
                {
487
                        ArgumentNullException.ThrowIfNull(addition, nameof(additions));
4✔
488
                        ArgumentNullException.ThrowIfNull(addition.Job, nameof(additions));
4✔
489
                }
490

491
                cancellationToken.ThrowIfCancellationRequested();
4✔
492
                lock (_gate)
493
                {
494
                        var current = GetOwnedActive(jobId, workerId);
4✔
495
                        var existingWaiters = GetUnsettledWaiters(jobId);
4✔
496
                        var newJobIds = new HashSet<string>(StringComparer.Ordinal);
4✔
497
                        var dependencyEdges = new List<JobContinuationEdge>(additions.Count);
4✔
498
                        var trackedAdditions = 0;
4✔
499

500
                        foreach (var addition in additions)
4✔
501
                        {
502
                                ValidateNewJob(addition.Job);
4✔
503
                                if (!newJobIds.Add(addition.Job.Id))
4✔
504
                                        throw new ImmediateJobException($"Job '{addition.Job.Id}' occurs more than once in the completion buffer.");
×
505
                                if (addition.Job.State is not (JobState.Pending or JobState.Scheduled))
4✔
506
                                        throw new ImmediateJobException($"Dynamic continuation '{addition.Job.Id}' has invalid state '{addition.Job.State}'.");
×
507
                                if (!Enum.IsDefined(addition.Trigger))
4✔
508
                                        throw new ArgumentOutOfRangeException(nameof(additions), "Unknown continuation trigger.");
×
509

510
                                if (addition.Options == ContinuationOptions.Detached)
4✔
511
                                {
512
                                        if (addition.Job.BatchId is not null)
4✔
513
                                                throw new ImmediateJobException("A detached continuation cannot belong to a batch.");
4✔
514
                                }
515
                                else if (addition.Options is ContinuationOptions.BesideContinuations or ContinuationOptions.BeforeContinuations)
4✔
516
                                {
517
                                        if (current.BatchId is null || !string.Equals(addition.Job.BatchId, current.BatchId, StringComparison.Ordinal))
4✔
518
                                                throw new ImmediateJobException("A batch-tracked continuation must belong to the current job's batch.");
4✔
519
                                        trackedAdditions++;
4✔
520
                                }
521
                                else
522
                                {
523
                                        throw new ArgumentOutOfRangeException(nameof(additions), "Unknown continuation option.");
×
524
                                }
525

526
                                dependencyEdges.Add(new()
4✔
527
                                {
4✔
528
                                        ChildJobId = addition.Job.Id,
4✔
529
                                        ParentJobId = jobId,
4✔
530
                                        Trigger = addition.Trigger,
4✔
531
                                });
4✔
532
                        }
533

534
                        if (dependencyEdges.Count != 0)
4✔
535
                                ValidateEdges([.. additions.Select(static addition => addition.Job)], dependencyEdges, current.BatchId);
4✔
536
                        ValidateSplice(existingWaiters, additions.Count(static addition => addition.Options == ContinuationOptions.BeforeContinuations));
4✔
537

538
                        IncrementBatchMembers(current.BatchId, trackedAdditions);
4✔
539
                        foreach (var addition in additions)
4✔
540
                        {
541
                                _jobs.Add(addition.Job.Id, NormalizeWaitingJob(addition.Job, dependencyCount: 1));
4✔
542
                                if (addition.Options == ContinuationOptions.BeforeContinuations)
4✔
543
                                        SpliceBeforeWaiters(addition.Job.Id, existingWaiters);
4✔
544
                        }
545

546
                        _edges.AddRange(dependencyEdges);
4✔
547
                        TransitionToTerminal(jobId, JobState.Succeeded, error: null, timeProvider.GetUtcNow());
4✔
548
                }
4✔
549

550
                return ValueTask.CompletedTask;
4✔
551
        }
552

553
        /// <inheritdoc />
554
        public ValueTask AddBatchJobAsync(
555
                string currentJobId,
556
                JobRecord job,
557
                ContinuationOptions options,
558
                CancellationToken cancellationToken = default
559
        )
560
        {
561
                ArgumentNullException.ThrowIfNull(job);
4✔
562
                cancellationToken.ThrowIfCancellationRequested();
4✔
563
                lock (_gate)
564
                {
565
                        if (!_jobs.TryGetValue(currentJobId, out var current) || current.State != JobState.Active)
4✔
566
                                throw new ImmediateJobException($"Job '{currentJobId}' is not currently active.");
×
567
                        if (current.BatchId is null || !_batches.ContainsKey(current.BatchId))
4✔
568
                                throw new ImmediateJobException("The current job does not belong to a batch.");
×
569
                        if (options == ContinuationOptions.Detached)
4✔
570
                                throw new ImmediateJobException("AddToBatchAsync(JobDetails, ...) cannot create detached work.");
×
571
                        if (options is not (ContinuationOptions.BesideContinuations or ContinuationOptions.BeforeContinuations))
4✔
572
                                throw new ArgumentOutOfRangeException(nameof(options));
×
573
                        ValidateNewJob(job);
4✔
574
                        if (!string.Equals(job.BatchId, current.BatchId, StringComparison.Ordinal))
4✔
575
                                throw new ImmediateJobException("The new job must belong to the current job's batch.");
×
576
                        if (job.State is JobState.Active or JobState.AwaitingContinuation || IsTerminal(job.State))
4✔
577
                                throw new ImmediateJobException($"Concurrent batch member '{job.Id}' has invalid state '{job.State}'.");
×
578

579
                        var existingWaiters = options == ContinuationOptions.BeforeContinuations
4✔
580
                                ? GetUnsettledWaiters(currentJobId)
4✔
581
                                : [];
4✔
582
                        ValidateSplice(existingWaiters, options == ContinuationOptions.BeforeContinuations ? 1 : 0);
4✔
583
                        IncrementBatchMembers(current.BatchId, 1);
4✔
584
                        _jobs.Add(job.Id, job with { RemainingDependencies = 0 });
4✔
585
                        if (options == ContinuationOptions.BeforeContinuations)
4✔
586
                                SpliceBeforeWaiters(job.Id, existingWaiters);
4✔
587
                }
4✔
588

589
                return ValueTask.CompletedTask;
4✔
590
        }
591

592
        /// <inheritdoc />
593
        public ValueTask FailAsync(
594
                string jobId,
595
                string workerId,
596
                string error,
597
                DateTimeOffset? nextRetryAt,
598
                CancellationToken cancellationToken = default
599
        )
600
        {
601
                cancellationToken.ThrowIfCancellationRequested();
4✔
602
                lock (_gate)
603
                {
604
                        var job = GetOwnedActive(jobId, workerId);
4✔
605
                        if (nextRetryAt.HasValue)
4✔
606
                        {
607
                                var now = timeProvider.GetUtcNow();
4✔
608
                                _jobs[jobId] = job with
4✔
609
                                {
4✔
610
                                        State = nextRetryAt <= now ? JobState.Pending : JobState.Scheduled,
4✔
611
                                        DueAt = nextRetryAt.Value,
4✔
612
                                        WorkerId = null,
4✔
613
                                        LeaseExpiresAt = null,
4✔
614
                                        LastError = error,
4✔
615
                                        CompletedAt = null,
4✔
616
                                };
4✔
617
                        }
618
                        else
619
                        {
620
                                TransitionToTerminal(jobId, JobState.Failed, error, timeProvider.GetUtcNow());
4✔
621
                        }
622
                }
4✔
623

624
                return ValueTask.CompletedTask;
4✔
625
        }
626

627
        /// <inheritdoc />
628
        public ValueTask UpsertRecurringAsync(RecurringJobSchedule schedule, CancellationToken cancellationToken = default)
629
        {
630
                ArgumentNullException.ThrowIfNull(schedule);
4✔
631
                cancellationToken.ThrowIfCancellationRequested();
4✔
632
                lock (_gate)
633
                {
634
                        if (_recurring.TryGetValue(schedule.Name, out var current))
4✔
635
                        {
636
                                if (current.IsCodeDefined && !schedule.IsCodeDefined)
4✔
637
                                        throw new ImmediateJobException("Code-defined recurring schedules cannot be replaced by dynamic schedules.");
4✔
638

639
                                schedule = schedule with { IsPaused = current.IsPaused, LastRunAt = current.LastRunAt };
4✔
640
                        }
641

642
                        _recurring[schedule.Name] = schedule;
4✔
643
                }
4✔
644

645
                return ValueTask.CompletedTask;
4✔
646
        }
647

648
        /// <inheritdoc />
649
        public ValueTask RemoveObsoleteCodeDefinedRecurringAsync(
650
                IReadOnlyCollection<string> activeScheduleNames,
651
                CancellationToken cancellationToken = default
652
        )
653
        {
654
                ArgumentNullException.ThrowIfNull(activeScheduleNames);
4✔
655
                cancellationToken.ThrowIfCancellationRequested();
4✔
656
                var activeNames = activeScheduleNames.ToHashSet(StringComparer.Ordinal);
4✔
657
                lock (_gate)
658
                {
659
                        var obsoleteNames = _recurring
4✔
660
                                .Where(schedule => schedule.Value.IsCodeDefined && !activeNames.Contains(schedule.Key))
4✔
661
                                .Select(static schedule => schedule.Key)
4✔
662
                                .ToArray();
4✔
663
                        foreach (var name in obsoleteNames)
4✔
664
                                _ = _recurring.Remove(name);
4✔
665
                }
4✔
666

667
                return ValueTask.CompletedTask;
4✔
668
        }
669

670
        /// <inheritdoc />
671
        public ValueTask RemoveRecurringAsync(string name, CancellationToken cancellationToken = default)
672
        {
673
                cancellationToken.ThrowIfCancellationRequested();
4✔
674
                lock (_gate)
675
                {
676
                        if (_recurring.TryGetValue(name, out var schedule) && schedule.IsCodeDefined)
4✔
677
                                throw new ImmediateJobException("Code-defined recurring schedules cannot be deleted.");
×
678

679
                        _ = _recurring.Remove(name);
4✔
680
                }
4✔
681

682
                return ValueTask.CompletedTask;
4✔
683
        }
684

685
        /// <inheritdoc />
686
        public ValueTask PauseRecurringAsync(string name, CancellationToken cancellationToken = default) =>
687
                SetRecurringPausedAsync(name, isPaused: true, cancellationToken);
4✔
688

689
        /// <inheritdoc />
690
        public ValueTask ResumeRecurringAsync(string name, CancellationToken cancellationToken = default) =>
691
                SetRecurringPausedAsync(name, isPaused: false, cancellationToken);
4✔
692

693
        /// <inheritdoc />
694
        public async ValueTask<IReadOnlyList<RecurringJobSchedule>> GetDueRecurringAsync(
695
                DateTimeOffset now,
696
                int batchSize,
697
                CancellationToken cancellationToken = default
698
        )
699
        {
700
                cancellationToken.ThrowIfCancellationRequested();
4✔
701
                lock (_gate)
702
                {
703
                        return
4✔
704
                        [
4✔
705
                                .. _recurring.Values.Where(x => !x.IsPaused && x.NextRunAt <= now).OrderBy(x => x.NextRunAt).Take(batchSize),
4✔
706
                        ];
4✔
707
                }
708
        }
4✔
709

710
        /// <inheritdoc />
711
        public async ValueTask<bool> MaterializeRecurringAsync(
712
                RecurringJobSchedule schedule,
713
                JobRecord job,
714
                DateTimeOffset nextRunAt,
715
                CancellationToken cancellationToken = default
716
        )
717
        {
718
                ArgumentNullException.ThrowIfNull(schedule);
4✔
719
                ArgumentNullException.ThrowIfNull(job);
4✔
720
                cancellationToken.ThrowIfCancellationRequested();
4✔
721
                lock (_gate)
722
                {
723
                        if (!_recurring.TryGetValue(schedule.Name, out var current) || current.NextRunAt != schedule.NextRunAt)
4✔
724
                                return false;
×
725

726
                        var inserted = job.RecurringKey is null || _recurringKeys.Add(job.RecurringKey);
4✔
727
                        if (inserted)
4✔
728
                                _jobs[job.Id] = job;
4✔
729
                        _recurring[schedule.Name] = current with { LastRunAt = schedule.NextRunAt, NextRunAt = nextRunAt };
4✔
730
                        return inserted;
4✔
731
                }
732
        }
4✔
733

734
        /// <inheritdoc />
735
        public async ValueTask<JobMonitoringSnapshot> GetMonitoringSnapshotAsync(CancellationToken cancellationToken = default)
736
        {
737
                cancellationToken.ThrowIfCancellationRequested();
4✔
738
                lock (_gate)
739
                {
740
                        var counts = Enum.GetValues<JobState>().ToDictionary(state => state, state => _jobs.Values.LongCount(x => x.State == state));
4✔
741
                        var cutoff = timeProvider.GetUtcNow() - TimeSpan.FromMinutes(2);
4✔
742
                        IReadOnlyList<JobServerSnapshot> servers = [.. _servers.Values.Where(x => x.LastHeartbeat >= cutoff)];
4✔
743
                        return new(timeProvider.GetUtcNow(), counts, [.. _recurring.Values], servers)
4✔
744
                        {
4✔
745
                                Capabilities = this.GetCapabilities(),
4✔
746
                        };
4✔
747
                }
748
        }
4✔
749

750
        /// <inheritdoc />
751
        public async ValueTask<IReadOnlyList<JobRecord>> QueryJobsAsync(JobQuery query, CancellationToken cancellationToken = default)
752
        {
753
                ArgumentNullException.ThrowIfNull(query);
4✔
754
                cancellationToken.ThrowIfCancellationRequested();
4✔
755
                lock (_gate)
756
                {
757
                        var jobs = _jobs.Values.AsEnumerable();
4✔
758
                        if (query.Id is { } id)
4✔
759
                                jobs = jobs.Where(x => string.Equals(x.Id, id, StringComparison.Ordinal));
4✔
760
                        if (query.State is { } state)
4✔
761
                                jobs = jobs.Where(x => x.State == state);
4✔
762
                        if (!string.IsNullOrWhiteSpace(query.QueueName))
4✔
763
                                jobs = jobs.Where(x => string.Equals(x.QueueName, query.QueueName, StringComparison.Ordinal));
4✔
764
                        if (!string.IsNullOrWhiteSpace(query.JobName))
4✔
765
                                jobs = jobs.Where(x => string.Equals(x.JobName, query.JobName, StringComparison.Ordinal));
4✔
766

767
                        if (!string.IsNullOrWhiteSpace(query.Search))
4✔
768
                                jobs = jobs.Where(x => x.JobName.Contains(query.Search, StringComparison.OrdinalIgnoreCase));
×
769

770
                        return
4✔
771
                        [
4✔
772
                                .. jobs.OrderByDescending(x => x.CreatedAt)
4✔
773
                                        .ThenBy(x => x.Id, StringComparer.Ordinal)
4✔
774
                                        .Skip(Math.Max(0, query.Skip))
4✔
775
                                        .Take(Math.Clamp(query.Take, 1, 1000)),
4✔
776
                        ];
4✔
777
                }
778
        }
4✔
779

780
        /// <inheritdoc />
781
        public async ValueTask<BatchStatus?> GetBatchStatusAsync(
782
                string batchId,
783
                CancellationToken cancellationToken = default
784
        )
785
        {
786
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
787
                cancellationToken.ThrowIfCancellationRequested();
4✔
788
                lock (_gate)
789
                {
790
                        if (!_batches.TryGetValue(batchId, out var batch))
4✔
791
                                return null;
4✔
792

793
                        return ToStatus(batch);
4✔
794
                }
795
        }
4✔
796

797
        /// <inheritdoc />
798
        public async ValueTask<IReadOnlyList<BatchStatus>> QueryBatchesAsync(
799
                JobBatchQuery query,
800
                CancellationToken cancellationToken = default
801
        )
802
        {
803
                ArgumentNullException.ThrowIfNull(query);
4✔
804
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip, nameof(query));
4✔
805
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0, nameof(query));
4✔
806

807
                cancellationToken.ThrowIfCancellationRequested();
4✔
808
                lock (_gate)
809
                {
810
                        var batches = _batches.Values.AsEnumerable();
4✔
811
                        if (query.State is { } state)
4✔
812
                                batches = batches.Where(batch => batch.State == state);
×
813
                        return
4✔
814
                        [
4✔
815
                                .. batches.OrderByDescending(static batch => batch.CreatedAt)
4✔
816
                                        .ThenBy(static batch => batch.Id, StringComparer.Ordinal)
4✔
817
                                        .Skip(query.Skip)
4✔
818
                                        .Take(query.Take)
4✔
819
                                        .Select(ToStatus),
4✔
820
                        ];
4✔
821
                }
822
        }
4✔
823

824
        /// <inheritdoc />
825
        public async ValueTask<IReadOnlyList<BatchMemberStatus>> QueryBatchMembersAsync(
826
                string batchId,
827
                BatchMemberQuery query,
828
                CancellationToken cancellationToken = default
829
        )
830
        {
831
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
832
                ArgumentNullException.ThrowIfNull(query);
4✔
833
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip, nameof(query));
4✔
834
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0, nameof(query));
4✔
835

836
                cancellationToken.ThrowIfCancellationRequested();
4✔
837
                lock (_gate)
838
                {
839
                        if (!_batches.ContainsKey(batchId))
4✔
840
                                return [];
×
841

842
                        var members = _jobs.Values.Where(job => string.Equals(job.BatchId, batchId, StringComparison.Ordinal));
4✔
843
                        if (query.State is { } state)
4✔
844
                                members = members.Where(job => job.State == state);
4✔
845

846
                        return
4✔
847
                        [
4✔
848
                                .. members
4✔
849
                                        .OrderBy(job => job.CreatedAt)
4✔
850
                                        .ThenBy(job => job.Id, StringComparer.Ordinal)
4✔
851
                                        .Skip(query.Skip)
4✔
852
                                        .Take(query.Take)
4✔
853
                                        .Select(static job => new BatchMemberStatus(
4✔
854
                                                job.Id,
4✔
855
                                                job.JobName,
4✔
856
                                                job.QueueName,
4✔
857
                                                job.State,
4✔
858
                                                job.Attempt,
4✔
859
                                                job.CreatedAt,
4✔
860
                                                job.CompletedAt,
4✔
861
                                                job.LastError
4✔
862
                                        )),
4✔
863
                        ];
4✔
864
                }
865
        }
4✔
866

867
        /// <inheritdoc />
868
        public async ValueTask<BatchGraph?> GetBatchGraphAsync(
869
                string batchId,
870
                CancellationToken cancellationToken = default
871
        )
872
        {
873
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
874
                cancellationToken.ThrowIfCancellationRequested();
4✔
875
                lock (_gate)
876
                {
877
                        if (!_batches.ContainsKey(batchId))
4✔
878
                                return null;
×
879

880
                        var members = _jobs.Values
4✔
881
                                .Where(job => string.Equals(job.BatchId, batchId, StringComparison.Ordinal))
4✔
882
                                .OrderBy(job => job.CreatedAt)
4✔
883
                                .ThenBy(job => job.Id, StringComparer.Ordinal)
4✔
884
                                .ToArray();
4✔
885
                        var memberIds = members.Select(static job => job.Id).ToHashSet(StringComparer.Ordinal);
4✔
886
                        return new(
4✔
887
                                batchId,
4✔
888
                                [.. members.Select(static job => new BatchGraphNode(job.Id, job.JobName, job.State))],
4✔
889
                                [.. _edges.Where(edge => memberIds.Contains(edge.ChildJobId)).Select(ToGraphEdge)]
4✔
890
                        );
4✔
891
                }
892
        }
4✔
893

894
        /// <inheritdoc />
895
        public async ValueTask<JobStatus?> GetJobStatusAsync(
896
                string jobId,
897
                CancellationToken cancellationToken = default
898
        )
899
        {
900
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
4✔
901
                cancellationToken.ThrowIfCancellationRequested();
4✔
902
                lock (_gate)
903
                {
904
                        if (!_jobs.TryGetValue(jobId, out var job))
4✔
905
                                return null;
×
906

907
                        return new(
4✔
908
                                job.Id,
4✔
909
                                job.JobName,
4✔
910
                                job.QueueName,
4✔
911
                                job.State,
4✔
912
                                job.Attempt,
4✔
913
                                MaxAttempts: null,
4✔
914
                                job.CreatedAt,
4✔
915
                                job.DueAt,
4✔
916
                                job.CompletedAt,
4✔
917
                                job.LastError,
4✔
918
                                job.BatchId,
4✔
919
                                [.. _edges.Where(edge => string.Equals(edge.ChildJobId, jobId, StringComparison.Ordinal)).Select(ToGraphEdge)]
4✔
920
                        );
4✔
921
                }
922
        }
4✔
923

924
        /// <inheritdoc />
925
        public ValueTask CancelBatchAsync(string batchId, CancellationToken cancellationToken = default)
926
        {
927
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
928
                cancellationToken.ThrowIfCancellationRequested();
4✔
929
                lock (_gate)
930
                {
931
                        if (!_batches.TryGetValue(batchId, out var batch))
4✔
932
                                throw new KeyNotFoundException($"Batch '{batchId}' was not found.");
4✔
933
                        if (batch.State != BatchState.Executing)
4✔
934
                                throw new ImmediateJobException("Only an executing batch can be cancelled.");
×
935

936
                        var now = timeProvider.GetUtcNow();
4✔
937
                        var jobIds = _jobs.Values
4✔
938
                                .Where(job => string.Equals(job.BatchId, batchId, StringComparison.Ordinal) && !IsTerminal(job.State))
4✔
939
                                .Select(static job => job.Id)
4✔
940
                                .ToArray();
4✔
941
                        foreach (var jobId in jobIds)
4✔
942
                        {
943
                                TransitionToTerminal(jobId, JobState.Cancelled, error: null, now, propagateContinuations: false);
4✔
944
                        }
945

946
                        foreach (var jobId in jobIds)
4✔
947
                                ProcessTerminalJob(jobId);
4✔
948
                }
4✔
949

950
                return ValueTask.CompletedTask;
4✔
951
        }
952

953
        /// <inheritdoc />
954
        public ValueTask DeleteBatchAsync(string batchId, CancellationToken cancellationToken = default)
955
        {
956
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
957
                cancellationToken.ThrowIfCancellationRequested();
4✔
958
                lock (_gate)
959
                {
960
                        if (!_batches.TryGetValue(batchId, out var batch))
4✔
961
                                throw new KeyNotFoundException($"Batch '{batchId}' was not found.");
4✔
962
                        if (batch.State == BatchState.Executing)
4✔
963
                                throw new ImmediateJobException("Only a terminal batch can be deleted.");
×
964

965
                        var jobIds = _jobs.Values
4✔
966
                                .Where(job => string.Equals(job.BatchId, batchId, StringComparison.Ordinal))
4✔
967
                                .Select(static job => job.Id)
4✔
968
                                .ToHashSet(StringComparer.Ordinal);
4✔
969
                        foreach (var jobId in jobIds)
4✔
970
                                _ = _jobs.Remove(jobId);
4✔
971
                        _ = _batches.Remove(batchId);
4✔
972
                        RemoveEdgesForJobs(jobIds, [batchId]);
4✔
973
                }
4✔
974

975
                return ValueTask.CompletedTask;
4✔
976
        }
977

978
        /// <inheritdoc />
979
        public ValueTask RetryAsync(string jobId, CancellationToken cancellationToken = default)
980
        {
981
                cancellationToken.ThrowIfCancellationRequested();
4✔
982
                lock (_gate)
983
                {
984
                        if (!_jobs.TryGetValue(jobId, out var job))
4✔
985
                                throw new KeyNotFoundException($"Job '{jobId}' was not found.");
4✔
NEW
986
                        if (job.State != JobState.Failed)
×
UNCOV
987
                                throw new ImmediateJobException("Only failed jobs can be retried.");
×
988

989
                        _jobs[jobId] = job with
×
990
                        {
×
991
                                State = JobState.Pending,
×
992
                                DueAt = timeProvider.GetUtcNow(),
×
993
                                CompletedAt = null,
×
994
                                LastError = null,
×
995
                        };
×
996
                        if (job.BatchId is { } batchId && _batches.TryGetValue(batchId, out var batch))
×
997
                        {
998
                                _batches[batchId] = batch with
×
999
                                {
×
1000
                                        State = BatchState.Executing,
×
1001
                                        PendingCount = batch.PendingCount + 1,
×
1002
                                        FailedCount = Math.Max(0, batch.FailedCount - 1),
×
1003
                                        CompletedAt = null,
×
1004
                                };
×
1005
                        }
1006
                }
×
1007

1008
                return ValueTask.CompletedTask;
×
1009
        }
1010

1011
        /// <inheritdoc />
1012
        public ValueTask DeleteAsync(string jobId, CancellationToken cancellationToken = default)
1013
        {
1014
                cancellationToken.ThrowIfCancellationRequested();
4✔
1015
                lock (_gate)
1016
                {
1017
                        if (!_jobs.TryGetValue(jobId, out var job))
4✔
1018
                                throw new KeyNotFoundException($"Job '{jobId}' was not found.");
4✔
1019
                        if (!IsTerminal(job.State))
×
1020
                                throw new ImmediateJobException("Only terminal jobs can be deleted.");
×
1021
                        if (job.BatchId is not null)
×
1022
                                throw new ImmediateJobException("Batch members cannot be deleted individually.");
×
1023

1024
                        _ = _jobs.Remove(jobId);
×
1025
                        RemoveEdgesForJobs(new(StringComparer.Ordinal) { jobId });
×
1026
                }
×
1027

1028
                return ValueTask.CompletedTask;
×
1029
        }
1030

1031
        /// <inheritdoc />
1032
        public ValueTask PurgeJobsAsync(
1033
                TimeSpan succeededRetention,
1034
                TimeSpan failedRetention,
1035
                CancellationToken cancellationToken = default
1036
        )
1037
        {
1038
                cancellationToken.ThrowIfCancellationRequested();
4✔
1039
                var now = timeProvider.GetUtcNow();
4✔
1040
                lock (_gate)
1041
                {
1042
                        var standaloneJobIds = _jobs.Values
4✔
1043
                                .Where(static job => job.BatchId is null)
4✔
1044
                                .Where(
4✔
1045
                                        x =>
4✔
1046
                                                x.CompletedAt is { } completed
4✔
1047
                                                && (
4✔
1048
                                                        (x.State is JobState.Succeeded && completed < now - succeededRetention)
4✔
1049
                                                        || (x.State is JobState.Failed or JobState.Cancelled or JobState.Skipped && completed < now - failedRetention)
4✔
1050
                                                )
4✔
1051
                                )
4✔
1052
                                .Select(static job => job.Id)
×
1053
                                .ToHashSet(StringComparer.Ordinal);
4✔
1054

1055
                        foreach (var id in standaloneJobIds)
4✔
1056
                                _ = _jobs.Remove(id);
×
1057
                        RemoveEdgesForJobs(standaloneJobIds);
4✔
1058
                }
4✔
1059

1060
                return ValueTask.CompletedTask;
4✔
1061
        }
1062

1063
        /// <inheritdoc />
1064
        public ValueTask PurgeBatchesAsync(
1065
                TimeSpan batchSucceededRetention,
1066
                TimeSpan batchFailedRetention,
1067
                CancellationToken cancellationToken = default
1068
        )
1069
        {
1070
                cancellationToken.ThrowIfCancellationRequested();
4✔
1071
                var now = timeProvider.GetUtcNow();
4✔
1072
                lock (_gate)
1073
                {
1074
                        var batchIds = _batches.Values
4✔
1075
                                .Where(
4✔
1076
                                        batch =>
4✔
1077
                                                batch.CompletedAt is { } completed
×
1078
                                                && (
×
1079
                                                        (batch.State is BatchState.Succeeded && completed < now - batchSucceededRetention)
×
1080
                                                        || (batch.State is BatchState.Failed or BatchState.Cancelled && completed < now - batchFailedRetention)
×
1081
                                                )
×
1082
                                )
4✔
1083
                                .Select(static batch => batch.Id)
×
1084
                                .ToHashSet(StringComparer.Ordinal);
4✔
1085

1086
                        var batchJobIds = _jobs.Values
4✔
1087
                                .Where(job => job.BatchId is { } batchId && batchIds.Contains(batchId))
4✔
1088
                                .Select(static job => job.Id)
×
1089
                                .ToHashSet(StringComparer.Ordinal);
4✔
1090
                        foreach (var id in batchJobIds)
4✔
1091
                                _ = _jobs.Remove(id);
×
1092
                        foreach (var batchId in batchIds)
4✔
1093
                                _ = _batches.Remove(batchId);
×
1094
                        RemoveEdgesForJobs(batchJobIds, batchIds);
4✔
1095
                }
4✔
1096

1097
                return ValueTask.CompletedTask;
4✔
1098
        }
1099

1100
        /// <inheritdoc />
1101
        public ValueTask HeartbeatAsync(JobServerSnapshot server, CancellationToken cancellationToken = default)
1102
        {
1103
                ArgumentNullException.ThrowIfNull(server);
4✔
1104
                cancellationToken.ThrowIfCancellationRequested();
4✔
1105
                lock (_gate)
1106
                        _servers[server.WorkerId] = server;
4✔
1107
                return ValueTask.CompletedTask;
4✔
1108
        }
1109

1110
        /// <inheritdoc />
1111
        public async ValueTask<bool> IsHealthyAsync(CancellationToken cancellationToken = default)
1112
        {
1113
                cancellationToken.ThrowIfCancellationRequested();
×
1114
                return true;
×
1115
        }
1116

1117
        private void ValidateNewJob(JobRecord job)
1118
        {
1119
                ArgumentException.ThrowIfNullOrWhiteSpace(job.Id);
4✔
1120
                ArgumentException.ThrowIfNullOrWhiteSpace(job.JobName);
4✔
1121
                if (_jobs.ContainsKey(job.Id))
4✔
1122
                        throw new ImmediateJobException($"Job '{job.Id}' already exists.");
×
1123
        }
4✔
1124

1125
        private void ValidateBatch(
1126
                JobBatchRecord batch,
1127
                IReadOnlyList<JobRecord> jobs,
1128
                IReadOnlyList<JobContinuationEdge> edges
1129
        )
1130
        {
1131
                ArgumentException.ThrowIfNullOrWhiteSpace(batch.Id);
4✔
1132
                if (_batches.ContainsKey(batch.Id))
4✔
1133
                        throw new ImmediateJobException($"Batch '{batch.Id}' already exists.");
×
1134
                if (jobs.Count == 0)
4✔
1135
                        throw new ImmediateJobException("An atomic batch cannot be empty.");
×
1136

1137
                var succeeded = jobs.Count(static job => job.State == JobState.Succeeded);
4✔
1138
                var failed = jobs.Count(static job => job.State == JobState.Failed);
4✔
1139
                var cancelled = jobs.Count(static job => job.State == JobState.Cancelled);
4✔
1140
                var skipped = jobs.Count(static job => job.State == JobState.Skipped);
4✔
1141
                var pending = jobs.Count - succeeded - failed - cancelled - skipped;
4✔
1142

1143
                var expectedState = true switch
1144
                {
1145
                        _ when pending != 0 => BatchState.Executing,
4✔
1146
                        _ when failed != 0 => BatchState.Failed,
4✔
1147
                        _ when cancelled != 0 => BatchState.Cancelled,
4✔
1148
                        _ => BatchState.Succeeded,
4✔
1149
                };
1150

1151
                if (
4✔
1152
                        batch.TotalJobs != jobs.Count ||
4✔
1153
                        batch.PendingCount != pending ||
4✔
1154
                        batch.SucceededCount != succeeded ||
4✔
1155
                        batch.FailedCount != failed ||
4✔
1156
                        batch.CancelledCount != cancelled ||
4✔
1157
                        batch.SkippedCount != skipped ||
4✔
1158
                        batch.State != expectedState ||
4✔
1159
                        ((pending == 0) != (batch.CompletedAt is not null))
4✔
1160
                )
4✔
1161
                {
1162
                        throw new ImmediateJobException("A batch header does not match its members or aggregate state.");
×
1163
                }
1164

1165
                var jobIds = new HashSet<string>(StringComparer.Ordinal);
4✔
1166
                foreach (var job in jobs)
4✔
1167
                {
1168
                        ValidateNewJob(job);
4✔
1169
                        if (!jobIds.Add(job.Id))
4✔
1170
                                throw new ImmediateJobException($"Job '{job.Id}' occurs more than once in the batch.");
×
1171
                        if (!string.Equals(job.BatchId, batch.Id, StringComparison.Ordinal))
4✔
1172
                                throw new ImmediateJobException($"Job '{job.Id}' does not belong to batch '{batch.Id}'.");
×
1173
                }
1174

1175
                ValidateEdges(jobs, edges, batch.Id);
4✔
1176
        }
4✔
1177

1178
        private void ValidateEdges(
1179
                IReadOnlyList<JobRecord> newJobs,
1180
                IReadOnlyList<JobContinuationEdge> edges,
1181
                string? batchId
1182
        )
1183
        {
1184
                if (batchId is null && edges.Count == 0)
4✔
1185
                        throw new ImmediateJobException("A continuation must have at least one parent.");
×
1186

1187
                var newJobIds = newJobs.Select(static job => job.Id).ToHashSet(StringComparer.Ordinal);
4✔
1188
                var logicalEdges = new HashSet<(string Child, string ParentKind, string Parent)>(
4✔
1189
                        EqualityComparer<(string Child, string ParentKind, string Parent)>.Default
4✔
1190
                );
4✔
1191
                var outgoing = newJobIds.ToDictionary(static id => id, static _ => new List<string>(), StringComparer.Ordinal);
4✔
1192
                var incoming = newJobIds.ToDictionary(static id => id, static _ => 0, StringComparer.Ordinal);
4✔
1193
                foreach (var edge in edges)
4✔
1194
                {
1195
                        ArgumentNullException.ThrowIfNull(edge);
4✔
1196
                        ArgumentException.ThrowIfNullOrWhiteSpace(edge.ChildJobId);
4✔
1197
                        if (!Enum.IsDefined(edge.Trigger))
4✔
1198
                                throw new ArgumentOutOfRangeException(nameof(edges), "Unknown continuation trigger.");
×
1199
                        if (!newJobIds.Contains(edge.ChildJobId))
4✔
1200
                                throw new ImmediateJobException($"Continuation child '{edge.ChildJobId}' is not part of the atomic insert.");
×
1201

1202
                        var hasJobParent = !string.IsNullOrWhiteSpace(edge.ParentJobId);
4✔
1203
                        var hasBatchParent = !string.IsNullOrWhiteSpace(edge.ParentBatchId);
4✔
1204
                        if (hasJobParent == hasBatchParent)
4✔
1205
                                throw new ImmediateJobException("A continuation edge must have exactly one job or batch parent.");
×
1206

1207
                        if (hasJobParent)
4✔
1208
                        {
1209
                                var parentId = edge.ParentJobId!;
4✔
1210
                                if (string.Equals(parentId, edge.ChildJobId, StringComparison.Ordinal))
4✔
1211
                                        throw new ImmediateJobException($"Continuation job '{edge.ChildJobId}' cannot depend on itself.");
×
1212
                                if (!newJobIds.Contains(parentId) && !_jobs.ContainsKey(parentId))
4✔
1213
                                        throw new KeyNotFoundException($"Continuation parent job '{parentId}' was not found.");
4✔
1214
                                if (!logicalEdges.Add((edge.ChildJobId, "job", parentId)))
4✔
1215
                                        throw new ImmediateJobException($"Duplicate continuation edge '{parentId}' -> '{edge.ChildJobId}'.");
×
1216

1217
                                if (newJobIds.Contains(parentId))
4✔
1218
                                {
1219
                                        outgoing[parentId].Add(edge.ChildJobId);
4✔
1220
                                        incoming[edge.ChildJobId]++;
4✔
1221
                                }
1222
                        }
1223
                        else
1224
                        {
1225
                                var parentId = edge.ParentBatchId!;
4✔
1226
                                if (!_batches.ContainsKey(parentId))
4✔
1227
                                        throw new KeyNotFoundException($"Continuation parent batch '{parentId}' was not found.");
×
1228
                                if (!logicalEdges.Add((edge.ChildJobId, "batch", parentId)))
4✔
1229
                                        throw new ImmediateJobException($"Duplicate continuation edge from batch '{parentId}' to '{edge.ChildJobId}'.");
×
1230
                        }
1231
                }
1232

1233
                var ready = new Queue<string>(incoming.Where(static pair => pair.Value == 0).Select(static pair => pair.Key));
4✔
1234
                var visited = 0;
4✔
1235
                while (ready.TryDequeue(out var parentId))
4✔
1236
                {
1237
                        visited++;
4✔
1238
                        foreach (var childId in outgoing[parentId])
4✔
1239
                        {
1240
                                if (--incoming[childId] == 0)
4✔
1241
                                        ready.Enqueue(childId);
4✔
1242
                        }
1243
                }
1244

1245
                if (visited != newJobIds.Count)
4✔
1246
                        throw new ImmediateJobException("The continuation graph contains a dependency cycle.");
×
1247
        }
4✔
1248

1249
        private static JobRecord NormalizeWaitingJob(JobRecord job, int dependencyCount) => job with
4✔
1250
        {
4✔
1251
                State = dependencyCount == 0 ? job.State : JobState.AwaitingContinuation,
4✔
1252
                RemainingDependencies = dependencyCount,
4✔
1253
                FailedDependencies = 0,
4✔
1254
                WorkerId = null,
4✔
1255
                LeaseExpiresAt = null,
4✔
1256
                CompletedAt = null,
4✔
1257
        };
4✔
1258

1259
        private bool IsRecoveredBatch(
1260
                JobBatchRecord batch,
1261
                IReadOnlyList<JobRecord> jobs,
1262
                IReadOnlyList<JobContinuationEdge> edges
1263
        )
1264
        {
1265
                if (batch.StartedAt is not null ||
4✔
1266
                        batch.CompletedAt is not null ||
4✔
1267
                        batch.SucceededCount != 0 ||
4✔
1268
                        batch.FailedCount != 0 ||
4✔
1269
                        batch.CancelledCount != 0 ||
4✔
1270
                        batch.SkippedCount != 0 ||
4✔
1271
                        jobs.Any(static job => job.State is JobState.Active or JobState.Succeeded or JobState.Failed or JobState.Cancelled or JobState.Skipped))
4✔
1272
                {
1273
                        return true;
4✔
1274
                }
1275

1276
                if (!HasTerminalParent(edges))
4✔
1277
                        return false;
4✔
1278

1279
                var incomingCounts = edges
4✔
1280
                        .GroupBy(static edge => edge.ChildJobId, StringComparer.Ordinal)
4✔
1281
                        .ToDictionary(static group => group.Key, static group => group.Count(), StringComparer.Ordinal);
4✔
1282
                return jobs.Any(job => incomingCounts.TryGetValue(job.Id, out var incoming) &&
4✔
1283
                        (job.State != JobState.AwaitingContinuation || job.RemainingDependencies < incoming));
4✔
1284
        }
1285

1286
        private bool HasTerminalParent(IEnumerable<JobContinuationEdge> edges) =>
1287
                edges.Any(IsTerminal);
4✔
1288

1289
        private void MarkTerminalParentEdgesSettled(IEnumerable<JobContinuationEdge> edges)
1290
        {
1291
                foreach (var edge in edges)
4✔
1292
                {
1293
                        if (IsTerminal(edge))
4✔
1294
                        {
1295
                                _ = _settledEdges.Add(edge);
4✔
1296
                        }
1297
                }
1298
        }
4✔
1299

1300
        private bool IsTerminal(JobContinuationEdge edge)
1301
        {
1302
                return (
4✔
1303
                        edge.ParentJobId is { } parentJobId
4✔
1304
                        && _jobs.TryGetValue(parentJobId, out var parentJob)
4✔
1305
                        && IsTerminal(parentJob.State)
4✔
1306
                ) || (
4✔
1307
                        edge.ParentBatchId is { } parentBatchId
4✔
1308
                        && _batches.TryGetValue(parentBatchId, out var parentBatch)
4✔
1309
                        && IsTerminal(parentBatch.State)
4✔
1310
                );
4✔
1311
        }
1312

1313
        private void EvaluateAlreadyTerminalParents(IReadOnlyList<JobContinuationEdge> edges)
1314
        {
1315
                foreach (var parentId in edges
4✔
1316
                        .Where(static edge => edge.ParentJobId is not null)
4✔
1317
                        .Select(static edge => edge.ParentJobId!)
4✔
1318
                        .Distinct(StringComparer.Ordinal)
4✔
1319
                        .Where(parentId => _jobs.TryGetValue(parentId, out var parent) && IsTerminal(parent.State))
4✔
1320
                        .ToArray())
4✔
1321
                {
1322
                        ProcessTerminalJob(parentId);
4✔
1323
                }
1324

1325
                foreach (var parentId in edges
4✔
1326
                        .Where(static edge => edge.ParentBatchId is not null)
4✔
1327
                        .Select(static edge => edge.ParentBatchId!)
4✔
1328
                        .Distinct(StringComparer.Ordinal)
4✔
1329
                        .Where(parentId => _batches.TryGetValue(parentId, out var parent) && IsTerminal(parent.State))
4✔
1330
                        .ToArray())
4✔
1331
                {
1332
                        ProcessTerminalBatch(parentId);
4✔
1333
                }
1334
        }
4✔
1335

1336
        private void TransitionToTerminal(
1337
                string jobId,
1338
                JobState terminalState,
1339
                string? error,
1340
                DateTimeOffset completedAt,
1341
                bool propagateContinuations = true
1342
        )
1343
        {
1344
                if (!IsTerminal(terminalState))
4✔
1345
                        throw new ArgumentOutOfRangeException(nameof(terminalState));
×
1346
                if (!_jobs.TryGetValue(jobId, out var job) || IsTerminal(job.State))
4✔
UNCOV
1347
                        return;
×
1348

1349
                _jobs[jobId] = job with
4✔
1350
                {
4✔
1351
                        State = terminalState,
4✔
1352
                        WorkerId = null,
4✔
1353
                        LeaseExpiresAt = null,
4✔
1354
                        LastError = error,
4✔
1355
                        CompletedAt = completedAt,
4✔
1356
                };
4✔
1357
                UpdateBatchAfterTerminal(job.BatchId, terminalState, completedAt);
4✔
1358
                if (propagateContinuations)
4✔
1359
                        ProcessTerminalJob(jobId);
4✔
1360
                RemoveFairQueueCursorWhenBacklogClears(job.QueueName, job.GroupId);
4✔
1361
        }
4✔
1362

1363
        private void RemoveFairQueueCursorWhenBacklogClears(string queueName, string? groupId)
1364
        {
1365
                if (groupId is null ||
4✔
1366
                        _jobs.Values.Any(job =>
4✔
1367
                                string.Equals(job.QueueName, queueName, StringComparison.Ordinal) &&
4✔
1368
                                string.Equals(job.GroupId, groupId, StringComparison.Ordinal) &&
4✔
1369
                                job.State is JobState.Pending or JobState.Scheduled or JobState.Active))
4✔
1370
                {
1371
                        return;
4✔
1372
                }
1373

1374
                _ = _fairQueueLastServed.Remove((queueName, groupId));
4✔
1375
        }
4✔
1376

1377
        private void UpdateBatchAfterTerminal(string? batchId, JobState state, DateTimeOffset completedAt)
1378
        {
1379
                if (batchId is null || !_batches.TryGetValue(batchId, out var batch))
4✔
1380
                        return;
4✔
1381

1382
                var pending = Math.Max(0, batch.PendingCount - 1);
4✔
1383
                batch = batch with
4✔
1384
                {
4✔
1385
                        PendingCount = pending,
4✔
1386
                        SucceededCount = batch.SucceededCount + (state == JobState.Succeeded ? 1 : 0),
4✔
1387
                        FailedCount = batch.FailedCount + (state == JobState.Failed ? 1 : 0),
4✔
1388
                        CancelledCount = batch.CancelledCount + (state == JobState.Cancelled ? 1 : 0),
4✔
1389
                        SkippedCount = batch.SkippedCount + (state == JobState.Skipped ? 1 : 0),
4✔
1390
                };
4✔
1391
                if (pending == 0)
4✔
1392
                {
1393
                        batch = batch with
4✔
1394
                        {
4✔
1395
                                State = batch.FailedCount != 0
4✔
1396
                                        ? BatchState.Failed
4✔
1397
                                        : batch.CancelledCount != 0 ? BatchState.Cancelled : BatchState.Succeeded,
4✔
1398
                                CompletedAt = completedAt,
4✔
1399
                        };
4✔
1400
                }
1401

1402
                _batches[batchId] = batch;
4✔
1403
                if (pending == 0)
4✔
1404
                        ProcessTerminalBatch(batchId);
4✔
1405
        }
4✔
1406

1407
        private void ProcessTerminalJob(string parentJobId)
1408
        {
1409
                if (!_jobs.TryGetValue(parentJobId, out var parent) || !IsTerminal(parent.State))
4✔
1410
                        return;
×
1411

1412
                foreach (var edge in _edges
4✔
1413
                        .Where(edge => string.Equals(edge.ParentJobId, parentJobId, StringComparison.Ordinal) && !_settledEdges.Contains(edge))
4✔
1414
                        .ToArray())
4✔
1415
                {
1416
                        _ = _settledEdges.Add(edge);
4✔
1417
                        SettleEdge(edge, parentFailed: parent.State == JobState.Failed);
4✔
1418
                }
1419
        }
4✔
1420

1421
        private void ProcessTerminalBatch(string parentBatchId)
1422
        {
1423
                if (!_batches.TryGetValue(parentBatchId, out var parent) || !IsTerminal(parent.State))
4✔
1424
                        return;
×
1425

1426
                foreach (var edge in _edges
4✔
1427
                        .Where(edge => string.Equals(edge.ParentBatchId, parentBatchId, StringComparison.Ordinal) && !_settledEdges.Contains(edge))
4✔
1428
                        .ToArray())
4✔
1429
                {
1430
                        _ = _settledEdges.Add(edge);
4✔
1431
                        SettleEdge(edge, parentFailed: parent.State == BatchState.Failed);
4✔
1432
                }
1433
        }
4✔
1434

1435
        private void SettleEdge(JobContinuationEdge edge, bool parentFailed)
1436
        {
1437
                if (!_jobs.TryGetValue(edge.ChildJobId, out var child) || IsTerminal(child.State))
4✔
1438
                        return;
4✔
1439

1440
                var remaining = Math.Max(0, child.RemainingDependencies - 1);
4✔
1441
                if (remaining != 0)
4✔
1442
                {
1443
                        _jobs[child.Id] = child with
4✔
1444
                        {
4✔
1445
                                RemainingDependencies = remaining,
4✔
1446
                                FailedDependencies = child.FailedDependencies + (parentFailed ? 1 : 0),
4✔
1447
                        };
4✔
1448
                        return;
4✔
1449
                }
1450

1451
                var (triggersSatisfied, failedDependencies) = EvaluateIncomingTriggers(child.Id);
4✔
1452
                _jobs[child.Id] = child with
4✔
1453
                {
4✔
1454
                        State = triggersSatisfied && child.State == JobState.AwaitingContinuation
4✔
1455
                                ? GetReadyState(child)
4✔
1456
                                : child.State,
4✔
1457
                        RemainingDependencies = 0,
4✔
1458
                        FailedDependencies = failedDependencies,
4✔
1459
                };
4✔
1460
                if (!triggersSatisfied)
4✔
1461
                        TransitionToTerminal(child.Id, JobState.Skipped, error: null, timeProvider.GetUtcNow());
4✔
1462
        }
4✔
1463

1464
        private (bool Satisfied, int FailedDependencies) EvaluateIncomingTriggers(string childJobId)
1465
        {
1466
                var allTerminal = true;
4✔
1467
                var requiresFailure = false;
4✔
1468
                var successViolated = false;
4✔
1469
                var failedDependencies = 0;
4✔
1470
                foreach (var edge in _edges.Where(edge => string.Equals(edge.ChildJobId, childJobId, StringComparison.Ordinal)))
4✔
1471
                {
1472
                        var parentTerminal = false;
4✔
1473
                        var parentSucceeded = false;
4✔
1474
                        var parentFailed = false;
4✔
1475
                        if (edge.ParentJobId is { } parentJobId && _jobs.TryGetValue(parentJobId, out var parentJob))
4✔
1476
                        {
1477
                                parentTerminal = IsTerminal(parentJob.State);
4✔
1478
                                parentSucceeded = parentJob.State == JobState.Succeeded;
4✔
1479
                                parentFailed = parentJob.State == JobState.Failed;
4✔
1480
                        }
1481
                        else if (edge.ParentBatchId is { } parentBatchId && _batches.TryGetValue(parentBatchId, out var parentBatch))
4✔
1482
                        {
1483
                                parentTerminal = IsTerminal(parentBatch.State);
4✔
1484
                                parentSucceeded = parentBatch.State == BatchState.Succeeded;
4✔
1485
                                parentFailed = parentBatch.State == BatchState.Failed;
4✔
1486
                        }
1487

1488
                        allTerminal &= parentTerminal;
4✔
1489
                        failedDependencies += parentFailed ? 1 : 0;
4✔
1490
                        requiresFailure |= edge.Trigger == ContinuationTrigger.Failure;
4✔
1491
                        successViolated |= edge.Trigger == ContinuationTrigger.Success && !parentSucceeded;
4✔
1492
                }
1493

1494
                return (
4✔
1495
                        allTerminal && !successViolated && (!requiresFailure || failedDependencies != 0),
4✔
1496
                        failedDependencies
4✔
1497
                );
4✔
1498
        }
1499

1500
        private JobState GetReadyState(JobRecord job) =>
1501
                job.DueAt <= timeProvider.GetUtcNow() ? JobState.Pending : JobState.Scheduled;
4✔
1502

1503
        private JobContinuationEdge[] GetUnsettledWaiters(string parentJobId) =>
1504
        [
4✔
1505
                .. _edges.Where(edge => string.Equals(edge.ParentJobId, parentJobId, StringComparison.Ordinal) &&
4✔
1506
                        !_settledEdges.Contains(edge) &&
4✔
1507
                        _jobs.TryGetValue(edge.ChildJobId, out var child) &&
4✔
1508
                        !IsTerminal(child.State)),
4✔
1509
        ];
4✔
1510

1511
        private void SpliceBeforeWaiters(
1512
                string newParentJobId,
1513
                IReadOnlyList<JobContinuationEdge> existingWaiters
1514
        )
1515
        {
1516
                foreach (var existingEdge in existingWaiters)
4✔
1517
                {
1518
                        if (!_jobs.TryGetValue(existingEdge.ChildJobId, out var child) || IsTerminal(child.State))
4✔
1519
                                continue;
1520

1521
                        _jobs[child.Id] = child with
4✔
1522
                        {
4✔
1523
                                State = JobState.AwaitingContinuation,
4✔
1524
                                RemainingDependencies = child.RemainingDependencies + 1,
4✔
1525
                        };
4✔
1526
                        _edges.Add(new()
4✔
1527
                        {
4✔
1528
                                ChildJobId = child.Id,
4✔
1529
                                ParentJobId = newParentJobId,
4✔
1530
                                Trigger = existingEdge.Trigger,
4✔
1531
                        });
4✔
1532
                }
1533
        }
4✔
1534

1535
        private void ValidateSplice(IReadOnlyList<JobContinuationEdge> existingWaiters, int additions)
1536
        {
1537
                if (additions == 0)
4✔
1538
                        return;
4✔
1539
                foreach (var existingEdge in existingWaiters)
4✔
1540
                {
1541
                        if (_jobs.TryGetValue(existingEdge.ChildJobId, out var child) &&
4✔
1542
                                child.RemainingDependencies > int.MaxValue - additions)
4✔
1543
                        {
1544
                                throw new ImmediateJobException($"Continuation dependency count overflow for job '{child.Id}'.");
×
1545
                        }
1546
                }
1547
        }
4✔
1548

1549
        private void IncrementBatchMembers(string? batchId, int count)
1550
        {
1551
                if (count == 0)
4✔
1552
                        return;
4✔
1553
                if (batchId is null || !_batches.TryGetValue(batchId, out var batch))
4✔
1554
                        throw new ImmediateJobException("The current job's batch was not found.");
×
1555
                if (batch.TotalJobs > int.MaxValue - count || batch.PendingCount > int.MaxValue - count)
4✔
1556
                        throw new ImmediateJobException($"Batch '{batchId}' member count overflow.");
×
1557

1558
                _batches[batchId] = batch with
4✔
1559
                {
4✔
1560
                        TotalJobs = batch.TotalJobs + count,
4✔
1561
                        PendingCount = batch.PendingCount + count,
4✔
1562
                };
4✔
1563
        }
4✔
1564

1565
        private void MarkBatchStarted(string? batchId, DateTimeOffset startedAt)
1566
        {
1567
                if (batchId is not null && _batches.TryGetValue(batchId, out var batch) && batch.StartedAt is null)
4✔
1568
                        _batches[batchId] = batch with { StartedAt = startedAt };
4✔
1569
        }
4✔
1570

1571
        private static bool IsTerminal(JobState state) =>
1572
                state is JobState.Succeeded or JobState.Failed or JobState.Cancelled or JobState.Skipped;
4✔
1573

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

1576
        private static BatchStatus ToStatus(JobBatchRecord batch) => new(
4✔
1577
                batch.Id,
4✔
1578
                batch.State,
4✔
1579
                batch.TotalJobs,
4✔
1580
                batch.SucceededCount,
4✔
1581
                batch.FailedCount,
4✔
1582
                batch.CancelledCount,
4✔
1583
                batch.SkippedCount,
4✔
1584
                batch.PendingCount,
4✔
1585
                batch.CreatedAt,
4✔
1586
                batch.StartedAt,
4✔
1587
                batch.CompletedAt,
4✔
1588
                BatchStatus.CalculateFractionSettled(batch.TotalJobs, batch.PendingCount)
4✔
1589
        );
4✔
1590

1591
        private static BatchGraphEdge ToGraphEdge(JobContinuationEdge edge) => new(
4✔
1592
                edge.ChildJobId,
4✔
1593
                edge.ParentJobId,
4✔
1594
                edge.ParentBatchId,
4✔
1595
                edge.Trigger
4✔
1596
        );
4✔
1597

1598
        private void RemoveEdgesForJobs(
1599
                HashSet<string> jobIds,
1600
                HashSet<string>? batchIds = null
1601
        )
1602
        {
1603
                if (jobIds.Count == 0 && (batchIds is null || batchIds.Count == 0))
4✔
1604
                        return;
4✔
1605

1606
                var batches = batchIds ?? [];
4✔
1607
                for (var index = _edges.Count - 1; index >= 0; index--)
4✔
1608
                {
1609
                        var edge = _edges[index];
4✔
1610
                        if (!jobIds.Contains(edge.ChildJobId) &&
4✔
1611
                                (edge.ParentJobId is null || !jobIds.Contains(edge.ParentJobId)) &&
4✔
1612
                                (edge.ParentBatchId is null || !batches.Contains(edge.ParentBatchId)))
4✔
1613
                        {
1614
                                continue;
1615
                        }
1616

1617
                        _edges.RemoveAt(index);
4✔
1618
                        _ = _settledEdges.Remove(edge);
4✔
1619
                }
1620
        }
4✔
1621

1622
        private JobRecord GetOwnedActive(string jobId, string workerId)
1623
        {
1624
                if (!_jobs.TryGetValue(jobId, out var job) || job.State != JobState.Active || !string.Equals(job.WorkerId, workerId, StringComparison.Ordinal))
4✔
1625
                        throw new ImmediateJobException($"Worker '{workerId}' does not own active job '{jobId}'.");
×
1626
                return job;
4✔
1627
        }
1628

1629
        private ValueTask SetRecurringPausedAsync(string name, bool isPaused, CancellationToken cancellationToken)
1630
        {
1631
                cancellationToken.ThrowIfCancellationRequested();
4✔
1632
                lock (_gate)
1633
                {
1634
                        if (!_recurring.TryGetValue(name, out var schedule))
4✔
1635
                                throw new KeyNotFoundException($"Recurring schedule '{name}' was not found.");
4✔
1636

1637
                        _recurring[name] = schedule with { IsPaused = isPaused };
×
1638
                }
×
1639

1640
                return ValueTask.CompletedTask;
×
1641
        }
1642
}
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