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

ImmediatePlatform / Immediate.Jobs / 30311105936

27 Jul 2026 10:32PM UTC coverage: 70.551%. First build
30311105936

Pull #39

github

web-flow
Merge 77909202c into 9f17d3070
Pull Request #39: General Polish

4 of 20 new or added lines in 7 files covered. (20.0%)

5170 of 7328 relevant lines covered (70.55%)

2.31 hits per line

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

85.78
/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) :
4✔
9
        IRecurringJobStorage,
10
        IJobGraphStorage,
11
        IJobStorageReplica
12
{
13
        private readonly Lock _gate = new();
4✔
14
        private readonly Dictionary<string, JobRecord> _jobs = new(StringComparer.Ordinal);
4✔
15
        private readonly Dictionary<string, JobBatchRecord> _batches = new(StringComparer.Ordinal);
4✔
16
        private readonly List<JobContinuationEdge> _edges = [];
4✔
17
        private readonly HashSet<JobContinuationEdge> _settledEdges = [];
4✔
18
        private readonly Dictionary<string, RecurringJobSchedule> _recurring = new(StringComparer.Ordinal);
4✔
19
        private readonly Dictionary<string, JobServerSnapshot> _servers = new(StringComparer.Ordinal);
4✔
20
        private readonly HashSet<string> _recurringKeys = new(StringComparer.Ordinal);
4✔
21
        private readonly Dictionary<(string QueueName, string GroupId), long> _fairQueueLastServed = [];
4✔
22

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

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

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

44
                return ValueTask.CompletedTask;
4✔
45
        }
46

47
        /// <inheritdoc />
48
        public ValueTask EnqueueContinuationAsync(
49
                JobRecord job,
50
                IReadOnlyList<JobContinuationEdge> edges,
51
                CancellationToken cancellationToken = default
52
        )
53
        {
54
                ArgumentNullException.ThrowIfNull(job);
4✔
55
                ArgumentNullException.ThrowIfNull(edges);
4✔
56
                cancellationToken.ThrowIfCancellationRequested();
4✔
57
                lock (_gate)
58
                {
59
                        ValidateNewJob(job);
4✔
60
                        ValidateEdges([job], edges, batchId: null);
4✔
61

62
                        var restoreExistingState = HasTerminalParent(edges) &&
4✔
63
                                (job.State != JobState.AwaitingContinuation || job.RemainingDependencies < edges.Count);
4✔
64
                        _jobs.Add(job.Id, restoreExistingState ? job : NormalizeWaitingJob(job, edges.Count));
4✔
65
                        _edges.AddRange(edges);
4✔
66
                        if (restoreExistingState)
4✔
67
                                MarkTerminalParentEdgesSettled(edges);
×
68
                        else
69
                                EvaluateAlreadyTerminalParents(edges);
4✔
70
                }
4✔
71

72
                return ValueTask.CompletedTask;
4✔
73
        }
74

75
        /// <inheritdoc />
76
        public ValueTask EnqueueBatchAsync(
77
                JobBatchRecord batch,
78
                IReadOnlyList<JobRecord> jobs,
79
                IReadOnlyList<JobContinuationEdge> edges,
80
                CancellationToken cancellationToken = default
81
        )
82
        {
83
                ArgumentNullException.ThrowIfNull(batch);
4✔
84
                ArgumentNullException.ThrowIfNull(jobs);
4✔
85
                ArgumentNullException.ThrowIfNull(edges);
4✔
86
                cancellationToken.ThrowIfCancellationRequested();
4✔
87
                lock (_gate)
88
                {
89
                        ValidateBatch(batch, jobs, edges);
4✔
90

91
                        var restoreExistingState = IsRecoveredBatch(batch, jobs, edges);
4✔
92
                        _batches.Add(batch.Id, batch);
4✔
93
                        var incomingCounts = edges
4✔
94
                                .GroupBy(static edge => edge.ChildJobId, StringComparer.Ordinal)
4✔
95
                                .ToDictionary(static group => group.Key, static group => group.Count(), StringComparer.Ordinal);
4✔
96
                        foreach (var job in jobs)
4✔
97
                        {
98
                                _jobs.Add(
4✔
99
                                        job.Id,
4✔
100
                                        restoreExistingState
4✔
101
                                                ? job
4✔
102
                                                : incomingCounts.TryGetValue(job.Id, out var dependencyCount)
4✔
103
                                                ? NormalizeWaitingJob(job, dependencyCount)
4✔
104
                                                : job with { BatchId = batch.Id, RemainingDependencies = 0, FailedDependencies = 0 }
4✔
105
                                );
4✔
106
                        }
107

108
                        _edges.AddRange(edges);
4✔
109
                        if (restoreExistingState)
4✔
110
                                MarkTerminalParentEdgesSettled(edges);
4✔
111
                        else
112
                                EvaluateAlreadyTerminalParents(edges);
4✔
113
                }
4✔
114

115
                return ValueTask.CompletedTask;
4✔
116
        }
117

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

142
                        if (request.FairQueues is null)
4✔
143
                                return AcquireInExistingOrder(request, now);
4✔
144

145
                        var acquired = new List<JobRecord>(request.BatchSize);
4✔
146
                        foreach (var queue in request.Queues)
4✔
147
                        {
148
                                var queueCapacity = Math.Min(queue.Capacity, request.BatchSize - acquired.Count);
4✔
149
                                if (queueCapacity <= 0)
4✔
150
                                        continue;
151

152
                                var jobCapacities = queue.JobCapacities.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal);
4✔
153
                                if (!HasEligibleGroupedJob(queue.QueueName, jobCapacities, now))
4✔
154
                                {
155
                                        AcquireQueueInExistingOrder(
4✔
156
                                                request,
4✔
157
                                                queue.QueueName,
4✔
158
                                                jobCapacities,
4✔
159
                                                queueCapacity,
4✔
160
                                                now,
4✔
161
                                                acquired
4✔
162
                                        );
4✔
163
                                        continue;
4✔
164
                                }
165

166
                                AcquireQueueFairly(
4✔
167
                                        request,
4✔
168
                                        queue.QueueName,
4✔
169
                                        jobCapacities,
4✔
170
                                        queueCapacity,
4✔
171
                                        now,
4✔
172
                                        acquired
4✔
173
                                );
4✔
174
                        }
175

176
                        return acquired;
4✔
177
                }
178
        }
4✔
179

180
        private List<JobRecord> AcquireInExistingOrder(JobAcquisitionRequest request, DateTimeOffset now)
181
        {
182
                var acquired = new List<JobRecord>(request.BatchSize);
4✔
183
                foreach (var queue in request.Queues)
4✔
184
                {
185
                        var queueCapacity = Math.Min(queue.Capacity, request.BatchSize - acquired.Count);
4✔
186
                        if (queueCapacity <= 0)
4✔
187
                                continue;
188

189
                        var jobCapacities = queue.JobCapacities.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal);
4✔
190
                        AcquireQueueInExistingOrder(
4✔
191
                                request,
4✔
192
                                queue.QueueName,
4✔
193
                                jobCapacities,
4✔
194
                                queueCapacity,
4✔
195
                                now,
4✔
196
                                acquired
4✔
197
                        );
4✔
198
                }
199

200
                return acquired;
4✔
201
        }
202

203
        private void AcquireQueueInExistingOrder(
204
                JobAcquisitionRequest request,
205
                string queueName,
206
                Dictionary<string, int> jobCapacities,
207
                int queueCapacity,
208
                DateTimeOffset now,
209
                List<JobRecord> acquired
210
        )
211
        {
212
                foreach (var candidate in _jobs.Values
4✔
213
                        .Where(job => job.QueueName == queueName &&
4✔
214
                                jobCapacities.ContainsKey(job.JobName) &&
4✔
215
                                job.State is JobState.Pending or JobState.Scheduled &&
4✔
216
                                job.DueAt <= now)
4✔
217
                        .OrderBy(job => job.DueAt)
4✔
218
                        .ThenBy(job => job.CreatedAt)
4✔
219
                        .ThenBy(job => job.Id))
4✔
220
                {
221
                        if (queueCapacity == 0)
4✔
222
                                break;
4✔
223
                        if (jobCapacities[candidate.JobName] <= 0)
4✔
224
                                continue;
225

226
                        acquired.Add(Acquire(candidate, request, now));
4✔
227
                        jobCapacities[candidate.JobName]--;
4✔
228
                        queueCapacity--;
4✔
229
                }
230
        }
4✔
231

232
        private bool HasEligibleGroupedJob(
233
                string queueName,
234
                Dictionary<string, int> jobCapacities,
235
                DateTimeOffset now
236
        ) =>
237
                _jobs.Values.Any(job => job.QueueName == queueName &&
4✔
238
                        job.GroupId is not null &&
4✔
239
                        jobCapacities.TryGetValue(job.JobName, out var capacity) &&
4✔
240
                        capacity > 0 &&
4✔
241
                        job.State is JobState.Pending or JobState.Scheduled &&
4✔
242
                        job.DueAt <= now);
4✔
243

