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

ImmediatePlatform / Immediate.Jobs / 30302568744

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

Pull #26

github

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

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

2855 of 4281 relevant lines covered (66.69%)

2.66 hits per line

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

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

3
#pragma warning disable IDE0391 // Keep synchronous storage methods async for .NET 11 runtime-async state machines.
4

5
/// <summary>
6
/// A best-effort, non-durable, single-node provider intended for development and tests.
7
/// </summary>
8
public sealed class InMemoryJobStorage(TimeProvider timeProvider) : IJobStorage, IJobStorageReplica
4✔
9
{
10
        private readonly Lock _gate = new();
4✔
11
        private readonly Dictionary<string, JobRecord> _jobs = new(StringComparer.Ordinal);
4✔
12
        private readonly Dictionary<string, JobBatchRecord> _batches = new(StringComparer.Ordinal);
4✔
13
        private readonly List<JobContinuationEdge> _edges = [];
4✔
14
        private readonly HashSet<JobContinuationEdge> _settledEdges = [];
4✔
15
        private readonly Dictionary<string, RecurringJobSchedule> _recurring = new(StringComparer.Ordinal);
4✔
16
        private readonly Dictionary<string, JobServerSnapshot> _servers = new(StringComparer.Ordinal);
4✔
17
        private readonly HashSet<string> _recurringKeys = new(StringComparer.Ordinal);
4✔
18

19
        /// <inheritdoc />
20
        public ValueTask InitializeAsync(CancellationToken cancellationToken = default)
21
        {
22
                cancellationToken.ThrowIfCancellationRequested();
4✔
23
                return ValueTask.CompletedTask;
4✔
24
        }
25

26
        /// <inheritdoc />
27
        public ValueTask EnqueueAsync(JobRecord job, CancellationToken cancellationToken = default)
28
        {
29
                ArgumentNullException.ThrowIfNull(job);
4✔
30
                cancellationToken.ThrowIfCancellationRequested();
4✔
31
                lock (_gate)
32
                {
33
                        if (!_jobs.TryAdd(job.Id, job))
4✔
34
                                throw new InvalidOperationException($"Job '{job.Id}' already exists.");
×
35
                }
4✔
36

37
                return ValueTask.CompletedTask;
4✔
38
        }
39

40
        /// <inheritdoc />
41
        public ValueTask EnqueueContinuationAsync(
42
                JobRecord job,
43
                IReadOnlyList<JobContinuationEdge> edges,
44
                CancellationToken cancellationToken = default
45
        )
46
        {
47
                ArgumentNullException.ThrowIfNull(job);
4✔
48
                ArgumentNullException.ThrowIfNull(edges);
4✔
49
                cancellationToken.ThrowIfCancellationRequested();
4✔
50
                lock (_gate)
51
                {
52
                        ValidateNewJob(job);
4✔
53
                        ValidateEdges([job], edges, batchId: null);
4✔
54

55
                        var restoreExistingState = HasTerminalParent(edges) &&
4✔
56
                                (job.State != JobState.AwaitingContinuation || job.RemainingDependencies < edges.Count);
4✔
57
                        _jobs.Add(job.Id, restoreExistingState ? job : NormalizeWaitingJob(job, edges.Count));
4✔
58
                        _edges.AddRange(edges);
4✔
59
                        if (restoreExistingState)
4✔
NEW
60
                                MarkTerminalParentEdgesSettled(edges);
×
61
                        else
62
                                EvaluateAlreadyTerminalParents(edges);
4✔
63
                }
4✔
64

65
                return ValueTask.CompletedTask;
4✔
66
        }
67

68
        /// <inheritdoc />
69
        public ValueTask EnqueueBatchAsync(
70
                JobBatchRecord batch,
71
                IReadOnlyList<JobRecord> jobs,
72
                IReadOnlyList<JobContinuationEdge> edges,
73
                CancellationToken cancellationToken = default
74
        )
75
        {
76
                ArgumentNullException.ThrowIfNull(batch);
4✔
77
                ArgumentNullException.ThrowIfNull(jobs);
4✔
78
                ArgumentNullException.ThrowIfNull(edges);
4✔
79
                cancellationToken.ThrowIfCancellationRequested();
4✔
80
                lock (_gate)
81
                {
82
                        ValidateBatch(batch, jobs, edges);
4✔
83

84
                        var restoreExistingState = IsRecoveredBatch(batch, jobs, edges);
4✔
85
                        _batches.Add(batch.Id, batch);
4✔
86
                        var incomingCounts = edges
4✔
87
                                .GroupBy(static edge => edge.ChildJobId, StringComparer.Ordinal)
4✔
88
                                .ToDictionary(static group => group.Key, static group => group.Count(), StringComparer.Ordinal);
4✔
89
                        foreach (var job in jobs)
4✔
90
                        {
91
                                _jobs.Add(
4✔
92
                                        job.Id,
4✔
93
                                        restoreExistingState
4✔
94
                                                ? job
4✔
95
                                                : incomingCounts.TryGetValue(job.Id, out var dependencyCount)
4✔
96
                                                ? NormalizeWaitingJob(job, dependencyCount)
4✔
97
                                                : job with { BatchId = batch.Id, RemainingDependencies = 0 }
4✔
98
                                );
4✔
99
                        }
100

101
                        _edges.AddRange(edges);
4✔
102
                        if (restoreExistingState)
4✔
103
                                MarkTerminalParentEdgesSettled(edges);
4✔
104
                        else
105
                                EvaluateAlreadyTerminalParents(edges);
4✔
106
                }
4✔
107

108
                return ValueTask.CompletedTask;
4✔
109
        }
110

111
        /// <inheritdoc />
112
        public async ValueTask<IReadOnlyList<JobRecord>> AcquireDueJobsAsync(
113
                JobAcquisitionRequest request,
114
                CancellationToken cancellationToken = default
115
        )
116
        {
117
                ArgumentNullException.ThrowIfNull(request);
4✔
118
                ArgumentException.ThrowIfNullOrWhiteSpace(request.WorkerId);
4✔
119
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(request.Lease, TimeSpan.Zero);
4✔
120
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(request.BatchSize, 0);
4✔
121
                cancellationToken.ThrowIfCancellationRequested();
4✔
122
                var now = timeProvider.GetUtcNow();
4✔
123
                lock (_gate)
124
                {
125
                        foreach (var expired in _jobs.Values.Where(x => x.State == JobState.Active && x.LeaseExpiresAt <= now).ToArray())
4✔
126
                        {
127
                                _jobs[expired.Id] = expired with
4✔
128
                                {
4✔
129
                                        State = JobState.Pending,
4✔
130
                                        WorkerId = null,
4✔
131
                                        LeaseExpiresAt = null,
4✔
132
                                };
4✔
133
                        }
134

135
                        var acquired = new List<JobRecord>(request.BatchSize);
4✔
136
                        foreach (var queue in request.Queues)
4✔
137
                        {
138
                                var queueCapacity = Math.Min(queue.Capacity, request.BatchSize - acquired.Count);
4✔
139
                                if (queueCapacity <= 0)
4✔
140
                                        continue;
141

142
                                var jobCapacities = queue.JobCapacities.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal);
4✔
143
                                foreach (var candidate in _jobs.Values
4✔
144
                                        .Where(job => job.QueueName == queue.QueueName &&
4✔
145
                                                jobCapacities.ContainsKey(job.JobName) &&
4✔
146
                                                job.State is JobState.Pending or JobState.Scheduled &&
4✔
147
                                                job.DueAt <= now)
4✔
148
                                        .OrderBy(job => job.DueAt)
4✔
149
                                        .ThenBy(job => job.CreatedAt)
4✔
150
                                        .ThenBy(job => job.Id))
4✔
151
                                {
152
                                        if (queueCapacity == 0)
4✔
153
                                                break;
1✔
154
                                        if (jobCapacities[candidate.JobName] <= 0)
4✔
155
                                                continue;
156

157
                                        var job = candidate with
4✔
158
                                        {
4✔
159
                                                State = JobState.Active,
4✔
160
                                                Attempt = candidate.Attempt + 1,
4✔
161
                                                WorkerId = request.WorkerId,
4✔
162
                                                LeaseExpiresAt = now + request.Lease,
4✔
163
                                        };
4✔
164
                                        _jobs[job.Id] = job;
4✔
165
                                        MarkBatchStarted(job.BatchId, now);
4✔
166
                                        acquired.Add(job);
4✔
167
                                        jobCapacities[job.JobName]--;
4✔
168
                                        queueCapacity--;
4✔
169
                                }
170
                        }
171

172
                        return acquired;
4✔
173
                }
174
        }
