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

ImmediatePlatform / Immediate.Jobs / 30575137802

30 Jul 2026 07:30PM UTC coverage: 81.264% (-0.2%) from 81.475%
30575137802

Pull #71

github

web-flow
Merge d9568563b into ca430db22
Pull Request #71: Add `Meziantou.Analyzers` for code quality/style

142 of 200 new or added lines in 21 files covered. (71.0%)

5 existing lines in 4 files now uncovered.

7187 of 8844 relevant lines covered (81.26%)

2.72 hits per line

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

81.64
/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 MaxConsecutiveFailedFairClaims = 5;
20
        private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
5✔
21

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

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

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

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

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

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

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

97
        private async ValueTask ExecuteGraphInsertAsync(
98
                JobBatchRecord? batch,
99
                IReadOnlyList<JobRecord> jobs,
100
                IReadOnlyList<JobContinuationEdge> edges,
101
                CancellationToken cancellationToken
102
        )
103
        {
104
                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
105
                var strategy = strategyContext.Database.CreateExecutionStrategy();
5✔
106
                await strategy.ExecuteAsync(
5✔
107
                        operationCancellationToken => InsertGraphCoreAsync(batch, jobs, edges, operationCancellationToken),
5✔
108
                        cancellationToken
5✔
109
                ).ConfigureAwait(false);
5✔
110
        }
5✔
111

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

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

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

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

166
                context.AddRange(jobEntities.Values);
5✔
167
                context.AddRange(edgeEntities);
5✔
168
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
169
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
5✔
170
        }
5✔
171

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

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

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

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

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

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

239
                                if (claimed.Count == 0)
5✔
240
                                        break;
241
                        }
4✔
242
                }
4✔
243

244
                return acquired;
5✔
245
        }
4✔
246

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

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

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

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

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

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

424
                                consecutiveFailedClaims = 0;
5✔
425
                                jobCapacities[claimedJob.JobName]--;
5✔
426
                                queueCapacity--;
5✔
427
                                acquired.Add(claimedJob);
5✔
428
                        }
4✔
429
                }
4✔
430

431
                return acquired;
5✔
432
        }
4✔
433

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

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

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

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

493
                        if (claimed.Count == 0)
4✔
494
                                break;
495
                }
3✔
496

497
                return acquired;
4✔
498
        }
3✔
499

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

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

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

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

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

602
        private static JobRecord? GetFirstOrDefault(IReadOnlyList<JobRecord> jobs) =>
603
                jobs.Count == 0 ? null : jobs[0];
4✔
604

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

618
        private sealed record FairQueueCandidateState(
5✔
619
                string JobId,
5✔
620
                int Inflight,
5✔
621
                long LastServedSequence
5✔
622
        );
5✔
623

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

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

649
                return await AcquireCandidatesAsync(candidates, workerId, lease, now, cancellationToken).ConfigureAwait(false);
5✔
650
        }
4✔
651

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

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

698
                return acquired;
5✔
699
        }
4✔
700

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

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

723
        /// <inheritdoc />
724
        public ValueTask CompleteAsync(string jobId, string workerId, CancellationToken cancellationToken = default) =>
725
                CompleteWithContinuationsAsync(jobId, workerId, [], cancellationToken);
5✔
726

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

747
        /// <inheritdoc />
748
        public async ValueTask AddBatchJobAsync(
749
                string currentJobId,
750
                JobRecord job,
751
                ContinuationOptions options,
752
                CancellationToken cancellationToken = default
753
        )
754
        {
755
                ArgumentNullException.ThrowIfNull(job);
5✔
756
                if (options == ContinuationOptions.Detached)
5✔
757
                        throw new ImmediateJobException("AddToBatchAsync cannot create a detached job.");
×
758
                const int MaxConcurrencyAttempts = 5;
759
                for (var attempt = 0; attempt < MaxConcurrencyAttempts; attempt++)
5✔
760
                {
761
                        try
762
                        {
763
                                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
764
                                var strategy = strategyContext.Database.CreateExecutionStrategy();
5✔
765
                                await strategy.ExecuteAsync(
5✔
766
                                        operationCancellationToken => AddBatchJobCoreAsync(
5✔
767
                                                currentJobId,
5✔
768
                                                job,
5✔
769
                                                options,
5✔
770
                                                operationCancellationToken
5✔
771
                                        ),
5✔
772
                                        cancellationToken
5✔
773
                                ).ConfigureAwait(false);
5✔
774
                                return;
×
775
                        }
×
776
                        catch (DbUpdateConcurrencyException) when (attempt + 1 < MaxConcurrencyAttempts)
5✔
777
                        {
778
                        }
×
779
                }
780
        }
×
781

782
        /// <inheritdoc />
783
        public ValueTask FailAsync(
784
                string jobId,
785
                string workerId,
786
                string error,
787
                DateTimeOffset? nextRetryAt,
788
                CancellationToken cancellationToken = default
789
        ) => MutateOwnedWithDependenciesAsync(jobId, workerId, error, nextRetryAt, succeeded: false, [], cancellationToken);
5✔
790

791
        /// <inheritdoc />
792
        public async ValueTask UpsertRecurringAsync(RecurringJobSchedule schedule, CancellationToken cancellationToken = default)
793
        {
794
                ArgumentNullException.ThrowIfNull(schedule);
5✔
795
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
796
                if (await UpdateRecurringAsync(context, schedule, cancellationToken).ConfigureAwait(false) != 0)
5✔
797
                        return;
798
                await ThrowIfReplacingCodeDefinedScheduleAsync(context, schedule, cancellationToken).ConfigureAwait(false);
5✔
799

800
                _ = context.Add(ToEntity(schedule));
5✔
801
                try
802
                {
803
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
804
                }
5✔
805
                catch (DbUpdateException)
806
                {
807
                        // A competing node inserted the same schedule after our update attempt.
808
                        await using var retryContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
809
                        if (await UpdateRecurringAsync(retryContext, schedule, cancellationToken).ConfigureAwait(false) != 0)
×
810
                                return;
811
                        await ThrowIfReplacingCodeDefinedScheduleAsync(retryContext, schedule, cancellationToken).ConfigureAwait(false);
×
812
                        throw;
×
813
                }
814
        }
4✔
815

816
        /// <inheritdoc />
817
        public async ValueTask RemoveObsoleteCodeDefinedRecurringAsync(
818
                IReadOnlyCollection<string> activeScheduleNames,
819
                CancellationToken cancellationToken = default
820
        )
821
        {
822
                ArgumentNullException.ThrowIfNull(activeScheduleNames);
4✔
823
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
824
                var schedules = context.Set<ImmediateRecurringJobEntity>()
4✔
825
                        .Where(schedule => schedule.IsCodeDefined);
4✔
826
                if (activeScheduleNames.Count != 0)
4✔
827
                        schedules = schedules.Where(schedule => !activeScheduleNames.Contains(schedule.Name));
4✔
828
                _ = await schedules.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
4✔
829
        }
4✔
830

831
        /// <inheritdoc />
832
        public async ValueTask RemoveRecurringAsync(string name, CancellationToken cancellationToken = default)
833
        {
834
                ArgumentException.ThrowIfNullOrWhiteSpace(name);
5✔
835
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
836
                var removed = await context.Set<ImmediateRecurringJobEntity>()
5✔
837
                        .Where(schedule => schedule.Name == name && !schedule.IsCodeDefined)
5✔
838
                        .ExecuteDeleteAsync(cancellationToken)
5✔
839
                        .ConfigureAwait(false);
5✔
840
                if (removed != 0)
5✔
841
                        return;
842
                if (await context.Set<ImmediateRecurringJobEntity>()
5✔
843
                        .AnyAsync(schedule => schedule.Name == name, cancellationToken)
5✔
844
                        .ConfigureAwait(false))
5✔
845
                {
846
                        throw new ImmediateJobException("Code-defined recurring schedules cannot be deleted.");
4✔
847
                }
848

849
                throw new KeyNotFoundException($"Recurring schedule '{name}' was not found.");
5✔
850
        }
3✔
851

852
        /// <inheritdoc />
853
        public ValueTask PauseRecurringAsync(string name, CancellationToken cancellationToken = default)
854
                => MutateRecurringAsync(name, schedule => schedule.IsPaused = true, cancellationToken);
×
855

856
        /// <inheritdoc />
857
        public ValueTask ResumeRecurringAsync(string name, CancellationToken cancellationToken = default)
858
                => MutateRecurringAsync(name, schedule => schedule.IsPaused = false, cancellationToken);
×
859

860
        /// <inheritdoc />
861
        public async ValueTask<IReadOnlyList<RecurringJobSchedule>> GetDueRecurringAsync(DateTimeOffset now, int batchSize, CancellationToken cancellationToken = default)
862
        {
863
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(batchSize, 0);
×
864
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
865
                return await context.Set<ImmediateRecurringJobEntity>()
×
866
                        .AsNoTracking()
×
867
                        .Where(schedule => !schedule.IsPaused && schedule.NextRunAt <= now)
×
868
                        .OrderBy(schedule => schedule.NextRunAt)
×
869
                        .Take(batchSize)
×
870
                        .Select(schedule => new RecurringJobSchedule
×
871
                        {
×
872
                                Name = schedule.Name,
×
873
                                JobName = schedule.JobName,
×
874
                                Cron = schedule.Cron,
×
875
                                TimeZone = schedule.TimeZone,
×
876
                                IsCodeDefined = schedule.IsCodeDefined,
×
877
                                IsPaused = schedule.IsPaused,
×
878
                                NextRunAt = schedule.NextRunAt,
×
879
                                LastRunAt = schedule.LastRunAt,
×
880
                        })
×
881
                        .ToListAsync(cancellationToken)
×
882
                        .ConfigureAwait(false);
×
883
        }
884

885
        /// <inheritdoc />
886
        public async ValueTask<bool> MaterializeRecurringAsync(
887
                RecurringJobSchedule schedule,
888
                JobRecord job,
889
                DateTimeOffset nextRunAt,
890
                CancellationToken cancellationToken = default
891
        )
892
        {
893
                ArgumentNullException.ThrowIfNull(schedule);
5✔
894
                ArgumentNullException.ThrowIfNull(job);
5✔
895
                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
896
                var strategy = strategyContext.Database.CreateExecutionStrategy();
5✔
897
                return await strategy.ExecuteAsync(
5✔
898
                        operationCancellationToken => MaterializeRecurringCoreAsync(
5✔
899
                                schedule,
5✔
900
                                job,
5✔
901
                                nextRunAt,
5✔
902
                                operationCancellationToken
5✔
903
                        ),
5✔
904
                        cancellationToken
5✔
905
                ).ConfigureAwait(false);
5✔
906
        }