244
        private void AcquireQueueFairly(
245
                JobAcquisitionRequest request,
246
                string queueName,
247
                Dictionary<string, int> jobCapacities,
248
                int queueCapacity,
249
                DateTimeOffset now,
250
                List<JobRecord> acquired
251
        )
252
        {
253
                var policy = request.FairQueues!;
4✔
254
                while (queueCapacity > 0)
4✔
255
                {
256
                        var eligible = _jobs.Values
4✔
257
                                .Where(job => job.QueueName == queueName &&
4✔
258
                                        jobCapacities.TryGetValue(job.JobName, out var capacity) &&
4✔
259
                                        capacity > 0 &&
4✔
260
                                        job.State is JobState.Pending or JobState.Scheduled &&
4✔
261
                                        job.DueAt <= now)
4✔
262
                                .ToArray();
4✔
263
                        if (eligible.Length == 0)
4✔
264
                                break;
265

266
                        var activeCounts = _jobs.Values
4✔
267
                                .Where(job => job.QueueName == queueName &&
4✔
268
                                        job.GroupId is not null &&
4✔
269
                                        job.State == JobState.Active &&
4✔
270
                                        job.LeaseExpiresAt > now)
4✔
271
                                .GroupBy(static job => job.GroupId!, StringComparer.Ordinal)
4✔
272
                                .ToDictionary(static group => group.Key, static group => group.Count(), StringComparer.Ordinal);
4✔
273
                        var totalActive = _jobs.Values.Count(job => job.QueueName == queueName &&
4✔
274
                                job.State == JobState.Active &&
4✔
275
                                job.LeaseExpiresAt > now);
4✔
276
                        var groupedHeads = eligible
4✔
277
                                .Where(static job => job.GroupId is not null)
4✔
278
                                .GroupBy(static job => job.GroupId!, StringComparer.Ordinal)
4✔
279
                                .Select(static group => group
4✔
280
                                        .OrderBy(static job => job.DueAt)
4✔
281
                                        .ThenBy(static job => job.CreatedAt)
4✔
282
                                        .ThenBy(static job => job.Id, StringComparer.Ordinal)
4✔
283
                                        .First());
4✔
284
                        var ungroupedHead = eligible
4✔
285
                                .Where(static job => job.GroupId is null)
4✔
286
                                .OrderBy(static job => job.DueAt)
4✔
287
                                .ThenBy(static job => job.CreatedAt)
4✔
288
                                .ThenBy(static job => job.Id, StringComparer.Ordinal)
4✔
289
                                .Take(1);
4✔
290
                        var candidates = groupedHeads.Concat(ungroupedHead);
4✔
291

292
                        var candidate = candidates
4✔
293
                                .OrderBy(job => IsNoisy(job.GroupId, activeCounts, totalActive, policy))
4✔
294
                                .ThenBy(job => GetNoisyInflight(job.GroupId, activeCounts, totalActive, policy))
4✔
295
                                .ThenBy(job => policy.GroupRoundRobin ? GetLastServed(queueName, job.GroupId) : 0)
4✔
296
                                .ThenBy(static job => job.DueAt)
4✔
297
                                .ThenBy(static job => job.CreatedAt)
4✔
298
                                .ThenBy(static job => job.Id, StringComparer.Ordinal)
4✔
299
                                .First();
4✔
300

301
                        var job = Acquire(candidate, request, now);
4✔
302
                        acquired.Add(job);
4✔
303
                        jobCapacities[job.JobName]--;
4✔
304
                        queueCapacity--;
4✔
305
                        if (policy.GroupRoundRobin && job.GroupId is { } groupId)
4✔
306
                                _fairQueueLastServed[(queueName, groupId)] = GetNextSequence(queueName);
4✔
307
                }
308
        }
4✔
309

310
        private static bool IsNoisy(
311
                string? groupId,
312
                Dictionary<string, int> activeCounts,
313
                int totalActive,
314
                FairQueuePolicy policy
315
        ) =>
316
                groupId is not null &&
4✔
317
                totalActive > 0 &&
4✔
318
                activeCounts.TryGetValue(groupId, out var groupActive) &&
4✔
319
                groupActive >= policy.MinInflightForNoisy &&
4✔
320
                (double)groupActive / totalActive > policy.ConcurrencyShareThreshold;
4✔
321

322
        private static int GetNoisyInflight(
323
                string? groupId,
324
                Dictionary<string, int> activeCounts,
325
                int totalActive,
326
                FairQueuePolicy policy
327
        ) =>
328
                IsNoisy(groupId, activeCounts, totalActive, policy) ? activeCounts[groupId!] : 0;
4✔
329

330
        private long GetLastServed(string queueName, string? groupId) =>
331
                groupId is not null && _fairQueueLastServed.TryGetValue((queueName, groupId), out var sequence)
4✔
332
                        ? sequence
4✔
333
                        : 0;
4✔
334

335
        private long GetNextSequence(string queueName) =>
336
                _fairQueueLastServed
4✔
337
                        .Where(pair => pair.Key.QueueName == queueName)
4✔
338
                        .Select(static pair => pair.Value)
4✔
339
                        .DefaultIfEmpty()
4✔
340
                        .Max() + 1;
4✔
341

342
        private JobRecord Acquire(JobRecord candidate, JobAcquisitionRequest request, DateTimeOffset now)
343
        {
344
                var job = candidate with
4✔
345
                {
4✔
346
                        State = JobState.Active,
4✔
347
                        Attempt = candidate.Attempt + 1,
4✔
348
                        WorkerId = request.WorkerId,
4✔
349
                        LeaseExpiresAt = now + request.Lease,
4✔
350
                        ExecutionTraceId = null,
4✔
351
                        ExecutionSpanId = null,
4✔
352
                        ExecutionStartedAt = null,
4✔
353
                };
4✔
354
                _jobs[job.Id] = job;
4✔
355
                MarkBatchStarted(job.BatchId, now);
4✔
356
                return job;
4✔
357
        }
358

359
        /// <inheritdoc />
360
        public async ValueTask<IReadOnlyList<JobRecord>> AcquireJobsAsync(
361
                IReadOnlyCollection<string> jobIds,
362
                string workerId,
363
                TimeSpan lease,
364
                CancellationToken cancellationToken = default
365
        )
366
        {
367
                ArgumentNullException.ThrowIfNull(jobIds);
4✔
368
                ArgumentException.ThrowIfNullOrWhiteSpace(workerId);
4✔
369
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lease, TimeSpan.Zero);
4✔
370
                cancellationToken.ThrowIfCancellationRequested();
4✔
371
                var now = timeProvider.GetUtcNow();
4✔
372
                lock (_gate)
373
                {
374
                        var acquired = new List<JobRecord>(jobIds.Count);
4✔
375
                        foreach (var id in jobIds)
4✔
376
                        {
377
                                if (!_jobs.TryGetValue(id, out var job))
4✔
378
                                        continue;
379
                                if (job.State == JobState.Active && job.LeaseExpiresAt <= now)
4✔
380
                                {
381
                                        job = job with { State = JobState.Pending, WorkerId = null, LeaseExpiresAt = null };
4✔
382
                                        _jobs[id] = job;
4✔
383
                                }
384

385
                                if (job.State is not (JobState.Pending or JobState.Scheduled) || job.DueAt > now)
4✔
386
                                        continue;
387

388
                                job = job with
4✔
389
                                {
4✔
390
                                        State = JobState.Active,
4✔
391
                                        Attempt = job.Attempt + 1,
4✔
392
                                        WorkerId = workerId,
4✔
393
                                        LeaseExpiresAt = now + lease,
4✔
394
                                        ExecutionTraceId = null,
4✔
395
                                        ExecutionSpanId = null,
4✔
396
                                        ExecutionStartedAt = null,
4✔
397
                                };
4✔
398
                                _jobs[id] = job;
4✔
399
                                MarkBatchStarted(job.BatchId, now);
4✔
400
                                acquired.Add(job);
4✔
401
                        }
402

403
                        return acquired;
4✔
404
                }
405
        }
4✔
406

407
        /// <inheritdoc />
408
        public ValueTask SetExecutionTelemetryAsync(
409
                string jobId,
410
                string workerId,
411
                string? traceId,
412
                string? spanId,
413
                DateTimeOffset startedAt,
414
                CancellationToken cancellationToken = default
415
        )
416
        {
417
                cancellationToken.ThrowIfCancellationRequested();
4✔
418
                lock (_gate)
419
                {
420
                        var job = GetOwnedActive(jobId, workerId);
4✔
421
                        _jobs[jobId] = job with
4✔
422
                        {
4✔
423
                                ExecutionTraceId = traceId,
4✔
424
                                ExecutionSpanId = spanId,
4✔
425
                                ExecutionStartedAt = startedAt,
4✔
426
                        };
4✔
427
                }
4✔
428

429
                return ValueTask.CompletedTask;
4✔
430
        }
431

432
        /// <inheritdoc />
433
        public ValueTask RenewLeaseAsync(string jobId, string workerId, TimeSpan lease, CancellationToken cancellationToken = default)