4✔
175

176
        /// <inheritdoc />
177
        public async ValueTask<IReadOnlyList<JobRecord>> AcquireJobsAsync(
178
                IReadOnlyCollection<string> jobIds,
179
                string workerId,
180
                TimeSpan lease,
181
                CancellationToken cancellationToken = default
182
        )
183
        {
184
                ArgumentNullException.ThrowIfNull(jobIds);
4✔
185
                ArgumentException.ThrowIfNullOrWhiteSpace(workerId);
4✔
186
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lease, TimeSpan.Zero);
4✔
187
                cancellationToken.ThrowIfCancellationRequested();
4✔
188
                var now = timeProvider.GetUtcNow();
4✔
189
                lock (_gate)
190
                {
191
                        var acquired = new List<JobRecord>(jobIds.Count);
4✔
192
                        foreach (var id in jobIds)
4✔
193
                        {
194
                                if (!_jobs.TryGetValue(id, out var job))
4✔
195
                                        continue;
196
                                if (job.State == JobState.Active && job.LeaseExpiresAt <= now)
4✔
197
                                {
198
                                        job = job with { State = JobState.Pending, WorkerId = null, LeaseExpiresAt = null };
4✔
199
                                        _jobs[id] = job;
4✔
200
                                }
201

202
                                if (job.State is not (JobState.Pending or JobState.Scheduled) || job.DueAt > now)
4✔
203
                                        continue;
204

205
                                job = job with
4✔
206
                                {
4✔
207
                                        State = JobState.Active,
4✔
208
                                        Attempt = job.Attempt + 1,
4✔
209
                                        WorkerId = workerId,
4✔
210
                                        LeaseExpiresAt = now + lease,
4✔
211
                                };
4✔
212
                                _jobs[id] = job;
4✔
213
                                MarkBatchStarted(job.BatchId, now);
4✔
214
                                acquired.Add(job);
4✔
215
                        }
216

217
                        return acquired;
4✔
218
                }
219
        }
4✔
220

221
        /// <inheritdoc />
222
        public ValueTask RenewLeaseAsync(string jobId, string workerId, TimeSpan lease, CancellationToken cancellationToken = default)
223
        {
224
                cancellationToken.ThrowIfCancellationRequested();
×
225
                lock (_gate)
226
                {
227
                        var job = GetOwnedActive(jobId, workerId);
×
228
                        _jobs[jobId] = job with { LeaseExpiresAt = timeProvider.GetUtcNow() + lease };
×
229
                }
×
230

231
                return ValueTask.CompletedTask;
×
232
        }
233

234
        /// <inheritdoc />
235
        public ValueTask CompleteAsync(string jobId, string workerId, CancellationToken cancellationToken = default)
236
                => CompleteWithContinuationsAsync(jobId, workerId, [], cancellationToken);
4✔
237

238
        /// <inheritdoc />
239
        public ValueTask CompleteWithContinuationsAsync(
240
                string jobId,
241
                string workerId,
242
                IReadOnlyList<JobContinuationAddition> additions,
243
                CancellationToken cancellationToken = default
244
        )
245
        {
246
                ArgumentNullException.ThrowIfNull(additions);
4✔
247
                cancellationToken.ThrowIfCancellationRequested();
4✔
248
                lock (_gate)
249
                {
250
                        var current = GetOwnedActive(jobId, workerId);
4✔
251
                        var existingWaiters = GetUnsettledWaiters(jobId);
4✔
252
                        var newJobIds = new HashSet<string>(StringComparer.Ordinal);
4✔
253
                        var dependencyEdges = new List<JobContinuationEdge>(additions.Count);
4✔
254
                        var trackedAdditions = 0;
4✔
255
                        foreach (var addition in additions)
4✔
256
                        {
257
                                ArgumentNullException.ThrowIfNull(addition);
4✔
258
                                ArgumentNullException.ThrowIfNull(addition.Job);
4✔
259
                                ValidateNewJob(addition.Job);
4✔
260
                                if (!newJobIds.Add(addition.Job.Id))
4✔
NEW
261
                                        throw new InvalidOperationException($"Job '{addition.Job.Id}' occurs more than once in the completion buffer.");
×
262
                                if (addition.Job.State is not (JobState.Pending or JobState.Scheduled))
4✔
NEW
263
                                        throw new InvalidOperationException($"Dynamic continuation '{addition.Job.Id}' has invalid state '{addition.Job.State}'.");
×
264
                                if (!Enum.IsDefined(addition.Trigger))
4✔
NEW
265
                                        throw new ArgumentOutOfRangeException(nameof(additions), "Unknown continuation trigger.");
×
266

267
                                if (addition.Options == ContinuationOptions.Detached)
4✔
268
                                {
NEW
269
                                        if (addition.Job.BatchId is not null)
×
NEW
270
                                                throw new InvalidOperationException("A detached continuation cannot belong to a batch.");
×
271
                                }
272
                                else if (addition.Options is ContinuationOptions.BesideContinuations or ContinuationOptions.BeforeContinuations)
4✔
273
                                {
274
                                        if (current.BatchId is null || addition.Job.BatchId != current.BatchId)
4✔
NEW
275
                                                throw new InvalidOperationException("A batch-tracked continuation must belong to the current job's batch.");
×
276
                                        trackedAdditions++;
4✔
277
                                }
278
                                else
279
                                {
NEW
280
                                        throw new ArgumentOutOfRangeException(nameof(additions), "Unknown continuation option.");
×
281
                                }
282

283
                                dependencyEdges.Add(new()
4✔
284
                                {
4✔
285
                                        ChildJobId = addition.Job.Id,
4✔
286
                                        ParentJobId = jobId,
4✔
287
                                        Trigger = addition.Trigger,
4✔
288
                                });
4✔
289
                        }
290

291
                        if (dependencyEdges.Count != 0)
4✔
292
                                ValidateEdges([.. additions.Select(static addition => addition.Job)], dependencyEdges, current.BatchId);
4✔
293
                        ValidateSplice(existingWaiters, additions.Count(static addition => addition.Options == ContinuationOptions.BeforeContinuations));
4✔
294

295
                        IncrementBatchMembers(current.BatchId, trackedAdditions);
4✔
296
                        foreach (var addition in additions)
4✔
297
                        {
298
                                _jobs.Add(addition.Job.Id, NormalizeWaitingJob(addition.Job, dependencyCount: 1));
4✔
299
                                if (addition.Options == ContinuationOptions.BeforeContinuations)
4✔
300
                                        SpliceBeforeWaiters(addition.Job.Id, existingWaiters);
4✔
301
                        }
302

303
                        _edges.AddRange(dependencyEdges);
4✔
304
                        TransitionToTerminal(jobId, JobState.Succeeded, error: null, timeProvider.GetUtcNow());
4✔
305
                }
4✔
306

307
                return ValueTask.CompletedTask;
4✔
308
        }
309

310
        /// <inheritdoc />
311
        public ValueTask AddBatchJobAsync(
312
                string currentJobId,
313
                JobRecord job,
314
                ContinuationOptions options,
315
                CancellationToken cancellationToken = default
316
        )
