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

ImmediatePlatform / Immediate.Jobs / 30589302714

30 Jul 2026 11:03PM UTC coverage: 82.354% (+1.1%) from 81.264%
30589302714

Pull #72

github

web-flow
Merge 04a205866 into e9404b875
Pull Request #72: Fix continuation and recurring scheduling correctness issues

284 of 306 new or added lines in 6 files covered. (92.81%)

2 existing lines in 2 files now uncovered.

7346 of 8920 relevant lines covered (82.35%)

2.72 hits per line

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

82.88
/src/Immediate.Jobs.EntityFrameworkCore/EntityFrameworkCoreJobStorage.cs
1
using System.Data.Common;
2
using Microsoft.EntityFrameworkCore;
3

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

7
namespace Immediate.Jobs.EntityFrameworkCore;
8

9
/// <summary>An optimistic-concurrency EF Core implementation of <see cref="IJobStorage"/>.</summary>
10
/// <typeparam name="TContext">The application context containing the Immediate.Jobs model.</typeparam>
11
/// <param name="contextFactory">The factory used to create application database contexts.</param>
12
/// <param name="timeProvider">The clock used for storage timestamps, or <see langword="null"/> to use the system clock.</param>
13
public sealed class EntityFrameworkCoreJobStorage<TContext>(
5✔
14
        IDbContextFactory<TContext> contextFactory,
5✔
15
        TimeProvider? timeProvider = null
5✔
16
) : IRecurringJobStorage, IJobGraphStorage, IJobStorageReplica
5✔
17
        where TContext : DbContext
18
{
19
        private const int MaxConcurrencyAttempts = 5;
20
        private const int MaxConsecutiveFailedFairClaims = 5;
21
        private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
5✔
22

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

26
        /// <inheritdoc />
27
        public async ValueTask InitializeAsync(CancellationToken cancellationToken = default)
28
        {
29
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
30
                _ = context.Model.FindEntityType(typeof(ImmediateJobEntity))
4✔
31
                        ?? throw new ImmediateJobException("Immediate.Jobs entities are not configured. Call modelBuilder.AddImmediateJobs() from OnModelCreating.");
4✔
32
        }
3✔
33

34
        /// <inheritdoc />
35
        public async ValueTask EnqueueAsync(JobRecord job, CancellationToken cancellationToken = default)
36
        {
37
                ArgumentNullException.ThrowIfNull(job);
5✔
38
                await ExecuteWithStrategyAsync(
5✔
39
                        operationCancellationToken => EnqueueCoreAsync(job, operationCancellationToken),
5✔
40
                        cancellationToken
5✔
41
                ).ConfigureAwait(false);
5✔
42
        }
5✔
43

44
        private async Task EnqueueCoreAsync(JobRecord job, CancellationToken cancellationToken)
45
        {
46
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
47
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
48
                if (job.GroupId is { } groupId && !await HasLiveGroupJobsAsync(
5✔
49
                        context,
5✔
50
                        job.QueueName,
5✔
51
                        groupId,
5✔
52
                        cancellationToken
5✔
53
                ).ConfigureAwait(false))
5✔
54
                {
55
                        var cursor = await context.Set<ImmediateFairQueueGroupEntity>()
5✔
56
                                .SingleOrDefaultAsync(
5✔
57
                                        group => group.QueueName == job.QueueName && group.GroupId == groupId,
5✔
58
                                        cancellationToken
5✔
59
                                )
5✔
60
                                .ConfigureAwait(false);
5✔
61
                        if (cursor is not null)
5✔
62
                                _ = context.Remove(cursor);
4✔
63
                }
64

65
                _ = context.Set<ImmediateJobEntity>().Add(ToEntity(job));
5✔
66
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
67
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
5✔
68
        }
5✔
69

70
        /// <inheritdoc />
71
        public async ValueTask EnqueueContinuationAsync(
72
                JobRecord job,
73
                IReadOnlyList<JobContinuationEdge> edges,
74
                CancellationToken cancellationToken = default
75
        )
76
        {
77
                ArgumentNullException.ThrowIfNull(job);
5✔
78
                ArgumentNullException.ThrowIfNull(edges);
5✔
79
                await ExecuteGraphInsertAsync(batch: null, [job], edges, cancellationToken).ConfigureAwait(false);
5✔
80
        }
5✔
81

82
        /// <inheritdoc />
83
        public async ValueTask EnqueueBatchAsync(
84
                JobBatchRecord batch,
85
                IReadOnlyList<JobRecord> jobs,
86
                IReadOnlyList<JobContinuationEdge> edges,
87
                CancellationToken cancellationToken = default
88
        )
89
        {
90
                ArgumentNullException.ThrowIfNull(batch);
5✔
91
                ArgumentNullException.ThrowIfNull(jobs);
5✔
92
                ArgumentNullException.ThrowIfNull(edges);
5✔
93
                if (jobs.Count == 0)
5✔
94
                        throw new ImmediateJobException("An atomic batch cannot be committed without jobs.");
×
95
                await ExecuteGraphInsertAsync(batch, jobs, edges, cancellationToken).ConfigureAwait(false);
5✔
96
        }
5✔
97

98
        private ValueTask ExecuteGraphInsertAsync(
99
                JobBatchRecord? batch,
100
                IReadOnlyList<JobRecord> jobs,
101
                IReadOnlyList<JobContinuationEdge> edges,
102
                CancellationToken cancellationToken
103
        ) => RetryConcurrencyAsync(
5✔
104
                operationCancellationToken => InsertGraphCoreAsync(batch, jobs, edges, operationCancellationToken),
5✔
105
                cancellationToken
5✔
106
        );
5✔
107

108
        private async Task InsertGraphCoreAsync(
109
                JobBatchRecord? batch,
110
                IReadOnlyList<JobRecord> jobs,
111
                IReadOnlyList<JobContinuationEdge> edges,
112
                CancellationToken cancellationToken
113
        )
114
        {
115
                var jobIds = jobs.Select(static job => job.Id).ToHashSet(StringComparer.Ordinal);
5✔
116
                if (jobIds.Count != jobs.Count)
5✔
117
                        throw new ImmediateJobException("A batch or continuation insert contains duplicate job identifiers.");
×
118
                if (batch is not null && jobs.Any(job => !string.Equals(job.BatchId, batch.Id, StringComparison.Ordinal)))
5✔
119
                        throw new ImmediateJobException("Every atomic batch member must carry the committed batch identifier.");
×
120

121
                var edgeEntities = edges.Select(ToEntity).ToArray();
5✔
122
                if (edgeEntities.Any(edge => !jobIds.Contains(edge.ChildJobId)))
5✔
123
                        throw new ImmediateJobException("Every continuation edge must target a job inserted by the same operation.");
×
124
                if (edgeEntities.DistinctBy(static edge => (edge.ChildJobId, edge.ParentKind, edge.ParentId)).Count() != edgeEntities.Length)
5✔
125
                        throw new ImmediateJobException("Duplicate continuation edges are not allowed.");
×
126
                ThrowIfCyclic(jobIds, edgeEntities);
5✔
127

128
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
129
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
130
                var jobEntities = jobs.Select(ToEntity).ToDictionary(static job => job.Id, StringComparer.Ordinal);
5✔
131
                await EvaluateInitialDependenciesAsync(
5✔
132
                        context,
5✔
133
                        jobEntities,
5✔
134
                        edgeEntities,
5✔
135
                        _timeProvider.GetUtcNow(),
5✔
136
                        cancellationToken
5✔
137
                ).ConfigureAwait(false);
5✔
138

139
                if (batch is not null)
5✔
140
                {
141
                        var terminal = jobEntities.Values.Where(static job => IsTerminal(job.State)).ToArray();
5✔
142
                        var pending = jobEntities.Count - terminal.Length;
5✔
143
                        var succeeded = terminal.Count(static job => job.State == JobState.Succeeded);
4✔
144
                        var failed = terminal.Count(static job => job.State == JobState.Failed);
4✔
145
                        var cancelled = terminal.Count(static job => job.State == JobState.Cancelled);
4✔
146
                        _ = context.Add(new ImmediateJobBatchEntity
5✔
147
                        {
5✔
148
                                Id = batch.Id,
5✔
149
                                CreatedAt = batch.CreatedAt,
5✔
150
                                TotalJobs = jobEntities.Count,
5✔
151
                                PendingCount = pending,
5✔
152
                                SucceededCount = succeeded,
5✔
153
                                FailedCount = failed,
5✔
154
                                CancelledCount = cancelled,
5✔
155
                                StartedAt = batch.StartedAt,
5✔
156
                                CompletedAt = pending == 0 ? batch.CompletedAt ?? _timeProvider.GetUtcNow() : null,
5✔
157
                                State = pending == 0 ? GetTerminalBatchState(failed, cancelled) : BatchState.Executing,
5✔
158
                                ConcurrencyStamp = Guid.NewGuid(),
5✔
159
                        });
5✔
160
                }
161

162
                context.AddRange(jobEntities.Values);
5✔
163
                context.AddRange(edgeEntities);
5✔
164
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
165
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
5✔
166
        }
5✔
167

168
        /// <inheritdoc />
169
        public async ValueTask<IReadOnlyList<JobRecord>> AcquireDueJobsAsync(
170
                JobAcquisitionRequest request,
171
                CancellationToken cancellationToken = default
172
        )
173
        {
174
                ArgumentNullException.ThrowIfNull(request);
5✔
175
                ArgumentException.ThrowIfNullOrWhiteSpace(request.WorkerId);
5✔
176
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(request.Lease, TimeSpan.Zero);
5✔
177
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(request.BatchSize, 0);
5✔
178
                if (request.FairQueues is not null)
5✔
179
                        return await AcquireDueJobsFairAsync(request, cancellationToken).ConfigureAwait(false);
5✔
180

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

189
                        var jobCapacities = queue.JobCapacities.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal);
5✔
190
                        while (queueCapacity > 0)
5✔
191
                        {
192
                                var eligibleNames = jobCapacities.Where(static pair => pair.Value > 0).Select(static pair => pair.Key).ToArray();
5✔
193
                                if (eligibleNames.Length == 0)
5✔
194
                                        break;
195

196
                                await using var readContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
197
                                var candidates = await readContext.Set<ImmediateJobEntity>()
5✔
198
                                        .AsNoTracking()
5✔
199
                                        .Where(job => job.QueueName == queue.QueueName && eligibleNames.Contains(job.JobName) &&
5✔
200
                                                (((job.State == JobState.Scheduled || job.State == JobState.Pending) && job.DueAt <= now)
5✔
201
                                                        || (job.State == JobState.Active && job.LeaseExpiresAt <= now)))
5✔
202
                                        .OrderBy(job => job.DueAt)
5✔
203
                                        .ThenBy(job => job.CreatedAt)
5✔
204
                                        .ThenBy(job => job.Id)
5✔
205
                                        .Take(queueCapacity)
5✔
206
                                        .ToListAsync(cancellationToken)
5✔
207
                                        .ConfigureAwait(false);
5✔
208
                                if (candidates.Count == 0)
5✔
209
                                        break;
210

211
                                var selected = new List<ImmediateJobEntity>(candidates.Count);
5✔
212
                                var selectionCapacities = new Dictionary<string, int>(jobCapacities, StringComparer.Ordinal);
5✔
213
                                foreach (var candidate in candidates)
5✔
214
                                {
215
                                        if (selectionCapacities[candidate.JobName] <= 0)
5✔
216
                                                continue;
217
                                        selectionCapacities[candidate.JobName]--;
5✔
218
                                        selected.Add(candidate);
5✔
219
                                }
220

221
                                var claimed = await AcquireCandidatesAsync(
5✔
222
                                        selected,
5✔
223
                                        request.WorkerId,
5✔
224
                                        request.Lease,
5✔
225
                                        now,
5✔
226
                                        cancellationToken
5✔
227
                                ).ConfigureAwait(false);
5✔
228
                                foreach (var job in claimed)
5✔
229
                                {
230
                                        jobCapacities[job.JobName]--;
5✔
231
                                        queueCapacity--;
5✔
232
                                        acquired.Add(job);
5✔
233
                                }
234

235
                                if (claimed.Count == 0)
5✔
236
                                        break;
237
                        }
4✔
238
                }
4✔
239

240
                return acquired;
5✔
241
        }
4✔
242

243
        private async ValueTask<IReadOnlyList<JobRecord>> AcquireDueJobsFairAsync(
244
                JobAcquisitionRequest request,
245
                CancellationToken cancellationToken
246
        )