4✔
907

908
        private async Task<bool> MaterializeRecurringCoreAsync(
909
                RecurringJobSchedule schedule,
910
                JobRecord job,
911
                DateTimeOffset nextRunAt,
912
                CancellationToken cancellationToken
913
        )
914
        {
915
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
916
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
917
                var entity = await context.Set<ImmediateRecurringJobEntity>()
5✔
918
                        .SingleOrDefaultAsync(item => item.Name == schedule.Name, cancellationToken)
5✔
919
                        .ConfigureAwait(false);
5✔
920
                if (entity is null || entity.IsPaused || entity.NextRunAt != schedule.NextRunAt)
5✔
921
                        return false;
1✔
922

923
                entity.LastRunAt = schedule.NextRunAt;
5✔
924
                entity.NextRunAt = nextRunAt;
5✔
925
                entity.ConcurrencyStamp = Guid.NewGuid();
5✔
926
                _ = context.Add(ToEntity(job));
5✔
927
                try
928
                {
929
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
930
                        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
5✔
931
                        return true;
5✔
932
                }
933
                catch (DbUpdateException)
934
                {
935
                        await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
×
936
                        return false;
×
937
                }
938
        }
4✔
939

940
        /// <inheritdoc />
941
        public async ValueTask<JobMonitoringSnapshot> GetMonitoringSnapshotAsync(CancellationToken cancellationToken = default)
942
        {
943
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
944
                var rawCounts = await context.Set<ImmediateJobEntity>()
5✔
945
                        .AsNoTracking()
5✔
946
                        .GroupBy(job => job.State)
5✔
947
                        .Select(group => new { State = group.Key, Count = group.LongCount() })
5✔
948
                        .ToListAsync(cancellationToken)
5✔
949
                        .ConfigureAwait(false);
5✔
950
                var counts = Enum.GetValues<JobState>().ToDictionary(static state => state, static _ => 0L);
5✔
951
                foreach (var item in rawCounts)
5✔
952
                        counts[item.State] = item.Count;
5✔
953

954
                var recurring = await context.Set<ImmediateRecurringJobEntity>()
5✔
955
                        .AsNoTracking()
5✔
956
                        .OrderBy(schedule => schedule.Name)
5✔
957
                        .Select(schedule => new RecurringJobSchedule
5✔
958
                        {
5✔
959
                                Name = schedule.Name,
5✔
960
                                JobName = schedule.JobName,
5✔
961
                                Cron = schedule.Cron,
5✔
962
                                TimeZone = schedule.TimeZone,
5✔
963
                                IsCodeDefined = schedule.IsCodeDefined,
5✔
964
                                IsPaused = schedule.IsPaused,
5✔
965
                                NextRunAt = schedule.NextRunAt,
5✔
966
                                LastRunAt = schedule.LastRunAt,
5✔
967
                        })
5✔
968
                        .ToListAsync(cancellationToken)
5✔
969
                        .ConfigureAwait(false);
5✔
970
                var cutoff = _timeProvider.GetUtcNow() - TimeSpan.FromMinutes(2);
5✔
971
                var servers = await context.Set<ImmediateJobServerEntity>()
5✔
972
                        .AsNoTracking()
5✔
973
                        .Where(server => server.LastHeartbeat >= cutoff)
5✔
974
                        .OrderBy(server => server.WorkerId)
5✔
975
                        .Select(server => new JobServerSnapshot(server.WorkerId, server.LastHeartbeat, server.ActiveWorkers, server.MaxWorkers))
5✔
976
                        .ToListAsync(cancellationToken)
5✔
977
                        .ConfigureAwait(false);
5✔
978
                return new(_timeProvider.GetUtcNow(), counts, recurring, servers)
5✔
979
                {
5✔
980
                        Capabilities = this.GetCapabilities(),
5✔
981
                };
5✔
982
        }
4✔
983

984
        /// <inheritdoc />
985
        public async ValueTask<IReadOnlyList<JobRecord>> QueryJobsAsync(JobQuery query, CancellationToken cancellationToken = default)
986
        {
987
                ArgumentNullException.ThrowIfNull(query);
5✔
988
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
5✔
989
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
5✔
990
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
991
                var jobs = context.Set<ImmediateJobEntity>().AsNoTracking();
5✔
992
                if (query.Id is { } id)
5✔
993
                        jobs = jobs.Where(job => job.Id == id);
5✔
994
                if (query.State is { } state)
5✔
995
                        jobs = jobs.Where(job => job.State == state);
4✔
996
                if (!string.IsNullOrWhiteSpace(query.QueueName))
5✔
997
                        jobs = jobs.Where(job => job.QueueName == query.QueueName);
×
998
                if (!string.IsNullOrWhiteSpace(query.JobName))
5✔
999
                        jobs = jobs.Where(job => job.JobName == query.JobName);
×
1000
                if (!string.IsNullOrWhiteSpace(query.Search))
5✔
1001
                {
1002
                        var search = query.Search.ToUpperInvariant();
×
1003
                        // The parameterless form is intentionally used because relational providers translate it to SQL.
1004
#pragma warning disable CA1304, CA1311, CA1862, MA0011
1005
                        jobs = jobs.Where(job => job.JobName.ToUpper().Contains(search));
×
1006
#pragma warning restore CA1304, CA1311, CA1862, MA0011
1007
                }
1008

1009
                var entities = await jobs.OrderByDescending(job => job.CreatedAt)
5✔
1010
                        .ThenBy(job => job.Id)
5✔
1011
                        .Skip(query.Skip)
5✔
1012
                        .Take(query.Take)
5✔
1013
                        .ToListAsync(cancellationToken)
5✔
1014
                        .ConfigureAwait(false);
5✔
1015
                return [.. entities.Select(ToRecord)];
5✔
1016
        }
4✔
1017

1018
        /// <inheritdoc />
1019
        public async ValueTask<BatchStatus?> GetBatchStatusAsync(
1020
                string batchId,
1021
                CancellationToken cancellationToken = default
1022
        )
1023
        {
1024
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
5✔
1025
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1026
                var batch = await context.Set<ImmediateJobBatchEntity>()
5✔
1027
                        .AsNoTracking()
5✔
1028
                        .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
5✔
1029
                        .ConfigureAwait(false);
5✔
1030
                return batch is null ? null : ToStatus(batch);
5✔
1031
        }
4✔
1032

1033
        /// <inheritdoc />
1034
        public async ValueTask<IReadOnlyList<JobContinuationEdge>> GetIncomingEdgesAsync(
1035
                IReadOnlyCollection<string> childJobIds,
1036
                CancellationToken cancellationToken = default
1037
        )
1038
        {
1039
                ArgumentNullException.ThrowIfNull(childJobIds);
5✔
1040
                foreach (var childJobId in childJobIds)
5✔
1041
                        ArgumentException.ThrowIfNullOrWhiteSpace(childJobId);
5✔
1042
                if (childJobIds.Count == 0)
5✔
1043
                        return [];
5✔
1044

1045
                var ids = childJobIds.Distinct(StringComparer.Ordinal).ToArray();
5✔
1046
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1047
                var edges = await context.Set<ImmediateJobContinuationEntity>()
5✔
1048
                        .AsNoTracking()
5✔
1049
                        .Where(edge => ids.Contains(edge.ChildJobId))
5✔
1050
                        .OrderBy(edge => edge.ChildJobId)
5✔
1051
                        .ThenBy(edge => edge.ParentKind)
5✔
1052
                        .ThenBy(edge => edge.ParentId)
5✔
1053
                        .ToListAsync(cancellationToken)
5✔
1054
                        .ConfigureAwait(false);
5✔
1055
                return [.. edges.Select(ToContinuationEdge)];
5✔
1056
        }
4✔
1057

1058
        /// <inheritdoc />
1059
        public async ValueTask<IReadOnlyList<BatchStatus>> QueryBatchesAsync(
1060
                JobBatchQuery query,
1061
                CancellationToken cancellationToken = default
1062
        )
1063
        {
1064
                ArgumentNullException.ThrowIfNull(query);
×
1065
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
×
1066
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
×
1067
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
NEW
1068
                var batches = context.Set<ImmediateJobBatchEntity>().AsNoTracking();
×
1069
                if (query.State is { } state)
×
1070
                        batches = batches.Where(batch => batch.State == state);
×
1071
                var entities = await batches.OrderByDescending(batch => batch.CreatedAt)
×
1072
                        .ThenBy(batch => batch.Id)
×
1073
                        .Skip(query.Skip)
×
1074
                        .Take(query.Take)
×
1075
                        .ToListAsync(cancellationToken)
×
1076
                        .ConfigureAwait(false);
×
1077
                return [.. entities.Select(ToStatus)];
×
1078
        }
1079

1080
        /// <inheritdoc />
1081
        public async ValueTask<IReadOnlyList<BatchMemberStatus>> QueryBatchMembersAsync(
1082
                string batchId,
1083
                BatchMemberQuery query,
1084
                CancellationToken cancellationToken = default
1085
        )
1086
        {
1087
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
1088
                ArgumentNullException.ThrowIfNull(query);
4✔
1089
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
4✔
1090
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
4✔
1091
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
1092
                var jobs = context.Set<ImmediateJobEntity>()
4✔
1093
                        .AsNoTracking()
4✔
1094
                        .Where(job => job.BatchId == batchId);
4✔
1095
                if (query.State is { } state)
4✔
1096
                        jobs = jobs.Where(job => job.State == state);
×
1097
                return await jobs.OrderBy(job => job.CreatedAt)
4✔
1098
                        .ThenBy(job => job.Id)
4✔
1099
                        .Skip(query.Skip)
4✔
1100
                        .Take(query.Take)
4✔
1101
                        .Select(job => new BatchMemberStatus(
4✔
1102
                                job.Id,
4✔
1103
                                job.JobName,
4✔
1104
                                job.QueueName,
4✔
1105
                                job.State,
4✔
1106
                                job.Attempt,
4✔
1107
                                job.CreatedAt,
4✔
1108
                                job.CompletedAt,
4✔
1109
                                job.LastError
4✔
1110
                        ))
4✔
1111
                        .ToListAsync(cancellationToken)
4✔
1112
                        .ConfigureAwait(false);
4✔
1113
        }
3✔
1114

1115
        /// <inheritdoc />
1116
        public async ValueTask<BatchGraph?> GetBatchGraphAsync(
1117
                string batchId,
1118
                CancellationToken cancellationToken = default
1119
        )