317
        {
NEW
318
                ArgumentNullException.ThrowIfNull(job);
×
NEW
319
                cancellationToken.ThrowIfCancellationRequested();
×
320
                lock (_gate)
321
                {
NEW
322
                        if (!_jobs.TryGetValue(currentJobId, out var current) || current.State != JobState.Active)
×
NEW
323
                                throw new InvalidOperationException($"Job '{currentJobId}' is not currently active.");
×
NEW
324
                        if (current.BatchId is null || !_batches.ContainsKey(current.BatchId))
×
NEW
325
                                throw new InvalidOperationException("The current job does not belong to a batch.");
×
NEW
326
                        if (options == ContinuationOptions.Detached)
×
NEW
327
                                throw new InvalidOperationException("IJOB020: AddToBatch(JobDetails, ...) cannot create detached work.");
×
NEW
328
                        if (options is not (ContinuationOptions.BesideContinuations or ContinuationOptions.BeforeContinuations))
×
NEW
329
                                throw new ArgumentOutOfRangeException(nameof(options));
×
NEW
330
                        ValidateNewJob(job);
×
NEW
331
                        if (job.BatchId != current.BatchId)
×
NEW
332
                                throw new InvalidOperationException("The new job must belong to the current job's batch.");
×
NEW
333
                        if (job.State is JobState.Active or JobState.AwaitingContinuation || IsTerminal(job.State))
×
NEW
334
                                throw new InvalidOperationException($"Concurrent batch member '{job.Id}' has invalid state '{job.State}'.");
×
335

NEW
336
                        var existingWaiters = options == ContinuationOptions.BeforeContinuations
×
NEW
337
                                ? GetUnsettledWaiters(currentJobId)
×
NEW
338
                                : [];
×
NEW
339
                        ValidateSplice(existingWaiters, options == ContinuationOptions.BeforeContinuations ? 1 : 0);
×
NEW
340
                        IncrementBatchMembers(current.BatchId, 1);
×
NEW
341
                        _jobs.Add(job.Id, job with { RemainingDependencies = 0 });
×
NEW
342
                        if (options == ContinuationOptions.BeforeContinuations)
×
NEW
343
                                SpliceBeforeWaiters(job.Id, existingWaiters);
×
344
                }
×
345

346
                return ValueTask.CompletedTask;
×
347
        }
348

349
        /// <inheritdoc />
350
        public ValueTask FailAsync(
351
                string jobId,
352
                string workerId,
353
                string error,
354
                DateTimeOffset? nextRetryAt,
355
                CancellationToken cancellationToken = default
356
        )
357
        {
358
                cancellationToken.ThrowIfCancellationRequested();
4✔
359
                lock (_gate)
360
                {
361
                        var job = GetOwnedActive(jobId, workerId);
4✔
362
                        if (nextRetryAt.HasValue)
4✔
363
                        {
364
                                var now = timeProvider.GetUtcNow();
4✔
365
                                _jobs[jobId] = job with
4✔
366
                                {
4✔
367
                                        State = nextRetryAt <= now ? JobState.Pending : JobState.Scheduled,
4✔
368
                                        DueAt = nextRetryAt.Value,
4✔
369
                                        WorkerId = null,
4✔
370
                                        LeaseExpiresAt = null,
4✔
371
                                        LastError = error,
4✔
372
                                        CompletedAt = null,
4✔
373
                                };
4✔
374
                        }
375
                        else
376
                        {
377
                                TransitionToTerminal(jobId, JobState.Failed, error, timeProvider.GetUtcNow());
4✔
378
                        }
379
                }
4✔
380

381
                return ValueTask.CompletedTask;
4✔
382
        }
383

384
        /// <inheritdoc />
385
        public ValueTask UpsertRecurringAsync(RecurringJobSchedule schedule, CancellationToken cancellationToken = default)
386
        {
387
                ArgumentNullException.ThrowIfNull(schedule);
4✔
388
                cancellationToken.ThrowIfCancellationRequested();
4✔
389
                lock (_gate)
390
                {
391
                        if (_recurring.TryGetValue(schedule.Name, out var current))
4✔
392
                        {
393
                                if (current.IsCodeDefined && !schedule.IsCodeDefined)
4✔
394
                                        throw new InvalidOperationException("Code-defined recurring schedules cannot be replaced by dynamic schedules.");
4✔
395

396
                                schedule = schedule with { IsPaused = current.IsPaused, LastRunAt = current.LastRunAt };
4✔
397
                        }
398

399
                        _recurring[schedule.Name] = schedule;
4✔
400
                }
4✔
401

402
                return ValueTask.CompletedTask;
4✔
403
        }
404

405
        /// <inheritdoc />
406
        public ValueTask RemoveObsoleteCodeDefinedRecurringAsync(
407
                IReadOnlyCollection<string> activeScheduleNames,
408
                CancellationToken cancellationToken = default
409
        )
410
        {
411
                ArgumentNullException.ThrowIfNull(activeScheduleNames);
4✔
412
                cancellationToken.ThrowIfCancellationRequested();
4✔
413
                var activeNames = activeScheduleNames.ToHashSet(StringComparer.Ordinal);
4✔
414
                lock (_gate)
415
                {
416
                        var obsoleteNames = _recurring
4✔
417
                                .Where(schedule => schedule.Value.IsCodeDefined && !activeNames.Contains(schedule.Key))
4✔
418
                                .Select(static schedule => schedule.Key)
4✔
419
                                .ToArray();
4✔
420
                        foreach (var name in obsoleteNames)
4✔
421
                                _ = _recurring.Remove(name);
4✔
422
                }
4✔
423

424
                return ValueTask.CompletedTask;
4✔
425
        }
426

427
        /// <inheritdoc />
428
        public ValueTask RemoveRecurringAsync(string name, CancellationToken cancellationToken = default)
429
        {
430
                cancellationToken.ThrowIfCancellationRequested();
×
431
                lock (_gate)
432
                {
433
                        if (_recurring.TryGetValue(name, out var schedule) && schedule.IsCodeDefined)
×
434
                                throw new InvalidOperationException("Code-defined recurring schedules cannot be deleted.");
×
435

436
                        _ = _recurring.Remove(name);
×
437
                }
×
438

439
                return ValueTask.CompletedTask;
×
440
        }
441

442
        /// <inheritdoc />
443
        public ValueTask PauseRecurringAsync(string name, CancellationToken cancellationToken = default) =>
444
                SetRecurringPaused(name, true, cancellationToken);
×
445

446
        /// <inheritdoc />
447
        public ValueTask ResumeRecurringAsync(string name, CancellationToken cancellationToken = default) =>
448
                SetRecurringPaused(name, false, cancellationToken);
×
449

450
        /// <inheritdoc />
451
        public async ValueTask<IReadOnlyList<RecurringJobSchedule>> GetDueRecurringAsync(
452
                DateTimeOffset now,
453
                int batchSize,
454
                CancellationToken cancellationToken = default
455
        )
456
        {
457
                cancellationToken.ThrowIfCancellationRequested();
4✔
458
                lock (_gate)
459
                {
460
                        return
4✔
461
                        [
4✔
462
                                .. _recurring.Values.Where(x => !x.IsPaused && x.NextRunAt <= now).OrderBy(x => x.NextRunAt).Take(batchSize),
4✔
463
                        ];
4✔
464
                }
465
        }
4✔
466

467
        /// <inheritdoc />
468
        public async ValueTask<bool> MaterializeRecurringAsync(
469
                RecurringJobSchedule schedule,
470
                JobRecord job,
471
                DateTimeOffset nextRunAt,
472
                CancellationToken cancellationToken = default
473
        )