247
        {
248
                var now = _timeProvider.GetUtcNow();
5✔
249
                var acquired = new List<JobRecord>(request.BatchSize);
5✔
250
                foreach (var queue in request.Queues)
5✔
251
                {
252
                        var consecutiveFailedClaims = 0;
5✔
253
                        var queueCapacity = Math.Min(queue.Capacity, request.BatchSize - acquired.Count);
5✔
254
                        if (queueCapacity <= 0)
5✔
255
                                continue;
256

257
                        var jobCapacities = queue.JobCapacities.ToDictionary(
5✔
258
                                static pair => pair.Key,
5✔
259
                                static pair => pair.Value,
5✔
260
                                StringComparer.Ordinal
5✔
261
                        );
5✔
262
                        while (queueCapacity > 0)
5✔
263
                        {
264
                                var eligibleNames = jobCapacities
5✔
265
                                        .Where(static pair => pair.Value > 0)
5✔
266
                                        .Select(static pair => pair.Key)
5✔
267
                                        .ToArray();
5✔
268
                                if (eligibleNames.Length == 0)
5✔
269
                                        break;
270

271
                                await using var readContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
272
                                var eligibleQuery = readContext.Set<ImmediateJobEntity>()
5✔
273
                                        .AsNoTracking()
5✔
274
                                        .Where(job => job.QueueName == queue.QueueName && eligibleNames.Contains(job.JobName) &&
5✔
275
                                                (((job.State == JobState.Scheduled || job.State == JobState.Pending) && job.DueAt <= now)
5✔
276
                                                        || (job.State == JobState.Active && job.LeaseExpiresAt <= now)));
5✔
277
                                if (!await eligibleQuery
5✔
278
                                        .AnyAsync(static job => job.GroupId != null, cancellationToken)
5✔
279
                                        .ConfigureAwait(false))
5✔
280
                                {
281
                                        var claimed = await AcquireFairFastPathAsync(
4✔
282
                                                queue.QueueName,
4✔
283
                                                jobCapacities,
4✔
284
                                                queueCapacity,
4✔
285
                                                request.WorkerId,
4✔
286
                                                request.Lease,
4✔
287
                                                now,
4✔
288
                                                cancellationToken
4✔
289
                                        ).ConfigureAwait(false);
4✔
290
                                        queueCapacity -= claimed.Count;
4✔
291
                                        acquired.AddRange(claimed);
4✔
292
                                        break;
4✔
293
                                }
294

295
                                var groupedHeads = await eligibleQuery
5✔
296
                                        .Where(static job => job.GroupId != null)
5✔
297
                                        .GroupBy(static job => job.GroupId)
5✔
298
                                        .Select(static group => group
5✔
299
                                                .OrderBy(job => job.DueAt)
5✔
300
                                                .ThenBy(job => job.CreatedAt)
5✔
301
                                                .ThenBy(job => job.Id)
5✔
302
                                                .First())
5✔
303
                                        .ToListAsync(cancellationToken)
5✔
304
                                        .ConfigureAwait(false);
5✔
305
                                var ungroupedHead = await eligibleQuery
5✔
306
                                        .Where(static job => job.GroupId == null)
5✔
307
                                        .OrderBy(job => job.DueAt)
5✔
308
                                        .ThenBy(job => job.CreatedAt)
5✔
309
                                        .ThenBy(job => job.Id)
5✔
310
                                        .FirstOrDefaultAsync(cancellationToken)
5✔
311
                                        .ConfigureAwait(false);
5✔
312
                                if (groupedHeads.Count == 0)
5✔
313
                                {
314
                                        var claimed = await AcquireFairFastPathAsync(
×
315
                                                queue.QueueName,
×
316
                                                jobCapacities,
×
317
                                                queueCapacity,
×
318
                                                request.WorkerId,
×
319
                                                request.Lease,
×
320
                                                now,
×
321
                                                cancellationToken
×
322
                                        ).ConfigureAwait(false);
×
323
                                        queueCapacity -= claimed.Count;
×
324
                                        acquired.AddRange(claimed);
×
325
                                        break;
×
326
                                }
327

328
                                var activeQuery = readContext.Set<ImmediateJobEntity>()
5✔
329
                                        .AsNoTracking()
5✔
330
                                        .Where(job => job.QueueName == queue.QueueName
5✔
331
                                                && job.State == JobState.Active
5✔
332
                                                && job.LeaseExpiresAt > now);
5✔
333
                                var totalInflight = await activeQuery
5✔
334
                                        .CountAsync(cancellationToken)
5✔
335
                                        .ConfigureAwait(false);
5✔
336
                                var groupedHeadIds = groupedHeads.Select(static job => job.Id).ToArray();
5✔
337
                                var cursorQuery = readContext.Set<ImmediateFairQueueGroupEntity>()
5✔
338
                                        .AsNoTracking()
5✔
339
                                        .Where(group => group.QueueName == queue.QueueName);
5✔
340
                                var groupStateQuery = eligibleQuery
5✔
341
                                        .Where(job => groupedHeadIds.Contains(job.Id));
5✔
342
                                var groupStates = request.FairQueues!.GroupRoundRobin
5✔
343
                                        ? await groupStateQuery
5✔
344
                                                .Select(job => new FairQueueCandidateState(
5✔
345
                                                        job.Id,
5✔
346
                                                        activeQuery.Count(active => active.GroupId == job.GroupId),
5✔
347
                                                        cursorQuery
5✔
348
                                                                .Where(cursor => cursor.GroupId == job.GroupId)
5✔
349
                                                                .Select(static cursor => cursor.LastServedSequence)
5✔
350
                                                                .FirstOrDefault()
5✔
351
                                                ))
5✔
352
                                                .ToDictionaryAsync(static state => state.JobId, StringComparer.Ordinal, cancellationToken)
5✔
353
                                                .ConfigureAwait(false)
5✔
354
                                        : await groupStateQuery
5✔
355
                                                .Select(job => new FairQueueCandidateState(
5✔
356
                                                        job.Id,
5✔
357
                                                        activeQuery.Count(active => active.GroupId == job.GroupId),
5✔
358
                                                        0
5✔
359
                                                ))
5✔
360
                                                .ToDictionaryAsync(static state => state.JobId, StringComparer.Ordinal, cancellationToken)
4✔
361
                                                .ConfigureAwait(false);
5✔
362
                                var nextSequence = 0L;
5✔
363
                                if (request.FairQueues.GroupRoundRobin)
5✔
364
                                {
365
                                        var maxSequence = await cursorQuery
5✔
366
                                                .MaxAsync(static group => (long?)group.LastServedSequence, cancellationToken)
5✔
367
                                                .ConfigureAwait(false);
5✔
368
                                        nextSequence = (maxSequence ?? 0) + 1;
5✔
369
                                }
370

371
                                var candidates = ungroupedHead is null ? groupedHeads : [.. groupedHeads, ungroupedHead];
5✔
372
                                var ranked = candidates.Select(job =>
5✔
373
                                {
5✔
374
                                        FairQueueCandidateState? state = null;
5✔
375
                                        if (job.GroupId is not null)
5✔
376
                                                _ = groupStates.TryGetValue(job.Id, out state);
5✔
377
                                        var noisy = IsNoisy(job.GroupId, state?.Inflight ?? 0, totalInflight, request.FairQueues);
5✔
378
                                        return new
5✔
379
                                        {
5✔
380
                                                Job = job,
5✔
381
                                                Noisy = noisy,
5✔
382
                                                NoisyInflight = noisy ? state!.Inflight : 0,
5✔
383
                                                LastServedSequence = state?.LastServedSequence ?? 0,
5✔
384
                                        };
5✔
385
                                });
5✔
386
                                var selected = ranked
5✔
387
                                        .OrderBy(static candidate => candidate.Noisy)
5✔
388
                                        .ThenBy(static candidate => candidate.NoisyInflight)
5✔
389
                                        .ThenBy(candidate => request.FairQueues.GroupRoundRobin
5✔
390
                                                ? candidate.LastServedSequence
5✔
391
                                                : 0)
5✔
392
                                        .ThenBy(static candidate => candidate.Job.DueAt)
5✔
393
                                        .ThenBy(static candidate => candidate.Job.CreatedAt)
5✔
394
                                        .ThenBy(static candidate => candidate.Job.Id, StringComparer.Ordinal)
5✔
395
                                        .First()
5✔
396
                                        .Job;
5✔
397
                                var claimedJob = request.FairQueues.GroupRoundRobin
5✔
398
                                        ? await AcquireFairCandidateAsync(
5✔
399
                                                selected,
5✔
400
                                                request.WorkerId,
5✔
401
                                                request.Lease,
5✔
402
                                                now,
5✔
403
                                                nextSequence,
5✔
404
                                                cancellationToken
5✔
405
                                        ).ConfigureAwait(false)
5✔
406
                                        : GetFirstOrDefault(await AcquireCandidatesAsync(
5✔
407
                                                        [selected],
5✔
408
                                                        request.WorkerId,
5✔
409
                                                        request.Lease,
5✔
410
                                                        now,
5✔
411
                                                        cancellationToken
5✔
412
                                                ).ConfigureAwait(false));
5✔
413
                                if (claimedJob is null)
5✔
414
                                {
415
                                        if (++consecutiveFailedClaims >= MaxConsecutiveFailedFairClaims)
4✔
416
                                                break;
4✔
417
                                        continue;
418
                                }
419

420
                                consecutiveFailedClaims = 0;
5✔
421
                                jobCapacities[claimedJob.JobName]--;
5✔
422
                                queueCapacity--;
5✔
423
                                acquired.Add(claimedJob);
5✔
424
                        }
4✔
425
                }
4✔
426

427
                return acquired;
5✔
428
        }
4✔
429

430
        private async ValueTask<IReadOnlyList<JobRecord>> AcquireFairFastPathAsync(
431
                string queueName,
432
                Dictionary<string, int> jobCapacities,
433
                int queueCapacity,
434
                string workerId,
435
                TimeSpan lease,
436
                DateTimeOffset now,
437
                CancellationToken cancellationToken
438
        )
439
        {
440
                var acquired = new List<JobRecord>(queueCapacity);
4✔
441
                while (queueCapacity > 0)
4✔
442
                {
443
                        var eligibleNames = jobCapacities
4✔
444
                                .Where(static pair => pair.Value > 0)
4✔
445
                                .Select(static pair => pair.Key)
4✔
446
                                .ToArray();
4✔
447
                        if (eligibleNames.Length == 0)
4✔
448
                                break;
449

450
                        await using var readContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
451
                        var candidates = await readContext.Set<ImmediateJobEntity>()
4✔
452
                                .AsNoTracking()
4✔
453
                                .Where(job => job.QueueName == queueName && eligibleNames.Contains(job.JobName) &&
4✔
454
                                        (((job.State == JobState.Scheduled || job.State == JobState.Pending) && job.DueAt <= now)
4✔
455
                                                || (job.State == JobState.Active && job.LeaseExpiresAt <= now)))
4✔
456
                                .OrderBy(job => job.DueAt)
4✔
457
                                .ThenBy(job => job.CreatedAt)
4✔
458
                                .ThenBy(job => job.Id)
4✔
459
                                .Take(queueCapacity)
4✔
460
                                .ToListAsync(cancellationToken)
4✔
461
                                .ConfigureAwait(false);
4✔
462
                        if (candidates.Count == 0)
4✔
463
                                break;
464

465
                        var selected = new List<ImmediateJobEntity>(candidates.Count);
4✔
466
                        var selectionCapacities = new Dictionary<string, int>(jobCapacities, StringComparer.Ordinal);
4✔
467
                        foreach (var candidate in candidates)
4✔
468
                        {
469
                                if (selectionCapacities[candidate.JobName] <= 0)
4✔
470
                                        continue;
471
                                selectionCapacities[candidate.JobName]--;
4✔
472
                                selected.Add(candidate);
4✔
473
                        }
474

475
                        var claimed = await AcquireCandidatesAsync(
4✔
476
                                selected,
4✔
477
                                workerId,
4✔
478
                                lease,
4✔
479
                                now,
4✔
480
                                cancellationToken
4✔
481
                        ).ConfigureAwait(false);
4✔
482
                        foreach (var job in claimed)
4✔
483
                        {
484
                                jobCapacities[job.JobName]--;
4✔
485
                                queueCapacity--;
4✔
486
                                acquired.Add(job);
4✔
487
                        }
488

489
                        if (claimed.Count == 0)
4✔
490
                                break;
491
                }
3✔
492

493
                return acquired;
4✔
494
        }
3✔
495

496
        private async ValueTask<JobRecord?> AcquireFairCandidateAsync(
497
                ImmediateJobEntity candidate,
498
                string workerId,
499
                TimeSpan lease,
500
                DateTimeOffset now,
501
                long nextSequence,
502
                CancellationToken cancellationToken
503
        )
504
        {
505
                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
506
                var strategy = strategyContext.Database.CreateExecutionStrategy();
5✔
507
                return await strategy.ExecuteAsync(
5✔
508
                        operationCancellationToken => AcquireFairCandidateCoreAsync(
5✔
509
                                candidate,
5✔
510
                                workerId,
5✔
511
                                lease,
5✔
512
                                now,
5✔
513
                                nextSequence,
5✔
514
                                operationCancellationToken
5✔
515
                        ),
5✔
516
                        cancellationToken
5✔
517
                ).ConfigureAwait(false);
5✔
518
        }
4✔
519

520
        private async Task<JobRecord?> AcquireFairCandidateCoreAsync(
521
                ImmediateJobEntity candidate,
522
                string workerId,
523
                TimeSpan lease,
524
                DateTimeOffset now,
525
                long nextSequence,
526
                CancellationToken cancellationToken
527
        )
528
        {
529
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
530
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
531
                var entity = Copy(candidate);
5✔
532
                _ = context.Attach(entity);
5✔
533
                entity.State = JobState.Active;
5✔
534
                entity.WorkerId = workerId;
5✔
535
                entity.LeaseExpiresAt = now + lease;
5✔
536
                entity.Attempt++;
5✔
537
                entity.CompletedAt = null;
5✔
538
                entity.ExecutionTraceId = null;
5✔
539
                entity.ExecutionSpanId = null;
5✔
540
                entity.ExecutionStartedAt = null;
5✔
541
                entity.ConcurrencyStamp = Guid.NewGuid();
5✔
542

543
                if (candidate.GroupId is { } groupId)
5✔
544
                {
545
                        var group = await context.Set<ImmediateFairQueueGroupEntity>()
5✔
546
                                .SingleOrDefaultAsync(
5✔
547
                                        item => item.QueueName == candidate.QueueName && item.GroupId == groupId,
5✔
548
                                        cancellationToken
5✔
549
                                )
5✔
550
                                .ConfigureAwait(false);
5✔
551
                        if (group is null)
5✔
552
                        {
553
                                _ = context.Add(new ImmediateFairQueueGroupEntity
5✔
554
                                {
5✔
555
                                        QueueName = candidate.QueueName,
5✔
556
                                        GroupId = groupId,
5✔
557
                                        LastServedSequence = nextSequence,
5✔
558
                                        ConcurrencyStamp = Guid.NewGuid(),
5✔
559
                                });
5✔
560
                        }
561
                        else if (group.LastServedSequence >= nextSequence)
4✔
562
                        {
563
                                // Selection observed an older cursor snapshot. Re-rank instead of moving this group backward.
564
                                return null;
4✔
565
                        }
566
                        else
567
                        {
568
                                group.LastServedSequence = nextSequence;
4✔
569
                                group.ConcurrencyStamp = Guid.NewGuid();
4✔
570
                        }
571
                }
572

573
                if (entity.BatchId is { } batchId)
5✔
574
                {
575
                        var batch = await context.Set<ImmediateJobBatchEntity>()
×
576
                                .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
×
577
                                .ConfigureAwait(false);
×
578
                        if (batch is not null && batch.StartedAt is null)
×
579
                        {
580
                                batch.StartedAt = now;
×
581
                                batch.ConcurrencyStamp = Guid.NewGuid();
×
582
                        }
583
                }
584

585
                try
586
                {
587
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
588
                        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
5✔
589
                        return ToRecord(entity);
5✔
590
                }
591
                catch (DbUpdateException)
4✔
592
                {
593
                        // The job or its group cursor changed after candidate selection.
594
                        return null;
5✔
595
                }
596
        }
4✔
597

598
        private static JobRecord? GetFirstOrDefault(IReadOnlyList<JobRecord> jobs) =>
599
                jobs.Count == 0 ? null : jobs[0];
4✔
600

601
        private static bool IsNoisy(
602
                string? groupId,
603
                int inflight,
604
                int totalInflight,
605
                FairQueuePolicy policy
606
        )