434
        {
435
                cancellationToken.ThrowIfCancellationRequested();
×
436
                lock (_gate)
437
                {
438
                        var job = GetOwnedActive(jobId, workerId);
×
439
                        _jobs[jobId] = job with { LeaseExpiresAt = timeProvider.GetUtcNow() + lease };
×
440
                }
×
441

442
                return ValueTask.CompletedTask;
×
443
        }
444

445
        /// <inheritdoc />
446
        public ValueTask CompleteAsync(string jobId, string workerId, CancellationToken cancellationToken = default)
447
                => CompleteWithContinuationsAsync(jobId, workerId, [], cancellationToken);
4✔
448

449
        /// <inheritdoc />
450
        public ValueTask CompleteWithContinuationsAsync(
451
                string jobId,
452
                string workerId,
453
                IReadOnlyList<JobContinuationAddition> additions,
454
                CancellationToken cancellationToken = default
455
        )
456
        {
457
                ArgumentNullException.ThrowIfNull(additions);
4✔
458
                cancellationToken.ThrowIfCancellationRequested();
4✔
459
                lock (_gate)
460
                {
461
                        var current = GetOwnedActive(jobId, workerId);
4✔
462
                        var existingWaiters = GetUnsettledWaiters(jobId);
4✔
463
                        var newJobIds = new HashSet<string>(StringComparer.Ordinal);
4✔
464
                        var dependencyEdges = new List<JobContinuationEdge>(additions.Count);
4✔
465
                        var trackedAdditions = 0;
4✔
466
                        foreach (var addition in additions)
4✔
467
                        {
468
                                ArgumentNullException.ThrowIfNull(addition);
4✔
469
                                ArgumentNullException.ThrowIfNull(addition.Job);
4✔
470
                                ValidateNewJob(addition.Job);
4✔
471
                                if (!newJobIds.Add(addition.Job.Id))
4✔
472
                                        throw new ImmediateJobException($"Job '{addition.Job.Id}' occurs more than once in the completion buffer.");
×
473
                                if (addition.Job.State is not (JobState.Pending or JobState.Scheduled))
4✔
474
                                        throw new ImmediateJobException($"Dynamic continuation '{addition.Job.Id}' has invalid state '{addition.Job.State}'.");
×
475
                                if (!Enum.IsDefined(addition.Trigger))
4✔
476
                                        throw new ArgumentOutOfRangeException(nameof(additions), "Unknown continuation trigger.");
×
477

478
                                if (addition.Options == ContinuationOptions.Detached)
4✔
479
                                {
480
                                        if (addition.Job.BatchId is not null)
×
481
                                                throw new ImmediateJobException("A detached continuation cannot belong to a batch.");
×
482
                                }
483
                                else if (addition.Options is ContinuationOptions.BesideContinuations or ContinuationOptions.BeforeContinuations)
4✔
484
                                {
485
                                        if (current.BatchId is null || addition.Job.BatchId != current.BatchId)
4✔
486
                                                throw new ImmediateJobException("A batch-tracked continuation must belong to the current job's batch.");
×
487
                                        trackedAdditions++;
4✔
488
                                }
489
                                else
490
                                {
491
                                        throw new ArgumentOutOfRangeException(nameof(additions), "Unknown continuation option.");
×
492
                                }
493

494
                                dependencyEdges.Add(new()
4✔
495
                                {
4✔
496
                                        ChildJobId = addition.Job.Id,
4✔
497
                                        ParentJobId = jobId,
4✔
498
                                        Trigger = addition.Trigger,
4✔
499
                                });
4✔
500
                        }
501

502
                        if (dependencyEdges.Count != 0)
4✔
503
                                ValidateEdges([.. additions.Select(static addition => addition.Job)], dependencyEdges, current.BatchId);
4✔
504
                        ValidateSplice(existingWaiters, additions.Count(static addition => addition.Options == ContinuationOptions.BeforeContinuations));
4✔
505

506
                        IncrementBatchMembers(current.BatchId, trackedAdditions);
4✔
507
                        foreach (var addition in additions)
4✔
508
                        {
509
                                _jobs.Add(addition.Job.Id, NormalizeWaitingJob(addition.Job, dependencyCount: 1));
4✔
510
                                if (addition.Options == ContinuationOptions.BeforeContinuations)
4✔
511
                                        SpliceBeforeWaiters(addition.Job.Id, existingWaiters);
4✔
512
                        }
513

514
                        _edges.AddRange(dependencyEdges);
4✔
515
                        TransitionToTerminal(jobId, JobState.Succeeded, error: null, timeProvider.GetUtcNow());
4✔
516
                }
4✔
517

518
                return ValueTask.CompletedTask;
4✔
519
        }
520

521
        /// <inheritdoc />
522
        public ValueTask AddBatchJobAsync(
523
                string currentJobId,
524
                JobRecord job,
525
                ContinuationOptions options,
526
                CancellationToken cancellationToken = default
527
        )
528
        {
529
                ArgumentNullException.ThrowIfNull(job);
×
530
                cancellationToken.ThrowIfCancellationRequested();
×
531
                lock (_gate)
532
                {
533
                        if (!_jobs.TryGetValue(currentJobId, out var current) || current.State != JobState.Active)
×
534
                                throw new ImmediateJobException($"Job '{currentJobId}' is not currently active.");
×
535
                        if (current.BatchId is null || !_batches.ContainsKey(current.BatchId))
×
536
                                throw new ImmediateJobException("The current job does not belong to a batch.");
×
537
                        if (options == ContinuationOptions.Detached)
×
NEW
538
                                throw new ImmediateJobException("AddToBatchAsync(JobDetails, ...) cannot create detached work.");
×
539
                        if (options is not (ContinuationOptions.BesideContinuations or ContinuationOptions.BeforeContinuations))
×
540
                                throw new ArgumentOutOfRangeException(nameof(options));
×
541
                        ValidateNewJob(job);
×
542
                        if (job.BatchId != current.BatchId)
×
543
                                throw new ImmediateJobException("The new job must belong to the current job's batch.");
×
544
                        if (job.State is JobState.Active or JobState.AwaitingContinuation || IsTerminal(job.State))
×
545
                                throw new ImmediateJobException($"Concurrent batch member '{job.Id}' has invalid state '{job.State}'.");
×
546

547
                        var existingWaiters = options == ContinuationOptions.BeforeContinuations
×
548
                                ? GetUnsettledWaiters(currentJobId)
×
549
                                : [];
×
550
                        ValidateSplice(existingWaiters, options == ContinuationOptions.BeforeContinuations ? 1 : 0);
×
551
                        IncrementBatchMembers(current.BatchId, 1);
×
552
                        _jobs.Add(job.Id, job with { RemainingDependencies = 0 });
×
553
                        if (options == ContinuationOptions.BeforeContinuations)
×
554
                                SpliceBeforeWaiters(job.Id, existingWaiters);
×
555
                }
×
556

557
                return ValueTask.CompletedTask;
×
558
        }
559

560
        /// <inheritdoc />
561
        public ValueTask FailAsync(
562
                string jobId,
563
                string workerId,
564
                string error,
565
                DateTimeOffset? nextRetryAt,
566
                CancellationToken cancellationToken = default
567
        )
568
        {
569
                cancellationToken.ThrowIfCancellationRequested();
4✔
570
                lock (_gate)
571
                {
572
                        var job = GetOwnedActive(jobId, workerId);
4✔
573
                        if (nextRetryAt.HasValue)
4✔
574
                        {
575
                                var now = timeProvider.GetUtcNow();
4✔
576
                                _jobs[jobId] = job with
4✔
577
                                {
4✔
578
                                        State = nextRetryAt <= now ? JobState.Pending : JobState.Scheduled,
4✔
579
                                        DueAt = nextRetryAt.Value,
4✔
580
                                        WorkerId = null,
4✔
581
                                        LeaseExpiresAt = null,
4✔
582
                                        LastError = error,
4✔
583
                                        CompletedAt = null,
4✔
584
                                };
4✔
585
                        }
586
                        else
587
                        {
588
                                TransitionToTerminal(jobId, JobState.Failed, error, timeProvider.GetUtcNow());
4✔
589
                        }
590
                }
4✔
591

592
                return ValueTask.CompletedTask;
4✔
593
        }
594

595
        /// <inheritdoc />
596
        public ValueTask UpsertRecurringAsync(RecurringJobSchedule schedule, CancellationToken cancellationToken = default)
597
        {
598
                ArgumentNullException.ThrowIfNull(schedule);
4✔
599
                cancellationToken.ThrowIfCancellationRequested();
4✔
600
                lock (_gate)
601
                {
602
                        if (_recurring.TryGetValue(schedule.Name, out var current))
4✔
603
                        {
604
                                if (current.IsCodeDefined && !schedule.IsCodeDefined)
4✔
605
                                        throw new ImmediateJobException("Code-defined recurring schedules cannot be replaced by dynamic schedules.");
4✔
606

607
                                schedule = schedule with { IsPaused = current.IsPaused, LastRunAt = current.LastRunAt };
4✔
608
                        }
609

610
                        _recurring[schedule.Name] = schedule;
4✔
611
                }
4✔
612

613
                return ValueTask.CompletedTask;
4✔
614
        }