474
        {
475
                ArgumentNullException.ThrowIfNull(schedule);
4✔
476
                ArgumentNullException.ThrowIfNull(job);
4✔
477
                cancellationToken.ThrowIfCancellationRequested();
4✔
478
                lock (_gate)
479
                {
480
                        if (!_recurring.TryGetValue(schedule.Name, out var current) || current.NextRunAt != schedule.NextRunAt)
4✔
481
                                return false;
×
482

483
                        var inserted = job.RecurringKey is null || _recurringKeys.Add(job.RecurringKey);
4✔
484
                        if (inserted)
4✔
485
                                _jobs[job.Id] = job;
4✔
486
                        _recurring[schedule.Name] = current with { LastRunAt = schedule.NextRunAt, NextRunAt = nextRunAt };
4✔
487
                        return inserted;
4✔
488
                }
489
        }
4✔
490

491
        /// <inheritdoc />
492
        public async ValueTask<JobMonitoringSnapshot> GetMonitoringSnapshotAsync(CancellationToken cancellationToken = default)
493
        {
494
                cancellationToken.ThrowIfCancellationRequested();
4✔
495
                lock (_gate)
496
                {
497
                        var counts = Enum.GetValues<JobState>().ToDictionary(state => state, state => _jobs.Values.LongCount(x => x.State == state));
4✔
498
                        var cutoff = timeProvider.GetUtcNow() - TimeSpan.FromMinutes(2);
4✔
499
                        IReadOnlyList<JobServerSnapshot> servers = [.. _servers.Values.Where(x => x.LastHeartbeat >= cutoff)];
4✔
500
                        return new(timeProvider.GetUtcNow(), counts, [.. _recurring.Values], servers);
4✔
501
                }
502
        }
4✔
503

504
        /// <inheritdoc />
505
        public async ValueTask<IReadOnlyList<JobRecord>> QueryJobsAsync(JobQuery query, CancellationToken cancellationToken = default)
506
        {
507
                ArgumentNullException.ThrowIfNull(query);
4✔
508
                cancellationToken.ThrowIfCancellationRequested();
4✔
509
                lock (_gate)
510
                {
511
                        var jobs = _jobs.Values.AsEnumerable();
4✔
512
                        if (query.Id is { } id)
4✔
513
                                jobs = jobs.Where(x => x.Id == id);
4✔
514
                        if (query.State is { } state)
4✔
515
                                jobs = jobs.Where(x => x.State == state);
4✔
516
                        if (!string.IsNullOrWhiteSpace(query.QueueName))
4✔
517
                                jobs = jobs.Where(x => x.QueueName == query.QueueName);
4✔
518

519
                        if (!string.IsNullOrWhiteSpace(query.Search))
4✔
520
                                jobs = jobs.Where(x => x.JobName.Contains(query.Search, StringComparison.OrdinalIgnoreCase));
3✔
521

522
                        return
4✔
523
                        [
4✔
524
                                .. jobs.OrderByDescending(x => x.CreatedAt).Skip(Math.Max(0, query.Skip)).Take(Math.Clamp(query.Take, 1, 1000)),
4✔
525
                        ];
4✔
526
                }
527
        }
4✔
528

529
        /// <inheritdoc />
530
        public async ValueTask<BatchStatus?> GetBatchStatusAsync(
531
                string batchId,
532
                CancellationToken cancellationToken = default
533
        )
534
        {
535
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
536
                cancellationToken.ThrowIfCancellationRequested();
4✔
537
                lock (_gate)
538
                {
539
                        if (!_batches.TryGetValue(batchId, out var batch))
4✔
540
                                return null;
4✔
541

542
                        return ToStatus(batch);
4✔
543
                }
544
        }
4✔
545

546
        /// <inheritdoc />
547
        public async ValueTask<IReadOnlyList<BatchStatus>> QueryBatchesAsync(
548
                JobBatchQuery query,
549
                CancellationToken cancellationToken = default
550
        )
551
        {
552
                ArgumentNullException.ThrowIfNull(query);
4✔
553
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
4✔
554
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
4✔
555
                cancellationToken.ThrowIfCancellationRequested();
4✔
556
                lock (_gate)
557
                {
558
                        var batches = _batches.Values.AsEnumerable();
4✔
559
                        if (query.State is { } state)
4✔
NEW
560
                                batches = batches.Where(batch => batch.State == state);
×
561
                        return
4✔
562
                        [
4✔
563
                                .. batches.OrderByDescending(static batch => batch.CreatedAt)
4✔
564
                                        .ThenBy(static batch => batch.Id, StringComparer.Ordinal)
4✔
565
                                        .Skip(query.Skip)
4✔
566
                                        .Take(query.Take)
4✔
567
                                        .Select(ToStatus),
4✔
568
                        ];
4✔
569
                }
570
        }
4✔
571

572
        /// <inheritdoc />
573
        public async ValueTask<IReadOnlyList<BatchMemberStatus>> QueryBatchMembersAsync(
574
                string batchId,
575
                BatchMemberQuery query,
576
                CancellationToken cancellationToken = default
577
        )
578
        {
579
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
580
                ArgumentNullException.ThrowIfNull(query);
4✔
581
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
4✔
582
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
4✔
583
                cancellationToken.ThrowIfCancellationRequested();
4✔
584
                lock (_gate)
585
                {
586
                        if (!_batches.ContainsKey(batchId))
4✔
NEW
587
                                return [];
×
588

589
                        var members = _jobs.Values.Where(job => job.BatchId == batchId);
4✔
590
                        if (query.State is { } state)
4✔
591
                                members = members.Where(job => job.State == state);
4✔
592

593
                        return
4✔
594
                        [
4✔
595
                                .. members
4✔
596
                                        .OrderBy(job => job.CreatedAt)
4✔
597
                                        .ThenBy(job => job.Id, StringComparer.Ordinal)
4✔
598
                                        .Skip(query.Skip)
4✔
599
                                        .Take(query.Take)
4✔
600
                                        .Select(static job => new BatchMemberStatus(
4✔
601
                                                job.Id,
4✔
602
                                                job.JobName,
4✔
603
                                                job.QueueName,
4✔
604
                                                job.State,
4✔
605
                                                job.Attempt,
4✔
606
                                                job.CreatedAt,
4✔
607
                                                job.CompletedAt,
4✔
608
                                                job.LastError
4✔
609
                                        )),
4✔
610
                        ];
4✔
611
                }
612
        }
4✔
613

614
        /// <inheritdoc />
615
        public async ValueTask<BatchGraph?> GetBatchGraphAsync(
616
                string batchId,
617
                CancellationToken cancellationToken = default
618
        )
619
        {
620
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
621
                cancellationToken.ThrowIfCancellationRequested();
4✔
622
                lock (_gate)
623
                {
624
                        if (!_batches.ContainsKey(batchId))
4✔
NEW
625
                                return null;
×
626

627
                        var members = _jobs.Values
4✔
628
                                .Where(job => job.BatchId == batchId)
4✔
629
                                .OrderBy(job => job.CreatedAt)
4✔
630
                                .ThenBy(job => job.Id, StringComparer.Ordinal)
4✔
631
                                .ToArray();
4✔
632
                        var memberIds = members.Select(static job => job.Id).ToHashSet(StringComparer.Ordinal);
4✔
633
                        return new(
4✔
634
                                batchId,
4✔
635
                                [.. members.Select(static job => new BatchGraphNode(job.Id, job.JobName, job.State))],
4✔
636
                                [.. _edges.Where(edge => memberIds.Contains(edge.ChildJobId)).Select(ToGraphEdge)]
4✔
637
                        );
4✔
638
                }
639
        }
4✔
640

641
        /// <inheritdoc />
642
        public async ValueTask<JobStatus?> GetJobStatusAsync(
643
                string jobId,
644
                CancellationToken cancellationToken = default
645
        )
