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

ImmediatePlatform / Immediate.Jobs / 30635655007

31 Jul 2026 01:44PM UTC coverage: 83.032% (+0.7%) from 82.321%
30635655007

Pull #77

github

web-flow
Merge 82b9a5347 into be7540808
Pull Request #77: Fix open dashboard, storage, and serialization issues

62 of 63 new or added lines in 5 files covered. (98.41%)

2 existing lines in 2 files now uncovered.

7443 of 8964 relevant lines covered (83.03%)

2.75 hits per line

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

90.29
/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
                        foreach (var jobId in _jobs.Values
4✔
937
                                .Where(job => string.Equals(job.BatchId, batchId, StringComparison.Ordinal) && !IsTerminal(job.State))
4✔
938
                                .Select(static job => job.Id)
4✔
939
                                .ToArray())
4✔
940
                        {
941
                                TransitionToTerminal(jobId, JobState.Cancelled, error: null, timeProvider.GetUtcNow());
4✔
942
                        }
943
                }
4✔
944

945
                return ValueTask.CompletedTask;
4✔
946
        }
947

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

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

970
                return ValueTask.CompletedTask;
4✔
971
        }
972

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

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

1003
                return ValueTask.CompletedTask;
×
1004
        }
1005

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

1019
                        _ = _jobs.Remove(jobId);
×
1020
                        RemoveEdgesForJobs(new(StringComparer.Ordinal) { jobId });
×
1021
                }
×
1022

1023
                return ValueTask.CompletedTask;
×
1024
        }
1025

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

1050
                        foreach (var id in standaloneJobIds)
4✔
1051
                                _ = _jobs.Remove(id);
×
1052
                        RemoveEdgesForJobs(standaloneJobIds);
4✔
1053
                }
4✔
1054

1055
                return ValueTask.CompletedTask;
4✔
1056
        }
1057

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

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

1092
                return ValueTask.CompletedTask;
4✔
1093
        }
1094

1095
        /// <inheritdoc />
1096
        public ValueTask HeartbeatAsync(JobServerSnapshot server, CancellationToken cancellationToken = default)
1097
        {
1098
                ArgumentNullException.ThrowIfNull(server);
4✔
1099
                cancellationToken.ThrowIfCancellationRequested();
4✔
1100
                lock (_gate)
1101
                        _servers[server.WorkerId] = server;
4✔
1102
                return ValueTask.CompletedTask;
4✔
1103
        }
1104

1105
        /// <inheritdoc />
1106
        public async ValueTask<bool> IsHealthyAsync(CancellationToken cancellationToken = default)
1107
        {
1108
                cancellationToken.ThrowIfCancellationRequested();
×
1109
                return true;
×
1110
        }
1111

1112
        private void ValidateNewJob(JobRecord job)
1113
        {
1114
                ArgumentException.ThrowIfNullOrWhiteSpace(job.Id);
4✔
1115
                ArgumentException.ThrowIfNullOrWhiteSpace(job.JobName);
4✔
1116
                if (_jobs.ContainsKey(job.Id))
4✔
1117
                        throw new ImmediateJobException($"Job '{job.Id}' already exists.");
×
1118
        }
4✔
1119

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

1132
                var succeeded = jobs.Count(static job => job.State == JobState.Succeeded);
4✔
1133
                var failed = jobs.Count(static job => job.State == JobState.Failed);
4✔
1134
                var cancelled = jobs.Count(static job => job.State == JobState.Cancelled);
4✔
1135
                var pending = jobs.Count - succeeded - failed - cancelled;
4✔
1136

1137
                var expectedState = true switch
1138
                {
1139
                        _ when pending != 0 => BatchState.Executing,
4✔
1140
                        _ when failed != 0 => BatchState.Failed,
4✔
1141
                        _ when cancelled != 0 => BatchState.Cancelled,
4✔
1142
                        _ => BatchState.Succeeded,
4✔
1143
                };
1144