1120
        {
1121
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
1122
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
1123
                if (!await context.Set<ImmediateJobBatchEntity>()
4✔
1124
                        .AnyAsync(batch => batch.Id == batchId, cancellationToken)
4✔
1125
                        .ConfigureAwait(false))
4✔
1126
                {
1127
                        return null;
4✔
1128
                }
1129

1130
                var jobs = await context.Set<ImmediateJobEntity>()
×
1131
                        .AsNoTracking()
×
1132
                        .Where(job => job.BatchId == batchId)
×
1133
                        .OrderBy(job => job.CreatedAt)
×
1134
                        .ThenBy(job => job.Id)
×
1135
                        .Select(job => new BatchGraphNode(job.Id, job.JobName, job.State))
×
1136
                        .ToListAsync(cancellationToken)
×
1137
                        .ConfigureAwait(false);
×
1138
                var ids = jobs.Select(static job => job.JobId).ToArray();
×
1139
                var edges = ids.Length == 0
×
1140
                        ? []
×
1141
                        : await context.Set<ImmediateJobContinuationEntity>()
×
1142
                                .AsNoTracking()
×
1143
                                .Where(edge => ids.Contains(edge.ChildJobId))
×
1144
                                .OrderBy(edge => edge.ChildJobId)
×
1145
                                .ThenBy(edge => edge.ParentKind)
×
1146
                                .ThenBy(edge => edge.ParentId)
×
1147
                                .ToListAsync(cancellationToken)
×
1148
                                .ConfigureAwait(false);
×
1149
                return new(batchId, jobs, [.. edges.Select(ToGraphEdge)]);
4✔
1150
        }
3✔
1151

1152
        /// <inheritdoc />
1153
        public async ValueTask<JobStatus?> GetJobStatusAsync(
1154
                string jobId,
1155
                CancellationToken cancellationToken = default
1156
        )
1157
        {
1158
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
5✔
1159
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1160
                var job = await context.Set<ImmediateJobEntity>()
5✔
1161
                        .AsNoTracking()
5✔
1162
                        .SingleOrDefaultAsync(item => item.Id == jobId, cancellationToken)
5✔
1163
                        .ConfigureAwait(false);
5✔
1164
                if (job is null)
5✔
1165
                        return null;
5✔
1166
                var edges = await context.Set<ImmediateJobContinuationEntity>()
5✔
1167
                        .AsNoTracking()
5✔
1168
                        .Where(edge => edge.ChildJobId == jobId)
5✔
1169
                        .OrderBy(edge => edge.ParentKind)
5✔
1170
                        .ThenBy(edge => edge.ParentId)
5✔
1171
                        .ToListAsync(cancellationToken)
5✔
1172
                        .ConfigureAwait(false);
5✔
1173
                return new(
5✔
1174
                        job.Id,
5✔
1175
                        job.JobName,
5✔
1176
                        job.QueueName,
5✔
1177
                        job.State,
5✔
1178
                        job.Attempt,
5✔
1179
                        MaxAttempts: null,
5✔
1180
                        job.CreatedAt,
5✔
1181
                        job.DueAt,
5✔
1182
                        job.CompletedAt,
5✔
1183
                        job.LastError,
5✔
1184
                        job.BatchId,
5✔
1185
                        [.. edges.Select(ToGraphEdge)]
5✔
1186
                );
5✔
1187
        }
4✔
1188

1189
        /// <inheritdoc />
1190
        public ValueTask CancelBatchAsync(string batchId, CancellationToken cancellationToken = default)
1191
        {
1192
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
5✔
1193
                return ExecuteWithStrategyAsync(
5✔
1194
                        operationCancellationToken => CancelBatchCoreAsync(batchId, operationCancellationToken),
5✔
1195
                        cancellationToken
5✔
1196
                );
5✔
1197
        }
1198

1199
        private async Task CancelBatchCoreAsync(string batchId, CancellationToken cancellationToken)
1200
        {
1201
                var now = _timeProvider.GetUtcNow();
5✔
1202
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1203
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1204
                var batch = await context.Set<ImmediateJobBatchEntity>()
5✔
1205
                        .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
5✔
1206
                        .ConfigureAwait(false)
5✔
1207
                        ?? throw new KeyNotFoundException($"Batch '{batchId}' was not found.");
5✔
1208
                if (batch.State != BatchState.Executing)
5✔
1209
                        throw new ImmediateJobException("Only an executing batch can be cancelled.");
×
1210

1211
                var jobs = await context.Set<ImmediateJobEntity>()
5✔
1212
                        .Where(job => job.BatchId == batchId)
5✔
1213
                        .ToListAsync(cancellationToken)
5✔
1214
                        .ConfigureAwait(false);
5✔
1215
                foreach (var job in jobs)
5✔
1216
                {
1217
                        if (IsTerminal(job.State))
5✔
1218
                                continue;
1219
                        job.State = JobState.Cancelled;
5✔
1220
                        job.CompletedAt = now;
5✔
1221
                        job.WorkerId = null;
5✔
1222
                        job.LeaseExpiresAt = null;
5✔
1223
                        job.ConcurrencyStamp = Guid.NewGuid();
5✔
1224
                        await PropagateTerminalAsync(context, job, now, cancellationToken).ConfigureAwait(false);
5✔
1225
                }
1226

1227
                var terminalGroups = GetTerminalFairQueueGroups(context);
5✔
1228
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
1229
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
5✔
1230
                foreach (var (queueName, groupId) in terminalGroups)
5✔
1231
                {
1232
                        await TryRemoveFairQueueCursorAsync(queueName, groupId, CancellationToken.None).ConfigureAwait(false);
×
1233
                }
1234
        }
5✔
1235

1236
        /// <inheritdoc />
1237
        public ValueTask DeleteBatchAsync(string batchId, CancellationToken cancellationToken = default)
1238
        {
1239
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
5✔
1240
                return ExecuteWithStrategyAsync(
5✔
1241
                        operationCancellationToken => DeleteBatchCoreAsync(batchId, operationCancellationToken),
5✔
1242
                        cancellationToken
5✔
1243
                );
5✔
1244
        }
1245

1246
        private async Task DeleteBatchCoreAsync(string batchId, CancellationToken cancellationToken)
1247
        {
1248
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1249
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1250
                var batch = await context.Set<ImmediateJobBatchEntity>()
5✔
1251
                        .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
5✔
1252
                        .ConfigureAwait(false)
5✔
1253
                        ?? throw new KeyNotFoundException($"Batch '{batchId}' was not found.");
5✔
1254
                if (batch.State == BatchState.Executing)
4✔
1255
                        throw new ImmediateJobException("Only a terminal batch can be deleted.");
×
1256

1257
                var jobs = await context.Set<ImmediateJobEntity>()
4✔
1258
                        .Where(job => job.BatchId == batchId)
4✔
1259
                        .ToListAsync(cancellationToken)
4✔
1260
                        .ConfigureAwait(false);
4✔
1261
                var jobIds = jobs.Select(static job => job.Id).ToArray();
4✔
1262
                var edges = await context.Set<ImmediateJobContinuationEntity>()
4✔
1263
                        .Where(edge =>
4✔
1264
                                jobIds.Contains(edge.ChildJobId)
4✔
1265
                                || (edge.ParentKind == ContinuationParentKind.Job && jobIds.Contains(edge.ParentId))
4✔
1266
                                || (edge.ParentKind == ContinuationParentKind.Batch && edge.ParentId == batchId))
4✔
1267
                        .ToListAsync(cancellationToken)
4✔
1268
                        .ConfigureAwait(false);
4✔
1269
                context.RemoveRange(edges);
4✔
1270
                context.RemoveRange(jobs);
4✔
1271
                _ = context.Remove(batch);
4✔
1272
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
1273
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
4✔
1274
        }
4✔
1275

1276
        /// <inheritdoc />
1277
        public ValueTask RetryAsync(string jobId, CancellationToken cancellationToken = default)
1278
        {
1279
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
5✔
1280
                return ExecuteWithStrategyAsync(
5✔
1281
                        operationCancellationToken => RetryCoreAsync(jobId, operationCancellationToken),
5✔
1282
                        cancellationToken
5✔
1283
                );
5✔
1284
        }
1285

1286
        private async Task RetryCoreAsync(string jobId, CancellationToken cancellationToken)
1287
        {
1288
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1289
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1290
                var job = await context.Set<ImmediateJobEntity>()
5✔
1291
                        .SingleOrDefaultAsync(item => item.Id == jobId && item.State == JobState.Failed, cancellationToken)
5✔
1292
                        .ConfigureAwait(false);
5✔
1293
                if (job is null)
5✔
1294
                {
1295
                        if (await context.Set<ImmediateJobEntity>()
5✔
1296
                                .AnyAsync(item => item.Id == jobId, cancellationToken)
5✔
1297
                                .ConfigureAwait(false))
5✔
1298
                        {
1299
                                throw new ImmediateJobException("Only failed jobs can be retried.");
4✔
1300
                        }
1301

1302
                        throw new KeyNotFoundException($"Job '{jobId}' was not found.");
5✔
1303
                }
1304

1305
                if (job.BatchId is { } batchId)
×
1306
                {
1307
                        var batch = await context.Set<ImmediateJobBatchEntity>()
×
1308
                                .SingleAsync(item => item.Id == batchId, cancellationToken)
×
1309
                                .ConfigureAwait(false);
×
1310
                        batch.PendingCount++;
×
1311
                        batch.FailedCount = Math.Max(0, batch.FailedCount - 1);
×
1312
                        batch.State = BatchState.Executing;
×
1313
                        batch.CompletedAt = null;
×
1314
                        batch.ConcurrencyStamp = Guid.NewGuid();
×
1315
                }
1316

1317
                job.State = JobState.Pending;
×
1318
                job.DueAt = _timeProvider.GetUtcNow();
×
1319
                job.WorkerId = null;
×
1320
                job.LeaseExpiresAt = null;
×
1321
                job.CompletedAt = null;
×
1322
                job.LastError = null;
×
1323
                job.ConcurrencyStamp = Guid.NewGuid();
×
1324
                try
1325
                {
1326
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1327
                }
×
1328
                catch (DbUpdateConcurrencyException)
1329
                {
1330
                        if (!await context.Set<ImmediateJobEntity>()
×
1331
                                .AsNoTracking()
×
1332
                                .AnyAsync(item => item.Id == jobId, cancellationToken)
×
1333
                                .ConfigureAwait(false))
×
1334
                        {
1335
                                throw new KeyNotFoundException($"Job '{jobId}' was not found.");
×
1336
                        }
1337

1338
                        throw new ImmediateJobException("Only failed jobs can be retried.");
×
1339
                }
1340

1341
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
×
1342
        }