607
        {
608
                return groupId is not null
5✔
609
                        && totalInflight > 0
5✔
610
                        && inflight >= policy.MinInflightForNoisy
5✔
611
                        && (double)inflight / totalInflight > policy.ConcurrencyShareThreshold;
5✔
612
        }
613

614
        private sealed record FairQueueCandidateState(
5✔
615
                string JobId,
5✔
616
                int Inflight,
5✔
617
                long LastServedSequence
5✔
618
        );
5✔
619

620
        /// <inheritdoc />
621
        public async ValueTask<IReadOnlyList<JobRecord>> AcquireJobsAsync(
622
                IReadOnlyCollection<string> jobIds,
623
                string workerId,
624
                TimeSpan lease,
625
                CancellationToken cancellationToken = default
626
        )
627
        {
628
                ArgumentNullException.ThrowIfNull(jobIds);
5✔
629
                ArgumentException.ThrowIfNullOrWhiteSpace(workerId);
5✔
630
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lease, TimeSpan.Zero);
5✔
631
                if (jobIds.Count == 0)
5✔
632
                        return [];
×
633

634
                var now = _timeProvider.GetUtcNow();
5✔
635
                var ids = jobIds.ToArray();
5✔
636
                await using var readContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
637
                var candidates = await readContext.Set<ImmediateJobEntity>()
5✔
638
                        .AsNoTracking()
5✔
639
                        .Where(job => ids.Contains(job.Id) &&
5✔
640
                                (((job.State == JobState.Scheduled || job.State == JobState.Pending) && job.DueAt <= now)
5✔
641
                                || (job.State == JobState.Active && job.LeaseExpiresAt <= now)))
5✔
642
                        .ToListAsync(cancellationToken)
5✔
643
                        .ConfigureAwait(false);
5✔
644

645
                return await AcquireCandidatesAsync(candidates, workerId, lease, now, cancellationToken).ConfigureAwait(false);
5✔
646
        }
4✔
647

648
        private async ValueTask<IReadOnlyList<JobRecord>> AcquireCandidatesAsync(
649
                List<ImmediateJobEntity> candidates,
650
                string workerId,
651
                TimeSpan lease,
652
                DateTimeOffset now,
653
                CancellationToken cancellationToken
654
        )
655
        {
656
                var acquired = new List<JobRecord>(candidates.Count);
5✔
657
                foreach (var candidate in candidates)
5✔
658
                {
659
                        await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
660
                        var entity = Copy(candidate);
5✔
661
                        _ = context.Attach(entity);
5✔
662
                        entity.State = JobState.Active;
5✔
663
                        entity.WorkerId = workerId;
5✔
664
                        entity.LeaseExpiresAt = now + lease;
5✔
665
                        entity.Attempt++;
5✔
666
                        entity.CompletedAt = null;
5✔
667
                        entity.ExecutionTraceId = null;
5✔
668
                        entity.ExecutionSpanId = null;
5✔
669
                        entity.ExecutionStartedAt = null;
5✔
670
                        entity.ConcurrencyStamp = Guid.NewGuid();
5✔
671
                        if (entity.BatchId is { } batchId)
5✔
672
                        {
673
                                var batch = await context.Set<ImmediateJobBatchEntity>()
5✔
674
                                        .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
5✔
675
                                        .ConfigureAwait(false);
5✔
676
                                if (batch is not null && batch.StartedAt is null)
5✔
677
                                {
678
                                        batch.StartedAt = now;
5✔
679
                                        batch.ConcurrencyStamp = Guid.NewGuid();
5✔
680
                                }
681
                        }
682

683
                        try
684
                        {
685
                                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
686
                                acquired.Add(ToRecord(entity));
5✔
687
                        }
5✔
688
                        catch (DbUpdateConcurrencyException)
1✔
689
                        {
690
                                // Another scheduler claimed or changed this candidate first.
691
                        }
1✔
692
                }
4✔
693

694
                return acquired;
5✔
695
        }
4✔
696

697
        /// <inheritdoc />
698
        public ValueTask SetExecutionTelemetryAsync(
699
                string jobId,
700
                string workerId,
701
                string? traceId,
702
                string? spanId,
703
                DateTimeOffset startedAt,
704
                CancellationToken cancellationToken = default
705
        ) => MutateOwnedAsync(jobId, workerId, job =>
1✔
706
        {
1✔
707
                job.ExecutionTraceId = traceId;
1✔
708
                job.ExecutionSpanId = spanId;
1✔
709
                job.ExecutionStartedAt = startedAt;
1✔
710
        }, cancellationToken);
1✔
711

712
        /// <inheritdoc />
713
        public ValueTask RenewLeaseAsync(string jobId, string workerId, TimeSpan lease, CancellationToken cancellationToken = default)
714
        {
715
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lease, TimeSpan.Zero);
×
716
                return MutateOwnedAsync(jobId, workerId, job => job.LeaseExpiresAt = _timeProvider.GetUtcNow() + lease, cancellationToken);
×
717
        }
718

719
        /// <inheritdoc />
720
        public ValueTask CompleteAsync(string jobId, string workerId, CancellationToken cancellationToken = default) =>
721
                CompleteWithContinuationsAsync(jobId, workerId, [], cancellationToken);
5✔
722

723
        /// <inheritdoc />
724
        public ValueTask CompleteWithContinuationsAsync(
725
                string jobId,
726
                string workerId,
727
                IReadOnlyList<JobContinuationAddition> additions,
728
                CancellationToken cancellationToken = default
729
        )
730
        {
731
                ArgumentNullException.ThrowIfNull(additions);
5✔
732
                return MutateOwnedWithDependenciesAsync(
5✔
733
                        jobId,
5✔
734
                        workerId,
5✔
735
                        error: null,
5✔
736
                        nextRetryAt: null,
5✔
737
                        succeeded: true,
5✔
738
                        additions,
5✔
739
                        cancellationToken
5✔
740
                );
5✔
741
        }
742

743
        /// <inheritdoc />
744
        public ValueTask AddBatchJobAsync(
745
                string currentJobId,
746
                JobRecord job,
747
                ContinuationOptions options,
748
                CancellationToken cancellationToken = default
749
        )
750
        {
751
                ArgumentNullException.ThrowIfNull(job);
5✔
752
                if (options == ContinuationOptions.Detached)
5✔
753
                        throw new ImmediateJobException("AddToBatchAsync cannot create a detached job.");
×
754
                return RetryConcurrencyAsync(
5✔
755
                        operationCancellationToken => AddBatchJobCoreAsync(
5✔
756
                                currentJobId,
5✔
757
                                job,
5✔
758
                                options,
5✔
759
                                operationCancellationToken
5✔
760
                        ),
5✔
761
                        cancellationToken
5✔
762
                );
5✔
763
        }
764

765
        /// <inheritdoc />
766
        public ValueTask FailAsync(
767
                string jobId,
768
                string workerId,
769
                string error,
770
                DateTimeOffset? nextRetryAt,
771
                CancellationToken cancellationToken = default
772
        ) => MutateOwnedWithDependenciesAsync(jobId, workerId, error, nextRetryAt, succeeded: false, [], cancellationToken);
5✔
773

774
        /// <inheritdoc />
775
        public async ValueTask UpsertRecurringAsync(RecurringJobSchedule schedule, CancellationToken cancellationToken = default)
776
        {
777
                ArgumentNullException.ThrowIfNull(schedule);
5✔
778
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
779
                if (await UpdateRecurringAsync(context, schedule, cancellationToken).ConfigureAwait(false) != 0)
5✔
780
                        return;
781
                await ThrowIfReplacingCodeDefinedScheduleAsync(context, schedule, cancellationToken).ConfigureAwait(false);
5✔
782

783
                _ = context.Add(ToEntity(schedule));
5✔
784
                try
785
                {
786
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
787
                }
5✔
788
                catch (DbUpdateException)
789
                {
790
                        // A competing node inserted the same schedule after our update attempt.
791
                        await using var retryContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
792
                        if (await UpdateRecurringAsync(retryContext, schedule, cancellationToken).ConfigureAwait(false) != 0)
×
793
                                return;
794
                        await ThrowIfReplacingCodeDefinedScheduleAsync(retryContext, schedule, cancellationToken).ConfigureAwait(false);
×
795
                        throw;
×
796
                }
797
        }
4✔
798

799
        /// <inheritdoc />
800
        public async ValueTask RemoveObsoleteCodeDefinedRecurringAsync(
801
                IReadOnlyCollection<string> activeScheduleNames,
802
                CancellationToken cancellationToken = default
803
        )
804
        {
805
                ArgumentNullException.ThrowIfNull(activeScheduleNames);
4✔
806
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
807
                var schedules = context.Set<ImmediateRecurringJobEntity>()
4✔
808
                        .Where(schedule => schedule.IsCodeDefined);
4✔
809
                if (activeScheduleNames.Count != 0)
4✔
810
                        schedules = schedules.Where(schedule => !activeScheduleNames.Contains(schedule.Name));
4✔
811
                _ = await schedules.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
4✔
812
        }
4✔
813

814
        /// <inheritdoc />
815
        public async ValueTask RemoveRecurringAsync(string name, CancellationToken cancellationToken = default)
816
        {
817
                ArgumentException.ThrowIfNullOrWhiteSpace(name);
5✔
818
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
819
                var removed = await context.Set<ImmediateRecurringJobEntity>()
5✔
820
                        .Where(schedule => schedule.Name == name && !schedule.IsCodeDefined)
5✔
821
                        .ExecuteDeleteAsync(cancellationToken)
5✔
822
                        .ConfigureAwait(false);
5✔
823
                if (removed != 0)
5✔
824
                        return;
825
                if (await context.Set<ImmediateRecurringJobEntity>()
5✔
826
                        .AnyAsync(schedule => schedule.Name == name, cancellationToken)
5✔
827
                        .ConfigureAwait(false))
5✔
828
                {
829
                        throw new ImmediateJobException("Code-defined recurring schedules cannot be deleted.");
4✔
830
                }
831

832
                throw new KeyNotFoundException($"Recurring schedule '{name}' was not found.");
5✔
833
        }
3✔
834

835
        /// <inheritdoc />
836
        public ValueTask PauseRecurringAsync(string name, CancellationToken cancellationToken = default)
837
                => MutateRecurringAsync(name, schedule => schedule.IsPaused = true, cancellationToken);
×
838

839
        /// <inheritdoc />
840
        public ValueTask ResumeRecurringAsync(string name, CancellationToken cancellationToken = default)
841
                => MutateRecurringAsync(name, schedule => schedule.IsPaused = false, cancellationToken);
×
842

843
        /// <inheritdoc />
844
        public async ValueTask<IReadOnlyList<RecurringJobSchedule>> GetDueRecurringAsync(DateTimeOffset now, int batchSize, CancellationToken cancellationToken = default)
845
        {
846
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(batchSize, 0);
×
847
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
848
                return await context.Set<ImmediateRecurringJobEntity>()
×
849
                        .AsNoTracking()
×
850
                        .Where(schedule => !schedule.IsPaused && schedule.NextRunAt <= now)
×
851
                        .OrderBy(schedule => schedule.NextRunAt)
×
852
                        .Take(batchSize)
×
853
                        .Select(schedule => new RecurringJobSchedule
×
854
                        {
×
855
                                Name = schedule.Name,
×
856
                                JobName = schedule.JobName,
×
857
                                Cron = schedule.Cron,
×
858
                                TimeZone = schedule.TimeZone,
×
859
                                IsCodeDefined = schedule.IsCodeDefined,
×
860
                                IsPaused = schedule.IsPaused,
×
861
                                NextRunAt = schedule.NextRunAt,
×
862
                                LastRunAt = schedule.LastRunAt,
×
863
                        })
×
864
                        .ToListAsync(cancellationToken)
×
865
                        .ConfigureAwait(false);
×
866
        }
867

868
        /// <inheritdoc />
869
        public async ValueTask<bool> MaterializeRecurringAsync(
870
                RecurringJobSchedule schedule,
871
                JobRecord job,
872
                DateTimeOffset nextRunAt,
873
                CancellationToken cancellationToken = default
874
        )
875
        {
876
                ArgumentNullException.ThrowIfNull(schedule);
5✔
877
                ArgumentNullException.ThrowIfNull(job);
5✔
878
                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
879
                var strategy = strategyContext.Database.CreateExecutionStrategy();
5✔
880
                return await strategy.ExecuteAsync(
5✔
881
                        operationCancellationToken => MaterializeRecurringCoreAsync(
5✔
882
                                schedule,
5✔
883
                                job,
5✔
884
                                nextRunAt,
5✔
885
                                operationCancellationToken
5✔
886
                        ),
5✔
887
                        cancellationToken
5✔
888
                ).ConfigureAwait(false);
5✔
889
        }
4✔
890

891
        private async Task<bool> MaterializeRecurringCoreAsync(
892
                RecurringJobSchedule schedule,
893
                JobRecord job,
894
                DateTimeOffset nextRunAt,
895
                CancellationToken cancellationToken
896
        )
897
        {
898
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
899
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
900
                var entity = await context.Set<ImmediateRecurringJobEntity>()
5✔
901
                        .SingleOrDefaultAsync(item => item.Name == schedule.Name, cancellationToken)
5✔
902
                        .ConfigureAwait(false);
5✔
903
                if (entity is null || entity.IsPaused || entity.NextRunAt != schedule.NextRunAt)
5✔
904
                        return false;
1✔
905

906
                entity.LastRunAt = schedule.NextRunAt;
5✔
907
                entity.NextRunAt = nextRunAt;
5✔
908
                entity.ConcurrencyStamp = Guid.NewGuid();
5✔
909
                _ = context.Add(ToEntity(job));
5✔
910
                try
911
                {
912
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
913
                        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
5✔
914
                        return true;
5✔
915
                }
916
                catch (DbUpdateException)
917
                {
918
                        await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
×
919
                        return false;
×
920
                }
921
        }
4✔
922

923
        /// <inheritdoc />
924
        public async ValueTask<JobMonitoringSnapshot> GetMonitoringSnapshotAsync(CancellationToken cancellationToken = default)