1145
                if (
4✔
1146
                        batch.TotalJobs != jobs.Count ||
4✔
1147
                        batch.PendingCount != pending ||
4✔
1148
                        batch.SucceededCount != succeeded ||
4✔
1149
                        batch.FailedCount != failed ||
4✔
1150
                        batch.CancelledCount != cancelled ||
4✔
1151
                        batch.State != expectedState ||
4✔
1152
                        ((pending == 0) != (batch.CompletedAt is not null))
4✔
1153
                )
4✔
1154
                {
1155
                        throw new ImmediateJobException("A batch header does not match its members or aggregate state.");
×
1156
                }
1157

1158
                var jobIds = new HashSet<string>(StringComparer.Ordinal);
4✔
1159
                foreach (var job in jobs)
4✔
1160
                {
1161
                        ValidateNewJob(job);
4✔
1162
                        if (!jobIds.Add(job.Id))
4✔
1163
                                throw new ImmediateJobException($"Job '{job.Id}' occurs more than once in the batch.");
×
1164
                        if (!string.Equals(job.BatchId, batch.Id, StringComparison.Ordinal))
4✔
1165
                                throw new ImmediateJobException($"Job '{job.Id}' does not belong to batch '{batch.Id}'.");
×
1166
                }
1167

1168
                ValidateEdges(jobs, edges, batch.Id);
4✔
1169
        }
4✔
1170

1171
        private void ValidateEdges(
1172
                IReadOnlyList<JobRecord> newJobs,
1173
                IReadOnlyList<JobContinuationEdge> edges,
1174
                string? batchId
1175
        )