×
1343

1344
        /// <inheritdoc />
1345
        public ValueTask DeleteAsync(string jobId, CancellationToken cancellationToken = default)
1346
        {
1347
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
5✔
1348
                return ExecuteWithStrategyAsync(
5✔
1349
                        operationCancellationToken => DeleteCoreAsync(jobId, operationCancellationToken),
5✔
1350
                        cancellationToken
5✔
1351
                );
5✔
1352
        }
1353

1354
        private async Task DeleteCoreAsync(string jobId, CancellationToken cancellationToken)
1355
        {
1356
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1357
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1358
                var job = await context.Set<ImmediateJobEntity>()
5✔
1359
                        .AsNoTracking()
5✔
1360
                        .SingleOrDefaultAsync(item => item.Id == jobId
5✔
1361
                                && (item.State == JobState.Succeeded || item.State == JobState.Failed || item.State == JobState.Cancelled), cancellationToken)
5✔
1362
                        .ConfigureAwait(false);
5✔
1363
                if (job is null)
5✔
1364
                {
1365
                        if (await context.Set<ImmediateJobEntity>()
5✔
1366
                                .AnyAsync(item => item.Id == jobId, cancellationToken)
5✔
1367
                                .ConfigureAwait(false))
5✔
1368
                        {
1369
                                throw new ImmediateJobException("Only terminal jobs can be deleted.");
4✔
1370
                        }
1371

1372
                        throw new KeyNotFoundException($"Job '{jobId}' was not found.");
5✔
1373
                }
1374

1375
                if (job.BatchId is not null)
1✔
1376
                        throw new ImmediateJobException("Batch members are deleted with their batch so the workflow remains coherent.");
×
1377
                _ = await context.Set<ImmediateJobContinuationEntity>()
1✔
1378
                        .Where(edge => edge.ChildJobId == jobId ||
1✔
1379
                                (edge.ParentKind == ContinuationParentKind.Job && edge.ParentId == jobId))
1✔
1380
                        .ExecuteDeleteAsync(cancellationToken)
1✔
1381
                        .ConfigureAwait(false);
1✔
1382
                var removed = await context.Set<ImmediateJobEntity>()
1✔
1383
                        .Where(item => item.Id == jobId &&
1✔
1384
                                (item.State == JobState.Succeeded || item.State == JobState.Failed || item.State == JobState.Cancelled))
1✔
1385
                        .ExecuteDeleteAsync(cancellationToken)
1✔
1386
                        .ConfigureAwait(false);
1✔
1387
                if (removed == 0)
1✔
1388
                {
1389
                        if (await context.Set<ImmediateJobEntity>()
×
1390
                                .AnyAsync(item => item.Id == jobId, cancellationToken)
×
1391
                                .ConfigureAwait(false))
×
1392
                        {
1393
                                throw new ImmediateJobException("Only terminal jobs can be deleted.");
×
1394
                        }
1395

1396
                        throw new KeyNotFoundException($"Job '{jobId}' was not found.");
×
1397
                }
1398

1399
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
1✔
1400
        }
1✔
1401

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

1420
        /// <inheritdoc />
1421
        public ValueTask PurgeBatchesAsync(
1422
                TimeSpan batchSucceededRetention,
1423
                TimeSpan batchFailedRetention,
1424
                CancellationToken cancellationToken = default
1425
        )
1426
        {
1427
                var now = _timeProvider.GetUtcNow();
4✔
1428
                return ExecuteWithStrategyAsync(
4✔
1429
                        operationCancellationToken => PurgeBatchesCoreAsync(
4✔
1430
                                now - batchSucceededRetention,
4✔
1431
                                now - batchFailedRetention,
4✔
1432
                                operationCancellationToken
4✔
1433
                        ),
4✔
1434
                        cancellationToken
4✔
1435
                );
4✔
1436
        }
1437

1438
        private async Task PurgeJobsCoreAsync(
1439
                DateTimeOffset succeededBefore,
1440
                DateTimeOffset failedBefore,
1441
                CancellationToken cancellationToken
1442
        )
1443
        {
1444
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
1445
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
4✔
1446
                var jobs = await context.Set<ImmediateJobEntity>()
4✔
1447
                        .Where(job => job.BatchId == null
4✔
1448
                                && ((job.State == JobState.Succeeded && job.CompletedAt < succeededBefore)
4✔
1449
                                || ((job.State == JobState.Failed || job.State == JobState.Cancelled) && job.CompletedAt < failedBefore))
4✔
1450
                        )
4✔
1451
                        .ToListAsync(cancellationToken)
4✔
1452
                        .ConfigureAwait(false);
4✔
1453
                if (jobs.Count != 0)
4✔
1454
                {
1455
                        var jobIds = jobs.Select(static job => job.Id).ToArray();
×
1456
                        var edges = await context.Set<ImmediateJobContinuationEntity>()
×
NEW
1457
                                .Where(edge =>
×
NEW
1458
                                        jobIds.Contains(edge.ChildJobId)
×
NEW
1459
                                        || (jobIds.Contains(edge.ParentId) && edge.ParentKind == ContinuationParentKind.Job)
×
NEW
1460
                                )
×
1461
                                .ToListAsync(cancellationToken)
×
1462
                                .ConfigureAwait(false);
×
1463
                        context.RemoveRange(edges);
×
1464
                }
1465

1466
                context.RemoveRange(jobs);
4✔
1467
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
1468
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
4✔
1469
        }
4✔
1470

1471
        private async Task PurgeBatchesCoreAsync(
1472
                DateTimeOffset batchSucceededBefore,
1473
                DateTimeOffset batchFailedBefore,
1474
                CancellationToken cancellationToken
1475
        )
1476
        {
1477
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
1478
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
4✔
1479
                var batches = await context.Set<ImmediateJobBatchEntity>()
4✔
1480
                        .Where(batch => (batch.State == BatchState.Succeeded && batch.CompletedAt < batchSucceededBefore)
4✔
1481
                                || ((batch.State == BatchState.Failed || batch.State == BatchState.Cancelled)
4✔
1482
                                        && batch.CompletedAt < batchFailedBefore))
4✔
1483
                        .ToListAsync(cancellationToken)
4✔
1484
                        .ConfigureAwait(false);
4✔
1485
                if (batches.Count != 0)
4✔
1486
                {
1487
                        var batchIds = batches.Select(static batch => batch.Id).ToArray();
×
1488
                        var memberIds = await context.Set<ImmediateJobEntity>()
×
1489
                                .Where(job => job.BatchId != null && batchIds.Contains(job.BatchId))
×
1490
                                .Select(job => job.Id)
×
1491
                                .ToListAsync(cancellationToken)
×
1492
                                .ConfigureAwait(false);
×
1493
                        var edges = await context.Set<ImmediateJobContinuationEntity>()
×
NEW
1494
                                .Where(edge =>
×
NEW
1495
                                        (batchIds.Contains(edge.ParentId) && edge.ParentKind == ContinuationParentKind.Batch)
×
1496
                                        || memberIds.Contains(edge.ChildJobId)
×
NEW
1497
                                        || (memberIds.Contains(edge.ParentId) && edge.ParentKind == ContinuationParentKind.Job)
×
NEW
1498
                                )
×
1499
                                .ToListAsync(cancellationToken)
×
1500
                                .ConfigureAwait(false);
×
1501
                        context.RemoveRange(edges);
×
1502
                        context.RemoveRange(batches);
×
1503
                }
1504

1505
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
1506
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
4✔
1507
        }
4✔
1508

1509
        /// <inheritdoc />
1510
        public async ValueTask HeartbeatAsync(JobServerSnapshot server, CancellationToken cancellationToken = default)
1511
        {
1512
                ArgumentNullException.ThrowIfNull(server);
1✔
1513
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
1✔
1514
                var cutoff = _timeProvider.GetUtcNow() - TimeSpan.FromMinutes(2);
1✔
1515
                _ = await context.Set<ImmediateJobServerEntity>()
1✔
1516
                        .Where(item => item.LastHeartbeat < cutoff)
1✔
1517
                        .ExecuteDeleteAsync(cancellationToken)
1✔
1518
                        .ConfigureAwait(false);
1✔
1519
                var entity = await context.Set<ImmediateJobServerEntity>().FindAsync([server.WorkerId], cancellationToken).ConfigureAwait(false);
1✔
1520
                if (entity is null)
1✔
1521
                {
1522
                        _ = context.Add(new ImmediateJobServerEntity
1✔
1523
                        {
1✔
1524
                                WorkerId = server.WorkerId,
1✔
1525
                                LastHeartbeat = server.LastHeartbeat,
1✔
1526
                                ActiveWorkers = server.ActiveWorkers,
1✔
1527
                                MaxWorkers = server.MaxWorkers,
1✔
1528
                        });
1✔
1529
                }
1530
                else
1531
                {
1532
                        entity.LastHeartbeat = server.LastHeartbeat;
×
1533
                        entity.ActiveWorkers = server.ActiveWorkers;
×
1534
                        entity.MaxWorkers = server.MaxWorkers;
×
1535
                }
1536

1537
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
1✔
1538
        }
1✔
1539

1540
        /// <inheritdoc />
1541
        public async ValueTask<bool> IsHealthyAsync(CancellationToken cancellationToken = default)
1542
        {
1543
                try
1544
                {
1545
                        await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
1546
                        return await context.Database.CanConnectAsync(cancellationToken).ConfigureAwait(false);
×
1547
                }
×
1548
                catch (Exception exception) when (exception is DbException or InvalidOperationException)
×
1549
                {
1550
                        return false;
×
1551
                }
1552
        }
×
1553

1554
        private async ValueTask MutateOwnedWithDependenciesAsync(
1555
                string jobId,
1556
                string workerId,
1557
                string? error,
1558
                DateTimeOffset? nextRetryAt,
1559
                bool succeeded,
1560
                IReadOnlyList<JobContinuationAddition> additions,
1561
                CancellationToken cancellationToken
1562
        )