925
        {
926
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
927
                var rawCounts = await context.Set<ImmediateJobEntity>()
5✔
928
                        .AsNoTracking()
5✔
929
                        .GroupBy(job => job.State)
5✔
930
                        .Select(group => new { State = group.Key, Count = group.LongCount() })
5✔
931
                        .ToListAsync(cancellationToken)
5✔
932
                        .ConfigureAwait(false);
5✔
933
                var counts = Enum.GetValues<JobState>().ToDictionary(static state => state, static _ => 0L);
5✔
934
                foreach (var item in rawCounts)
5✔
935
                        counts[item.State] = item.Count;
5✔
936

937
                var recurring = await context.Set<ImmediateRecurringJobEntity>()
5✔
938
                        .AsNoTracking()
5✔
939
                        .OrderBy(schedule => schedule.Name)
5✔
940
                        .Select(schedule => new RecurringJobSchedule
5✔
941
                        {
5✔
942
                                Name = schedule.Name,
5✔
943
                                JobName = schedule.JobName,
5✔
944
                                Cron = schedule.Cron,
5✔
945
                                TimeZone = schedule.TimeZone,
5✔
946
                                IsCodeDefined = schedule.IsCodeDefined,
5✔
947
                                IsPaused = schedule.IsPaused,
5✔
948
                                NextRunAt = schedule.NextRunAt,
5✔
949
                                LastRunAt = schedule.LastRunAt,
5✔
950
                        })
5✔
951
                        .ToListAsync(cancellationToken)
5✔
952
                        .ConfigureAwait(false);
5✔
953
                var cutoff = _timeProvider.GetUtcNow() - TimeSpan.FromMinutes(2);
5✔
954
                var servers = await context.Set<ImmediateJobServerEntity>()
5✔
955
                        .AsNoTracking()
5✔
956
                        .Where(server => server.LastHeartbeat >= cutoff)
5✔
957
                        .OrderBy(server => server.WorkerId)
5✔
958
                        .Select(server => new JobServerSnapshot(server.WorkerId, server.LastHeartbeat, server.ActiveWorkers, server.MaxWorkers))
5✔
959
                        .ToListAsync(cancellationToken)
5✔
960
                        .ConfigureAwait(false);
5✔
961
                return new(_timeProvider.GetUtcNow(), counts, recurring, servers)
5✔
962
                {
5✔
963
                        Capabilities = this.GetCapabilities(),
5✔
964
                };
5✔
965
        }
4✔
966

967
        /// <inheritdoc />
968
        public async ValueTask<IReadOnlyList<JobRecord>> QueryJobsAsync(JobQuery query, CancellationToken cancellationToken = default)
969
        {
970
                ArgumentNullException.ThrowIfNull(query);
5✔
971
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
5✔
972
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
5✔
973
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
974
                var jobs = context.Set<ImmediateJobEntity>().AsNoTracking();
5✔
975
                if (query.Id is { } id)
5✔
976
                        jobs = jobs.Where(job => job.Id == id);
5✔
977
                if (query.State is { } state)
5✔
978
                        jobs = jobs.Where(job => job.State == state);
4✔
979
                if (!string.IsNullOrWhiteSpace(query.QueueName))
5✔
980
                        jobs = jobs.Where(job => job.QueueName == query.QueueName);
×
981
                if (!string.IsNullOrWhiteSpace(query.JobName))
5✔
982
                        jobs = jobs.Where(job => job.JobName == query.JobName);
×
983
                if (!string.IsNullOrWhiteSpace(query.Search))
5✔
984
                {
985
                        var search = query.Search.ToUpperInvariant();
×
986
                        // The parameterless form is intentionally used because relational providers translate it to SQL.
987
#pragma warning disable CA1304, CA1311, CA1862, MA0011
988
                        jobs = jobs.Where(job => job.JobName.ToUpper().Contains(search));
×
989
#pragma warning restore CA1304, CA1311, CA1862, MA0011
990
                }
991

992
                var entities = await jobs.OrderByDescending(job => job.CreatedAt)
5✔
993
                        .ThenBy(job => job.Id)
5✔
994
                        .Skip(query.Skip)
5✔
995
                        .Take(query.Take)
5✔
996
                        .ToListAsync(cancellationToken)
5✔
997
                        .ConfigureAwait(false);
5✔
998
                return [.. entities.Select(ToRecord)];
5✔
999
        }
4✔
1000

1001
        /// <inheritdoc />
1002
        public async ValueTask<BatchStatus?> GetBatchStatusAsync(
1003
                string batchId,
1004
                CancellationToken cancellationToken = default
1005
        )
1006
        {
1007
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
5✔
1008
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1009
                var batch = await context.Set<ImmediateJobBatchEntity>()
5✔
1010
                        .AsNoTracking()
5✔
1011
                        .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
5✔
1012
                        .ConfigureAwait(false);
5✔
1013
                return batch is null ? null : ToStatus(batch);
5✔
1014
        }
4✔
1015

1016
        /// <inheritdoc />
1017
        public async ValueTask<IReadOnlyList<JobContinuationEdge>> GetIncomingEdgesAsync(
1018
                IReadOnlyCollection<string> childJobIds,
1019
                CancellationToken cancellationToken = default
1020
        )
1021
        {
1022
                ArgumentNullException.ThrowIfNull(childJobIds);
5✔
1023
                foreach (var childJobId in childJobIds)
5✔
1024
                        ArgumentException.ThrowIfNullOrWhiteSpace(childJobId);
5✔
1025
                if (childJobIds.Count == 0)
5✔
1026
                        return [];
5✔
1027

1028
                var ids = childJobIds.Distinct(StringComparer.Ordinal).ToArray();
5✔
1029
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1030
                var edges = await context.Set<ImmediateJobContinuationEntity>()
5✔
1031
                        .AsNoTracking()
5✔
1032
                        .Where(edge => ids.Contains(edge.ChildJobId))
5✔
1033
                        .OrderBy(edge => edge.ChildJobId)
5✔
1034
                        .ThenBy(edge => edge.ParentKind)
5✔
1035
                        .ThenBy(edge => edge.ParentId)
5✔
1036
                        .ToListAsync(cancellationToken)
5✔
1037
                        .ConfigureAwait(false);
5✔
1038
                return [.. edges.Select(ToContinuationEdge)];
5✔
1039
        }
4✔
1040

1041
        /// <inheritdoc />
1042
        public async ValueTask<IReadOnlyList<BatchStatus>> QueryBatchesAsync(
1043
                JobBatchQuery query,
1044
                CancellationToken cancellationToken = default
1045
        )
1046
        {
1047
                ArgumentNullException.ThrowIfNull(query);
×
1048
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
×
1049
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
×
1050
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
1051
                var batches = context.Set<ImmediateJobBatchEntity>().AsNoTracking();
×
1052
                if (query.State is { } state)
×
1053
                        batches = batches.Where(batch => batch.State == state);
×
1054
                var entities = await batches.OrderByDescending(batch => batch.CreatedAt)
×
1055
                        .ThenBy(batch => batch.Id)
×
1056
                        .Skip(query.Skip)
×
1057
                        .Take(query.Take)
×
1058
                        .ToListAsync(cancellationToken)
×
1059
                        .ConfigureAwait(false);
×
1060
                return [.. entities.Select(ToStatus)];
×
1061
        }
1062

1063
        /// <inheritdoc />
1064
        public async ValueTask<IReadOnlyList<BatchMemberStatus>> QueryBatchMembersAsync(
1065
                string batchId,
1066
                BatchMemberQuery query,
1067
                CancellationToken cancellationToken = default
1068
        )
1069
        {
1070
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
1071
                ArgumentNullException.ThrowIfNull(query);
4✔
1072
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
4✔
1073
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
4✔
1074
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
1075
                var jobs = context.Set<ImmediateJobEntity>()
4✔
1076
                        .AsNoTracking()
4✔
1077
                        .Where(job => job.BatchId == batchId);
4✔
1078
                if (query.State is { } state)
4✔
1079
                        jobs = jobs.Where(job => job.State == state);
×
1080
                return await jobs.OrderBy(job => job.CreatedAt)
4✔
1081
                        .ThenBy(job => job.Id)
4✔
1082
                        .Skip(query.Skip)
4✔
1083
                        .Take(query.Take)
4✔
1084
                        .Select(job => new BatchMemberStatus(
4✔
1085
                                job.Id,
4✔
1086
                                job.JobName,
4✔
1087
                                job.QueueName,
4✔
1088
                                job.State,
4✔
1089
                                job.Attempt,
4✔
1090
                                job.CreatedAt,
4✔
1091
                                job.CompletedAt,
4✔
1092
                                job.LastError
4✔
1093
                        ))
4✔
1094
                        .ToListAsync(cancellationToken)
4✔
1095
                        .ConfigureAwait(false);
4✔
1096
        }
3✔
1097

1098
        /// <inheritdoc />
1099
        public async ValueTask<BatchGraph?> GetBatchGraphAsync(
1100
                string batchId,
1101
                CancellationToken cancellationToken = default
1102
        )
1103
        {
1104
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
1105
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
1106
                if (!await context.Set<ImmediateJobBatchEntity>()
4✔
1107
                        .AnyAsync(batch => batch.Id == batchId, cancellationToken)
4✔
1108
                        .ConfigureAwait(false))
4✔
1109
                {
1110
                        return null;
4✔
1111
                }
1112

1113
                var jobs = await context.Set<ImmediateJobEntity>()
×
1114
                        .AsNoTracking()
×
1115
                        .Where(job => job.BatchId == batchId)
×
1116
                        .OrderBy(job => job.CreatedAt)
×
1117
                        .ThenBy(job => job.Id)
×
1118
                        .Select(job => new BatchGraphNode(job.Id, job.JobName, job.State))
×
1119
                        .ToListAsync(cancellationToken)
×
1120
                        .ConfigureAwait(false);
×
1121
                var ids = jobs.Select(static job => job.JobId).ToArray();
×
1122
                var edges = ids.Length == 0
×
1123
                        ? []
×
1124
                        : await context.Set<ImmediateJobContinuationEntity>()
×
1125
                                .AsNoTracking()
×
1126
                                .Where(edge => ids.Contains(edge.ChildJobId))
×
1127
                                .OrderBy(edge => edge.ChildJobId)
×
1128
                                .ThenBy(edge => edge.ParentKind)
×
1129
                                .ThenBy(edge => edge.ParentId)
×
1130
                                .ToListAsync(cancellationToken)
×
1131
                                .ConfigureAwait(false);
×
1132
                return new(batchId, jobs, [.. edges.Select(ToGraphEdge)]);
4✔
1133
        }
3✔
1134

1135
        /// <inheritdoc />
1136
        public async ValueTask<JobStatus?> GetJobStatusAsync(
1137
                string jobId,
1138
                CancellationToken cancellationToken = default
1139
        )
1140
        {
1141
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
5✔
1142
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1143
                var job = await context.Set<ImmediateJobEntity>()
5✔
1144
                        .AsNoTracking()
5✔
1145
                        .SingleOrDefaultAsync(item => item.Id == jobId, cancellationToken)
5✔
1146
                        .ConfigureAwait(false);
5✔
1147
                if (job is null)
5✔
1148
                        return null;
5✔
1149
                var edges = await context.Set<ImmediateJobContinuationEntity>()
5✔
1150
                        .AsNoTracking()
5✔
1151
                        .Where(edge => edge.ChildJobId == jobId)
5✔
1152
                        .OrderBy(edge => edge.ParentKind)
5✔
1153
                        .ThenBy(edge => edge.ParentId)
5✔
1154
                        .ToListAsync(cancellationToken)
5✔
1155
                        .ConfigureAwait(false);
5✔
1156
                return new(
5✔
1157
                        job.Id,
5✔
1158
                        job.JobName,
5✔
1159
                        job.QueueName,
5✔
1160
                        job.State,
5✔
1161
                        job.Attempt,
5✔
1162
                        MaxAttempts: null,
5✔
1163
                        job.CreatedAt,
5✔
1164
                        job.DueAt,
5✔
1165
                        job.CompletedAt,
5✔
1166
                        job.LastError,
5✔
1167
                        job.BatchId,
5✔
1168
                        [.. edges.Select(ToGraphEdge)]
5✔
1169
                );
5✔
1170
        }
4✔
1171

1172
        /// <inheritdoc />
1173
        public ValueTask CancelBatchAsync(string batchId, CancellationToken cancellationToken = default)
1174
        {
1175
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
5✔
1176
                return ExecuteWithStrategyAsync(
5✔
1177
                        operationCancellationToken => CancelBatchCoreAsync(batchId, operationCancellationToken),
5✔
1178
                        cancellationToken
5✔
1179
                );
5✔
1180
        }
1181

1182
        private async Task CancelBatchCoreAsync(string batchId, CancellationToken cancellationToken)
1183
        {
1184
                var now = _timeProvider.GetUtcNow();
5✔
1185
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1186
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1187
                var batch = await context.Set<ImmediateJobBatchEntity>()
5✔
1188
                        .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
5✔
1189
                        .ConfigureAwait(false)
5✔
1190
                        ?? throw new KeyNotFoundException($"Batch '{batchId}' was not found.");
5✔
1191
                if (batch.State != BatchState.Executing)
5✔
1192
                        throw new ImmediateJobException("Only an executing batch can be cancelled.");
×
1193

1194
                var jobs = await context.Set<ImmediateJobEntity>()
5✔
1195
                        .Where(job => job.BatchId == batchId)
5✔
1196
                        .ToListAsync(cancellationToken)
5✔
1197
                        .ConfigureAwait(false);
5✔
1198
                foreach (var job in jobs)
5✔
1199
                {
1200
                        if (IsTerminal(job.State))
5✔
1201
                                continue;
1202
                        job.State = JobState.Cancelled;
5✔
1203
                        job.CompletedAt = now;
5✔
1204
                        job.WorkerId = null;
5✔
1205
                        job.LeaseExpiresAt = null;
5✔
1206
                        job.ConcurrencyStamp = Guid.NewGuid();
5✔
1207
                        await PropagateTerminalAsync(context, job, now, cancellationToken).ConfigureAwait(false);
5✔
1208
                }
1209

1210
                var terminalGroups = GetTerminalFairQueueGroups(context);
5✔
1211
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
1212
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
5✔
1213
                foreach (var (queueName, groupId) in terminalGroups)
5✔
1214
                {
1215
                        await TryRemoveFairQueueCursorAsync(queueName, groupId, CancellationToken.None).ConfigureAwait(false);
×
1216
                }
1217
        }
5✔
1218

1219
        /// <inheritdoc />
1220
        public ValueTask DeleteBatchAsync(string batchId, CancellationToken cancellationToken = default)
1221
        {
1222
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
5✔
1223
                return ExecuteWithStrategyAsync(
5✔
1224
                        operationCancellationToken => DeleteBatchCoreAsync(batchId, operationCancellationToken),
5✔
1225
                        cancellationToken
5✔
1226
                );
5✔
1227
        }
1228

1229
        private async Task DeleteBatchCoreAsync(string batchId, CancellationToken cancellationToken)