646
        {
647
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
4✔
648
                cancellationToken.ThrowIfCancellationRequested();
4✔
649
                lock (_gate)
650
                {
651
                        if (!_jobs.TryGetValue(jobId, out var job))
4✔
NEW
652
                                return null;
×
653

654
                        return new(
4✔
655
                                job.Id,
4✔
656
                                job.JobName,
4✔
657
                                job.QueueName,
4✔
658
                                job.State,
4✔
659
                                job.Attempt,
4✔
660
                                MaxAttempts: 0,
4✔
661
                                job.CreatedAt,
4✔
662
                                job.DueAt,
4✔
663
                                job.CompletedAt,
4✔
664
                                job.LastError,
4✔
665
                                job.BatchId,
4✔
666
                                [.. _edges.Where(edge => edge.ChildJobId == jobId).Select(ToGraphEdge)]
4✔
667
                        );
4✔
668
                }
669
        }
4✔
670

671
        /// <inheritdoc />
672
        public ValueTask CancelBatchAsync(string batchId, CancellationToken cancellationToken = default)
673
        {
674
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
675
                cancellationToken.ThrowIfCancellationRequested();
4✔
676
                lock (_gate)
677
                {
678
                        if (!_batches.TryGetValue(batchId, out var batch))
4✔
NEW
679
                                throw new KeyNotFoundException($"Batch '{batchId}' was not found.");
×
680
                        if (batch.State != BatchState.Executing)
4✔
NEW
681
                                throw new InvalidOperationException("Only an executing batch can be cancelled.");
×
682

683
                        foreach (var jobId in _jobs.Values
4✔
684
                                .Where(job => job.BatchId == batchId && !IsTerminal(job.State))
4✔
685
                                .Select(static job => job.Id)
4✔
686
                                .ToArray())
4✔
687
                        {
688
                                TransitionToTerminal(jobId, JobState.Cancelled, error: null, timeProvider.GetUtcNow());
4✔
689
                        }
690
                }
4✔
691

692
                return ValueTask.CompletedTask;
4✔
693
        }
694

695
        /// <inheritdoc />
696
        public ValueTask DeleteBatchAsync(string batchId, CancellationToken cancellationToken = default)
697
        {
698
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
699
                cancellationToken.ThrowIfCancellationRequested();
4✔
700
                lock (_gate)
701
                {
702
                        if (!_batches.TryGetValue(batchId, out var batch))
4✔
NEW
703
                                throw new KeyNotFoundException($"Batch '{batchId}' was not found.");
×
704
                        if (batch.State == BatchState.Executing)
4✔
NEW
705
                                throw new InvalidOperationException("Only a terminal batch can be deleted.");
×
706

707
                        var jobIds = _jobs.Values
4✔
708
                                .Where(job => job.BatchId == batchId)
4✔
709
                                .Select(static job => job.Id)
4✔
710
                                .ToHashSet(StringComparer.Ordinal);
4✔
711
                        foreach (var jobId in jobIds)
4✔
712
                                _ = _jobs.Remove(jobId);
4✔
713
                        _ = _batches.Remove(batchId);
4✔
714
                        RemoveEdgesForJobs(jobIds, [batchId]);
4✔
715
                }
4✔
716

717
                return ValueTask.CompletedTask;
4✔
718
        }
719

720
        /// <inheritdoc />
721
        public ValueTask RetryAsync(string jobId, CancellationToken cancellationToken = default)
722
        {
723
                cancellationToken.ThrowIfCancellationRequested();
×
724
                lock (_gate)
725
                {
726
                        if (!_jobs.TryGetValue(jobId, out var job) || job.State != JobState.Failed)
×
727
                                throw new InvalidOperationException("Only failed jobs can be retried.");
×
728

NEW
729
                        _jobs[jobId] = job with
×
NEW
730
                        {
×
NEW
731
                                State = JobState.Pending,
×
NEW
732
                                DueAt = timeProvider.GetUtcNow(),
×
NEW
733
                                CompletedAt = null,
×
NEW
734
                                LastError = null,
×
NEW
735
                        };
×
NEW
736
                        if (job.BatchId is { } batchId && _batches.TryGetValue(batchId, out var batch))
×
737
                        {
NEW
738
                                _batches[batchId] = batch with
×
NEW
739
                                {
×
NEW
740
                                        State = BatchState.Executing,
×
NEW
741
                                        PendingCount = batch.PendingCount + 1,
×
NEW
742
                                        FailedCount = Math.Max(0, batch.FailedCount - 1),
×
NEW
743
                                        CompletedAt = null,
×
NEW
744
                                };
×
745
                        }
746
                }
×
747

748
                return ValueTask.CompletedTask;
×
749
        }
750

751
        /// <inheritdoc />
752
        public ValueTask DeleteAsync(string jobId, CancellationToken cancellationToken = default)
753
        {
754
                cancellationToken.ThrowIfCancellationRequested();
×
755
                lock (_gate)
756
                {
NEW
757
                        if (!_jobs.TryGetValue(jobId, out var job))
×
NEW
758
                                return ValueTask.CompletedTask;
×
NEW
759
                        if (!IsTerminal(job.State))
×
760
                                throw new InvalidOperationException("Only terminal jobs can be deleted.");
×
NEW
761
                        if (job.BatchId is not null)
×
NEW
762
                                throw new InvalidOperationException("Batch members cannot be deleted individually.");
×
763

764
                        _ = _jobs.Remove(jobId);
×
NEW
765
                        RemoveEdgesForJobs(new(StringComparer.Ordinal) { jobId });
×
766
                }
×
767

768
                return ValueTask.CompletedTask;
×
769
        }
×
770

771
        /// <inheritdoc />
772
        public ValueTask PurgeAsync(
773
                TimeSpan succeededRetention,
774
                TimeSpan failedRetention,
775
                TimeSpan batchSucceededRetention,
776
                TimeSpan batchFailedRetention,
777
                CancellationToken cancellationToken = default
778
        )
779
        {
780
                cancellationToken.ThrowIfCancellationRequested();
4✔
781
                var now = timeProvider.GetUtcNow();
4✔
782
                lock (_gate)
783
                {
784
                        var batchIds = _batches.Values
4✔
NEW
785
                                .Where(batch => batch.CompletedAt is { } completed &&
×
NEW
786
                                        (batch.State == BatchState.Succeeded && completed < now - batchSucceededRetention ||
×
NEW
787
                                         batch.State is BatchState.Failed or BatchState.Cancelled && completed < now - batchFailedRetention))
×
NEW
788
                                .Select(static batch => batch.Id)
×
789
                                .ToHashSet(StringComparer.Ordinal);
4✔
790
                        var batchJobIds = _jobs.Values
4✔
791
                                .Where(job => job.BatchId is { } batchId && batchIds.Contains(batchId))
4✔
NEW
792
                                .Select(static job => job.Id)
×
793
                                .ToHashSet(StringComparer.Ordinal);
4✔
794
                        foreach (var id in batchJobIds)
4✔
NEW
795
                                _ = _jobs.Remove(id);
×
796
                        foreach (var batchId in batchIds)
4✔
NEW
797
                                _ = _batches.Remove(batchId);
×
798
                        RemoveEdgesForJobs(batchJobIds, batchIds);
4✔
799

800
                        var standaloneJobIds = _jobs.Values
4✔
801
                                .Where(static job => job.BatchId is null)
4✔
802
                                .Where(x => x.CompletedAt is { } completed &&
4✔
803
                                        (x.State == JobState.Succeeded && completed < now - succeededRetention ||
4✔
804
                                         x.State is JobState.Failed or JobState.Cancelled && completed < now - failedRetention))
4✔
NEW
805
                                .Select(static job => job.Id)
×
806
                                .ToHashSet(StringComparer.Ordinal);
4✔
807
                        foreach (var id in standaloneJobIds)
4✔
808
                                _ = _jobs.Remove(id);
×
809
                        RemoveEdgesForJobs(standaloneJobIds);
4✔
810
                }
4✔
811

812
                return ValueTask.CompletedTask;
4✔
813
        }
814

815
        /// <inheritdoc />
816
        public ValueTask HeartbeatAsync(JobServerSnapshot server, CancellationToken cancellationToken = default)