1563
        {
1564
                const int MaxConcurrencyAttempts = 5;
1565
                for (var attempt = 0; attempt < MaxConcurrencyAttempts; attempt++)
5✔
1566
                {
1567
                        try
1568
                        {
1569
                                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1570
                                var strategy = strategyContext.Database.CreateExecutionStrategy();
5✔
1571
                                await strategy.ExecuteAsync(
5✔
1572
                                        operationCancellationToken => MutateOwnedCoreAsync(
5✔
1573
                                                jobId,
5✔
1574
                                                workerId,
5✔
1575
                                                error,
5✔
1576
                                                nextRetryAt,
5✔
1577
                                                succeeded,
5✔
1578
                                                additions,
5✔
1579
                                                operationCancellationToken
5✔
1580
                                        ),
5✔
1581
                                        cancellationToken
5✔
1582
                                ).ConfigureAwait(false);
5✔
1583
                                return;
5✔
1584
                        }
×
1585
                        catch (DbUpdateConcurrencyException) when (attempt + 1 < MaxConcurrencyAttempts)
5✔
1586
                        {
1587
                                // A competing parent completion changed a shared child or batch counter. The whole
1588
                                // transaction rolled back, so retrying re-evaluates the graph from durable state.
1589
                        }
×
1590
                }
1591
        }
5✔
1592

1593
        private async ValueTask ExecuteWithStrategyAsync(
1594
                Func<CancellationToken, Task> operation,
1595
                CancellationToken cancellationToken
1596
        )
1597
        {
1598
                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1599
                var strategy = strategyContext.Database.CreateExecutionStrategy();
5✔
1600
                await strategy.ExecuteAsync(operation, cancellationToken).ConfigureAwait(false);
5✔
1601
        }
5✔
1602

1603
        private async ValueTask MutateOwnedAsync(
1604
                string jobId,
1605
                string workerId,
1606
                Action<ImmediateJobEntity> mutate,
1607
                CancellationToken cancellationToken
1608
        )
1609
        {
1610
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
1✔
1611
                var job = await context.Set<ImmediateJobEntity>()
1✔
1612
                        .SingleOrDefaultAsync(item => item.Id == jobId && item.State == JobState.Active && item.WorkerId == workerId, cancellationToken)
1✔
1613
                        .ConfigureAwait(false);
1✔
1614
                if (job is null)
1✔
1615
                        return;
1616
                mutate(job);
1✔
1617
                job.ConcurrencyStamp = Guid.NewGuid();
1✔
1618
                try
1619
                {
1620
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
1✔
1621
                }
1✔
1622
                catch (DbUpdateConcurrencyException)
×
1623
                {
1624
                        // A stale worker must not update a lease that has been reclaimed.
1625
                }
1✔
1626
        }
1✔
1627

1628
        private async Task MutateOwnedCoreAsync(
1629
                string jobId,
1630
                string workerId,
1631
                string? error,
1632
                DateTimeOffset? nextRetryAt,
1633
                bool succeeded,
1634
                IReadOnlyList<JobContinuationAddition> additions,
1635
                CancellationToken cancellationToken
1636
        )
1637
        {
1638
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1639
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1640
                var job = await context.Set<ImmediateJobEntity>()
5✔
1641
                        .SingleOrDefaultAsync(item => item.Id == jobId && item.State == JobState.Active && item.WorkerId == workerId, cancellationToken)
5✔
1642
                        .ConfigureAwait(false);
5✔
1643
                if (job is null)
5✔
1644
                        return;
1645

1646
                var now = _timeProvider.GetUtcNow();
5✔
1647
                job.WorkerId = null;
5✔
1648
                job.LeaseExpiresAt = null;
5✔
1649
                job.LastError = error;
5✔
1650
                job.ConcurrencyStamp = Guid.NewGuid();
5✔
1651
                if (!succeeded && nextRetryAt is { } retryAt)
5✔
1652
                {
1653
                        job.State = retryAt <= now ? JobState.Pending : JobState.Scheduled;
×
1654
                        job.DueAt = retryAt;
×
1655
                        job.CompletedAt = null;
×
1656
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1657
                        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
×
1658
                        return;
×
1659
                }
1660

1661
                if (succeeded && additions.Count != 0)
5✔
1662
                {
1663
                        await FlushContinuationAdditionsAsync(context, job, additions, cancellationToken).ConfigureAwait(false);
5✔
1664
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1665
                }
1666

1667
                job.State = succeeded ? JobState.Succeeded : JobState.Failed;
5✔
1668
                job.CompletedAt = now;
5✔
1669
                await PropagateTerminalAsync(context, job, now, cancellationToken).ConfigureAwait(false);
5✔
1670
                var terminalGroups = GetTerminalFairQueueGroups(context);
5✔
1671
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
1672
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
5✔
1673
                foreach (var (queueName, groupId) in terminalGroups)
5✔
1674
                        await TryRemoveFairQueueCursorAsync(queueName, groupId, CancellationToken.None).ConfigureAwait(false);
5✔
1675
        }
5✔
1676

1677
        private static (string QueueName, string GroupId)[] GetTerminalFairQueueGroups(TContext context) =>
1678
                [
5✔
1679
                        .. context.ChangeTracker
5✔
1680
                                .Entries<ImmediateJobEntity>()
5✔
1681
                                .Select(static entry => entry.Entity)
5✔
1682
                                .Where(static job => job.GroupId is not null && IsTerminal(job.State))
5✔
1683
                                .Select(static job => (job.QueueName, GroupId: job.GroupId!))
5✔
1684
                                .Distinct(),
5✔
1685
                ];
5✔
1686

1687
        private async ValueTask TryRemoveFairQueueCursorAsync(
1688
                string queueName,
1689
                string groupId,
1690
                CancellationToken cancellationToken
1691
        )
1692
        {
1693
                try
1694
                {
1695
                        await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1696
                        if (await HasLiveGroupJobsAsync(context, queueName, groupId, cancellationToken).ConfigureAwait(false))
5✔
1697
                        {
1698
                                return;
1699
                        }
1700

1701
                        var cursor = await context.Set<ImmediateFairQueueGroupEntity>()
4✔
1702
                                .SingleOrDefaultAsync(
4✔
1703
                                        group => group.QueueName == queueName && group.GroupId == groupId,
4✔
1704
                                        cancellationToken
4✔
1705
                                )
4✔
1706
                                .ConfigureAwait(false);
4✔
1707
                        if (cursor is null)
4✔
1708
                                return;
1709

1710
                        _ = context.Remove(cursor);
4✔
1711
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
1712
                }
4✔
1713
                catch (Exception exception) when (
×
1714
                        !cancellationToken.IsCancellationRequested
×
1715
                        && exception is DbException or DbUpdateException
×
1716
                )
×
1717
                {
1718
                        // Cleanup is best-effort metadata maintenance and must not invalidate a committed transition.
1719
                }
×
1720
        }
5✔
1721

1722
        private static Task<bool> HasLiveGroupJobsAsync(
1723
                TContext context,
1724
                string queueName,
1725
                string groupId,
1726
                CancellationToken cancellationToken
1727
        ) => context.Set<ImmediateJobEntity>().AnyAsync(
5✔
1728
                job => job.QueueName == queueName
5✔
1729
                        && job.GroupId == groupId
5✔
1730
                        && (job.State == JobState.Pending
5✔
1731
                                || job.State == JobState.Scheduled
5✔
1732
                                || job.State == JobState.Active),
5✔
1733
                cancellationToken
5✔
1734
        );
5✔
1735

1736
        private async Task AddBatchJobCoreAsync(
1737
                string currentJobId,
1738
                JobRecord record,
1739
                ContinuationOptions options,
1740
                CancellationToken cancellationToken
1741
        )
1742
        {
1743
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1744
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1745
                var current = await context.Set<ImmediateJobEntity>()
5✔
1746
                        .SingleOrDefaultAsync(job => job.Id == currentJobId && job.State == JobState.Active, cancellationToken)
5✔
1747
                        .ConfigureAwait(false)
5✔
1748
                        ?? throw new ImmediateJobException($"The current active job '{currentJobId}' was not found.");
5✔
1749
                if (current.BatchId is not { } batchId)
5✔
1750
                        throw new ImmediateJobException("The current job does not belong to a batch.");
×
1751
                if (options is not (ContinuationOptions.BesideContinuations or ContinuationOptions.BeforeContinuations))
5✔
1752
                        throw new ArgumentOutOfRangeException(nameof(options));
×
1753
                if (!string.Equals(record.BatchId, batchId, StringComparison.Ordinal))
5✔
1754
                        throw new ImmediateJobException("The new job must belong to the current job's batch.");
5✔
1755
                if (record.State is JobState.Active or JobState.AwaitingContinuation || IsTerminal(record.State))
×
1756
                        throw new ImmediateJobException($"Concurrent batch member '{record.Id}' has invalid state '{record.State}'.");
×
1757

1758
                var batch = await context.Set<ImmediateJobBatchEntity>()
×
1759
                        .SingleAsync(item => item.Id == batchId && item.State == BatchState.Executing, cancellationToken)
×
1760
                        .ConfigureAwait(false);
×
1761
                var job = ToEntity(record);
×
1762
                _ = context.Add(job);
×
1763
                batch.TotalJobs++;
×
1764
                batch.PendingCount++;
×
1765
                batch.ConcurrencyStamp = Guid.NewGuid();
×
1766

1767
                if (options == ContinuationOptions.BeforeContinuations)
×
1768
                {
1769
                        var waiters = await GetActiveWaitersAsync(context, currentJobId, cancellationToken).ConfigureAwait(false);
×
1770
                        foreach (var waiter in waiters)
×
1771
                        {
1772
                                _ = context.Add(new ImmediateJobContinuationEntity
×
1773
                                {
×
1774
                                        ChildJobId = waiter.Id,
×
1775
                                        ParentKind = ContinuationParentKind.Job,
×
1776
                                        ParentId = job.Id,
×
1777
                                        Trigger = ContinuationTrigger.Success,
×
1778
                                });
×
1779
                                waiter.RemainingDependencies++;
×
1780
                                waiter.ConcurrencyStamp = Guid.NewGuid();
×
1781
                        }
1782
                }
1783

1784
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1785
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
×
1786
        }
×
1787

1788
        private static async Task FlushContinuationAdditionsAsync(
1789
                TContext context,
1790
                ImmediateJobEntity current,
1791
                IReadOnlyList<JobContinuationAddition> additions,
1792
                CancellationToken cancellationToken
1793
        )