1230
        {
1231
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1232
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1233
                var batch = await context.Set<ImmediateJobBatchEntity>()
5✔
1234
                        .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
5✔
1235
                        .ConfigureAwait(false)
5✔
1236
                        ?? throw new KeyNotFoundException($"Batch '{batchId}' was not found.");
5✔
1237
                if (batch.State == BatchState.Executing)
4✔
1238
                        throw new ImmediateJobException("Only a terminal batch can be deleted.");
×
1239

1240
                var jobs = await context.Set<ImmediateJobEntity>()
4✔
1241
                        .Where(job => job.BatchId == batchId)
4✔
1242
                        .ToListAsync(cancellationToken)
4✔
1243
                        .ConfigureAwait(false);
4✔
1244
                var jobIds = jobs.Select(static job => job.Id).ToArray();
4✔
1245
                var edges = await context.Set<ImmediateJobContinuationEntity>()
4✔
1246
                        .Where(edge =>
4✔
1247
                                jobIds.Contains(edge.ChildJobId)
4✔
1248
                                || (edge.ParentKind == ContinuationParentKind.Job && jobIds.Contains(edge.ParentId))
4✔
1249
                                || (edge.ParentKind == ContinuationParentKind.Batch && edge.ParentId == batchId))
4✔
1250
                        .ToListAsync(cancellationToken)
4✔
1251
                        .ConfigureAwait(false);
4✔
1252
                context.RemoveRange(edges);
4✔
1253
                context.RemoveRange(jobs);
4✔
1254
                _ = context.Remove(batch);
4✔
1255
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
1256
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
4✔
1257
        }
4✔
1258

1259
        /// <inheritdoc />
1260
        public ValueTask RetryAsync(string jobId, CancellationToken cancellationToken = default)
1261
        {
1262
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
5✔
1263
                return ExecuteWithStrategyAsync(
5✔
1264
                        operationCancellationToken => RetryCoreAsync(jobId, operationCancellationToken),
5✔
1265
                        cancellationToken
5✔
1266
                );
5✔
1267
        }
1268

1269
        private async Task RetryCoreAsync(string jobId, CancellationToken cancellationToken)
1270
        {
1271
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1272
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1273
                var job = await context.Set<ImmediateJobEntity>()
5✔
1274
                        .SingleOrDefaultAsync(item => item.Id == jobId && item.State == JobState.Failed, cancellationToken)
5✔
1275
                        .ConfigureAwait(false);
5✔
1276
                if (job is null)
5✔
1277
                {
1278
                        if (await context.Set<ImmediateJobEntity>()
5✔
1279
                                .AnyAsync(item => item.Id == jobId, cancellationToken)
5✔
1280
                                .ConfigureAwait(false))
5✔
1281
                        {
1282
                                throw new ImmediateJobException("Only failed jobs can be retried.");
4✔
1283
                        }
1284

1285
                        throw new KeyNotFoundException($"Job '{jobId}' was not found.");
5✔
1286
                }
1287

1288
                if (job.BatchId is { } batchId)
1✔
1289
                {
1290
                        var batch = await context.Set<ImmediateJobBatchEntity>()
×
1291
                                .SingleAsync(item => item.Id == batchId, cancellationToken)
×
1292
                                .ConfigureAwait(false);
×
1293
                        batch.PendingCount++;
×
1294
                        batch.FailedCount = Math.Max(0, batch.FailedCount - 1);
×
1295
                        batch.State = BatchState.Executing;
×
1296
                        batch.CompletedAt = null;
×
1297
                        batch.ConcurrencyStamp = Guid.NewGuid();
×
1298
                }
1299

1300
                job.State = JobState.Pending;
1✔
1301
                job.DueAt = _timeProvider.GetUtcNow();
1✔
1302
                job.WorkerId = null;
1✔
1303
                job.LeaseExpiresAt = null;
1✔
1304
                job.CompletedAt = null;
1✔
1305
                job.LastError = null;
1✔
1306
                job.ConcurrencyStamp = Guid.NewGuid();
1✔
1307
                try
1308
                {
1309
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
1✔
1310
                }
1✔
1311
                catch (DbUpdateConcurrencyException)
1312
                {
1313
                        if (!await context.Set<ImmediateJobEntity>()
×
1314
                                .AsNoTracking()
×
1315
                                .AnyAsync(item => item.Id == jobId, cancellationToken)
×
1316
                                .ConfigureAwait(false))
×
1317
                        {
1318
                                throw new KeyNotFoundException($"Job '{jobId}' was not found.");
×
1319
                        }
1320

1321
                        throw new ImmediateJobException("Only failed jobs can be retried.");
×
1322
                }
1323

1324
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
1✔
1325
        }
1✔
1326

1327
        /// <inheritdoc />
1328
        public ValueTask DeleteAsync(string jobId, CancellationToken cancellationToken = default)
1329
        {
1330
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
5✔
1331
                return ExecuteWithStrategyAsync(
5✔
1332
                        operationCancellationToken => DeleteCoreAsync(jobId, operationCancellationToken),
5✔
1333
                        cancellationToken
5✔
1334
                );
5✔
1335
        }
1336

1337
        private async Task DeleteCoreAsync(string jobId, CancellationToken cancellationToken)
1338
        {
1339
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1340
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1341
                var job = await context.Set<ImmediateJobEntity>()
5✔
1342
                        .AsNoTracking()
5✔
1343
                        .SingleOrDefaultAsync(item => item.Id == jobId
5✔
1344
                                && (item.State == JobState.Succeeded || item.State == JobState.Failed || item.State == JobState.Cancelled), cancellationToken)
5✔
1345
                        .ConfigureAwait(false);
5✔
1346
                if (job is null)
5✔
1347
                {
1348
                        if (await context.Set<ImmediateJobEntity>()
5✔
1349
                                .AnyAsync(item => item.Id == jobId, cancellationToken)
5✔
1350
                                .ConfigureAwait(false))
5✔
1351
                        {
1352
                                throw new ImmediateJobException("Only terminal jobs can be deleted.");
4✔
1353
                        }
1354

1355
                        throw new KeyNotFoundException($"Job '{jobId}' was not found.");
5✔
1356
                }
1357

1358
                if (job.BatchId is not null)
1✔
1359
                        throw new ImmediateJobException("Batch members are deleted with their batch so the workflow remains coherent.");
×
1360
                _ = await context.Set<ImmediateJobContinuationEntity>()
1✔
1361
                        .Where(edge => edge.ChildJobId == jobId ||
1✔
1362
                                (edge.ParentKind == ContinuationParentKind.Job && edge.ParentId == jobId))
1✔
1363
                        .ExecuteDeleteAsync(cancellationToken)
1✔
1364
                        .ConfigureAwait(false);
1✔
1365
                var removed = await context.Set<ImmediateJobEntity>()
1✔
1366
                        .Where(item => item.Id == jobId &&
1✔
1367
                                (item.State == JobState.Succeeded || item.State == JobState.Failed || item.State == JobState.Cancelled))
1✔
1368
                        .ExecuteDeleteAsync(cancellationToken)
1✔
1369
                        .ConfigureAwait(false);
1✔
1370
                if (removed == 0)
1✔
1371
                {
1372
                        if (await context.Set<ImmediateJobEntity>()
×
1373
                                .AnyAsync(item => item.Id == jobId, cancellationToken)
×
1374
                                .ConfigureAwait(false))
×
1375
                        {
1376
                                throw new ImmediateJobException("Only terminal jobs can be deleted.");
×
1377
                        }
1378

1379
                        throw new KeyNotFoundException($"Job '{jobId}' was not found.");
×
1380
                }
1381

1382
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
1✔
1383
        }
1✔
1384

1385
        /// <inheritdoc />
1386
        public ValueTask PurgeJobsAsync(
1387
                TimeSpan succeededRetention,
1388
                TimeSpan failedRetention,
1389
                CancellationToken cancellationToken = default
1390
        )
1391
        {
1392
                var now = _timeProvider.GetUtcNow();
4✔
1393
                return ExecuteWithStrategyAsync(
4✔
1394
                        operationCancellationToken => PurgeJobsCoreAsync(
4✔
1395
                                now - succeededRetention,
4✔
1396
                                now - failedRetention,
4✔
1397
                                operationCancellationToken
4✔
1398
                        ),
4✔
1399
                        cancellationToken
4✔
1400
                );
4✔
1401
        }
1402

1403
        /// <inheritdoc />
1404
        public ValueTask PurgeBatchesAsync(
1405
                TimeSpan batchSucceededRetention,
1406
                TimeSpan batchFailedRetention,
1407
                CancellationToken cancellationToken = default
1408
        )
1409
        {
1410
                var now = _timeProvider.GetUtcNow();
4✔
1411
                return ExecuteWithStrategyAsync(
4✔
1412
                        operationCancellationToken => PurgeBatchesCoreAsync(
4✔
1413
                                now - batchSucceededRetention,
4✔
1414
                                now - batchFailedRetention,
4✔
1415
                                operationCancellationToken
4✔
1416
                        ),
4✔
1417
                        cancellationToken
4✔
1418
                );
4✔
1419
        }
1420

1421
        private async Task PurgeJobsCoreAsync(
1422
                DateTimeOffset succeededBefore,
1423
                DateTimeOffset failedBefore,
1424
                CancellationToken cancellationToken
1425
        )
1426
        {
1427
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
1428
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
4✔
1429
                var jobs = await context.Set<ImmediateJobEntity>()
4✔
1430
                        .Where(job => job.BatchId == null
4✔
1431
                                && ((job.State == JobState.Succeeded && job.CompletedAt < succeededBefore)
4✔
1432
                                || ((job.State == JobState.Failed || job.State == JobState.Cancelled) && job.CompletedAt < failedBefore))
4✔
1433
                        )
4✔
1434
                        .ToListAsync(cancellationToken)
4✔
1435
                        .ConfigureAwait(false);
4✔
1436
                if (jobs.Count != 0)
4✔
1437
                {
1438
                        var jobIds = jobs.Select(static job => job.Id).ToArray();
×
1439
                        var edges = await context.Set<ImmediateJobContinuationEntity>()
×
1440
                                .Where(edge =>
×
1441
                                        jobIds.Contains(edge.ChildJobId)
×
1442
                                        || (jobIds.Contains(edge.ParentId) && edge.ParentKind == ContinuationParentKind.Job)
×
1443
                                )
×
1444
                                .ToListAsync(cancellationToken)
×
1445
                                .ConfigureAwait(false);
×
1446
                        context.RemoveRange(edges);
×
1447
                }
1448

1449
                context.RemoveRange(jobs);
4✔
1450
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
1451
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
4✔
1452
        }
4✔
1453

1454
        private async Task PurgeBatchesCoreAsync(
1455
                DateTimeOffset batchSucceededBefore,
1456
                DateTimeOffset batchFailedBefore,
1457
                CancellationToken cancellationToken
1458
        )
1459
        {
1460
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
1461
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
4✔
1462
                var batches = await context.Set<ImmediateJobBatchEntity>()
4✔
1463
                        .Where(batch => (batch.State == BatchState.Succeeded && batch.CompletedAt < batchSucceededBefore)
4✔
1464
                                || ((batch.State == BatchState.Failed || batch.State == BatchState.Cancelled)
4✔
1465
                                        && batch.CompletedAt < batchFailedBefore))
4✔
1466
                        .ToListAsync(cancellationToken)
4✔
1467
                        .ConfigureAwait(false);
4✔
1468
                if (batches.Count != 0)
4✔
1469
                {
1470
                        var batchIds = batches.Select(static batch => batch.Id).ToArray();
×
1471
                        var memberIds = await context.Set<ImmediateJobEntity>()
×
1472
                                .Where(job => job.BatchId != null && batchIds.Contains(job.BatchId))
×
1473
                                .Select(job => job.Id)
×
1474
                                .ToListAsync(cancellationToken)
×
1475
                                .ConfigureAwait(false);
×
1476
                        var edges = await context.Set<ImmediateJobContinuationEntity>()
×
1477
                                .Where(edge =>
×
1478
                                        (batchIds.Contains(edge.ParentId) && edge.ParentKind == ContinuationParentKind.Batch)
×
1479
                                        || memberIds.Contains(edge.ChildJobId)
×
1480
                                        || (memberIds.Contains(edge.ParentId) && edge.ParentKind == ContinuationParentKind.Job)
×
1481
                                )
×
1482
                                .ToListAsync(cancellationToken)
×
1483
                                .ConfigureAwait(false);
×
1484
                        context.RemoveRange(edges);
×
1485
                        context.RemoveRange(batches);
×
1486
                }
1487

1488
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
1489
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
4✔
1490
        }
4✔
1491

1492
        /// <inheritdoc />
1493
        public async ValueTask HeartbeatAsync(JobServerSnapshot server, CancellationToken cancellationToken = default)
1494
        {
1495
                ArgumentNullException.ThrowIfNull(server);
1✔
1496
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
1✔
1497
                var cutoff = _timeProvider.GetUtcNow() - TimeSpan.FromMinutes(2);
1✔
1498
                _ = await context.Set<ImmediateJobServerEntity>()
1✔
1499
                        .Where(item => item.LastHeartbeat < cutoff)
1✔
1500
                        .ExecuteDeleteAsync(cancellationToken)
1✔
1501
                        .ConfigureAwait(false);
1✔
1502
                var entity = await context.Set<ImmediateJobServerEntity>().FindAsync([server.WorkerId], cancellationToken).ConfigureAwait(false);
1✔
1503
                if (entity is null)
1✔
1504
                {
1505
                        _ = context.Add(new ImmediateJobServerEntity
1✔
1506
                        {
1✔
1507
                                WorkerId = server.WorkerId,
1✔
1508
                                LastHeartbeat = server.LastHeartbeat,
1✔
1509
                                ActiveWorkers = server.ActiveWorkers,
1✔
1510
                                MaxWorkers = server.MaxWorkers,
1✔
1511
                        });
1✔
1512
                }
1513
                else
1514
                {
1515
                        entity.LastHeartbeat = server.LastHeartbeat;
×
1516
                        entity.ActiveWorkers = server.ActiveWorkers;
×
1517
                        entity.MaxWorkers = server.MaxWorkers;
×
1518
                }
1519

1520
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
1✔
1521
        }
1✔
1522

1523
        /// <inheritdoc />
1524
        public async ValueTask<bool> IsHealthyAsync(CancellationToken cancellationToken = default)
1525
        {
1526
                try
1527
                {
1528
                        await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
1529
                        return await context.Database.CanConnectAsync(cancellationToken).ConfigureAwait(false);
×
1530
                }
×
1531
                catch (Exception exception) when (exception is DbException or InvalidOperationException)
×
1532
                {
1533
                        return false;
×
1534
                }
1535
        }