615

616
        /// <inheritdoc />
617
        public ValueTask RemoveObsoleteCodeDefinedRecurringAsync(
618
                IReadOnlyCollection<string> activeScheduleNames,
619
                CancellationToken cancellationToken = default
620
        )
621
        {
622
                ArgumentNullException.ThrowIfNull(activeScheduleNames);
4✔
623
                cancellationToken.ThrowIfCancellationRequested();
4✔
624
                var activeNames = activeScheduleNames.ToHashSet(StringComparer.Ordinal);
4✔
625
                lock (_gate)
626
                {
627
                        var obsoleteNames = _recurring
4✔
628
                                .Where(schedule => schedule.Value.IsCodeDefined && !activeNames.Contains(schedule.Key))
4✔
629
                                .Select(static schedule => schedule.Key)
4✔
630
                                .ToArray();
4✔
631
                        foreach (var name in obsoleteNames)
4✔
632
                                _ = _recurring.Remove(name);
4✔
633
                }
4✔
634

635
                return ValueTask.CompletedTask;
4✔
636
        }
637

638
        /// <inheritdoc />
639
        public ValueTask RemoveRecurringAsync(string name, CancellationToken cancellationToken = default)
640
        {
641
                cancellationToken.ThrowIfCancellationRequested();
×
642
                lock (_gate)
643
                {
644
                        if (_recurring.TryGetValue(name, out var schedule) && schedule.IsCodeDefined)
×
645
                                throw new ImmediateJobException("Code-defined recurring schedules cannot be deleted.");
×
646

647
                        _ = _recurring.Remove(name);
×
648
                }
×
649

650
                return ValueTask.CompletedTask;
×
651
        }
652

653
        /// <inheritdoc />
654
        public ValueTask PauseRecurringAsync(string name, CancellationToken cancellationToken = default) =>
655
                SetRecurringPausedAsync(name, true, cancellationToken);
×
656

657
        /// <inheritdoc />
658
        public ValueTask ResumeRecurringAsync(string name, CancellationToken cancellationToken = default) =>
659
                SetRecurringPausedAsync(name, false, cancellationToken);
×
660

661
        /// <inheritdoc />
662
        public async ValueTask<IReadOnlyList<RecurringJobSchedule>> GetDueRecurringAsync(
663
                DateTimeOffset now,
664
                int batchSize,
665
                CancellationToken cancellationToken = default
666
        )
667
        {
668
                cancellationToken.ThrowIfCancellationRequested();
4✔
669
                lock (_gate)
670
                {
671
                        return
4✔
672
                        [
4✔
673
                                .. _recurring.Values.Where(x => !x.IsPaused && x.NextRunAt <= now).OrderBy(x => x.NextRunAt).Take(batchSize),
4✔
674
                        ];
4✔
675
                }
676
        }
4✔
677

678
        /// <inheritdoc />
679
        public async ValueTask<bool> MaterializeRecurringAsync(
680
                RecurringJobSchedule schedule,
681
                JobRecord job,
682
                DateTimeOffset nextRunAt,
683
                CancellationToken cancellationToken = default
684
        )
685
        {
686
                ArgumentNullException.ThrowIfNull(schedule);
4✔
687
                ArgumentNullException.ThrowIfNull(job);
4✔
688
                cancellationToken.ThrowIfCancellationRequested();
4✔
689
                lock (_gate)
690
                {
691
                        if (!_recurring.TryGetValue(schedule.Name, out var current) || current.NextRunAt != schedule.NextRunAt)
4✔
692
                                return false;
×
693

694
                        var inserted = job.RecurringKey is null || _recurringKeys.Add(job.RecurringKey);
4✔
695
                        if (inserted)
4✔
696
                                _jobs[job.Id] = job;
4✔
697
                        _recurring[schedule.Name] = current with { LastRunAt = schedule.NextRunAt, NextRunAt = nextRunAt };
4✔
698
                        return inserted;
4✔
699
                }
700
        }
4✔
701

702
        /// <inheritdoc />
703
        public async ValueTask<JobMonitoringSnapshot> GetMonitoringSnapshotAsync(CancellationToken cancellationToken = default)
704
        {
705
                cancellationToken.ThrowIfCancellationRequested();
4✔
706
                lock (_gate)
707
                {
708
                        var counts = Enum.GetValues<JobState>().ToDictionary(state => state, state => _jobs.Values.LongCount(x => x.State == state));
4✔
709
                        var cutoff = timeProvider.GetUtcNow() - TimeSpan.FromMinutes(2);
4✔
710
                        IReadOnlyList<JobServerSnapshot> servers = [.. _servers.Values.Where(x => x.LastHeartbeat >= cutoff)];
4✔
711
                        return new(timeProvider.GetUtcNow(), counts, [.. _recurring.Values], servers)
4✔
712
                        {
4✔
713
                                Capabilities = this.GetCapabilities(),
4✔
714
                        };
4✔
715
                }
716
        }
4✔
717

718
        /// <inheritdoc />
719
        public async ValueTask<IReadOnlyList<JobRecord>> QueryJobsAsync(JobQuery query, CancellationToken cancellationToken = default)
720
        {
721
                ArgumentNullException.ThrowIfNull(query);
4✔
722
                cancellationToken.ThrowIfCancellationRequested();
4✔
723
                lock (_gate)
724
                {
725
                        var jobs = _jobs.Values.AsEnumerable();
4✔
726
                        if (query.Id is { } id)
4✔
727
                                jobs = jobs.Where(x => x.Id == id);
4✔
728
                        if (query.State is { } state)
4✔
729
                                jobs = jobs.Where(x => x.State == state);
4✔
730
                        if (!string.IsNullOrWhiteSpace(query.QueueName))
4✔
731
                                jobs = jobs.Where(x => x.QueueName == query.QueueName);
4✔
732

733
                        if (!string.IsNullOrWhiteSpace(query.Search))
4✔
734
                                jobs = jobs.Where(x => x.JobName.Contains(query.Search, StringComparison.OrdinalIgnoreCase));
3✔
735

736
                        return
4✔
737
                        [
4✔
738
                                .. jobs.OrderByDescending(x => x.CreatedAt).Skip(Math.Max(0, query.Skip)).Take(Math.Clamp(query.Take, 1, 1000)),
4✔
739
                        ];
4✔
740
                }
741
        }
4✔
742

743
        /// <inheritdoc />
744
        public async ValueTask<BatchStatus?> GetBatchStatusAsync(
745
                string batchId,
746
                CancellationToken cancellationToken = default
747
        )
748
        {
749
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
750
                cancellationToken.ThrowIfCancellationRequested();
4✔
751
                lock (_gate)
752
                {
753
                        if (!_batches.TryGetValue(batchId, out var batch))
4✔
754
                                return null;
4✔
755

756
                        return ToStatus(batch);
4✔
757
                }
758
        }
4✔
759

760
        /// <inheritdoc />
761
        public async ValueTask<IReadOnlyList<BatchStatus>> QueryBatchesAsync(
762
                JobBatchQuery query,
763
                CancellationToken cancellationToken = default
764
        )
765
        {
766
                ArgumentNullException.ThrowIfNull(query);
4✔
767
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
4✔
768
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
4✔
769
                cancellationToken.ThrowIfCancellationRequested();
4✔
770
                lock (_gate)
771
                {
772
                        var batches = _batches.Values.AsEnumerable();
4✔
773
                        if (query.State is { } state)
4✔
774
                                batches = batches.Where(batch => batch.State == state);
×
775
                        return
4✔
776
                        [
4✔
777
                                .. batches.OrderByDescending(static batch => batch.CreatedAt)
4✔
778
                                        .ThenBy(static batch => batch.Id, StringComparer.Ordinal)
4✔
779
                                        .Skip(query.Skip)
4✔
780
                                        .Take(query.Take)
4✔
781
                                        .Select(ToStatus),
4✔
782
                        ];
4✔
783
                }
784
        }
4✔
785

786
        /// <inheritdoc />
787
        public async ValueTask<IReadOnlyList<BatchMemberStatus>> QueryBatchMembersAsync(
788
                string batchId,
789
                BatchMemberQuery query,
790
                CancellationToken cancellationToken = default
791
        )
792
        {
793
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
794
                ArgumentNullException.ThrowIfNull(query);
4✔
795
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
4✔
796
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
4✔
797
                cancellationToken.ThrowIfCancellationRequested();
4✔
798
                lock (_gate)
799
                {
800
                        if (!_batches.ContainsKey(batchId))
4✔
801
                                return [];
×
802

803
                        var members = _jobs.Values.Where(job => job.BatchId == batchId);
4✔
804
                        if (query.State is { } state)
4✔
805
                                members = members.Where(job => job.State == state);
4✔
806

807
                        return
4✔
808
                        [
4✔
809
                                .. members
4✔
810
                                        .OrderBy(job => job.CreatedAt)
4✔
811
                                        .ThenBy(job => job.Id, StringComparer.Ordinal)
4✔
812
                                        .Skip(query.Skip)
4✔
813
                                        .Take(query.Take)
4✔
814
                                        .Select(static job => new BatchMemberStatus(
4✔
815
                                                job.Id,
4✔
816
                                                job.JobName,
4✔
817
                                                job.QueueName,
4✔
818
                                                job.State,
4✔
819
                                                job.Attempt,
4✔
820
                                                job.CreatedAt,
4✔
821
                                                job.CompletedAt,
4✔
822
                                                job.LastError
4✔
823
                                        )),
4✔
824
                        ];
4✔
825
                }
826
        }