1794
        {
1795
                var ids = new HashSet<string>(StringComparer.Ordinal);
5✔
1796
                var trackedAdditions = 0;
5✔
1797
                foreach (var addition in additions)
5✔
1798
                {
1799
                        ArgumentNullException.ThrowIfNull(addition);
5✔
1800
                        ArgumentNullException.ThrowIfNull(addition.Job);
5✔
1801
                        if (!ids.Add(addition.Job.Id))
5✔
1802
                                throw new ImmediateJobException("Buffered continuations contain duplicate job identifiers.");
×
1803
                        if (!Enum.IsDefined(addition.Trigger))
5✔
1804
                                throw new ArgumentOutOfRangeException(nameof(additions), "Unknown continuation trigger.");
4✔
1805
                        if (addition.Job.State is not (JobState.Pending or JobState.Scheduled))
5✔
1806
                                throw new ImmediateJobException($"Dynamic continuation '{addition.Job.Id}' has invalid state '{addition.Job.State}'.");
×
1807

1808
                        if (addition.Options == ContinuationOptions.Detached)
5✔
1809
                        {
1810
                                if (addition.Job.BatchId is not null)
5✔
1811
                                        throw new ImmediateJobException("A detached continuation cannot belong to a batch.");
5✔
1812
                        }
1813
                        else if (addition.Options is ContinuationOptions.BesideContinuations or ContinuationOptions.BeforeContinuations)
5✔
1814
                        {
1815
                                if (current.BatchId is null || !string.Equals(addition.Job.BatchId, current.BatchId, StringComparison.Ordinal))
5✔
1816
                                        throw new ImmediateJobException("A batch-tracked continuation must belong to the current job's batch.");
5✔
1817
                                trackedAdditions++;
×
1818
                        }
1819
                        else
1820
                        {
1821
                                throw new ArgumentOutOfRangeException(nameof(additions), "Unknown continuation option.");
4✔
1822
                        }
1823
                }
1824

1825
                var waiters = additions.Any(static addition => addition.Options == ContinuationOptions.BeforeContinuations)
×
1826
                        ? await GetActiveWaitersAsync(context, current.Id, cancellationToken).ConfigureAwait(false)
×
1827
                        : [];
×
1828
                ImmediateJobBatchEntity? batch = null;
1829
                if (trackedAdditions != 0)
×
1830
                {
1831
                        if (current.BatchId is not { } batchId)
×
1832
                                throw new ImmediateJobException("The current job does not belong to a batch.");
×
1833
                        batch = await context.Set<ImmediateJobBatchEntity>()
×
1834
                                .SingleAsync(item => item.Id == batchId && item.State == BatchState.Executing, cancellationToken)
×
1835
                                .ConfigureAwait(false);
×
1836
                        batch.TotalJobs += trackedAdditions;
×
1837
                        batch.PendingCount += trackedAdditions;
×
1838
                        batch.ConcurrencyStamp = Guid.NewGuid();
×
1839
                }
1840

1841
                foreach (var addition in additions)
×
1842
                {
1843
                        var job = ToEntity(addition.Job with
×
1844
                        {
×
1845
                                State = JobState.AwaitingContinuation,
×
1846
                                RemainingDependencies = 1,
×
1847
                        });
×
1848
                        _ = context.Add(job);
×
1849
                        _ = context.Add(new ImmediateJobContinuationEntity
×
1850
                        {
×
1851
                                ChildJobId = job.Id,
×
1852
                                ParentKind = ContinuationParentKind.Job,
×
1853
                                ParentId = current.Id,
×
1854
                                Trigger = addition.Trigger,
×
1855
                        });
×
1856

1857
                        if (addition.Options != ContinuationOptions.BeforeContinuations)
×
1858
                                continue;
1859
                        foreach (var waiter in waiters)
×
1860
                        {
1861
                                _ = context.Add(new ImmediateJobContinuationEntity
×
1862
                                {
×
1863
                                        ChildJobId = waiter.Id,
×
1864
                                        ParentKind = ContinuationParentKind.Job,
×
1865
                                        ParentId = job.Id,
×
1866
                                        Trigger = ContinuationTrigger.Success,
×
1867
                                });
×
1868
                                waiter.RemainingDependencies++;
×
1869
                                waiter.ConcurrencyStamp = Guid.NewGuid();
×
1870
                        }
1871
                }
1872
        }
×
1873

1874
        private static async Task<List<ImmediateJobEntity>> GetActiveWaitersAsync(
1875
                TContext context,
1876
                string currentJobId,
1877
                CancellationToken cancellationToken
1878
        )
1879
        {
1880
                var waiterIds = await context.Set<ImmediateJobContinuationEntity>()
×
1881
                        .Where(edge => edge.ParentKind == ContinuationParentKind.Job && edge.ParentId == currentJobId)
×
1882
                        .Select(edge => edge.ChildJobId)
×
1883
                        .Distinct()
×
1884
                        .ToArrayAsync(cancellationToken)
×
1885
                        .ConfigureAwait(false);
×
1886
                return waiterIds.Length == 0
×
1887
                        ? []
×
1888
                        : await context.Set<ImmediateJobEntity>()
×
1889
                                .Where(job => waiterIds.Contains(job.Id) && job.State == JobState.AwaitingContinuation)
×
1890
                                .ToListAsync(cancellationToken)
×
1891
                                .ConfigureAwait(false);
×
1892
        }
1893

1894
        private static async Task PropagateTerminalAsync(
1895
                TContext context,
1896
                ImmediateJobEntity terminalJob,
1897
                DateTimeOffset now,
1898
                CancellationToken cancellationToken
1899
        )
1900
        {
1901
                var parents = new Queue<(ContinuationParentKind Kind, string Id, bool Failed)>();
5✔
1902
                var processed = new HashSet<(ContinuationParentKind Kind, string Id)>();
5✔
1903
                parents.Enqueue((
5✔
1904
                        ContinuationParentKind.Job,
5✔
1905
                        terminalJob.Id,
5✔
1906
                        terminalJob.State == JobState.Failed
5✔
1907
                ));
5✔
1908
                await UpdateBatchForTerminalJobAsync(context, terminalJob, now, parents, cancellationToken).ConfigureAwait(false);
5✔
1909

1910
                while (parents.TryDequeue(out var parent))
5✔
1911
                {
1912
                        if (!processed.Add((parent.Kind, parent.Id)))
5✔
1913
                                continue;
1914
                        var edges = await context.Set<ImmediateJobContinuationEntity>()
5✔
1915
                                .Where(edge => edge.ParentKind == parent.Kind && edge.ParentId == parent.Id)
5✔
1916
                                .ToListAsync(cancellationToken)
5✔
1917
                                .ConfigureAwait(false);
5✔
1918
                        foreach (var edge in edges)
5✔
1919
                        {
1920
                                var child = await context.Set<ImmediateJobEntity>()
5✔
1921
                                        .SingleOrDefaultAsync(job => job.Id == edge.ChildJobId, cancellationToken)
5✔
1922
                                        .ConfigureAwait(false);
5✔
1923
                                if (child is null || IsTerminal(child.State))
5✔
1924
                                        continue;
1925

1926
                                if (child.State != JobState.AwaitingContinuation || child.RemainingDependencies <= 0)
5✔
1927
                                        continue;
1928
                                child.RemainingDependencies--;
5✔
1929
                                if (parent.Failed)
5✔
1930
                                        child.FailedDependencies++;
5✔
1931
                                if (child.RemainingDependencies == 0)
5✔
1932
                                {
1933
                                        var cancel = await ShouldCancelSettledContinuationAsync(
5✔
1934
                                                context,
5✔
1935
                                                child.Id,
5✔
1936
                                                cancellationToken
5✔
1937
                                        ).ConfigureAwait(false);
5✔
1938
                                        if (cancel)
5✔
1939
                                        {
1940
                                                child.State = JobState.Cancelled;
5✔
1941
                                                child.CompletedAt = now;
5✔
1942
                                                parents.Enqueue((ContinuationParentKind.Job, child.Id, Failed: false));
5✔
1943
                                                await UpdateBatchForTerminalJobAsync(context, child, now, parents, cancellationToken).ConfigureAwait(false);
5✔
1944
                                        }
1945
                                        else
1946
                                        {
1947
                                                child.State = child.DueAt <= now ? JobState.Pending : JobState.Scheduled;
5✔
1948
                                        }
1949
                                }
1950

1951
                                child.ConcurrencyStamp = Guid.NewGuid();
5✔
1952
                        }
4✔
1953
                }
4✔
1954
        }
5✔
1955

1956
        private static async Task<bool> ShouldCancelSettledContinuationAsync(
1957
                TContext context,
1958
                string childJobId,
1959
                CancellationToken cancellationToken
1960
        )
1961
        {
1962
                var edges = await context.Set<ImmediateJobContinuationEntity>()
5✔
1963
                        .Where(edge => edge.ChildJobId == childJobId)
5✔
1964
                        .ToListAsync(cancellationToken)
5✔
1965
                        .ConfigureAwait(false);
5✔
1966
                var parentJobIds = edges
5✔
1967
                        .Where(static edge => edge.ParentKind == ContinuationParentKind.Job)
5✔
1968
                        .Select(static edge => edge.ParentId)
5✔
1969
                        .Distinct(StringComparer.Ordinal)
5✔
1970
                        .ToArray();
5✔
1971
                var parentBatchIds = edges
5✔
1972
                        .Where(static edge => edge.ParentKind == ContinuationParentKind.Batch)
5✔
1973
                        .Select(static edge => edge.ParentId)
×
1974
                        .Distinct(StringComparer.Ordinal)
5✔
1975
                        .ToArray();
5✔
1976
                var parentJobs = parentJobIds.Length == 0
5✔
1977
                        ? []
5✔
1978
                        : await context.Set<ImmediateJobEntity>()
5✔
1979
                                .Where(job => parentJobIds.Contains(job.Id))
5✔
1980
                                .ToDictionaryAsync(job => job.Id, StringComparer.Ordinal, cancellationToken)
5✔
1981
                                .ConfigureAwait(false);
5✔
1982
                var parentBatches = parentBatchIds.Length == 0
5✔
1983
                        ? []
5✔
1984
                        : await context.Set<ImmediateJobBatchEntity>()
5✔
1985
                                .Where(batch => parentBatchIds.Contains(batch.Id))
5✔
1986
                                .ToDictionaryAsync(batch => batch.Id, StringComparer.Ordinal, cancellationToken)
×
1987
                                .ConfigureAwait(false);
5✔
1988
                var requiresFailure = false;
5✔
1989
                var anyFailed = false;
5✔
1990
                // A missing parent has no success or failure outcome; Complete continuations can still proceed.
1991
                foreach (var edge in edges)
5✔
1992
                {
1993
                        bool succeeded;
1994
                        bool failed;
1995
                        if (edge.ParentKind == ContinuationParentKind.Job)
5✔
1996
                        {
1997
                                var state = parentJobs.TryGetValue(edge.ParentId, out var parentJob) ? parentJob?.State : null;
5✔
1998
                                succeeded = state == JobState.Succeeded;
5✔
1999
                                failed = state == JobState.Failed;
5✔
2000
                        }
2001
                        else
2002
                        {
2003
                                var state = parentBatches.TryGetValue(edge.ParentId, out var parentBatch) ? parentBatch?.State : null;
×
2004
                                succeeded = state == BatchState.Succeeded;
×
2005
                                failed = state == BatchState.Failed;
×
2006
                        }
2007

2008
                        if (edge.Trigger == ContinuationTrigger.Success && !succeeded)
5✔
2009
                                return true;
×
2010
                        requiresFailure |= edge.Trigger == ContinuationTrigger.Failure;
5✔
2011
                        anyFailed |= failed;
5✔
2012
                }
2013

2014
                return requiresFailure && !anyFailed;
5✔
2015
        }