1176
        {
1177
                if (batchId is null && edges.Count == 0)
4✔
1178
                        throw new ImmediateJobException("A continuation must have at least one parent.");
×
1179

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

1195
                        var hasJobParent = !string.IsNullOrWhiteSpace(edge.ParentJobId);
4✔
1196
                        var hasBatchParent = !string.IsNullOrWhiteSpace(edge.ParentBatchId);
4✔
1197
                        if (hasJobParent == hasBatchParent)
4✔
1198
                                throw new ImmediateJobException("A continuation edge must have exactly one job or batch parent.");
×
1199

1200
                        if (hasJobParent)
4✔
1201
                        {
1202
                                var parentId = edge.ParentJobId!;
4✔
1203
                                if (string.Equals(parentId, edge.ChildJobId, StringComparison.Ordinal))
4✔
1204
                                        throw new ImmediateJobException($"Continuation job '{edge.ChildJobId}' cannot depend on itself.");
×
1205
                                if (!newJobIds.Contains(parentId) && !_jobs.ContainsKey(parentId))
4✔
1206
                                        throw new KeyNotFoundException($"Continuation parent job '{parentId}' was not found.");
4✔
1207
                                if (!logicalEdges.Add((edge.ChildJobId, "job", parentId)))
4✔
1208
                                        throw new ImmediateJobException($"Duplicate continuation edge '{parentId}' -> '{edge.ChildJobId}'.");
×
1209

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

1226
                var ready = new Queue<string>(incoming.Where(static pair => pair.Value == 0).Select(static pair => pair.Key));
4✔
1227
                var visited = 0;
4✔
1228
                while (ready.TryDequeue(out var parentId))
4✔
1229
                {
1230
                        visited++;
4✔
1231
                        foreach (var childId in outgoing[parentId])
4✔
1232
                        {
1233
                                if (--incoming[childId] == 0)
4✔
1234
                                        ready.Enqueue(childId);
4✔
1235
                        }
1236
                }
1237

1238
                if (visited != newJobIds.Count)
4✔
1239
                        throw new ImmediateJobException("The continuation graph contains a dependency cycle.");
×
1240
        }
4✔
1241

1242
        private static JobRecord NormalizeWaitingJob(JobRecord job, int dependencyCount) => job with
4✔
1243
        {
4✔
1244
                State = dependencyCount == 0 ? job.State : JobState.AwaitingContinuation,
4✔
1245
                RemainingDependencies = dependencyCount,
4✔
1246
                FailedDependencies = 0,
4✔
1247
                WorkerId = null,
4✔
1248
                LeaseExpiresAt = null,
4✔
1249
                CompletedAt = null,
4✔
1250
        };
4✔
1251

1252
        private bool IsRecoveredBatch(
1253
                JobBatchRecord batch,
1254
                IReadOnlyList<JobRecord> jobs,
1255
                IReadOnlyList<JobContinuationEdge> edges
1256
        )
1257
        {
1258
                if (batch.StartedAt is not null ||
4✔
1259
                        batch.CompletedAt is not null ||
4✔
1260
                        batch.SucceededCount != 0 ||
4✔
1261
                        batch.FailedCount != 0 ||
4✔
1262
                        batch.CancelledCount != 0 ||
4✔
1263
                        jobs.Any(static job => job.State is JobState.Active or JobState.Succeeded or JobState.Failed or JobState.Cancelled))
4✔
1264
                {
1265
                        return true;
4✔
1266
                }
1267

1268
                if (!HasTerminalParent(edges))
4✔
1269
                        return false;
4✔
1270

1271
                var incomingCounts = edges
4✔
1272
                        .GroupBy(static edge => edge.ChildJobId, StringComparer.Ordinal)
4✔
1273
                        .ToDictionary(static group => group.Key, static group => group.Count(), StringComparer.Ordinal);
4✔
1274
                return jobs.Any(job => incomingCounts.TryGetValue(job.Id, out var incoming) &&
4✔
1275
                        (job.State != JobState.AwaitingContinuation || job.RemainingDependencies < incoming));
4✔
1276
        }
1277

1278
        private bool HasTerminalParent(IEnumerable<JobContinuationEdge> edges) =>
1279
                edges.Any(IsTerminal);
4✔
1280

1281
        private void MarkTerminalParentEdgesSettled(IEnumerable<JobContinuationEdge> edges)
1282
        {
1283
                foreach (var edge in edges)
4✔
1284
                {
1285
                        if (IsTerminal(edge))
4✔
1286
                        {
1287
                                _ = _settledEdges.Add(edge);
4✔
1288
                        }
1289
                }
1290
        }
4✔
1291

1292
        private bool IsTerminal(JobContinuationEdge edge)
1293
        {
1294
                return (
4✔
1295
                        edge.ParentJobId is { } parentJobId
4✔
1296
                        && _jobs.TryGetValue(parentJobId, out var parentJob)
4✔
1297
                        && IsTerminal(parentJob.State)
4✔
1298
                ) || (
4✔
1299
                        edge.ParentBatchId is { } parentBatchId
4✔
1300
                        && _batches.TryGetValue(parentBatchId, out var parentBatch)
4✔
1301
                        && IsTerminal(parentBatch.State)
4✔
1302
                );
4✔
1303
        }
1304

1305
        private void EvaluateAlreadyTerminalParents(IReadOnlyList<JobContinuationEdge> edges)
1306
        {
1307
                foreach (var parentId in edges
4✔
1308
                        .Where(static edge => edge.ParentJobId is not null)
4✔
1309
                        .Select(static edge => edge.ParentJobId!)
4✔
1310
                        .Distinct(StringComparer.Ordinal)
4✔
1311
                        .Where(parentId => _jobs.TryGetValue(parentId, out var parent) && IsTerminal(parent.State))
4✔
1312
                        .ToArray())
4✔
1313
                {
1314
                        ProcessTerminalJob(parentId);
4✔
1315
                }
1316

1317
                foreach (var parentId in edges
4✔
1318
                        .Where(static edge => edge.ParentBatchId is not null)
4✔
1319
                        .Select(static edge => edge.ParentBatchId!)
4✔
1320
                        .Distinct(StringComparer.Ordinal)
4✔
1321
                        .Where(parentId => _batches.TryGetValue(parentId, out var parent) && IsTerminal(parent.State))
4✔
1322
                        .ToArray())
4✔
1323
                {
1324
                        ProcessTerminalBatch(parentId);
4✔
1325
                }
1326
        }
4✔
1327

1328
        private void TransitionToTerminal(
1329
                string jobId,
1330
                JobState terminalState,
1331
                string? error,
1332
                DateTimeOffset completedAt
1333
        )
1334
        {
1335
                if (!IsTerminal(terminalState))
4✔
1336
                        throw new ArgumentOutOfRangeException(nameof(terminalState));
×
1337
                if (!_jobs.TryGetValue(jobId, out var job) || IsTerminal(job.State))
4✔
1338
                        return;
4✔
1339

1340
                _jobs[jobId] = job with
4✔
1341
                {
4✔
1342
                        State = terminalState,
4✔
1343
                        WorkerId = null,
4✔
1344
                        LeaseExpiresAt = null,
4✔
1345
                        LastError = error,
4✔
1346
                        CompletedAt = completedAt,
4✔
1347
                };
4✔
1348
                UpdateBatchAfterTerminal(job.BatchId, terminalState, completedAt);
4✔
1349
                ProcessTerminalJob(jobId);
4✔
1350
                RemoveFairQueueCursorWhenBacklogClears(job.QueueName, job.GroupId);
4✔
1351
        }
4✔
1352

1353
        private void RemoveFairQueueCursorWhenBacklogClears(string queueName, string? groupId)
1354
        {
1355
                if (groupId is null ||
4✔
1356
                        _jobs.Values.Any(job =>
4✔
1357
                                string.Equals(job.QueueName, queueName, StringComparison.Ordinal) &&
4✔
1358
                                string.Equals(job.GroupId, groupId, StringComparison.Ordinal) &&
4✔
1359
                                job.State is JobState.Pending or JobState.Scheduled or JobState.Active))
4✔
1360
                {
1361
                        return;
4✔
1362
                }
1363

1364
                _ = _fairQueueLastServed.Remove((queueName, groupId));
4✔
1365
        }
4✔
1366

1367
        private void UpdateBatchAfterTerminal(string? batchId, JobState state, DateTimeOffset completedAt)
1368
        {
1369
                if (batchId is null || !_batches.TryGetValue(batchId, out var batch))
4✔
1370
                        return;
4✔
1371

1372
                var pending = Math.Max(0, batch.PendingCount - 1);
4✔
1373
                batch = batch with
4✔
1374
                {
4✔
1375
                        PendingCount = pending,
4✔
1376
                        SucceededCount = batch.SucceededCount + (state == JobState.Succeeded ? 1 : 0),
4✔
1377
                        FailedCount = batch.FailedCount + (state == JobState.Failed ? 1 : 0),
4✔
1378
                        CancelledCount = batch.CancelledCount + (state == JobState.Cancelled ? 1 : 0),
4✔
1379
                };
4✔
1380
                if (pending == 0)
4✔
1381
                {
1382
                        batch = batch with
4✔
1383
                        {
4✔
1384
                                State = batch.FailedCount != 0
4✔
1385
                                        ? BatchState.Failed
4✔
1386
                                        : batch.CancelledCount != 0 ? BatchState.Cancelled : BatchState.Succeeded,
4✔
1387
                                CompletedAt = completedAt,
4✔
1388
                        };
4✔
1389
                }
1390

1391
                _batches[batchId] = batch;
4✔
1392
                if (pending == 0)
4✔
1393
                        ProcessTerminalBatch(batchId);
4✔
1394
        }
4✔
1395

1396
        private void ProcessTerminalJob(string parentJobId)
1397
        {
1398
                if (!_jobs.TryGetValue(parentJobId, out var parent) || !IsTerminal(parent.State))
4✔
1399
                        return;
×
1400

1401
                foreach (var edge in _edges
4✔
1402
                        .Where(edge => string.Equals(edge.ParentJobId, parentJobId, StringComparison.Ordinal) && !_settledEdges.Contains(edge))
4✔
1403
                        .ToArray())
4✔
1404
                {
1405
                        _ = _settledEdges.Add(edge);
4✔
1406
                        SettleEdge(edge, parentFailed: parent.State == JobState.Failed);
4✔
1407
                }
1408
        }
4✔
1409

1410
        private void ProcessTerminalBatch(string parentBatchId)
1411
        {
1412
                if (!_batches.TryGetValue(parentBatchId, out var parent) || !IsTerminal(parent.State))
4✔
1413
                        return;
×
1414

1415
                foreach (var edge in _edges
4✔
1416
                        .Where(edge => string.Equals(edge.ParentBatchId, parentBatchId, StringComparison.Ordinal) && !_settledEdges.Contains(edge))
4✔
1417
                        .ToArray())
4✔
1418
                {
1419
                        _ = _settledEdges.Add(edge);
4✔
1420
                        SettleEdge(edge, parentFailed: parent.State == BatchState.Failed);
4✔
1421
                }
1422
        }
4✔
1423

1424
        private void SettleEdge(JobContinuationEdge edge, bool parentFailed)
1425
        {
1426
                if (!_jobs.TryGetValue(edge.ChildJobId, out var child) || IsTerminal(child.State))
4✔
1427
                        return;
×
1428

1429
                var remaining = Math.Max(0, child.RemainingDependencies - 1);
4✔
1430
                if (remaining != 0)
4✔
1431
                {
1432
                        _jobs[child.Id] = child with
4✔
1433
                        {
4✔
1434
                                RemainingDependencies = remaining,
4✔
1435
                                FailedDependencies = child.FailedDependencies + (parentFailed ? 1 : 0),
4✔
1436
                        };
4✔
1437
                        return;
4✔
1438
                }
1439

1440
                var (triggersSatisfied, failedDependencies) = EvaluateIncomingTriggers(child.Id);
4✔
1441
                _jobs[child.Id] = child with
4✔
1442
                {
4✔
1443
                        State = triggersSatisfied && child.State == JobState.AwaitingContinuation
4✔
1444
                                ? GetReadyState(child)
4✔
1445
                                : child.State,
4✔
1446
                        RemainingDependencies = 0,
4✔
1447
                        FailedDependencies = failedDependencies,
4✔
1448
                };
4✔
1449
                if (!triggersSatisfied)
4✔
1450
                        TransitionToTerminal(child.Id, JobState.Cancelled, error: null, timeProvider.GetUtcNow());
4✔
1451
        }
4✔
1452

1453
        private (bool Satisfied, int FailedDependencies) EvaluateIncomingTriggers(string childJobId)
1454
        {
1455
                var allTerminal = true;
4✔
1456
                var requiresFailure = false;
4✔
1457
                var successViolated = false;
4✔
1458
                var failedDependencies = 0;
4✔
1459
                foreach (var edge in _edges.Where(edge => string.Equals(edge.ChildJobId, childJobId, StringComparison.Ordinal)))
4✔
1460
                {
1461
                        var parentTerminal = false;
4✔
1462
                        var parentSucceeded = false;
4✔
1463
                        var parentFailed = false;
4✔
1464
                        if (edge.ParentJobId is { } parentJobId && _jobs.TryGetValue(parentJobId, out var parentJob))
4✔
1465
                        {
1466
                                parentTerminal = IsTerminal(parentJob.State);
4✔
1467
                                parentSucceeded = parentJob.State == JobState.Succeeded;
4✔
1468
                                parentFailed = parentJob.State == JobState.Failed;
4✔
1469
                        }
1470
                        else if (edge.ParentBatchId is { } parentBatchId && _batches.TryGetValue(parentBatchId, out var parentBatch))
4✔
1471
                        {
1472
                                parentTerminal = IsTerminal(parentBatch.State);
4✔
1473
                                parentSucceeded = parentBatch.State == BatchState.Succeeded;
4✔
1474
                                parentFailed = parentBatch.State == BatchState.Failed;
4✔
1475
                        }
1476

1477
                        allTerminal &= parentTerminal;
4✔
1478
                        failedDependencies += parentFailed ? 1 : 0;
4✔
1479
                        requiresFailure |= edge.Trigger == ContinuationTrigger.Failure;
4✔
1480
                        successViolated |= edge.Trigger == ContinuationTrigger.Success && !parentSucceeded;
4✔
1481
                }
1482

1483
                return (
4✔
1484
                        allTerminal && !successViolated && (!requiresFailure || failedDependencies != 0),
4✔
1485
                        failedDependencies
4✔
1486
                );
4✔
1487
        }
1488

1489
        private JobState GetReadyState(JobRecord job) =>
1490
                job.DueAt <= timeProvider.GetUtcNow() ? JobState.Pending : JobState.Scheduled;
4✔
1491

1492
        private JobContinuationEdge[] GetUnsettledWaiters(string parentJobId) =>
1493
        [
4✔
1494
                .. _edges.Where(edge => string.Equals(edge.ParentJobId, parentJobId, StringComparison.Ordinal) &&
4✔
1495
                        !_settledEdges.Contains(edge) &&
4✔
1496
                        _jobs.TryGetValue(edge.ChildJobId, out var child) &&
4✔
1497
                        !IsTerminal(child.State)),
4✔
1498
        ];
4✔
1499

1500
        private void SpliceBeforeWaiters(
1501
                string newParentJobId,
1502
                IReadOnlyList<JobContinuationEdge> existingWaiters
1503
        )
1504
        {
1505
                foreach (var existingEdge in existingWaiters)
4✔
1506
                {
1507
                        if (!_jobs.TryGetValue(existingEdge.ChildJobId, out var child) || IsTerminal(child.State))
4✔
1508
                                continue;
1509

1510
                        _jobs[child.Id] = child with
4✔
1511
                        {
4✔
1512
                                State = JobState.AwaitingContinuation,
4✔
1513
                                RemainingDependencies = child.RemainingDependencies + 1,
4✔
1514
                        };
4✔
1515
                        _edges.Add(new()
4✔
1516
                        {
4✔
1517
                                ChildJobId = child.Id,
4✔
1518
                                ParentJobId = newParentJobId,
4✔
1519
                                Trigger = existingEdge.Trigger,
4✔
1520
                        });
4✔
1521
                }
1522
        }
4✔
1523

1524
        private void ValidateSplice(IReadOnlyList<JobContinuationEdge> existingWaiters, int additions)
1525
        {
1526
                if (additions == 0)
4✔
1527
                        return;
4✔
1528
                foreach (var existingEdge in existingWaiters)
4✔
1529
                {
1530
                        if (_jobs.TryGetValue(existingEdge.ChildJobId, out var child) &&
4✔
1531
                                child.RemainingDependencies > int.MaxValue - additions)
4✔
1532
                        {
1533
                                throw new ImmediateJobException($"Continuation dependency count overflow for job '{child.Id}'.");
×
1534
                        }
1535
                }
1536
        }
4✔
1537

1538
        private void IncrementBatchMembers(string? batchId, int count)
1539
        {
1540
                if (count == 0)
4✔
1541
                        return;
4✔
1542
                if (batchId is null || !_batches.TryGetValue(batchId, out var batch))
4✔
1543
                        throw new ImmediateJobException("The current job's batch was not found.");
×
1544
                if (batch.TotalJobs > int.MaxValue - count || batch.PendingCount > int.MaxValue - count)
4✔
1545
                        throw new ImmediateJobException($"Batch '{batchId}' member count overflow.");
×
1546

1547
                _batches[batchId] = batch with
4✔
1548
                {
4✔
1549
                        TotalJobs = batch.TotalJobs + count,
4✔
1550
                        PendingCount = batch.PendingCount + count,
4✔
1551
                };
4✔
1552
        }
4✔
1553

1554
        private void MarkBatchStarted(string? batchId, DateTimeOffset startedAt)
1555
        {
1556
                if (batchId is not null && _batches.TryGetValue(batchId, out var batch) && batch.StartedAt is null)
4✔
1557
                        _batches[batchId] = batch with { StartedAt = startedAt };
4✔
1558
        }
4✔
1559

1560
        private static bool IsTerminal(JobState state) =>
1561
                state is JobState.Succeeded or JobState.Failed or JobState.Cancelled;
4✔
1562

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

1565
        private static BatchStatus ToStatus(JobBatchRecord batch) => new(
4✔
1566
                batch.Id,
4✔
1567
                batch.State,
4✔
1568
                batch.TotalJobs,
4✔
1569
                batch.SucceededCount,
4✔
1570
                batch.FailedCount,
4✔
1571
                batch.CancelledCount,
4✔
1572
                batch.PendingCount,
4✔
1573
                batch.CreatedAt,
4✔
1574
                batch.StartedAt,
4✔
1575
                batch.CompletedAt,
4✔
1576
                BatchStatus.CalculateFractionSettled(batch.TotalJobs, batch.PendingCount)
4✔
1577
        );
4✔
1578

1579
        private static BatchGraphEdge ToGraphEdge(JobContinuationEdge edge) => new(
4✔
1580
                edge.ChildJobId,
4✔
1581
                edge.ParentJobId,
4✔
1582
                edge.ParentBatchId,
4✔
1583
                edge.Trigger
4✔
1584
        );
4✔
1585

1586
        private void RemoveEdgesForJobs(
1587
                HashSet<string> jobIds,
1588
                HashSet<string>? batchIds = null
1589
        )
1590
        {
1591
                if (jobIds.Count == 0 && (batchIds is null || batchIds.Count == 0))
4✔
1592
                        return;
4✔
1593

1594
                var batches = batchIds ?? [];
4✔
1595
                for (var index = _edges.Count - 1; index >= 0; index--)
4✔
1596
                {
1597
                        var edge = _edges[index];
4✔
1598
                        if (!jobIds.Contains(edge.ChildJobId) &&
4✔
1599
                                (edge.ParentJobId is null || !jobIds.Contains(edge.ParentJobId)) &&
4✔
1600
                                (edge.ParentBatchId is null || !batches.Contains(edge.ParentBatchId)))
4✔
1601
                        {
1602
                                continue;
1603
                        }
1604

1605
                        _edges.RemoveAt(index);
4✔
1606
                        _ = _settledEdges.Remove(edge);
4✔
1607
                }
1608
        }
4✔
1609

1610
        private JobRecord GetOwnedActive(string jobId, string workerId)
1611
        {
1612
                if (!_jobs.TryGetValue(jobId, out var job) || job.State != JobState.Active || !string.Equals(job.WorkerId, workerId, StringComparison.Ordinal))
4✔
1613
                        throw new ImmediateJobException($"Worker '{workerId}' does not own active job '{jobId}'.");
×
1614
                return job;
4✔
1615
        }
1616

1617
        private ValueTask SetRecurringPausedAsync(string name, bool isPaused, CancellationToken cancellationToken)
1618
        {
1619
                cancellationToken.ThrowIfCancellationRequested();
4✔
1620
                lock (_gate)
1621
                {
1622
                        if (!_recurring.TryGetValue(name, out var schedule))
4✔
1623
                                throw new KeyNotFoundException($"Recurring schedule '{name}' was not found.");
4✔
1624

1625
                        _recurring[name] = schedule with { IsPaused = isPaused };
×
1626
                }
×
1627

1628
                return ValueTask.CompletedTask;
×
1629
        }
1630
}
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