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

ImmediatePlatform / Immediate.Jobs / 30639748860

31 Jul 2026 02:42PM UTC coverage: 83.192% (+0.1%) from 83.088%
30639748860

Pull #80

github

web-flow
Merge 69689179b into 15ab68066
Pull Request #80: Fast-forward scheduled jobs from the dashboard

23 of 25 new or added lines in 4 files covered. (92.0%)

5 existing lines in 1 file now uncovered.

7464 of 8972 relevant lines covered (83.19%)

2.75 hits per line

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

91.46
/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✔
981
                        var wasFailed = job.State == JobState.Failed;
4✔
982
                        if (!wasFailed && job.State != JobState.Scheduled)
4✔
NEW
983
                                throw new ImmediateJobException("Only failed or scheduled jobs can be retried.");
×
984

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

1004
                return ValueTask.CompletedTask;
4✔
1005
        }
1006

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

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

1024
                return ValueTask.CompletedTask;
×
1025
        }
1026

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

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

1056
                return ValueTask.CompletedTask;
4✔
1057
        }
1058

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

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

1093
                return ValueTask.CompletedTask;
4✔
1094
        }
1095

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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