4✔
827

828
        /// <inheritdoc />
829
        public async ValueTask<BatchGraph?> GetBatchGraphAsync(
830
                string batchId,
831
                CancellationToken cancellationToken = default
832
        )
833
        {
834
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
835
                cancellationToken.ThrowIfCancellationRequested();
4✔
836
                lock (_gate)
837
                {
838
                        if (!_batches.ContainsKey(batchId))
4✔
839
                                return null;
×
840

841
                        var members = _jobs.Values
4✔
842
                                .Where(job => job.BatchId == batchId)
4✔
843
                                .OrderBy(job => job.CreatedAt)
4✔
844
                                .ThenBy(job => job.Id, StringComparer.Ordinal)
4✔
845
                                .ToArray();
4✔
846
                        var memberIds = members.Select(static job => job.Id).ToHashSet(StringComparer.Ordinal);
4✔
847
                        return new(
4✔
848
                                batchId,
4✔
849
                                [.. members.Select(static job => new BatchGraphNode(job.Id, job.JobName, job.State))],
4✔
850
                                [.. _edges.Where(edge => memberIds.Contains(edge.ChildJobId)).Select(ToGraphEdge)]
4✔
851
                        );
4✔
852
                }
853
        }
4✔
854

855
        /// <inheritdoc />
856
        public async ValueTask<JobStatus?> GetJobStatusAsync(
857
                string jobId,
858
                CancellationToken cancellationToken = default
859
        )
860
        {
861
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
4✔
862
                cancellationToken.ThrowIfCancellationRequested();
4✔
863
                lock (_gate)
864
                {
865
                        if (!_jobs.TryGetValue(jobId, out var job))
4✔
866
                                return null;
×
867

868
                        return new(
4✔
869
                                job.Id,
4✔
870
                                job.JobName,
4✔
871
                                job.QueueName,
4✔
872
                                job.State,
4✔
873
                                job.Attempt,
4✔
874
                                MaxAttempts: 0,
4✔
875
                                job.CreatedAt,
4✔
876
                                job.DueAt,
4✔
877
                                job.CompletedAt,
4✔
878
                                job.LastError,
4✔
879
                                job.BatchId,
4✔
880
                                [.. _edges.Where(edge => edge.ChildJobId == jobId).Select(ToGraphEdge)]
4✔
881
                        );
4✔
882
                }
883
        }
4✔
884

885
        /// <inheritdoc />
886
        public ValueTask CancelBatchAsync(string batchId, CancellationToken cancellationToken = default)
887
        {
888
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
889
                cancellationToken.ThrowIfCancellationRequested();
4✔
890
                lock (_gate)
891
                {
892
                        if (!_batches.TryGetValue(batchId, out var batch))
4✔
893
                                throw new KeyNotFoundException($"Batch '{batchId}' was not found.");
×
894
                        if (batch.State != BatchState.Executing)
4✔
895
                                throw new ImmediateJobException("Only an executing batch can be cancelled.");
×
896

897
                        foreach (var jobId in _jobs.Values
4✔
898
                                .Where(job => job.BatchId == batchId && !IsTerminal(job.State))
4✔
899
                                .Select(static job => job.Id)
4✔
900
                                .ToArray())
4✔
901
                        {
902
                                TransitionToTerminal(jobId, JobState.Cancelled, error: null, timeProvider.GetUtcNow());
4✔
903
                        }
904
                }
4✔
905

906
                return ValueTask.CompletedTask;
4✔
907
        }
908

909
        /// <inheritdoc />
910
        public ValueTask DeleteBatchAsync(string batchId, CancellationToken cancellationToken = default)
911
        {
912
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
913
                cancellationToken.ThrowIfCancellationRequested();
4✔
914
                lock (_gate)
915
                {
916
                        if (!_batches.TryGetValue(batchId, out var batch))
4✔
917
                                throw new KeyNotFoundException($"Batch '{batchId}' was not found.");
×
918
                        if (batch.State == BatchState.Executing)
4✔
919
                                throw new ImmediateJobException("Only a terminal batch can be deleted.");
×
920

921
                        var jobIds = _jobs.Values
4✔
922
                                .Where(job => job.BatchId == batchId)
4✔
923
                                .Select(static job => job.Id)
4✔
924
                                .ToHashSet(StringComparer.Ordinal);
4✔
925
                        foreach (var jobId in jobIds)
4✔
926
                                _ = _jobs.Remove(jobId);
4✔
927
                        _ = _batches.Remove(batchId);
4✔
928
                        RemoveEdgesForJobs(jobIds, [batchId]);
4✔
929
                }
4✔
930

931
                return ValueTask.CompletedTask;
4✔
932
        }
933

934
        /// <inheritdoc />
935
        public ValueTask RetryAsync(string jobId, CancellationToken cancellationToken = default)
936
        {
937
                cancellationToken.ThrowIfCancellationRequested();
×
938
                lock (_gate)
939
                {
940
                        if (!_jobs.TryGetValue(jobId, out var job) || job.State != JobState.Failed)
×
941
                                throw new ImmediateJobException("Only failed jobs can be retried.");
×
942

943
                        _jobs[jobId] = job with
×
944
                        {
×
945
                                State = JobState.Pending,
×
946
                                DueAt = timeProvider.GetUtcNow(),
×
947
                                CompletedAt = null,
×
948
                                LastError = null,
×
949
                        };
×
950
                        if (job.BatchId is { } batchId && _batches.TryGetValue(batchId, out var batch))
×
951
                        {
952
                                _batches[batchId] = batch with
×
953
                                {
×
954
                                        State = BatchState.Executing,
×
955
                                        PendingCount = batch.PendingCount + 1,
×
956
                                        FailedCount = Math.Max(0, batch.FailedCount - 1),
×
957
                                        CompletedAt = null,
×
958
                                };
×
959
                        }
960
                }
×
961

962
                return ValueTask.CompletedTask;
×
963
        }
964

965
        /// <inheritdoc />
966
        public ValueTask DeleteAsync(string jobId, CancellationToken cancellationToken = default)
967
        {
968
                cancellationToken.ThrowIfCancellationRequested();
×
969
                lock (_gate)
970
                {
971
                        if (!_jobs.TryGetValue(jobId, out var job))
×
972
                                return ValueTask.CompletedTask;
×
973
                        if (!IsTerminal(job.State))
×
974
                                throw new ImmediateJobException("Only terminal jobs can be deleted.");
×
975
                        if (job.BatchId is not null)
×
976
                                throw new ImmediateJobException("Batch members cannot be deleted individually.");
×
977

978
                        _ = _jobs.Remove(jobId);
×
979
                        RemoveEdgesForJobs(new(StringComparer.Ordinal) { jobId });
×
980
                }
×
981

982
                return ValueTask.CompletedTask;
×
983
        }
×
984

985
        /// <inheritdoc />
986
        public ValueTask PurgeJobsAsync(
987
                TimeSpan succeededRetention,
988
                TimeSpan failedRetention,
989
                CancellationToken cancellationToken = default
990
        )
991
        {
992
                cancellationToken.ThrowIfCancellationRequested();
4✔
993
                var now = timeProvider.GetUtcNow();
4✔
994
                lock (_gate)
995
                {
996
                        var standaloneJobIds = _jobs.Values
4✔
997
                                .Where(static job => job.BatchId is null)
4✔
998
                                .Where(x => x.CompletedAt is { } completed &&
4✔
999
                                        (x.State == JobState.Succeeded && completed < now - succeededRetention ||
4✔
1000
                                         x.State is JobState.Failed or JobState.Cancelled && completed < now - failedRetention))
4✔
1001
                                .Select(static job => job.Id)
×
1002
                                .ToHashSet(StringComparer.Ordinal);
4✔
1003
                        foreach (var id in standaloneJobIds)
4✔
1004
                                _ = _jobs.Remove(id);
×
1005
                        RemoveEdgesForJobs(standaloneJobIds);
4✔
1006
                }
4✔
1007

1008
                return ValueTask.CompletedTask;
4✔
1009
        }
1010

1011
        /// <inheritdoc />
1012
        public ValueTask PurgeBatchesAsync(
1013
                TimeSpan batchSucceededRetention,
1014
                TimeSpan batchFailedRetention,
1015
                CancellationToken cancellationToken = default
1016
        )