817
        {
818
                ArgumentNullException.ThrowIfNull(server);
4✔
819
                cancellationToken.ThrowIfCancellationRequested();
4✔
820
                lock (_gate)
821
                        _servers[server.WorkerId] = server;
4✔
822
                return ValueTask.CompletedTask;
4✔
823
        }
824

825
        /// <inheritdoc />
826
        public async ValueTask<bool> IsHealthyAsync(CancellationToken cancellationToken = default)
827
        {
828
                cancellationToken.ThrowIfCancellationRequested();
×
829
                return true;
×
830
        }
831

832
        private void ValidateNewJob(JobRecord job)
833
        {
834
                ArgumentException.ThrowIfNullOrWhiteSpace(job.Id);
4✔
835
                ArgumentException.ThrowIfNullOrWhiteSpace(job.JobName);
4✔
836
                if (_jobs.ContainsKey(job.Id))
4✔
NEW
837
                        throw new InvalidOperationException($"Job '{job.Id}' already exists.");
×
838
        }
4✔
839

840
        private void ValidateBatch(
841
                JobBatchRecord batch,
842
                IReadOnlyList<JobRecord> jobs,
843
                IReadOnlyList<JobContinuationEdge> edges
844
        )
845
        {
846
                ArgumentException.ThrowIfNullOrWhiteSpace(batch.Id);
4✔
847
                if (_batches.ContainsKey(batch.Id))
4✔
NEW
848
                        throw new InvalidOperationException($"Batch '{batch.Id}' already exists.");
×
849
                if (jobs.Count == 0)
4✔
NEW
850
                        throw new InvalidOperationException("An atomic batch cannot be empty.");
×
851
                var succeeded = jobs.Count(static job => job.State == JobState.Succeeded);
4✔
852
                var failed = jobs.Count(static job => job.State == JobState.Failed);
4✔
853
                var cancelled = jobs.Count(static job => job.State == JobState.Cancelled);
4✔
854
                var pending = jobs.Count - succeeded - failed - cancelled;
4✔
855
                var expectedState = pending != 0
4✔
856
                        ? BatchState.Executing
4✔
857
                        : failed != 0 ? BatchState.Failed : cancelled != 0 ? BatchState.Cancelled : BatchState.Succeeded;
4✔
858
                if (batch.TotalJobs != jobs.Count ||
4✔
859
                        batch.PendingCount != pending ||
4✔
860
                        batch.SucceededCount != succeeded ||
4✔
861
                        batch.FailedCount != failed ||
4✔
862
                        batch.CancelledCount != cancelled ||
4✔
863
                        batch.State != expectedState ||
4✔
864
                        (pending == 0) != (batch.CompletedAt is not null))
4✔
865
                {
NEW
866
                        throw new InvalidOperationException("A batch header does not match its members or aggregate state.");
×
867
                }
868

869
                var jobIds = new HashSet<string>(StringComparer.Ordinal);
4✔
870
                foreach (var job in jobs)
4✔
871
                {
872
                        ValidateNewJob(job);
4✔
873
                        if (!jobIds.Add(job.Id))
4✔
NEW
874
                                throw new InvalidOperationException($"Job '{job.Id}' occurs more than once in the batch.");
×
875
                        if (job.BatchId != batch.Id)
4✔
NEW
876
                                throw new InvalidOperationException($"Job '{job.Id}' does not belong to batch '{batch.Id}'.");
×
877
                }
878

879
                ValidateEdges(jobs, edges, batch.Id);
4✔
880
        }
4✔
881

882
        private void ValidateEdges(
883
                IReadOnlyList<JobRecord> newJobs,
884
                IReadOnlyList<JobContinuationEdge> edges,
885
                string? batchId
886
        )
887
        {
888
                if (batchId is null && edges.Count == 0)
4✔
NEW
889
                        throw new InvalidOperationException("A continuation must have at least one parent.");
×
890

891
                var newJobIds = newJobs.Select(static job => job.Id).ToHashSet(StringComparer.Ordinal);
4✔
892
                var logicalEdges = new HashSet<(string Child, string ParentKind, string Parent)>(
4✔
893
                        EqualityComparer<(string Child, string ParentKind, string Parent)>.Default
4✔
894
                );
4✔
895
                var outgoing = newJobIds.ToDictionary(static id => id, static _ => new List<string>(), StringComparer.Ordinal);
4✔
896
                var incoming = newJobIds.ToDictionary(static id => id, static _ => 0, StringComparer.Ordinal);
4✔
897
                foreach (var edge in edges)
4✔
898
                {
899
                        ArgumentNullException.ThrowIfNull(edge);
4✔
900
                        ArgumentException.ThrowIfNullOrWhiteSpace(edge.ChildJobId);
4✔
901
                        if (!Enum.IsDefined(edge.Trigger))
4✔
NEW
902
                                throw new ArgumentOutOfRangeException(nameof(edges), "Unknown continuation trigger.");
×
903
                        if (!newJobIds.Contains(edge.ChildJobId))
4✔
NEW
904
                                throw new InvalidOperationException($"Continuation child '{edge.ChildJobId}' is not part of the atomic insert.");
×
905

906
                        var hasJobParent = !string.IsNullOrWhiteSpace(edge.ParentJobId);
4✔
907
                        var hasBatchParent = !string.IsNullOrWhiteSpace(edge.ParentBatchId);
4✔
908
                        if (hasJobParent == hasBatchParent)
4✔
NEW
909
                                throw new InvalidOperationException("A continuation edge must have exactly one job or batch parent.");
×
910

911
                        if (hasJobParent)
4✔
912
                        {
913
                                var parentId = edge.ParentJobId!;
4✔
914
                                if (parentId == edge.ChildJobId)
4✔
NEW
915
                                        throw new InvalidOperationException($"Continuation job '{edge.ChildJobId}' cannot depend on itself.");
×
916
                                if (!newJobIds.Contains(parentId) && !_jobs.ContainsKey(parentId))
4✔
917
                                        throw new KeyNotFoundException($"Continuation parent job '{parentId}' was not found.");
4✔
918
                                if (!logicalEdges.Add((edge.ChildJobId, "job", parentId)))
4✔
NEW
919
                                        throw new InvalidOperationException($"Duplicate continuation edge '{parentId}' -> '{edge.ChildJobId}'.");
×
920

921
                                if (newJobIds.Contains(parentId))
4✔
922
                                {
923
                                        outgoing[parentId].Add(edge.ChildJobId);
4✔
924
                                        incoming[edge.ChildJobId]++;
4✔
925
                                }
926
                        }
927
                        else
928
                        {
929
                                var parentId = edge.ParentBatchId!;
4✔
930
                                if (!_batches.ContainsKey(parentId))
4✔
NEW
931
                                        throw new KeyNotFoundException($"Continuation parent batch '{parentId}' was not found.");
×
932
                                if (!logicalEdges.Add((edge.ChildJobId, "batch", parentId)))
4✔
NEW
933
                                        throw new InvalidOperationException($"Duplicate continuation edge from batch '{parentId}' to '{edge.ChildJobId}'.");
×
934
                        }
935
                }
936

937
                var ready = new Queue<string>(incoming.Where(static pair => pair.Value == 0).Select(static pair => pair.Key));
4✔
938
                var visited = 0;
4✔
939
                while (ready.TryDequeue(out var parentId))
4✔
940
                {
941
                        visited++;
4✔
942
                        foreach (var childId in outgoing[parentId])
4✔
943
                        {
944
                                if (--incoming[childId] == 0)
4✔
945
                                        ready.Enqueue(childId);
4✔
946
                        }
947
                }
948

949
                if (visited != newJobIds.Count)
4✔
NEW
950
                        throw new InvalidOperationException("IJOB018: The continuation graph contains a dependency cycle.");
×
951
        }
4✔
952

953
        private static JobRecord NormalizeWaitingJob(JobRecord job, int dependencyCount) => job with