4✔
2016

2017
        private static async Task UpdateBatchForTerminalJobAsync(
2018
                TContext context,
2019
                ImmediateJobEntity job,
2020
                DateTimeOffset now,
2021
                Queue<(ContinuationParentKind Kind, string Id, bool Failed)> parents,
2022
                CancellationToken cancellationToken
2023
        )
2024
        {
2025
                if (job.BatchId is not { } batchId)
5✔
2026
                        return;
5✔
2027
                var batch = await context.Set<ImmediateJobBatchEntity>()
5✔
2028
                        .SingleAsync(item => item.Id == batchId, cancellationToken)
5✔
2029
                        .ConfigureAwait(false);
5✔
2030
                batch.PendingCount = Math.Max(0, batch.PendingCount - 1);
5✔
2031
                switch (job.State)
5✔
2032
                {
2033
                        case JobState.Succeeded:
2034
                                batch.SucceededCount++;
5✔
2035
                                break;
5✔
2036
                        case JobState.Failed:
2037
                                batch.FailedCount++;
×
2038
                                break;
×
2039
                        case JobState.Cancelled:
2040
                                batch.CancelledCount++;
5✔
2041
                                break;
5✔
2042
                        case JobState.AwaitingContinuation:
2043
                        case JobState.AwaitingParameters:
2044
                        case JobState.Scheduled:
2045
                        case JobState.Pending:
2046
                        case JobState.Active:
2047
                                throw new ImmediateJobException($"Job '{job.Id}' is not terminal.");
×
2048
                        default:
2049
                                throw new ArgumentOutOfRangeException(nameof(job), job.State, "Unknown job state.");
×
2050
                }
2051

2052
                batch.ConcurrencyStamp = Guid.NewGuid();
5✔
2053
                if (batch.PendingCount != 0)
5✔
2054
                        return;
5✔
2055
                batch.State = GetTerminalBatchState(batch.FailedCount, batch.CancelledCount);
5✔
2056
                batch.CompletedAt = now;
5✔
2057
                parents.Enqueue((
5✔
2058
                        ContinuationParentKind.Batch,
5✔
2059
                        batch.Id,
5✔
2060
                        batch.State == BatchState.Failed
5✔
2061
                ));
5✔
2062
        }
5✔
2063

2064
        private static async Task EvaluateInitialDependenciesAsync(
2065
                TContext context,
2066
                Dictionary<string, ImmediateJobEntity> jobs,
2067
                ImmediateJobContinuationEntity[] edges,
2068
                DateTimeOffset now,
2069
                CancellationToken cancellationToken
2070
        )
2071
        {
2072
                var externalJobIds = edges
5✔
2073
                        .Where(edge => edge.ParentKind == ContinuationParentKind.Job && !jobs.ContainsKey(edge.ParentId))
5✔
2074
                        .Select(static edge => edge.ParentId)
5✔
2075
                        .Distinct(StringComparer.Ordinal)
5✔
2076
                        .ToArray();
5✔
2077
                var externalBatchIds = edges
5✔
2078
                        .Where(static edge => edge.ParentKind == ContinuationParentKind.Batch)
5✔
2079
                        .Select(static edge => edge.ParentId)
5✔
2080
                        .Distinct(StringComparer.Ordinal)
5✔
2081
                        .ToArray();
5✔
2082
                var externalJobs = externalJobIds.Length == 0
5✔
2083
                        ? []
5✔
2084
                        : await context.Set<ImmediateJobEntity>()
5✔
2085
                                .AsNoTracking()
5✔
2086
                                .Where(job => externalJobIds.Contains(job.Id))
5✔
2087
                                .ToDictionaryAsync(job => job.Id, job => job.State, StringComparer.Ordinal, cancellationToken)
5✔
2088
                                .ConfigureAwait(false);
5✔
2089
                var externalBatches = externalBatchIds.Length == 0
5✔
2090
                        ? []
5✔
2091
                        : await context.Set<ImmediateJobBatchEntity>()
5✔
2092
                                .AsNoTracking()
5✔
2093
                                .Where(batch => externalBatchIds.Contains(batch.Id))
5✔
2094
                                .ToDictionaryAsync(batch => batch.Id, batch => batch.State, StringComparer.Ordinal, cancellationToken)
5✔
2095
                                .ConfigureAwait(false);
5✔
2096
                if (externalJobs.Count != externalJobIds.Length || externalBatches.Count != externalBatchIds.Length)
5✔
2097
                        throw new ImmediateJobException("A continuation parent does not exist.");
5✔
2098

2099
                var incoming = edges.ToLookup(static edge => edge.ChildJobId, StringComparer.Ordinal);
5✔
2100
                var changed = true;
5✔
2101
                while (changed)
5✔
2102
                {
2103
                        changed = false;
5✔
2104
                        foreach (var job in jobs.Values)
5✔
2105
                        {
2106
                                var dependencies = incoming[job.Id];
5✔
2107
                                if (!dependencies.Any() || IsTerminal(job.State))
5✔
2108
                                        continue;
2109
                                var remaining = 0;
5✔
2110
                                var failedDependencies = 0;
5✔
2111
                                var requiresFailure = false;
5✔
2112
                                var violated = false;
5✔
2113
                                foreach (var edge in dependencies)
5✔
2114
                                {
2115
                                        var (terminal, parentSucceeded, parentFailed) = GetParentState(
5✔
2116
                                                edge,
5✔
2117
                                                jobs,
5✔
2118
                                                externalJobs,
5✔
2119
                                                externalBatches
5✔
2120
                                        );
5✔
2121
                                        requiresFailure |= edge.Trigger == ContinuationTrigger.Failure;
5✔
2122
                                        if (!terminal)
5✔
2123
                                        {
2124
                                                remaining++;
5✔
2125
                                                continue;
5✔
2126
                                        }
2127

2128
                                        if (parentFailed)
1✔
2129
                                                failedDependencies++;
×
2130
                                        if (edge.Trigger == ContinuationTrigger.Success && !parentSucceeded)
1✔
2131
                                                violated = true;
×
2132
                                }
2133

2134
                                job.FailedDependencies = failedDependencies;
5✔
2135
                                if (violated || (remaining == 0 && requiresFailure && failedDependencies == 0))
5✔
2136
                                {
2137
                                        job.State = JobState.Cancelled;
×
2138
                                        job.RemainingDependencies = 0;
×
2139
                                        job.CompletedAt = now;
×
2140
                                        changed = true;
×
2141
                                }
2142
                                else if (remaining == 0)
5✔
2143
                                {
2144
                                        job.State = job.DueAt <= now ? JobState.Pending : JobState.Scheduled;
×
2145
                                        job.RemainingDependencies = 0;
×
2146
                                }
2147
                                else
2148
                                {
2149
                                        job.State = JobState.AwaitingContinuation;
5✔
2150
                                        job.RemainingDependencies = remaining;
5✔
2151
                                }
2152
                        }
2153
                }
2154
        }
5✔
2155

2156
        private static (bool Terminal, bool Succeeded, bool Failed) GetParentState(
2157
                ImmediateJobContinuationEntity edge,
2158
                Dictionary<string, ImmediateJobEntity> jobs,
2159
                Dictionary<string, JobState> externalJobs,
2160
                Dictionary<string, BatchState> externalBatches
2161
        )
2162
        {
2163
                if (edge.ParentKind == ContinuationParentKind.Batch)
5✔
2164
                {
2165
                        var state = externalBatches[edge.ParentId];
5✔
2166
                        return (state != BatchState.Executing, state == BatchState.Succeeded, state == BatchState.Failed);
5✔
2167
                }
2168

2169
                var jobState = jobs.TryGetValue(edge.ParentId, out var job) ? job.State : externalJobs[edge.ParentId];
5✔
2170
                return (IsTerminal(jobState), jobState == JobState.Succeeded, jobState == JobState.Failed);
5✔
2171
        }
2172

2173
        private static void ThrowIfCyclic(
2174
                HashSet<string> jobIds,
2175
                IReadOnlyList<ImmediateJobContinuationEntity> edges
2176
        )
2177
        {
2178
                var indegree = jobIds.ToDictionary(static id => id, static _ => 0, StringComparer.Ordinal);
5✔
2179
                var children = new Dictionary<string, List<string>>(StringComparer.Ordinal);
5✔
2180
                foreach (var edge in edges.Where(edge =>
5✔
2181
                        edge.ParentKind == ContinuationParentKind.Job && jobIds.Contains(edge.ParentId)))
5✔
2182
                {
2183
                        indegree[edge.ChildJobId]++;
5✔
2184
                        if (!children.TryGetValue(edge.ParentId, out var values))
5✔
2185
                                children[edge.ParentId] = values = [];
5✔
2186
                        values.Add(edge.ChildJobId);
5✔
2187
                }
2188

2189
                var ready = new Queue<string>(indegree.Where(static pair => pair.Value == 0).Select(static pair => pair.Key));
5✔
2190
                var visited = 0;
5✔
2191
                while (ready.TryDequeue(out var parent))
5✔
2192
                {
2193
                        visited++;
5✔
2194
                        if (!children.TryGetValue(parent, out var values))
5✔
2195
                                continue;
2196
                        foreach (var child in values)
5✔
2197
                        {
2198
                                if (--indegree[child] == 0)
5✔
2199
                                        ready.Enqueue(child);
5✔
2200
                        }
2201
                }
2202

2203
                if (visited != jobIds.Count)
5✔
2204
                        throw new ImmediateJobException("The continuation graph contains a dependency cycle.");
×
2205
        }
5✔
2206

2207
        private static bool IsTerminal(JobState state) =>
2208
                state is JobState.Succeeded or JobState.Failed or JobState.Cancelled;
5✔
2209

2210
        private static BatchState GetTerminalBatchState(int failed, int cancelled) =>