×
1536

1537
        private ValueTask MutateOwnedWithDependenciesAsync(
1538
                string jobId,
1539
                string workerId,
1540
                string? error,
1541
                DateTimeOffset? nextRetryAt,
1542
                bool succeeded,
1543
                IReadOnlyList<JobContinuationAddition> additions,
1544
                CancellationToken cancellationToken
1545
        ) => RetryConcurrencyAsync(
5✔
1546
                operationCancellationToken => MutateOwnedCoreAsync(
5✔
1547
                        jobId,
5✔
1548
                        workerId,
5✔
1549
                        error,
5✔
1550
                        nextRetryAt,
5✔
1551
                        succeeded,
5✔
1552
                        additions,
5✔
1553
                        operationCancellationToken
5✔
1554
                ),
5✔
1555
                cancellationToken
5✔
1556
        );
5✔
1557

1558
        private async ValueTask RetryConcurrencyAsync(
1559
                Func<CancellationToken, Task> operation,
1560
                CancellationToken cancellationToken
1561
        )
1562
        {
1563
                for (var attempt = 0; attempt < MaxConcurrencyAttempts; attempt++)
5✔
1564
                {
1565
                        try
1566
                        {
1567
                                await ExecuteWithStrategyAsync(operation, cancellationToken).ConfigureAwait(false);
5✔
1568
                                return;
5✔
1569
                        }
1570
                        catch (DbUpdateConcurrencyException) when (attempt + 1 < MaxConcurrencyAttempts)
5✔
1571
                        {
1572
                                // The transaction rolled back, so the next attempt re-evaluates the graph from durable state.
1573
                        }
1✔
1574
                }
1575
        }
5✔
1576

1577
        private async ValueTask ExecuteWithStrategyAsync(
1578
                Func<CancellationToken, Task> operation,
1579
                CancellationToken cancellationToken
1580
        )
1581
        {
1582
                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1583
                var strategy = strategyContext.Database.CreateExecutionStrategy();
5✔
1584
                await strategy.ExecuteAsync(operation, cancellationToken).ConfigureAwait(false);
5✔
1585
        }
5✔
1586

1587
        private async ValueTask MutateOwnedAsync(
1588
                string jobId,
1589
                string workerId,
1590
                Action<ImmediateJobEntity> mutate,
1591
                CancellationToken cancellationToken
1592
        )
1593
        {
1594
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
1✔
1595
                var job = await context.Set<ImmediateJobEntity>()
1✔
1596
                        .SingleOrDefaultAsync(item => item.Id == jobId && item.State == JobState.Active && item.WorkerId == workerId, cancellationToken)
1✔
1597
                        .ConfigureAwait(false);
1✔
1598
                if (job is null)
1✔
1599
                        return;
1600
                mutate(job);
1✔
1601
                job.ConcurrencyStamp = Guid.NewGuid();
1✔
1602
                try
1603
                {
1604
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
1✔
1605
                }
1✔
1606
                catch (DbUpdateConcurrencyException)
×
1607
                {
1608
                        // A stale worker must not update a lease that has been reclaimed.
1609
                }
1✔
1610
        }
1✔
1611

1612
        private async Task MutateOwnedCoreAsync(
1613
                string jobId,
1614
                string workerId,
1615
                string? error,
1616
                DateTimeOffset? nextRetryAt,
1617
                bool succeeded,
1618
                IReadOnlyList<JobContinuationAddition> additions,
1619
                CancellationToken cancellationToken
1620
        )
1621
        {
1622
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1623
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1624
                var job = await context.Set<ImmediateJobEntity>()
5✔
1625
                        .SingleOrDefaultAsync(item => item.Id == jobId && item.State == JobState.Active && item.WorkerId == workerId, cancellationToken)
5✔
1626
                        .ConfigureAwait(false);
5✔
1627
                if (job is null)
5✔
1628
                        throw new ImmediateJobException($"Worker '{workerId}' does not own active job '{jobId}'.");
1✔
1629

1630
                var now = _timeProvider.GetUtcNow();
5✔
1631
                job.WorkerId = null;
5✔
1632
                job.LeaseExpiresAt = null;
5✔
1633
                job.LastError = error;
5✔
1634
                job.ConcurrencyStamp = Guid.NewGuid();
5✔
1635
                if (!succeeded && nextRetryAt is { } retryAt)
5✔
1636
                {
1637
                        job.State = retryAt <= now ? JobState.Pending : JobState.Scheduled;
×
1638
                        job.DueAt = retryAt;
×
1639
                        job.CompletedAt = null;
×
1640
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1641
                        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
×
1642
                        return;
×
1643
                }
1644

1645
                if (succeeded && additions.Count != 0)
5✔
1646
                {
1647
                        await FlushContinuationAdditionsAsync(context, job, additions, cancellationToken).ConfigureAwait(false);
5✔
1648
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1649
                }
1650

1651
                job.State = succeeded ? JobState.Succeeded : JobState.Failed;
5✔
1652
                job.CompletedAt = now;
5✔
1653
                await PropagateTerminalAsync(context, job, now, cancellationToken).ConfigureAwait(false);
5✔
1654
                var terminalGroups = GetTerminalFairQueueGroups(context);
5✔
1655
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
1656
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
5✔
1657
                foreach (var (queueName, groupId) in terminalGroups)
5✔
1658
                        await TryRemoveFairQueueCursorAsync(queueName, groupId, CancellationToken.None).ConfigureAwait(false);
5✔
1659
        }
5✔
1660

1661
        private static (string QueueName, string GroupId)[] GetTerminalFairQueueGroups(TContext context) =>
1662
                [
5✔
1663
                        .. context.ChangeTracker
5✔
1664
                                .Entries<ImmediateJobEntity>()
5✔
1665
                                .Select(static entry => entry.Entity)
5✔
1666
                                .Where(static job => job.GroupId is not null && IsTerminal(job.State))
5✔
1667
                                .Select(static job => (job.QueueName, GroupId: job.GroupId!))
5✔
1668
                                .Distinct(),
5✔
1669
                ];
5✔
1670

1671
        private async ValueTask TryRemoveFairQueueCursorAsync(
1672
                string queueName,
1673
                string groupId,
1674
                CancellationToken cancellationToken
1675
        )
1676
        {
1677
                try
1678
                {
1679
                        await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1680
                        if (await HasLiveGroupJobsAsync(context, queueName, groupId, cancellationToken).ConfigureAwait(false))
5✔
1681
                        {
1682
                                return;
1683
                        }
1684

1685
                        var cursor = await context.Set<ImmediateFairQueueGroupEntity>()
4✔
1686
                                .SingleOrDefaultAsync(
4✔
1687
                                        group => group.QueueName == queueName && group.GroupId == groupId,
4✔
1688
                                        cancellationToken
4✔
1689
                                )
4✔
1690
                                .ConfigureAwait(false);
4✔
1691
                        if (cursor is null)
4✔
1692
                                return;
1693

1694
                        _ = context.Remove(cursor);
4✔
1695
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
1696
                }
4✔
1697
                catch (Exception exception) when (
×
1698
                        !cancellationToken.IsCancellationRequested
×
1699
                        && exception is DbException or DbUpdateException
×
1700
                )
×
1701
                {
1702
                        // Cleanup is best-effort metadata maintenance and must not invalidate a committed transition.
1703
                }
×
1704
        }
5✔
1705

1706
        private static Task<bool> HasLiveGroupJobsAsync(
1707
                TContext context,
1708
                string queueName,
1709
                string groupId,
1710
                CancellationToken cancellationToken
1711
        ) => context.Set<ImmediateJobEntity>().AnyAsync(
5✔
1712
                job => job.QueueName == queueName
5✔
1713
                        && job.GroupId == groupId
5✔
1714
                        && (job.State == JobState.Pending
5✔
1715
                                || job.State == JobState.Scheduled
5✔
1716
                                || job.State == JobState.Active),
5✔
1717
                cancellationToken
5✔
1718
        );
5✔
1719

1720
        private async Task AddBatchJobCoreAsync(
1721
                string currentJobId,
1722
                JobRecord record,
1723
                ContinuationOptions options,
1724
                CancellationToken cancellationToken
1725
        )
1726
        {
1727
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1728
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1729
                var current = await context.Set<ImmediateJobEntity>()
5✔
1730
                        .SingleOrDefaultAsync(job => job.Id == currentJobId && job.State == JobState.Active, cancellationToken)
5✔
1731
                        .ConfigureAwait(false)
5✔
1732
                        ?? throw new ImmediateJobException($"The current active job '{currentJobId}' was not found.");
5✔
1733
                if (current.BatchId is not { } batchId)
5✔
1734
                        throw new ImmediateJobException("The current job does not belong to a batch.");
×
1735
                if (options is not (ContinuationOptions.BesideContinuations or ContinuationOptions.BeforeContinuations))
5✔
1736
                        throw new ArgumentOutOfRangeException(nameof(options));
×
1737
                if (!string.Equals(record.BatchId, batchId, StringComparison.Ordinal))
5✔
1738
                        throw new ImmediateJobException("The new job must belong to the current job's batch.");
5✔
1739
                if (record.State is JobState.Active or JobState.AwaitingContinuation || IsTerminal(record.State))
×
1740
                        throw new ImmediateJobException($"Concurrent batch member '{record.Id}' has invalid state '{record.State}'.");
×
1741

1742
                var batch = await context.Set<ImmediateJobBatchEntity>()
×
1743
                        .SingleAsync(item => item.Id == batchId && item.State == BatchState.Executing, cancellationToken)
×
1744
                        .ConfigureAwait(false);
×
1745
                var job = ToEntity(record);
×
1746
                _ = context.Add(job);
×
1747
                batch.TotalJobs++;
×
1748
                batch.PendingCount++;
×
1749
                batch.ConcurrencyStamp = Guid.NewGuid();
×
1750

1751
                if (options == ContinuationOptions.BeforeContinuations)
×
1752
                {
1753
                        var waiters = await GetActiveWaitersAsync(context, currentJobId, cancellationToken).ConfigureAwait(false);
×
1754
                        foreach (var waiter in waiters)
×
1755
                        {
1756
                                _ = context.Add(new ImmediateJobContinuationEntity
×
1757
                                {
×
1758
                                        ChildJobId = waiter.Id,
×
1759
                                        ParentKind = ContinuationParentKind.Job,
×
1760
                                        ParentId = job.Id,
×
1761
                                        Trigger = ContinuationTrigger.Success,
×
1762
                                });
×
1763
                                waiter.RemainingDependencies++;
×
1764
                                waiter.ConcurrencyStamp = Guid.NewGuid();
×
1765
                        }
1766
                }
1767

1768
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1769
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
×
1770
        }
×
1771

1772
        private static async Task FlushContinuationAdditionsAsync(
1773
                TContext context,
1774
                ImmediateJobEntity current,
1775
                IReadOnlyList<JobContinuationAddition> additions,
1776
                CancellationToken cancellationToken
1777
        )
1778
        {
1779
                var ids = new HashSet<string>(StringComparer.Ordinal);
5✔
1780
                var trackedAdditions = 0;
5✔
1781
                foreach (var addition in additions)
5✔
1782
                {
1783
                        ArgumentNullException.ThrowIfNull(addition);
5✔
1784
                        ArgumentNullException.ThrowIfNull(addition.Job);
5✔
1785
                        if (!ids.Add(addition.Job.Id))
5✔
1786
                                throw new ImmediateJobException("Buffered continuations contain duplicate job identifiers.");
×
1787
                        if (!Enum.IsDefined(addition.Trigger))
5✔
1788
                                throw new ArgumentOutOfRangeException(nameof(additions), "Unknown continuation trigger.");
4✔
1789
                        if (addition.Job.State is not (JobState.Pending or JobState.Scheduled))
5✔
1790
                                throw new ImmediateJobException($"Dynamic continuation '{addition.Job.Id}' has invalid state '{addition.Job.State}'.");
×
1791

1792
                        if (addition.Options == ContinuationOptions.Detached)
5✔
1793
                        {
1794
                                if (addition.Job.BatchId is not null)
5✔
1795
                                        throw new ImmediateJobException("A detached continuation cannot belong to a batch.");
5✔
1796
                        }
1797
                        else if (addition.Options is ContinuationOptions.BesideContinuations or ContinuationOptions.BeforeContinuations)
5✔
1798
                        {
1799
                                if (current.BatchId is null || !string.Equals(addition.Job.BatchId, current.BatchId, StringComparison.Ordinal))
5✔
1800
                                        throw new ImmediateJobException("A batch-tracked continuation must belong to the current job's batch.");
5✔
1801
                                trackedAdditions++;
×
1802
                        }
1803
                        else
1804
                        {
1805
                                throw new ArgumentOutOfRangeException(nameof(additions), "Unknown continuation option.");
4✔
1806
                        }
1807
                }
1808

1809
                var waiters = additions.Any(static addition => addition.Options == ContinuationOptions.BeforeContinuations)
×
1810
                        ? await GetActiveWaitersAsync(context, current.Id, cancellationToken).ConfigureAwait(false)
×
1811
                        : [];
×
1812
                ImmediateJobBatchEntity? batch = null;
1813
                if (trackedAdditions != 0)
×
1814
                {
1815
                        if (current.BatchId is not { } batchId)
×
1816
                                throw new ImmediateJobException("The current job does not belong to a batch.");
×
1817
                        batch = await context.Set<ImmediateJobBatchEntity>()
×
1818
                                .SingleAsync(item => item.Id == batchId && item.State == BatchState.Executing, cancellationToken)
×
1819
                                .ConfigureAwait(false);
×
1820
                        batch.TotalJobs += trackedAdditions;
×
1821
                        batch.PendingCount += trackedAdditions;
×
1822
                        batch.ConcurrencyStamp = Guid.NewGuid();
×
1823
                }
1824

1825
                foreach (var addition in additions)
×
1826
                {
1827
                        var job = ToEntity(addition.Job with
×
1828
                        {
×
1829
                                State = JobState.AwaitingContinuation,
×
1830
                                RemainingDependencies = 1,
×
1831
                        });
×
1832
                        _ = context.Add(job);
×
1833
                        _ = context.Add(new ImmediateJobContinuationEntity
×
1834
                        {
×
1835
                                ChildJobId = job.Id,
×
1836
                                ParentKind = ContinuationParentKind.Job,
×
1837
                                ParentId = current.Id,
×
1838
                                Trigger = addition.Trigger,
×
1839
                        });
×
1840

1841
                        if (addition.Options != ContinuationOptions.BeforeContinuations)
×
1842
                                continue;
1843
                        foreach (var waiter in waiters)
×
1844
                        {
1845
                                _ = context.Add(new ImmediateJobContinuationEntity
×
1846
                                {
×
1847
                                        ChildJobId = waiter.Id,
×
1848
                                        ParentKind = ContinuationParentKind.Job,
×
1849
                                        ParentId = job.Id,
×
1850
                                        Trigger = ContinuationTrigger.Success,
×
1851
                                });
×
1852
                                waiter.RemainingDependencies++;
×
1853
                                waiter.ConcurrencyStamp = Guid.NewGuid();
×
1854
                        }
1855
                }
1856
        }