1017
        {
1018
                cancellationToken.ThrowIfCancellationRequested();
4✔
1019
                var now = timeProvider.GetUtcNow();
4✔
1020
                lock (_gate)
1021
                {
1022
                        var batchIds = _batches.Values
4✔
1023
                                .Where(batch => batch.CompletedAt is { } completed &&
×
1024
                                        (batch.State == BatchState.Succeeded && completed < now - batchSucceededRetention ||
×
1025
                                         batch.State is BatchState.Failed or BatchState.Cancelled && completed < now - batchFailedRetention))
×
1026
                                .Select(static batch => batch.Id)
×
1027
                                .ToHashSet(StringComparer.Ordinal);
4✔
1028
                        var batchJobIds = _jobs.Values
4✔
1029
                                .Where(job => job.BatchId is { } batchId && batchIds.Contains(batchId))
4✔
1030
                                .Select(static job => job.Id)
×
1031
                                .ToHashSet(StringComparer.Ordinal);
4✔
1032
                        foreach (var id in batchJobIds)
4✔
1033
                                _ = _jobs.Remove(id);
×
1034
                        foreach (var batchId in batchIds)
4✔
1035
                                _ = _batches.Remove(batchId);
×
1036
                        RemoveEdgesForJobs(batchJobIds, batchIds);
4✔
1037
                }
4✔
1038

1039
                return ValueTask.CompletedTask;
4✔
1040
        }
1041

1042
        /// <inheritdoc />
1043
        public ValueTask HeartbeatAsync(JobServerSnapshot server, CancellationToken cancellationToken = default)
1044
        {
1045
                ArgumentNullException.ThrowIfNull(server);
4✔
1046
                cancellationToken.ThrowIfCancellationRequested();
4✔
1047
                lock (_gate)
1048
                        _servers[server.WorkerId] = server;
4✔
1049
                return ValueTask.CompletedTask;
4✔
1050
        }
1051

1052
        /// <inheritdoc />
1053
        public async ValueTask<bool> IsHealthyAsync(CancellationToken cancellationToken = default)
1054
        {
1055
                cancellationToken.ThrowIfCancellationRequested();
×
1056
                return true;
×
1057
        }
1058

1059
        private void ValidateNewJob(JobRecord job)
1060
        {
1061
                ArgumentException.ThrowIfNullOrWhiteSpace(job.Id);
4✔
1062
                ArgumentException.ThrowIfNullOrWhiteSpace(job.JobName);
4✔
1063
                if (_jobs.ContainsKey(job.Id))
4✔
1064
                        throw new ImmediateJobException($"Job '{job.Id}' already exists.");
×
1065
        }
4✔
1066

1067
        private void ValidateBatch(
1068
                JobBatchRecord batch,
1069
                IReadOnlyList<JobRecord> jobs,
1070
                IReadOnlyList<JobContinuationEdge> edges
1071
        )
1072
        {
1073
                ArgumentException.ThrowIfNullOrWhiteSpace(batch.Id);
4✔
1074
                if (_batches.ContainsKey(batch.Id))
4✔
1075
                        throw new ImmediateJobException($"Batch '{batch.Id}' already exists.");
×
1076
                if (jobs.Count == 0)
4✔
1077
                        throw new ImmediateJobException("An atomic batch cannot be empty.");
×
1078
                var succeeded = jobs.Count(static job => job.State == JobState.Succeeded);
4✔
1079
                var failed = jobs.Count(static job => job.State == JobState.Failed);
4✔
1080
                var cancelled = jobs.Count(static job => job.State == JobState.Cancelled);
4✔
1081
                var pending = jobs.Count - succeeded - failed - cancelled;
4✔
1082
                var expectedState = pending != 0
4✔
1083
                        ? BatchState.Executing
4✔
1084
                        : failed != 0 ? BatchState.Failed : cancelled != 0 ? BatchState.Cancelled : BatchState.Succeeded;
4✔
1085
                if (batch.TotalJobs != jobs.Count ||
4✔
1086
                        batch.PendingCount != pending ||
4✔
1087
                        batch.SucceededCount != succeeded ||
4✔
1088
                        batch.FailedCount != failed ||
4✔
1089
                        batch.CancelledCount != cancelled ||
4✔
1090
                        batch.State != expectedState ||
4✔
1091
                        (pending == 0) != (batch.CompletedAt is not null))
4✔
1092
                {
1093
                        throw new ImmediateJobException("A batch header does not match its members or aggregate state.");
×
1094
                }
1095

1096
                var jobIds = new HashSet<string>(StringComparer.Ordinal);
4✔
1097
                foreach (var job in jobs)
4✔
1098
                {
1099
                        ValidateNewJob(job);
4✔
1100
                        if (!jobIds.Add(job.Id))
4✔
1101
                                throw new ImmediateJobException($"Job '{job.Id}' occurs more than once in the batch.");
×
1102
                        if (job.BatchId != batch.Id)
4✔
1103
                                throw new ImmediateJobException($"Job '{job.Id}' does not belong to batch '{batch.Id}'.");
×
1104
                }
1105

1106
                ValidateEdges(jobs, edges, batch.Id);
4✔
1107
        }
4✔
1108

1109
        private void ValidateEdges(
1110
                IReadOnlyList<JobRecord> newJobs,
1111
                IReadOnlyList<JobContinuationEdge> edges,
1112
                string? batchId
1113
        )
1114
        {
1115
                if (batchId is null && edges.Count == 0)
4✔
1116
                        throw new ImmediateJobException("A continuation must have at least one parent.");
×
1117

1118
                var newJobIds = newJobs.Select(static job => job.Id).ToHashSet(StringComparer.Ordinal);
4✔
1119
                var logicalEdges = new HashSet<(string Child, string ParentKind, string Parent)>(
4✔
1120
                        EqualityComparer<(string Child, string ParentKind, string Parent)>.Default
4✔
1121
                );
4✔
1122
                var outgoing = newJobIds.ToDictionary(static id => id, static _ => new List<string>(), StringComparer.Ordinal);
4✔
1123
                var incoming = newJobIds.ToDictionary(static id => id, static _ => 0, StringComparer.Ordinal);
4✔
1124
                foreach (var edge in edges)
4✔
1125
                {
1126
                        ArgumentNullException.ThrowIfNull(edge);
4✔
1127
                        ArgumentException.ThrowIfNullOrWhiteSpace(edge.ChildJobId);
4✔
1128
                        if (!Enum.IsDefined(edge.Trigger))
4✔
1129
                                throw new ArgumentOutOfRangeException(nameof(edges), "Unknown continuation trigger.");
×
1130
                        if (!newJobIds.Contains(edge.ChildJobId))
4✔
1131
                                throw new ImmediateJobException($"Continuation child '{edge.ChildJobId}' is not part of the atomic insert.");
×
1132

1133
                        var hasJobParent = !string.IsNullOrWhiteSpace(edge.ParentJobId);
4✔
1134
                        var hasBatchParent = !string.IsNullOrWhiteSpace(edge.ParentBatchId);
4✔
1135
                        if (hasJobParent == hasBatchParent)
4✔
1136
                                throw new ImmediateJobException("A continuation edge must have exactly one job or batch parent.");
×
1137

1138
                        if (hasJobParent)
4✔
1139
                        {
1140
                                var parentId = edge.ParentJobId!;
4✔
1141
                                if (parentId == edge.ChildJobId)
4✔
1142
                                        throw new ImmediateJobException($"Continuation job '{edge.ChildJobId}' cannot depend on itself.");
×
1143
                                if (!newJobIds.Contains(parentId) && !_jobs.ContainsKey(parentId))
4✔
1144
                                        throw new KeyNotFoundException($"Continuation parent job '{parentId}' was not found.");
4✔
1145
                                if (!logicalEdges.Add((edge.ChildJobId, "job", parentId)))
4✔
1146
                                        throw new ImmediateJobException($"Duplicate continuation edge '{parentId}' -> '{edge.ChildJobId}'.");
×
1147

1148
                                if (newJobIds.Contains(parentId))
4✔
1149
                                {
1150
                                        outgoing[parentId].Add(edge.ChildJobId);
4✔
1151
                                        incoming[edge.ChildJobId]++;
4✔
1152
                                }
1153
                        }
1154
                        else
1155
                        {
1156
                                var parentId = edge.ParentBatchId!;
4✔
1157
                                if (!_batches.ContainsKey(parentId))
4✔
1158
                                        throw new KeyNotFoundException($"Continuation parent batch '{parentId}' was not found.");
×
1159
                                if (!logicalEdges.Add((edge.ChildJobId, "batch", parentId)))
4✔
1160
                                        throw new ImmediateJobException($"Duplicate continuation edge from batch '{parentId}' to '{edge.ChildJobId}'.");
×
1161
                        }
1162
                }
1163

1164
                var ready = new Queue<string>(incoming.Where(static pair => pair.Value == 0).Select(static pair => pair.Key));
4✔
1165
                var visited = 0;
4✔
1166
                while (ready.TryDequeue(out var parentId))
4✔
1167
                {
1168
                        visited++;
4✔
1169
                        foreach (var childId in outgoing[parentId])
4✔
1170
                        {
1171
                                if (--incoming[childId] == 0)
4✔
1172
                                        ready.Enqueue(childId);
4✔
1173
                        }
1174
                }
1175

1176
                if (visited != newJobIds.Count)
4✔
NEW
1177
                        throw new ImmediateJobException("The continuation graph contains a dependency cycle.");
×
1178
        }