2211
                failed != 0 ? BatchState.Failed : cancelled != 0 ? BatchState.Cancelled : BatchState.Succeeded;
5✔
2212

2213
        private async ValueTask MutateRecurringAsync(string name, Action<ImmediateRecurringJobEntity> mutate, CancellationToken cancellationToken)
2214
        {
2215
                ArgumentException.ThrowIfNullOrWhiteSpace(name);
×
2216
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
2217
                var schedule = await context.Set<ImmediateRecurringJobEntity>().FindAsync([name], cancellationToken).ConfigureAwait(false);
×
2218
                if (schedule is null)
×
2219
                        return;
2220
                mutate(schedule);
×
2221
                schedule.ConcurrencyStamp = Guid.NewGuid();
×
2222
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
2223
        }
×
2224

2225
        private static Task<int> UpdateRecurringAsync(
2226
                TContext context,
2227
                RecurringJobSchedule schedule,
2228
                CancellationToken cancellationToken
2229
        )
2230
        {
2231
                var concurrencyStamp = Guid.NewGuid();
5✔
2232
                return context.Set<ImmediateRecurringJobEntity>()
5✔
2233
                        .Where(entity => entity.Name == schedule.Name && (schedule.IsCodeDefined || !entity.IsCodeDefined))
5✔
2234
                        .ExecuteUpdateAsync(setters => setters
5✔
2235
                                .SetProperty(entity => entity.JobName, schedule.JobName)
5✔
2236
                                .SetProperty(entity => entity.Cron, schedule.Cron)
5✔
2237
                                .SetProperty(entity => entity.TimeZone, schedule.TimeZone)
5✔
2238
                                .SetProperty(entity => entity.IsCodeDefined, schedule.IsCodeDefined)
5✔
2239
                                .SetProperty(entity => entity.NextRunAt, schedule.NextRunAt)
5✔
2240
                                .SetProperty(entity => entity.ConcurrencyStamp, concurrencyStamp),
5✔
2241
                                cancellationToken);
5✔
2242
        }
2243

2244
        private static async Task ThrowIfReplacingCodeDefinedScheduleAsync(
2245
                TContext context,
2246
                RecurringJobSchedule schedule,
2247
                CancellationToken cancellationToken
2248
        )
2249
        {
2250
                if (!schedule.IsCodeDefined && await context.Set<ImmediateRecurringJobEntity>()
5✔
2251
                        .AnyAsync(entity => entity.Name == schedule.Name && entity.IsCodeDefined, cancellationToken)
5✔
2252
                        .ConfigureAwait(false))
5✔
2253
                {
2254
                        throw new ImmediateJobException("Code-defined recurring schedules cannot be replaced by dynamic schedules.");
4✔
2255
                }
2256
        }
5✔
2257

2258
        private static ImmediateJobEntity ToEntity(JobRecord job) => new()
5✔
2259
        {
5✔
2260
                Id = job.Id,
5✔
2261
                QueueName = job.QueueName,
5✔
2262
                JobName = job.JobName,
5✔
2263
                GroupId = job.GroupId,
5✔
2264
                Payload = job.Payload,
5✔
2265
                Context = job.Context,
5✔
2266
                State = job.State,
5✔
2267
                DueAt = job.DueAt,
5✔
2268
                CreatedAt = job.CreatedAt,
5✔
2269
                Attempt = job.Attempt,
5✔
2270
                WorkerId = job.WorkerId,
5✔
2271
                LeaseExpiresAt = job.LeaseExpiresAt,
5✔
2272
                LastError = job.LastError,
5✔
2273
                CompletedAt = job.CompletedAt,
5✔
2274
                RecurringKey = job.RecurringKey,
5✔
2275
                TraceParent = job.TraceParent,
5✔
2276
                TraceState = job.TraceState,
5✔
2277
                ExecutionTraceId = job.ExecutionTraceId,
5✔
2278
                ExecutionSpanId = job.ExecutionSpanId,
5✔
2279
                ExecutionStartedAt = job.ExecutionStartedAt,
5✔
2280
                BatchId = job.BatchId,
5✔
2281
                RemainingDependencies = job.RemainingDependencies,
5✔
2282
                FailedDependencies = job.FailedDependencies,
5✔
2283
                ConcurrencyStamp = Guid.NewGuid(),
5✔
2284
        };
5✔
2285

2286
        private static ImmediateJobContinuationEntity ToEntity(JobContinuationEdge edge)
2287
        {
2288
                var hasJobParent = edge.ParentJobId is not null;
5✔
2289
                var hasBatchParent = edge.ParentBatchId is not null;
5✔
2290
                if (hasJobParent == hasBatchParent)
5✔
2291
                        throw new ImmediateJobException("A continuation edge must identify exactly one parent job or batch.");
×
2292
                return new()
5✔
2293
                {
5✔
2294
                        ChildJobId = edge.ChildJobId,
5✔
2295
                        ParentKind = hasJobParent ? ContinuationParentKind.Job : ContinuationParentKind.Batch,
5✔
2296
                        ParentId = edge.ParentJobId ?? edge.ParentBatchId!,
5✔
2297
                        Trigger = edge.Trigger,
5✔
2298
                };
5✔
2299
        }
2300

2301
        private static ImmediateJobEntity Copy(ImmediateJobEntity job) => new()
5✔
2302
        {
5✔
2303
                Id = job.Id,
5✔
2304
                QueueName = job.QueueName,
5✔
2305
                JobName = job.JobName,
5✔
2306
                GroupId = job.GroupId,
5✔
2307
                Payload = job.Payload,
5✔
2308
                Context = job.Context,
5✔
2309
                State = job.State,
5✔
2310
                DueAt = job.DueAt,
5✔
2311
                CreatedAt = job.CreatedAt,
5✔
2312
                Attempt = job.Attempt,
5✔
2313
                WorkerId = job.WorkerId,
5✔
2314
                LeaseExpiresAt = job.LeaseExpiresAt,
5✔
2315
                LastError = job.LastError,
5✔
2316
                CompletedAt = job.CompletedAt,
5✔
2317
                RecurringKey = job.RecurringKey,
5✔
2318
                TraceParent = job.TraceParent,
5✔
2319
                TraceState = job.TraceState,
5✔
2320
                ExecutionTraceId = job.ExecutionTraceId,
5✔
2321
                ExecutionSpanId = job.ExecutionSpanId,
5✔
2322
                ExecutionStartedAt = job.ExecutionStartedAt,
5✔
2323
                BatchId = job.BatchId,
5✔
2324
                RemainingDependencies = job.RemainingDependencies,
5✔
2325
                FailedDependencies = job.FailedDependencies,
5✔
2326
                ConcurrencyStamp = job.ConcurrencyStamp,
5✔
2327
        };
5✔
2328

2329
        private static JobRecord ToRecord(ImmediateJobEntity job) => new()
5✔
2330
        {
5✔
2331
                Id = job.Id,
5✔
2332
                QueueName = job.QueueName,
5✔
2333
                JobName = job.JobName,
5✔
2334
                GroupId = job.GroupId,
5✔
2335
                Payload = job.Payload,
5✔
2336
                Context = job.Context,
5✔
2337
                State = job.State,
5✔
2338
                DueAt = job.DueAt,
5✔
2339
                CreatedAt = job.CreatedAt,
5✔
2340
                Attempt = job.Attempt,
5✔
2341
                WorkerId = job.WorkerId,
5✔
2342
                LeaseExpiresAt = job.LeaseExpiresAt,
5✔
2343
                LastError = job.LastError,
5✔
2344
                CompletedAt = job.CompletedAt,
5✔
2345
                RecurringKey = job.RecurringKey,
5✔
2346
                TraceParent = job.TraceParent,
5✔
2347
                TraceState = job.TraceState,
5✔
2348
                ExecutionTraceId = job.ExecutionTraceId,
5✔
2349
                ExecutionSpanId = job.ExecutionSpanId,
5✔
2350
                ExecutionStartedAt = job.ExecutionStartedAt,
5✔
2351
                BatchId = job.BatchId,
5✔
2352
                RemainingDependencies = job.RemainingDependencies,
5✔
2353
                FailedDependencies = job.FailedDependencies,
5✔
2354
        };
5✔
2355

2356
        private static BatchStatus ToStatus(ImmediateJobBatchEntity batch) => new(
5✔
2357
                batch.Id,
5✔
2358
                batch.State,
5✔
2359
                batch.TotalJobs,
5✔
2360
                batch.SucceededCount,
5✔
2361
                batch.FailedCount,
5✔
2362
                batch.CancelledCount,
5✔
2363
                batch.PendingCount,
5✔
2364
                batch.CreatedAt,
5✔
2365
                batch.StartedAt,
5✔
2366
                batch.CompletedAt,
5✔
2367
                BatchStatus.CalculateFractionSettled(batch.TotalJobs, batch.PendingCount)
5✔
2368
        );
5✔
2369

2370
        private static JobContinuationEdge ToContinuationEdge(ImmediateJobContinuationEntity edge) => new()
5✔
2371
        {
5✔
2372
                ChildJobId = edge.ChildJobId,
5✔
2373
                ParentJobId = edge.ParentKind == ContinuationParentKind.Job ? edge.ParentId : null,
5✔
2374
                ParentBatchId = edge.ParentKind == ContinuationParentKind.Batch ? edge.ParentId : null,
5✔
2375
                Trigger = edge.Trigger,
5✔
2376
        };
5✔
2377

2378
        private static BatchGraphEdge ToGraphEdge(ImmediateJobContinuationEntity edge) => new(
5✔
2379
                edge.ChildJobId,
5✔
2380
                edge.ParentKind == ContinuationParentKind.Job ? edge.ParentId : null,
5✔
2381
                edge.ParentKind == ContinuationParentKind.Batch ? edge.ParentId : null,
5✔
2382
                edge.Trigger
5✔
2383
        );
5✔
2384

2385
        private static ImmediateRecurringJobEntity ToEntity(RecurringJobSchedule schedule) => new()
5✔
2386
        {
5✔
2387
                Name = schedule.Name,
5✔
2388
                JobName = schedule.JobName,
5✔
2389
                Cron = schedule.Cron,
5✔
2390
                TimeZone = schedule.TimeZone,
5✔
2391
                IsCodeDefined = schedule.IsCodeDefined,
5✔
2392
                IsPaused = schedule.IsPaused,
5✔
2393
                NextRunAt = schedule.NextRunAt,
5✔
2394
                LastRunAt = schedule.LastRunAt,
5✔
2395
                ConcurrencyStamp = Guid.NewGuid(),
5✔
2396
        };
5✔
2397
}
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