×
1857

1858
        private static async Task<List<ImmediateJobEntity>> GetActiveWaitersAsync(
1859
                TContext context,
1860
                string currentJobId,
1861
                CancellationToken cancellationToken
1862
        )
1863
        {
1864
                var waiterIds = await context.Set<ImmediateJobContinuationEntity>()
×
1865
                        .Where(edge => edge.ParentKind == ContinuationParentKind.Job && edge.ParentId == currentJobId)
×
1866
                        .Select(edge => edge.ChildJobId)
×
1867
                        .Distinct()
×
1868
                        .ToArrayAsync(cancellationToken)
×
1869
                        .ConfigureAwait(false);
×
1870
                return waiterIds.Length == 0
×
1871
                        ? []
×
1872
                        : await context.Set<ImmediateJobEntity>()
×
1873
                                .Where(job => waiterIds.Contains(job.Id) && job.State == JobState.AwaitingContinuation)
×
1874
                                .ToListAsync(cancellationToken)
×
1875
                                .ConfigureAwait(false);
×
1876
        }
1877

1878
        private static async Task PropagateTerminalAsync(
1879
                TContext context,
1880
                ImmediateJobEntity terminalJob,
1881
                DateTimeOffset now,
1882
                CancellationToken cancellationToken
1883
        )
1884
        {
1885
                var parents = new Queue<(ContinuationParentKind Kind, string Id, ContinuationParentOutcome Outcome)>();
5✔
1886
                var processed = new HashSet<(ContinuationParentKind Kind, string Id)>();
5✔
1887
                parents.Enqueue((
5✔
1888
                        ContinuationParentKind.Job,
5✔
1889
                        terminalJob.Id,
5✔
1890
                        GetParentOutcome(terminalJob.State)
5✔
1891
                ));
5✔
1892
                await UpdateBatchForTerminalJobAsync(context, terminalJob, now, parents, cancellationToken).ConfigureAwait(false);
5✔
1893

1894
                while (parents.TryDequeue(out var parent))
5✔
1895
                {
1896
                        if (!processed.Add((parent.Kind, parent.Id)))
5✔
1897
                                continue;
1898
                        var edges = await context.Set<ImmediateJobContinuationEntity>()
5✔
1899
                                .Where(edge => edge.ParentKind == parent.Kind
5✔
1900
                                        && edge.ParentId == parent.Id
5✔
1901
                                        && edge.ParentOutcome == ContinuationParentOutcome.Unsettled)
5✔
1902
                                .ToListAsync(cancellationToken)
5✔
1903
                                .ConfigureAwait(false);
5✔
1904
                        foreach (var edge in edges)
5✔
1905
                        {
1906
                                edge.ParentOutcome = parent.Outcome;
5✔
1907
                                var child = await context.Set<ImmediateJobEntity>()
5✔
1908
                                        .SingleOrDefaultAsync(job => job.Id == edge.ChildJobId, cancellationToken)
5✔
1909
                                        .ConfigureAwait(false);
5✔
1910
                                if (child is null || IsTerminal(child.State))
5✔
1911
                                        continue;
1912

1913
                                if (child.State != JobState.AwaitingContinuation || child.RemainingDependencies <= 0)
5✔
1914
                                        continue;
1915
                                child.RemainingDependencies--;
5✔
1916
                                if (parent.Outcome == ContinuationParentOutcome.Failed)
5✔
1917
                                        child.FailedDependencies++;
5✔
1918
                                if (child.RemainingDependencies == 0)
5✔
1919
                                {
1920
                                        var cancel = await ShouldCancelSettledContinuationAsync(
5✔
1921
                                                context,
5✔
1922
                                                child.Id,
5✔
1923
                                                cancellationToken
5✔
1924
                                        ).ConfigureAwait(false);
5✔
1925
                                        if (cancel)
5✔
1926
                                        {
1927
                                                child.State = JobState.Cancelled;
5✔
1928
                                                child.CompletedAt = now;
5✔
1929
                                                parents.Enqueue((ContinuationParentKind.Job, child.Id, ContinuationParentOutcome.Other));
5✔
1930
                                                await UpdateBatchForTerminalJobAsync(context, child, now, parents, cancellationToken).ConfigureAwait(false);
5✔
1931
                                        }
1932
                                        else
1933
                                        {
1934
                                                child.State = child.DueAt <= now ? JobState.Pending : JobState.Scheduled;
5✔
1935
                                        }
1936
                                }
1937

1938
                                child.ConcurrencyStamp = Guid.NewGuid();
5✔
1939
                        }
4✔
1940
                }
4✔
1941
        }
5✔
1942

1943
        private static async Task<bool> ShouldCancelSettledContinuationAsync(
1944
                TContext context,
1945
                string childJobId,
1946
                CancellationToken cancellationToken
1947
        )
1948
        {
1949
                var edges = await context.Set<ImmediateJobContinuationEntity>()
5✔
1950
                        .Where(edge => edge.ChildJobId == childJobId)
5✔
1951
                        .ToListAsync(cancellationToken)
5✔
1952
                        .ConfigureAwait(false);
5✔
1953
                var requiresFailure = false;
5✔
1954
                var anyFailed = false;
5✔
1955
                foreach (var edge in edges)
5✔
1956
                {
1957
                        if (edge.Trigger == ContinuationTrigger.Success
5✔
1958
                                && edge.ParentOutcome != ContinuationParentOutcome.Succeeded)
5✔
UNCOV
1959
                                return true;
×
1960
                        requiresFailure |= edge.Trigger == ContinuationTrigger.Failure;
5✔
1961
                        anyFailed |= edge.ParentOutcome == ContinuationParentOutcome.Failed;
5✔
1962
                }
1963

1964
                return requiresFailure && !anyFailed;
5✔
1965
        }
4✔
1966

1967
        private static async Task UpdateBatchForTerminalJobAsync(
1968
                TContext context,
1969
                ImmediateJobEntity job,
1970
                DateTimeOffset now,
1971
                Queue<(ContinuationParentKind Kind, string Id, ContinuationParentOutcome Outcome)> parents,
1972
                CancellationToken cancellationToken
1973
        )
1974
        {
1975
                if (job.BatchId is not { } batchId)
5✔
1976
                        return;
5✔
1977
                var batch = await context.Set<ImmediateJobBatchEntity>()
5✔
1978
                        .SingleAsync(item => item.Id == batchId, cancellationToken)
5✔
1979
                        .ConfigureAwait(false);
5✔
1980
                batch.PendingCount = Math.Max(0, batch.PendingCount - 1);
5✔
1981
                switch (job.State)
5✔
1982
                {
1983
                        case JobState.Succeeded:
1984
                                batch.SucceededCount++;
5✔
1985
                                break;
5✔
1986
                        case JobState.Failed:
1987
                                batch.FailedCount++;
×
1988
                                break;
×
1989
                        case JobState.Cancelled:
1990
                                batch.CancelledCount++;
5✔
1991
                                break;
5✔
1992
                        case JobState.AwaitingContinuation:
1993
                        case JobState.AwaitingParameters:
1994
                        case JobState.Scheduled:
1995
                        case JobState.Pending:
1996
                        case JobState.Active:
1997
                                throw new ImmediateJobException($"Job '{job.Id}' is not terminal.");
×
1998
                        default:
1999
                                throw new ArgumentOutOfRangeException(nameof(job), job.State, "Unknown job state.");
×
2000
                }
2001

2002
                batch.ConcurrencyStamp = Guid.NewGuid();
5✔
2003
                if (batch.PendingCount != 0)
5✔
2004
                        return;
5✔
2005
                batch.State = GetTerminalBatchState(batch.FailedCount, batch.CancelledCount);
5✔
2006
                batch.CompletedAt = now;
5✔
2007
                parents.Enqueue((
5✔
2008
                        ContinuationParentKind.Batch,
5✔
2009
                        batch.Id,
5✔
2010
                        GetParentOutcome(batch.State)
5✔
2011
                ));
5✔
2012
        }
5✔
2013

2014
        private static async Task EvaluateInitialDependenciesAsync(
2015
                TContext context,
2016
                Dictionary<string, ImmediateJobEntity> jobs,
2017
                ImmediateJobContinuationEntity[] edges,
2018
                DateTimeOffset now,
2019
                CancellationToken cancellationToken
2020
        )
2021
        {
2022
                var externalJobIds = edges
5✔
2023
                        .Where(edge => edge.ParentKind == ContinuationParentKind.Job && !jobs.ContainsKey(edge.ParentId))
5✔
2024
                        .Select(static edge => edge.ParentId)
5✔
2025
                        .Distinct(StringComparer.Ordinal)
5✔
2026
                        .Order(StringComparer.Ordinal)
5✔
2027
                        .ToArray();
5✔
2028
                var externalBatchIds = edges
5✔
2029
                        .Where(static edge => edge.ParentKind == ContinuationParentKind.Batch)
5✔
2030
                        .Select(static edge => edge.ParentId)
5✔
2031
                        .Distinct(StringComparer.Ordinal)
5✔
2032
                        .Order(StringComparer.Ordinal)
5✔
2033
                        .ToArray();
5✔
2034
                var externalJobEntities = externalJobIds.Length == 0
5✔
2035
                        ? []
5✔
2036
                        : await context.Set<ImmediateJobEntity>()
5✔
2037
                                .Where(job => externalJobIds.Contains(job.Id))
5✔
2038
                                .OrderBy(static job => job.Id)
5✔
2039
                                .ToListAsync(cancellationToken)
5✔
2040
                                .ConfigureAwait(false);
5✔
2041
                var externalBatchEntities = externalBatchIds.Length == 0
5✔
2042
                        ? []
5✔
2043
                        : await context.Set<ImmediateJobBatchEntity>()
5✔
2044
                                .Where(batch => externalBatchIds.Contains(batch.Id))
5✔
2045
                                .OrderBy(static batch => batch.Id)
5✔
2046
                                .ToListAsync(cancellationToken)
5✔
2047
                                .ConfigureAwait(false);
5✔
2048
                var externalJobs = externalJobEntities.ToDictionary(job => job.Id, StringComparer.Ordinal);
5✔
2049
                var externalBatches = externalBatchEntities.ToDictionary(batch => batch.Id, StringComparer.Ordinal);
5✔
2050
                if (externalJobs.Count != externalJobIds.Length || externalBatches.Count != externalBatchIds.Length)
5✔
2051
                        throw new ImmediateJobException("A continuation parent does not exist.");
5✔
2052
                foreach (var parent in externalJobEntities.Where(parent => !IsTerminal(parent.State)))
5✔
2053
                        parent.ConcurrencyStamp = Guid.NewGuid();
5✔
2054
                foreach (var parent in externalBatchEntities.Where(parent => parent.State == BatchState.Executing))
5✔
2055
                        parent.ConcurrencyStamp = Guid.NewGuid();
4✔
2056

2057
                var incoming = edges.ToLookup(static edge => edge.ChildJobId, StringComparer.Ordinal);
5✔
2058
                var changed = true;
5✔
2059
                while (changed)
5✔
2060
                {
2061
                        changed = false;
5✔
2062
                        foreach (var job in jobs.Values)
5✔
2063
                        {
2064
                                var dependencies = incoming[job.Id];
5✔
2065
                                if (!dependencies.Any() || IsTerminal(job.State))
5✔
2066
                                        continue;
2067
                                var remaining = 0;
5✔
2068
                                var failedDependencies = 0;
5✔
2069
                                var requiresFailure = false;
5✔
2070
                                var violated = false;
5✔
2071
                                foreach (var edge in dependencies)
5✔
2072
                                {
2073
                                        var (terminal, parentSucceeded, parentFailed) = GetParentState(
5✔
2074
                                                edge,
5✔
2075
                                                jobs,
5✔
2076
                                                externalJobs,
5✔
2077
                                                externalBatches
5✔
2078
                                        );
5✔
2079
                                        requiresFailure |= edge.Trigger == ContinuationTrigger.Failure;
5✔
2080
                                        if (!terminal)
5✔
2081
                                        {
2082
                                                remaining++;
5✔
2083
                                                continue;
5✔
2084
                                        }
2085

2086
                                        edge.ParentOutcome = GetParentOutcome(parentSucceeded, parentFailed);
1✔
2087

2088
                                        if (parentFailed)
1✔
2089
                                                failedDependencies++;
×
2090
                                        if (edge.Trigger == ContinuationTrigger.Success && !parentSucceeded)
1✔
2091
                                                violated = true;
×
2092
                                }
2093

2094
                                job.FailedDependencies = failedDependencies;
5✔
2095
                                if (violated || (remaining == 0 && requiresFailure && failedDependencies == 0))
5✔
2096
                                {
2097
                                        job.State = JobState.Cancelled;
×
2098
                                        job.RemainingDependencies = 0;
×
2099
                                        job.CompletedAt = now;
×
2100
                                        changed = true;
×
2101
                                }
2102
                                else if (remaining == 0)
5✔
2103
                                {
2104
                                        job.State = job.DueAt <= now ? JobState.Pending : JobState.Scheduled;
1✔
2105
                                        job.RemainingDependencies = 0;
1✔
2106
                                }
2107
                                else
2108
                                {
2109
                                        job.State = JobState.AwaitingContinuation;
5✔
2110
                                        job.RemainingDependencies = remaining;
5✔
2111
                                }
2112
                        }
2113
                }
2114
        }
5✔
2115

2116
        private static (bool Terminal, bool Succeeded, bool Failed) GetParentState(
2117
                ImmediateJobContinuationEntity edge,
2118
                Dictionary<string, ImmediateJobEntity> jobs,
2119
                Dictionary<string, ImmediateJobEntity> externalJobs,
2120
                Dictionary<string, ImmediateJobBatchEntity> externalBatches
2121
        )