4✔
1179

1180
        private static JobRecord NormalizeWaitingJob(JobRecord job, int dependencyCount) => job with
4✔
1181
        {
4✔
1182
                State = dependencyCount == 0 ? job.State : JobState.AwaitingContinuation,
4✔
1183
                RemainingDependencies = dependencyCount,
4✔
1184
                FailedDependencies = 0,
4✔
1185
                WorkerId = null,
4✔
1186
                LeaseExpiresAt = null,
4✔
1187
                CompletedAt = null,
4✔
1188
        };
4✔
1189

1190
        private bool IsRecoveredBatch(
1191
                JobBatchRecord batch,
1192
                IReadOnlyList<JobRecord> jobs,
1193
                IReadOnlyList<JobContinuationEdge> edges
1194
        )
1195
        {
1196
                if (batch.StartedAt is not null ||
4✔
1197
                        batch.CompletedAt is not null ||
4✔
1198
                        batch.SucceededCount != 0 ||
4✔
1199
                        batch.FailedCount != 0 ||
4✔
1200
                        batch.CancelledCount != 0 ||
4✔
1201
                        jobs.Any(static job => job.State is JobState.Active or JobState.Succeeded or JobState.Failed or JobState.Cancelled))
4✔
1202
                {
1203
                        return true;
4✔
1204
                }
1205

1206
                if (!HasTerminalParent(edges))
4✔
1207
                        return false;
4✔
1208

1209
                var incomingCounts = edges
4✔
1210
                        .GroupBy(static edge => edge.ChildJobId, StringComparer.Ordinal)
4✔
1211
                        .ToDictionary(static group => group.Key, static group => group.Count(), StringComparer.Ordinal);
4✔
1212
                return jobs.Any(job => incomingCounts.TryGetValue(job.Id, out var incoming) &&
4✔
1213
                        (job.State != JobState.AwaitingContinuation || job.RemainingDependencies < incoming));
4✔
1214
        }
1215

1216
        private bool HasTerminalParent(IEnumerable<JobContinuationEdge> edges) => edges.Any(edge =>
4✔
1217
                edge.ParentJobId is { } parentJobId &&
4✔
1218
                        _jobs.TryGetValue(parentJobId, out var parentJob) &&
4✔
1219
                        IsTerminal(parentJob.State) ||
4✔
1220
                edge.ParentBatchId is { } parentBatchId &&
4✔
1221
                        _batches.TryGetValue(parentBatchId, out var parentBatch) &&
4✔
1222
                        IsTerminal(parentBatch.State));
4✔
1223

1224
        private void MarkTerminalParentEdgesSettled(IEnumerable<JobContinuationEdge> edges)
1225
        {
1226
                foreach (var edge in edges)
4✔
1227
                {
1228
                        if (edge.ParentJobId is { } parentJobId &&
4✔
1229
                                _jobs.TryGetValue(parentJobId, out var parentJob) &&
4✔
1230
                                IsTerminal(parentJob.State) ||
4✔
1231
                                edge.ParentBatchId is { } parentBatchId &&
4✔
1232
                                _batches.TryGetValue(parentBatchId, out var parentBatch) &&
4✔
1233
                                IsTerminal(parentBatch.State))
4✔
1234
                        {
1235
                                _ = _settledEdges.Add(edge);
4✔
1236
                        }
1237
                }
1238
        }
4✔
1239

1240
        private void EvaluateAlreadyTerminalParents(IReadOnlyList<JobContinuationEdge> edges)
1241
        {
1242
                foreach (var parentId in edges
4✔
1243
                        .Where(static edge => edge.ParentJobId is not null)
4✔
1244
                        .Select(static edge => edge.ParentJobId!)
4✔
1245
                        .Distinct(StringComparer.Ordinal)
4✔
1246
                        .Where(parentId => _jobs.TryGetValue(parentId, out var parent) && IsTerminal(parent.State))
4✔
1247
                        .ToArray())
4✔
1248
                {
1249
                        ProcessTerminalJob(parentId);
4✔
1250
                }
1251

1252
                foreach (var parentId in edges
4✔
1253
                        .Where(static edge => edge.ParentBatchId is not null)
4✔
1254
                        .Select(static edge => edge.ParentBatchId!)
4✔
1255
                        .Distinct(StringComparer.Ordinal)
4✔
1256
                        .Where(parentId => _batches.TryGetValue(parentId, out var parent) && IsTerminal(parent.State))
4✔
1257
                        .ToArray())
4✔
1258
                {
1259
                        ProcessTerminalBatch(parentId);
4✔
1260
                }
1261
        }
4✔
1262

1263
        private void TransitionToTerminal(
1264
                string jobId,
1265
                JobState terminalState,
1266
                string? error,
1267
                DateTimeOffset completedAt
1268
        )
1269
        {
1270
                if (!IsTerminal(terminalState))
4✔
1271
                        throw new ArgumentOutOfRangeException(nameof(terminalState));
×
1272
                if (!_jobs.TryGetValue(jobId, out var job) || IsTerminal(job.State))
4✔
1273
                        return;
4✔
1274

1275
                _jobs[jobId] = job with
4✔
1276
                {
4✔
1277
                        State = terminalState,
4✔
1278
                        WorkerId = null,
4✔
1279
                        LeaseExpiresAt = null,
4✔
1280
                        LastError = error,
4✔
1281
                        CompletedAt = completedAt,
4✔
1282
                };
4✔
1283
                UpdateBatchAfterTerminal(job.BatchId, terminalState, completedAt);
4✔
1284
                ProcessTerminalJob(jobId);
4✔
1285
                RemoveFairQueueCursorWhenBacklogClears(job.QueueName, job.GroupId);
4✔
1286
        }
4✔
1287

1288
        private void RemoveFairQueueCursorWhenBacklogClears(string queueName, string? groupId)
1289
        {
1290
                if (groupId is null ||
4✔
1291
                        _jobs.Values.Any(job => job.QueueName == queueName &&
4✔
1292
                                job.GroupId == groupId &&
4✔
1293
                                job.State is JobState.Pending or JobState.Scheduled or JobState.Active))
4✔
1294
                {
1295
                        return;
4✔
1296
                }
1297

1298
                _ = _fairQueueLastServed.Remove((queueName, groupId));
4✔
1299
        }
4✔
1300

1301
        private void UpdateBatchAfterTerminal(string? batchId, JobState state, DateTimeOffset completedAt)
1302
        {
1303
                if (batchId is null || !_batches.TryGetValue(batchId, out var batch))
4✔
1304
                        return;
4✔
1305

1306
                var pending = Math.Max(0, batch.PendingCount - 1);
4✔
1307
                batch = batch with
4✔
1308
                {
4✔
1309
                        PendingCount = pending,
4✔
1310
                        SucceededCount = batch.SucceededCount + (state == JobState.Succeeded ? 1 : 0),
4✔
1311
                        FailedCount = batch.FailedCount + (state == JobState.Failed ? 1 : 0),
4✔
1312
                        CancelledCount = batch.CancelledCount + (state == JobState.Cancelled ? 1 : 0),
4✔
1313
                };
4✔
1314
                if (pending == 0)
4✔
1315
                {
1316
                        batch = batch with
4✔
1317
                        {
4✔
1318
                                State = batch.FailedCount != 0
4✔
1319
                                        ? BatchState.Failed
4✔
1320
                                        : batch.CancelledCount != 0 ? BatchState.Cancelled : BatchState.Succeeded,
4✔
1321
                                CompletedAt = completedAt,
4✔
1322
                        };
4✔
1323
                }
1324

1325
                _batches[batchId] = batch;
4✔
1326
                if (pending == 0)
4✔
1327
                        ProcessTerminalBatch(batchId);
4✔
1328
        }
4✔
1329

1330
        private void ProcessTerminalJob(string parentJobId)
1331
        {
1332
                if (!_jobs.TryGetValue(parentJobId, out var parent) || !IsTerminal(parent.State))
4✔
1333
                        return;
×
1334

1335
                foreach (var edge in _edges
4✔
1336
                        .Where(edge => edge.ParentJobId == parentJobId && !_settledEdges.Contains(edge))
4✔
1337
                        .ToArray())
4✔
1338
                {
1339
                        _ = _settledEdges.Add(edge);
4✔
1340
                        SettleEdge(
4✔
1341
                                edge,
4✔
1342
                                parentSucceeded: parent.State == JobState.Succeeded,
4✔
1343
                                parentFailed: parent.State == JobState.Failed
4✔
1344
                        );
4✔
1345
                }
1346
        }
4✔
1347

1348
        private void ProcessTerminalBatch(string parentBatchId)