4✔
954
        {
4✔
955
                State = dependencyCount == 0 ? job.State : JobState.AwaitingContinuation,
4✔
956
                RemainingDependencies = dependencyCount,
4✔
957
                WorkerId = null,
4✔
958
                LeaseExpiresAt = null,
4✔
959
                CompletedAt = null,
4✔
960
        };
4✔
961

962
        private bool IsRecoveredBatch(
963
                JobBatchRecord batch,
964
                IReadOnlyList<JobRecord> jobs,
965
                IReadOnlyList<JobContinuationEdge> edges
966
        )
967
        {
968
                if (batch.StartedAt is not null ||
4✔
969
                        batch.CompletedAt is not null ||
4✔
970
                        batch.SucceededCount != 0 ||
4✔
971
                        batch.FailedCount != 0 ||
4✔
972
                        batch.CancelledCount != 0 ||
4✔
973
                        jobs.Any(static job => job.State is JobState.Active or JobState.Succeeded or JobState.Failed or JobState.Cancelled))
4✔
974
                {
975
                        return true;
4✔
976
                }
977

978
                if (!HasTerminalParent(edges))
4✔
979
                        return false;
4✔
980

981
                var incomingCounts = edges
4✔
982
                        .GroupBy(static edge => edge.ChildJobId, StringComparer.Ordinal)
4✔
983
                        .ToDictionary(static group => group.Key, static group => group.Count(), StringComparer.Ordinal);
4✔
984
                return jobs.Any(job => incomingCounts.TryGetValue(job.Id, out var incoming) &&
4✔
985
                        (job.State != JobState.AwaitingContinuation || job.RemainingDependencies < incoming));
4✔
986
        }
987

988
        private bool HasTerminalParent(IEnumerable<JobContinuationEdge> edges) => edges.Any(edge =>
4✔
989
                edge.ParentJobId is { } parentJobId &&
4✔
990
                        _jobs.TryGetValue(parentJobId, out var parentJob) &&
4✔
991
                        IsTerminal(parentJob.State) ||
4✔
992
                edge.ParentBatchId is { } parentBatchId &&
4✔
993
                        _batches.TryGetValue(parentBatchId, out var parentBatch) &&
4✔
994
                        IsTerminal(parentBatch.State));
4✔
995

996
        private void MarkTerminalParentEdgesSettled(IEnumerable<JobContinuationEdge> edges)
997
        {
998
                foreach (var edge in edges)
4✔
999
                {
1000
                        if (edge.ParentJobId is { } parentJobId &&
4✔
1001
                                _jobs.TryGetValue(parentJobId, out var parentJob) &&
4✔
1002
                                IsTerminal(parentJob.State) ||
4✔
1003
                                edge.ParentBatchId is { } parentBatchId &&
4✔
1004
                                _batches.TryGetValue(parentBatchId, out var parentBatch) &&
4✔
1005
                                IsTerminal(parentBatch.State))
4✔
1006
                        {
1007
                                _ = _settledEdges.Add(edge);
4✔
1008
                        }
1009
                }
1010
        }
4✔
1011

1012
        private void EvaluateAlreadyTerminalParents(IReadOnlyList<JobContinuationEdge> edges)
1013
        {
1014
                foreach (var parentId in edges
4✔
1015
                        .Where(static edge => edge.ParentJobId is not null)
4✔
1016
                        .Select(static edge => edge.ParentJobId!)
4✔
1017
                        .Distinct(StringComparer.Ordinal)
4✔
1018
                        .Where(parentId => _jobs.TryGetValue(parentId, out var parent) && IsTerminal(parent.State))
4✔
1019
                        .ToArray())
4✔
1020
                {
1021
                        ProcessTerminalJob(parentId);
4✔
1022
                }
1023

1024
                foreach (var parentId in edges
4✔
1025
                        .Where(static edge => edge.ParentBatchId is not null)
4✔
1026
                        .Select(static edge => edge.ParentBatchId!)
4✔
1027
                        .Distinct(StringComparer.Ordinal)
4✔
1028
                        .Where(parentId => _batches.TryGetValue(parentId, out var parent) && IsTerminal(parent.State))
4✔
1029
                        .ToArray())
4✔
1030
                {
1031
                        ProcessTerminalBatch(parentId);
4✔
1032
                }
1033
        }
4✔
1034

1035
        private void TransitionToTerminal(
1036
                string jobId,
1037
                JobState terminalState,
1038
                string? error,
1039
                DateTimeOffset completedAt
1040
        )
1041
        {
1042
                if (!IsTerminal(terminalState))
4✔
NEW
1043
                        throw new ArgumentOutOfRangeException(nameof(terminalState));
×
1044
                if (!_jobs.TryGetValue(jobId, out var job) || IsTerminal(job.State))
4✔
1045
                        return;
4✔
1046

1047
                _jobs[jobId] = job with
4✔
1048
                {
4✔
1049
                        State = terminalState,
4✔
1050
                        WorkerId = null,
4✔
1051
                        LeaseExpiresAt = null,
4✔
1052
                        LastError = error,
4✔
1053
                        CompletedAt = completedAt,
4✔
1054
                };
4✔
1055
                UpdateBatchAfterTerminal(job.BatchId, terminalState, completedAt);
4✔
1056
                ProcessTerminalJob(jobId);
4✔
1057
        }
4✔
1058

1059
        private void UpdateBatchAfterTerminal(string? batchId, JobState state, DateTimeOffset completedAt)
1060
        {
1061
                if (batchId is null || !_batches.TryGetValue(batchId, out var batch))
4✔
1062
                        return;
4✔
1063

1064
                var pending = Math.Max(0, batch.PendingCount - 1);
4✔
1065
                batch = batch with
4✔
1066
                {
4✔
1067
                        PendingCount = pending,
4✔
1068
                        SucceededCount = batch.SucceededCount + (state == JobState.Succeeded ? 1 : 0),
4✔
1069
                        FailedCount = batch.FailedCount + (state == JobState.Failed ? 1 : 0),
4✔
1070
                        CancelledCount = batch.CancelledCount + (state == JobState.Cancelled ? 1 : 0),
4✔
1071
                };
4✔
1072
                if (pending == 0)
4✔
1073
                {
1074
                        batch = batch with
4✔
1075
                        {
4✔
1076
                                State = batch.FailedCount != 0
4✔
1077
                                        ? BatchState.Failed
4✔
1078
                                        : batch.CancelledCount != 0 ? BatchState.Cancelled : BatchState.Succeeded,
4✔
1079
                                CompletedAt = completedAt,
4✔
1080
                        };
4✔
1081
                }
1082

1083
                _batches[batchId] = batch;
4✔
1084
                if (pending == 0)
4✔
1085
                        ProcessTerminalBatch(batchId);
4✔
1086
        }
4✔
1087

1088
        private void ProcessTerminalJob(string parentJobId)
1089
        {
1090
                if (!_jobs.TryGetValue(parentJobId, out var parent) || !IsTerminal(parent.State))
4✔
NEW
1091
                        return;
×
1092

1093
                foreach (var edge in _edges
4✔
1094
                        .Where(edge => edge.ParentJobId == parentJobId && !_settledEdges.Contains(edge))
4✔
1095
                        .ToArray())
4✔
1096
                {
1097
                        _ = _settledEdges.Add(edge);
4✔
1098
                        SettleEdge(edge, parent.State == JobState.Succeeded);
4✔
1099
                }
1100
        }
4✔
1101

1102
        private void ProcessTerminalBatch(string parentBatchId)
1103
        {
1104
                if (!_batches.TryGetValue(parentBatchId, out var parent) || !IsTerminal(parent.State))
4✔
NEW
1105
                        return;
×
1106

1107
                foreach (var edge in _edges
4✔
1108
                        .Where(edge => edge.ParentBatchId == parentBatchId && !_settledEdges.Contains(edge))
4✔
1109
                        .ToArray())
4✔
1110
                {
1111
                        _ = _settledEdges.Add(edge);
4✔
1112
                        SettleEdge(edge, parent.State == BatchState.Succeeded);
4✔
1113
                }
1114
        }