2122
        {
2123
                if (edge.ParentKind == ContinuationParentKind.Batch)
5✔
2124
                {
2125
                        var state = externalBatches[edge.ParentId].State;
5✔
2126
                        return (state != BatchState.Executing, state == BatchState.Succeeded, state == BatchState.Failed);
5✔
2127
                }
2128

2129
                var jobState = jobs.TryGetValue(edge.ParentId, out var job) ? job.State : externalJobs[edge.ParentId].State;
5✔
2130
                return (IsTerminal(jobState), jobState == JobState.Succeeded, jobState == JobState.Failed);
5✔
2131
        }
2132

2133
        private static void ThrowIfCyclic(
2134
                HashSet<string> jobIds,
2135
                IReadOnlyList<ImmediateJobContinuationEntity> edges
2136
        )
2137
        {
2138
                var indegree = jobIds.ToDictionary(static id => id, static _ => 0, StringComparer.Ordinal);
5✔
2139
                var children = new Dictionary<string, List<string>>(StringComparer.Ordinal);
5✔
2140
                foreach (var edge in edges.Where(edge =>
5✔
2141
                        edge.ParentKind == ContinuationParentKind.Job && jobIds.Contains(edge.ParentId)))
5✔
2142
                {
2143
                        indegree[edge.ChildJobId]++;
5✔
2144
                        if (!children.TryGetValue(edge.ParentId, out var values))
5✔
2145
                                children[edge.ParentId] = values = [];
5✔
2146
                        values.Add(edge.ChildJobId);
5✔
2147
                }
2148

2149
                var ready = new Queue<string>(indegree.Where(static pair => pair.Value == 0).Select(static pair => pair.Key));
5✔
2150
                var visited = 0;
5✔
2151
                while (ready.TryDequeue(out var parent))
5✔
2152
                {
2153
                        visited++;
5✔
2154
                        if (!children.TryGetValue(parent, out var values))
5✔
2155
                                continue;
2156
                        foreach (var child in values)
5✔
2157
                        {
2158
                                if (--indegree[child] == 0)
5✔
2159
                                        ready.Enqueue(child);
5✔
2160
                        }
2161
                }
2162

2163
                if (visited != jobIds.Count)
5✔
2164
                        throw new ImmediateJobException("The continuation graph contains a dependency cycle.");
×
2165
        }
5✔
2166

2167
        private static bool IsTerminal(JobState state) =>
2168
                state is JobState.Succeeded or JobState.Failed or JobState.Cancelled;
5✔
2169

2170
        private static ContinuationParentOutcome GetParentOutcome(JobState state) => state switch
5✔
2171
        {
5✔
2172
                JobState.Succeeded => ContinuationParentOutcome.Succeeded,
5✔
2173
                JobState.Failed => ContinuationParentOutcome.Failed,
5✔
2174
                JobState.AwaitingContinuation or
5✔
2175
                JobState.AwaitingParameters or
5✔
2176
                JobState.Scheduled or
5✔
2177
                JobState.Pending or
5✔
2178
                JobState.Active or
5✔
2179
                JobState.Cancelled => ContinuationParentOutcome.Other,
5✔
NEW
2180
                _ => throw new ArgumentOutOfRangeException(nameof(state), state, "Unknown job state."),
×
2181
        };
5✔
2182

2183
        private static ContinuationParentOutcome GetParentOutcome(BatchState state) => state switch
5✔
2184
        {
5✔
2185
                BatchState.Succeeded => ContinuationParentOutcome.Succeeded,
5✔
NEW
2186
                BatchState.Failed => ContinuationParentOutcome.Failed,
×
2187
                BatchState.Executing or BatchState.Cancelled => ContinuationParentOutcome.Other,
5✔
NEW
2188
                _ => throw new ArgumentOutOfRangeException(nameof(state), state, "Unknown batch state."),
×
2189
        };
5✔
2190

2191
        private static ContinuationParentOutcome GetParentOutcome(bool succeeded, bool failed) =>
2192
                (succeeded, failed) switch
1✔
2193
                {
1✔
2194
                        (true, _) => ContinuationParentOutcome.Succeeded,
1✔
NEW
2195
                        (_, true) => ContinuationParentOutcome.Failed,
×
NEW
2196
                        _ => ContinuationParentOutcome.Other,
×
2197
                };
1✔
2198

2199
        private static BatchState GetTerminalBatchState(int failed, int cancelled) =>
2200
                failed != 0 ? BatchState.Failed : cancelled != 0 ? BatchState.Cancelled : BatchState.Succeeded;
5✔
2201

2202
        private async ValueTask MutateRecurringAsync(string name, Action<ImmediateRecurringJobEntity> mutate, CancellationToken cancellationToken)
2203
        {
2204
                ArgumentException.ThrowIfNullOrWhiteSpace(name);
×
2205
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
2206
                var schedule = await context.Set<ImmediateRecurringJobEntity>().FindAsync([name], cancellationToken).ConfigureAwait(false);
×
2207
                if (schedule is null)
×
2208
                        return;
2209
                mutate(schedule);
×
2210
                schedule.ConcurrencyStamp = Guid.NewGuid();
×
2211
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
2212
        }
×
2213

2214
        private static Task<int> UpdateRecurringAsync(
2215
                TContext context,
2216
                RecurringJobSchedule schedule,
2217
                CancellationToken cancellationToken
2218
        )
2219
        {
2220
                var concurrencyStamp = Guid.NewGuid();
5✔
2221
                return context.Set<ImmediateRecurringJobEntity>()
5✔
2222
                        .Where(entity => entity.Name == schedule.Name && (schedule.IsCodeDefined || !entity.IsCodeDefined))
5✔
2223
                        .ExecuteUpdateAsync(setters => setters
5✔
2224
                                .SetProperty(entity => entity.JobName, schedule.JobName)
5✔
2225
                                .SetProperty(entity => entity.Cron, schedule.Cron)
5✔
2226
                                .SetProperty(entity => entity.TimeZone, schedule.TimeZone)
5✔
2227
                                .SetProperty(entity => entity.IsCodeDefined, schedule.IsCodeDefined)
5✔
2228
                                .SetProperty(entity => entity.NextRunAt, schedule.NextRunAt)
5✔
2229
                                .SetProperty(entity => entity.ConcurrencyStamp, concurrencyStamp),
5✔
2230
                                cancellationToken);
5✔
2231
        }
2232

2233
        private static async Task ThrowIfReplacingCodeDefinedScheduleAsync(
2234
                TContext context,
2235
                RecurringJobSchedule schedule,
2236
                CancellationToken cancellationToken
2237
        )
2238
        {
2239
                if (!schedule.IsCodeDefined && await context.Set<ImmediateRecurringJobEntity>()
5✔
2240
                        .AnyAsync(entity => entity.Name == schedule.Name && entity.IsCodeDefined, cancellationToken)
5✔
2241
                        .ConfigureAwait(false))
5✔
2242
                {
2243
                        throw new ImmediateJobException("Code-defined recurring schedules cannot be replaced by dynamic schedules.");
4✔
2244
                }
2245
        }
5✔
2246

2247
        private static ImmediateJobEntity ToEntity(JobRecord job) => new()
5✔
2248
        {
5✔
2249
                Id = job.Id,
5✔
2250
                QueueName = job.QueueName,
5✔
2251
                JobName = job.JobName,
5✔
2252
                GroupId = job.GroupId,
5✔
2253
                Payload = job.Payload,
5✔
2254
                Context = job.Context,
5✔
2255
                State = job.State,
5✔
2256
                DueAt = job.DueAt,
5✔
2257
                CreatedAt = job.CreatedAt,
5✔
2258
                Attempt = job.Attempt,
5✔
2259
                WorkerId = job.WorkerId,
5✔
2260
                LeaseExpiresAt = job.LeaseExpiresAt,
5✔
2261
                LastError = job.LastError,
5✔
2262
                CompletedAt = job.CompletedAt,
5✔
2263
                RecurringKey = job.RecurringKey,
5✔
2264
                TraceParent = job.TraceParent,
5✔
2265
                TraceState = job.TraceState,
5✔
2266
                ExecutionTraceId = job.ExecutionTraceId,
5✔
2267
                ExecutionSpanId = job.ExecutionSpanId,
5✔
2268
                ExecutionStartedAt = job.ExecutionStartedAt,
5✔
2269
                BatchId = job.BatchId,
5✔
2270
                RemainingDependencies = job.RemainingDependencies,
5✔
2271
                FailedDependencies = job.FailedDependencies,
5✔
2272
                ConcurrencyStamp = Guid.NewGuid(),
5✔
2273
        };
5✔
2274

2275
        private static ImmediateJobContinuationEntity ToEntity(JobContinuationEdge edge)
2276
        {
2277
                var hasJobParent = edge.ParentJobId is not null;
5✔
2278
                var hasBatchParent = edge.ParentBatchId is not null;
5✔
2279
                if (hasJobParent == hasBatchParent)
5✔
2280
                        throw new ImmediateJobException("A continuation edge must identify exactly one parent job or batch.");
×
2281
                return new()
5✔
2282
                {
5✔
2283
                        ChildJobId = edge.ChildJobId,
5✔
2284
                        ParentKind = hasJobParent ? ContinuationParentKind.Job : ContinuationParentKind.Batch,
5✔
2285
                        ParentId = edge.ParentJobId ?? edge.ParentBatchId!,
5✔
2286
                        Trigger = edge.Trigger,
5✔
2287
                };
5✔
2288
        }
2289

2290
        private static ImmediateJobEntity Copy(ImmediateJobEntity job) => new()
5✔
2291
        {
5✔
2292
                Id = job.Id,
5✔
2293
                QueueName = job.QueueName,
5✔
2294
                JobName = job.JobName,
5✔
2295
                GroupId = job.GroupId,
5✔
2296
                Payload = job.Payload,
5✔
2297
                Context = job.Context,
5✔
2298
                State = job.State,
5✔
2299
                DueAt = job.DueAt,
5✔
2300
                CreatedAt = job.CreatedAt,
5✔
2301
                Attempt = job.Attempt,
5✔
2302
                WorkerId = job.WorkerId,
5✔
2303
                LeaseExpiresAt = job.LeaseExpiresAt,
5✔
2304
                LastError = job.LastError,
5✔
2305
                CompletedAt = job.CompletedAt,
5✔
2306
                RecurringKey = job.RecurringKey,
5✔
2307
                TraceParent = job.TraceParent,
5✔
2308
                TraceState = job.TraceState,
5✔
2309
                ExecutionTraceId = job.ExecutionTraceId,
5✔
2310
                ExecutionSpanId = job.ExecutionSpanId,
5✔
2311
                ExecutionStartedAt = job.ExecutionStartedAt,
5✔
2312
                BatchId = job.BatchId,
5✔
2313
                RemainingDependencies = job.RemainingDependencies,
5✔
2314
                FailedDependencies = job.FailedDependencies,
5✔
2315
                ConcurrencyStamp = job.ConcurrencyStamp,
5✔
2316
        };
5✔
2317

2318
        private static JobRecord ToRecord(ImmediateJobEntity job) => new()
5✔
2319
        {
5✔
2320
                Id = job.Id,
5✔
2321
                QueueName = job.QueueName,
5✔
2322
                JobName = job.JobName,
5✔
2323
                GroupId = job.GroupId,
5✔
2324
                Payload = job.Payload,
5✔
2325
                Context = job.Context,
5✔
2326
                State = job.State,
5✔
2327
                DueAt = job.DueAt,
5✔
2328
                CreatedAt = job.CreatedAt,
5✔
2329
                Attempt = job.Attempt,
5✔
2330
                WorkerId = job.WorkerId,
5✔
2331
                LeaseExpiresAt = job.LeaseExpiresAt,
5✔
2332
                LastError = job.LastError,
5✔
2333
                CompletedAt = job.CompletedAt,
5✔
2334
                RecurringKey = job.RecurringKey,
5✔
2335
                TraceParent = job.TraceParent,
5✔
2336
                TraceState = job.TraceState,
5✔
2337
                ExecutionTraceId = job.ExecutionTraceId,
5✔
2338
                ExecutionSpanId = job.ExecutionSpanId,
5✔
2339
                ExecutionStartedAt = job.ExecutionStartedAt,
5✔
2340
                BatchId = job.BatchId,
5✔
2341
                RemainingDependencies = job.RemainingDependencies,
5✔
2342
                FailedDependencies = job.FailedDependencies,
5✔
2343
        };
5✔
2344

2345
        private static BatchStatus ToStatus(ImmediateJobBatchEntity batch) => new(
5✔
2346
                batch.Id,
5✔
2347
                batch.State,
5✔
2348
                batch.TotalJobs,
5✔
2349
                batch.SucceededCount,
5✔
2350
                batch.FailedCount,
5✔
2351
                batch.CancelledCount,
5✔
2352
                batch.PendingCount,
5✔
2353
                batch.CreatedAt,
5✔
2354
                batch.StartedAt,
5✔
2355
                batch.CompletedAt,
5✔
2356
                BatchStatus.CalculateFractionSettled(batch.TotalJobs, batch.PendingCount)
5✔
2357
        );
5✔
2358

2359
        private static JobContinuationEdge ToContinuationEdge(ImmediateJobContinuationEntity edge) => new()
5✔
2360
        {
5✔
2361
                ChildJobId = edge.ChildJobId,
5✔
2362
                ParentJobId = edge.ParentKind == ContinuationParentKind.Job ? edge.ParentId : null,
5✔
2363
                ParentBatchId = edge.ParentKind == ContinuationParentKind.Batch ? edge.ParentId : null,
5✔
2364
                Trigger = edge.Trigger,
5✔
2365
        };
5✔
2366

2367
        private static BatchGraphEdge ToGraphEdge(ImmediateJobContinuationEntity edge) => new(
5✔
2368
                edge.ChildJobId,
5✔
2369
                edge.ParentKind == ContinuationParentKind.Job ? edge.ParentId : null,
5✔
2370
                edge.ParentKind == ContinuationParentKind.Batch ? edge.ParentId : null,
5✔
2371
                edge.Trigger
5✔
2372
        );
5✔
2373

2374
        private static ImmediateRecurringJobEntity ToEntity(RecurringJobSchedule schedule) => new()
5✔
2375
        {
5✔
2376
                Name = schedule.Name,
5✔
2377
                JobName = schedule.JobName,
5✔
2378
                Cron = schedule.Cron,
5✔
2379
                TimeZone = schedule.TimeZone,
5✔
2380
                IsCodeDefined = schedule.IsCodeDefined,
5✔
2381
                IsPaused = schedule.IsPaused,
5✔
2382
                NextRunAt = schedule.NextRunAt,
5✔
2383
                LastRunAt = schedule.LastRunAt,
5✔
2384
                ConcurrencyStamp = Guid.NewGuid(),
5✔
2385
        };
5✔
2386
}
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