1349
        {
1350
                if (!_batches.TryGetValue(parentBatchId, out var parent) || !IsTerminal(parent.State))
4✔
1351
                        return;
×
1352

1353
                foreach (var edge in _edges
4✔
1354
                        .Where(edge => edge.ParentBatchId == parentBatchId && !_settledEdges.Contains(edge))
4✔
1355
                        .ToArray())
4✔
1356
                {
1357
                        _ = _settledEdges.Add(edge);
4✔
1358
                        SettleEdge(
4✔
1359
                                edge,
4✔
1360
                                parentSucceeded: parent.State == BatchState.Succeeded,
4✔
1361
                                parentFailed: parent.State == BatchState.Failed
4✔
1362
                        );
4✔
1363
                }
1364
        }
4✔
1365

1366
        private void SettleEdge(JobContinuationEdge edge, bool parentSucceeded, bool parentFailed)
1367
        {
1368
                if (!_jobs.TryGetValue(edge.ChildJobId, out var child) || IsTerminal(child.State))
4✔
1369
                        return;
×
1370
                if (edge.Trigger == ContinuationTrigger.Success && !parentSucceeded)
4✔
1371
                {
1372
                        _jobs[child.Id] = child with { RemainingDependencies = 0 };
4✔
1373
                        TransitionToTerminal(child.Id, JobState.Cancelled, error: null, timeProvider.GetUtcNow());
4✔
1374
                        return;
4✔
1375
                }
1376

1377
                var remaining = Math.Max(0, child.RemainingDependencies - 1);
4✔
1378
                var failed = child.FailedDependencies + (parentFailed ? 1 : 0);
4✔
1379
                if (remaining == 0 && edge.Trigger == ContinuationTrigger.Failure && failed == 0)
4✔
1380
                {
1381
                        _jobs[child.Id] = child with { RemainingDependencies = 0, FailedDependencies = failed };
4✔
1382
                        TransitionToTerminal(child.Id, JobState.Cancelled, error: null, timeProvider.GetUtcNow());
4✔
1383
                        return;
4✔
1384
                }
1385

1386
                _jobs[child.Id] = child with
4✔
1387
                {
4✔
1388
                        State = remaining == 0 && child.State == JobState.AwaitingContinuation
4✔
1389
                                ? GetReadyState(child)
4✔
1390
                                : child.State,
4✔
1391
                        RemainingDependencies = remaining,
4✔
1392
                        FailedDependencies = failed,
4✔
1393
                };
4✔
1394
        }
4✔
1395

1396
        private JobState GetReadyState(JobRecord job) =>
1397
                job.DueAt <= timeProvider.GetUtcNow() ? JobState.Pending : JobState.Scheduled;
4✔
1398

1399
        private JobContinuationEdge[] GetUnsettledWaiters(string parentJobId) =>
1400
        [
4✔
1401
                .. _edges.Where(edge => edge.ParentJobId == parentJobId &&
4✔
1402
                        !_settledEdges.Contains(edge) &&
4✔
1403
                        _jobs.TryGetValue(edge.ChildJobId, out var child) &&
4✔
1404
                        !IsTerminal(child.State)),
4✔
1405
        ];
4✔
1406

1407
        private void SpliceBeforeWaiters(
1408
                string newParentJobId,
1409
                IReadOnlyList<JobContinuationEdge> existingWaiters
1410
        )
1411
        {
1412
                foreach (var existingEdge in existingWaiters)
4✔
1413
                {
1414
                        if (!_jobs.TryGetValue(existingEdge.ChildJobId, out var child) || IsTerminal(child.State))
4✔
1415
                                continue;
1416

1417
                        _jobs[child.Id] = child with
4✔
1418
                        {
4✔
1419
                                State = JobState.AwaitingContinuation,
4✔
1420
                                RemainingDependencies = child.RemainingDependencies + 1,
4✔
1421
                        };
4✔
1422
                        _edges.Add(new()
4✔
1423
                        {
4✔
1424
                                ChildJobId = child.Id,
4✔
1425
                                ParentJobId = newParentJobId,
4✔
1426
                                Trigger = existingEdge.Trigger,
4✔
1427
                        });
4✔
1428
                }
1429
        }
4✔
1430

1431
        private void ValidateSplice(IReadOnlyList<JobContinuationEdge> existingWaiters, int additions)
1432
        {
1433
                if (additions == 0)
4✔
1434
                        return;
4✔
1435
                foreach (var existingEdge in existingWaiters)
4✔
1436
                {
1437
                        if (_jobs.TryGetValue(existingEdge.ChildJobId, out var child) &&
4✔
1438
                                child.RemainingDependencies > int.MaxValue - additions)
4✔
1439
                        {
1440
                                throw new ImmediateJobException($"Continuation dependency count overflow for job '{child.Id}'.");
×
1441
                        }
1442
                }
1443
        }
4✔
1444

1445
        private void IncrementBatchMembers(string? batchId, int count)
1446
        {
1447
                if (count == 0)
4✔
1448
                        return;
4✔
1449
                if (batchId is null || !_batches.TryGetValue(batchId, out var batch))
4✔
1450
                        throw new ImmediateJobException("The current job's batch was not found.");
×
1451
                if (batch.TotalJobs > int.MaxValue - count || batch.PendingCount > int.MaxValue - count)
4✔
1452
                        throw new ImmediateJobException($"Batch '{batchId}' member count overflow.");
×
1453

1454
                _batches[batchId] = batch with
4✔
1455
                {
4✔
1456
                        TotalJobs = batch.TotalJobs + count,
4✔
1457
                        PendingCount = batch.PendingCount + count,
4✔
1458
                };
4✔
1459
        }
4✔
1460

1461
        private void MarkBatchStarted(string? batchId, DateTimeOffset startedAt)
1462
        {
1463
                if (batchId is not null && _batches.TryGetValue(batchId, out var batch) && batch.StartedAt is null)
4✔
1464
                        _batches[batchId] = batch with { StartedAt = startedAt };
4✔
1465
        }
4✔
1466

1467
        private static bool IsTerminal(JobState state) =>
1468
                state is JobState.Succeeded or JobState.Failed or JobState.Cancelled;
4✔
1469

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

1472
        private static BatchStatus ToStatus(JobBatchRecord batch) => new(
4✔
1473
                batch.Id,
4✔
1474
                batch.State,
4✔
1475
                batch.TotalJobs,
4✔
1476
                batch.SucceededCount,
4✔
1477
                batch.FailedCount,
4✔
1478
                batch.CancelledCount,
4✔
1479
                batch.PendingCount,
4✔
1480
                batch.CreatedAt,
4✔
1481
                batch.StartedAt,
4✔
1482
                batch.CompletedAt,
4✔
1483
                batch.TotalJobs == 0 ? 0 : (double)(batch.TotalJobs - batch.PendingCount) / batch.TotalJobs
4✔
1484
        );
4✔
1485

1486
        private static BatchGraphEdge ToGraphEdge(JobContinuationEdge edge) => new(
4✔
1487
                edge.ChildJobId,
4✔
1488
                edge.ParentJobId,
4✔
1489
                edge.ParentBatchId,
4✔
1490
                edge.Trigger
4✔
1491
        );
4✔
1492

1493
        private void RemoveEdgesForJobs(
1494
                HashSet<string> jobIds,
1495
                HashSet<string>? batchIds = null
1496
        )
1497
        {
1498
                if (jobIds.Count == 0 && (batchIds is null || batchIds.Count == 0))
4✔
1499
                        return;
4✔
1500

1501
                var batches = batchIds ?? [];
4✔
1502
                for (var index = _edges.Count - 1; index >= 0; index--)
4✔
1503
                {
1504
                        var edge = _edges[index];
4✔
1505
                        if (!jobIds.Contains(edge.ChildJobId) &&
4✔
1506
                                (edge.ParentJobId is null || !jobIds.Contains(edge.ParentJobId)) &&
4✔
1507
                                (edge.ParentBatchId is null || !batches.Contains(edge.ParentBatchId)))
4✔
1508
                        {
1509
                                continue;
1510
                        }
1511

1512
                        _edges.RemoveAt(index);
4✔
1513
                        _ = _settledEdges.Remove(edge);
4✔
1514
                }
1515
        }
4✔
1516

1517
        private JobRecord GetOwnedActive(string jobId, string workerId)
1518
        {
1519
                if (!_jobs.TryGetValue(jobId, out var job) || job.State != JobState.Active || job.WorkerId != workerId)
4✔
1520
                        throw new ImmediateJobException($"Worker '{workerId}' does not own active job '{jobId}'.");
×
1521
                return job;
4✔
1522
        }
1523

1524
        private ValueTask SetRecurringPausedAsync(string name, bool isPaused, CancellationToken cancellationToken)
1525
        {
1526
                cancellationToken.ThrowIfCancellationRequested();
×
1527
                lock (_gate)
1528
                {
1529
                        if (!_recurring.TryGetValue(name, out var schedule))
×
1530
                                throw new KeyNotFoundException($"Recurring schedule '{name}' was not found.");
×
1531

1532
                        _recurring[name] = schedule with { IsPaused = isPaused };
×
1533
                }
×
1534

1535
                return ValueTask.CompletedTask;
×
1536
        }
1537
}
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