4✔
1115

1116
        private void SettleEdge(JobContinuationEdge edge, bool parentSucceeded)
1117
        {
1118
                if (!_jobs.TryGetValue(edge.ChildJobId, out var child) || IsTerminal(child.State))
4✔
NEW
1119
                        return;
×
1120
                if (edge.Trigger == ContinuationTrigger.AllSucceeded && !parentSucceeded)
4✔
1121
                {
1122
                        TransitionToTerminal(child.Id, JobState.Cancelled, error: null, timeProvider.GetUtcNow());
4✔
1123
                        return;
4✔
1124
                }
1125

1126
                var remaining = Math.Max(0, child.RemainingDependencies - 1);
4✔
1127
                _jobs[child.Id] = child with
4✔
1128
                {
4✔
1129
                        State = remaining == 0 && child.State == JobState.AwaitingContinuation
4✔
1130
                                ? GetReadyState(child)
4✔
1131
                                : child.State,
4✔
1132
                        RemainingDependencies = remaining,
4✔
1133
                };
4✔
1134
        }
4✔
1135

1136
        private JobState GetReadyState(JobRecord job) =>
1137
                job.DueAt <= timeProvider.GetUtcNow() ? JobState.Pending : JobState.Scheduled;
4✔
1138

1139
        private JobContinuationEdge[] GetUnsettledWaiters(string parentJobId) =>
1140
        [
4✔
1141
                .. _edges.Where(edge => edge.ParentJobId == parentJobId &&
4✔
1142
                        !_settledEdges.Contains(edge) &&
4✔
1143
                        _jobs.TryGetValue(edge.ChildJobId, out var child) &&
4✔
1144
                        !IsTerminal(child.State)),
4✔
1145
        ];
4✔
1146

1147
        private void SpliceBeforeWaiters(
1148
                string newParentJobId,
1149
                IReadOnlyList<JobContinuationEdge> existingWaiters
1150
        )
1151
        {
1152
                foreach (var existingEdge in existingWaiters)
4✔
1153
                {
1154
                        if (!_jobs.TryGetValue(existingEdge.ChildJobId, out var child) || IsTerminal(child.State))
4✔
1155
                                continue;
1156

1157
                        _jobs[child.Id] = child with
4✔
1158
                        {
4✔
1159
                                State = JobState.AwaitingContinuation,
4✔
1160
                                RemainingDependencies = child.RemainingDependencies + 1,
4✔
1161
                        };
4✔
1162
                        _edges.Add(new()
4✔
1163
                        {
4✔
1164
                                ChildJobId = child.Id,
4✔
1165
                                ParentJobId = newParentJobId,
4✔
1166
                                Trigger = existingEdge.Trigger,
4✔
1167
                        });
4✔
1168
                }
1169
        }
4✔
1170

1171
        private void ValidateSplice(IReadOnlyList<JobContinuationEdge> existingWaiters, int additions)
1172
        {
1173
                if (additions == 0)
4✔
1174
                        return;
4✔
1175
                foreach (var existingEdge in existingWaiters)
4✔
1176
                {
1177
                        if (_jobs.TryGetValue(existingEdge.ChildJobId, out var child) &&
4✔
1178
                                child.RemainingDependencies > int.MaxValue - additions)
4✔
1179
                        {
NEW
1180
                                throw new InvalidOperationException($"Continuation dependency count overflow for job '{child.Id}'.");
×
1181
                        }
1182
                }
1183
        }
4✔
1184

1185
        private void IncrementBatchMembers(string? batchId, int count)
1186
        {
1187
                if (count == 0)
4✔
1188
                        return;
4✔
1189
                if (batchId is null || !_batches.TryGetValue(batchId, out var batch))
4✔
NEW
1190
                        throw new InvalidOperationException("The current job's batch was not found.");
×
1191
                if (batch.TotalJobs > int.MaxValue - count || batch.PendingCount > int.MaxValue - count)
4✔
NEW
1192
                        throw new InvalidOperationException($"Batch '{batchId}' member count overflow.");
×
1193

1194
                _batches[batchId] = batch with
4✔
1195
                {
4✔
1196
                        TotalJobs = batch.TotalJobs + count,
4✔
1197
                        PendingCount = batch.PendingCount + count,
4✔
1198
                };
4✔
1199
        }
4✔
1200

1201
        private void MarkBatchStarted(string? batchId, DateTimeOffset startedAt)
1202
        {
1203
                if (batchId is not null && _batches.TryGetValue(batchId, out var batch) && batch.StartedAt is null)
4✔
1204
                        _batches[batchId] = batch with { StartedAt = startedAt };
4✔
1205
        }
4✔
1206

1207
        private static bool IsTerminal(JobState state) =>
1208
                state is JobState.Succeeded or JobState.Failed or JobState.Cancelled;
4✔
1209

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

1212
        private static BatchStatus ToStatus(JobBatchRecord batch) => new(
4✔
1213
                batch.Id,
4✔
1214
                batch.State,
4✔
1215
                batch.TotalJobs,
4✔
1216
                batch.SucceededCount,
4✔
1217
                batch.FailedCount,
4✔
1218
                batch.CancelledCount,
4✔
1219
                batch.PendingCount,
4✔
1220
                batch.CreatedAt,
4✔
1221
                batch.StartedAt,
4✔
1222
                batch.CompletedAt,
4✔
1223
                batch.TotalJobs == 0 ? 0 : (double)(batch.TotalJobs - batch.PendingCount) / batch.TotalJobs
4✔
1224
        );
4✔
1225

1226
        private static BatchGraphEdge ToGraphEdge(JobContinuationEdge edge) => new(
4✔
1227
                edge.ChildJobId,
4✔
1228
                edge.ParentJobId,
4✔
1229
                edge.ParentBatchId,
4✔
1230
                edge.Trigger
4✔
1231
        );
4✔
1232

1233
        private void RemoveEdgesForJobs(
1234
                HashSet<string> jobIds,
1235
                HashSet<string>? batchIds = null
1236
        )
1237
        {
1238
                if (jobIds.Count == 0 && (batchIds is null || batchIds.Count == 0))
4✔
1239
                        return;
4✔
1240

1241
                var batches = batchIds ?? [];
4✔
1242
                for (var index = _edges.Count - 1; index >= 0; index--)
4✔
1243
                {
1244
                        var edge = _edges[index];
4✔
1245
                        if (!jobIds.Contains(edge.ChildJobId) &&
4✔
1246
                                (edge.ParentJobId is null || !jobIds.Contains(edge.ParentJobId)) &&
4✔
1247
                                (edge.ParentBatchId is null || !batches.Contains(edge.ParentBatchId)))
4✔
1248
                        {
1249
                                continue;
1250
                        }
1251

1252
                        _edges.RemoveAt(index);
4✔
1253
                        _ = _settledEdges.Remove(edge);
4✔
1254
                }
1255
        }
4✔
1256

1257
        private JobRecord GetOwnedActive(string jobId, string workerId)
1258
        {
1259
                if (!_jobs.TryGetValue(jobId, out var job) || job.State != JobState.Active || job.WorkerId != workerId)
4✔
1260
                        throw new InvalidOperationException($"Worker '{workerId}' does not own active job '{jobId}'.");
×
1261
                return job;
4✔
1262
        }
1263

1264
        private ValueTask SetRecurringPaused(string name, bool isPaused, CancellationToken cancellationToken)
1265
        {
1266
                cancellationToken.ThrowIfCancellationRequested();
×
1267
                lock (_gate)
1268
                {
1269
                        if (!_recurring.TryGetValue(name, out var schedule))
×
1270
                                throw new KeyNotFoundException($"Recurring schedule '{name}' was not found.");
×
1271

1272
                        _recurring[name] = schedule with { IsPaused = isPaused };
×
1273
                }
×
1274

1275
                return ValueTask.CompletedTask;
×
1276
        }
1277
}
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