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

ImmediatePlatform / Immediate.Jobs / 30586745594

30 Jul 2026 10:19PM UTC coverage: 82.267% (+1.0%) from 81.264%
30586745594

Pull #72

github

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

257 of 281 new or added lines in 6 files covered. (91.46%)

1 existing line in 1 file now uncovered.

7344 of 8927 relevant lines covered (82.27%)

2.72 hits per line

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

82.69
/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
                const int MaxConcurrencyAttempts = 5;
105
                for (var attempt = 0; attempt < MaxConcurrencyAttempts; attempt++)
5✔
106
                {
107
                        try
108
                        {
109
                                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
110
                                var strategy = strategyContext.Database.CreateExecutionStrategy();
5✔
111
                                await strategy.ExecuteAsync(
5✔
112
                                        operationCancellationToken => InsertGraphCoreAsync(batch, jobs, edges, operationCancellationToken),
5✔
113
                                        cancellationToken
5✔
114
                                ).ConfigureAwait(false);
5✔
115
                                return;
5✔
NEW
116
                        }
×
117
                        catch (DbUpdateConcurrencyException) when (attempt + 1 < MaxConcurrencyAttempts)
5✔
118
                        {
119
                                // A parent completed while its continuation edge was being inserted. Retry so
120
                                // dependency evaluation observes either the edge or the terminal parent.
121
                        }
1✔
122
                }
123
        }
5✔
124

125
        private async Task InsertGraphCoreAsync(
126
                JobBatchRecord? batch,
127
                IReadOnlyList<JobRecord> jobs,
128
                IReadOnlyList<JobContinuationEdge> edges,
129
                CancellationToken cancellationToken
130
        )
131
        {
132
                var jobIds = jobs.Select(static job => job.Id).ToHashSet(StringComparer.Ordinal);
5✔
133
                if (jobIds.Count != jobs.Count)
5✔
134
                        throw new ImmediateJobException("A batch or continuation insert contains duplicate job identifiers.");
×
135
                if (batch is not null && jobs.Any(job => !string.Equals(job.BatchId, batch.Id, StringComparison.Ordinal)))
5✔
136
                        throw new ImmediateJobException("Every atomic batch member must carry the committed batch identifier.");
×
137

138
                var edgeEntities = edges.Select(ToEntity).ToArray();
5✔
139
                if (edgeEntities.Any(edge => !jobIds.Contains(edge.ChildJobId)))
5✔
140
                        throw new ImmediateJobException("Every continuation edge must target a job inserted by the same operation.");
×
141
                if (edgeEntities.DistinctBy(static edge => (edge.ChildJobId, edge.ParentKind, edge.ParentId)).Count() != edgeEntities.Length)
5✔
142
                        throw new ImmediateJobException("Duplicate continuation edges are not allowed.");
×
143
                ThrowIfCyclic(jobIds, edgeEntities);
5✔
144

145
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
146
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
147
                var jobEntities = jobs.Select(ToEntity).ToDictionary(static job => job.Id, StringComparer.Ordinal);
5✔
148
                await EvaluateInitialDependenciesAsync(
5✔
149
                        context,
5✔
150
                        jobEntities,
5✔
151
                        edgeEntities,
5✔
152
                        _timeProvider.GetUtcNow(),
5✔
153
                        cancellationToken
5✔
154
                ).ConfigureAwait(false);
5✔
155

156
                if (batch is not null)
5✔
157
                {
158
                        var terminal = jobEntities.Values.Where(static job => IsTerminal(job.State)).ToArray();
5✔
159
                        var pending = jobEntities.Count - terminal.Length;
5✔
160
                        var succeeded = terminal.Count(static job => job.State == JobState.Succeeded);
4✔
161
                        var failed = terminal.Count(static job => job.State == JobState.Failed);
4✔
162
                        var cancelled = terminal.Count(static job => job.State == JobState.Cancelled);
4✔
163
                        _ = context.Add(new ImmediateJobBatchEntity
5✔
164
                        {
5✔
165
                                Id = batch.Id,
5✔
166
                                CreatedAt = batch.CreatedAt,
5✔
167
                                TotalJobs = jobEntities.Count,
5✔
168
                                PendingCount = pending,
5✔
169
                                SucceededCount = succeeded,
5✔
170
                                FailedCount = failed,
5✔
171
                                CancelledCount = cancelled,
5✔
172
                                StartedAt = batch.StartedAt,
5✔
173
                                CompletedAt = pending == 0 ? batch.CompletedAt ?? _timeProvider.GetUtcNow() : null,
5✔
174
                                State = pending == 0 ? GetTerminalBatchState(failed, cancelled) : BatchState.Executing,
5✔
175
                                ConcurrencyStamp = Guid.NewGuid(),
5✔
176
                        });
5✔
177
                }
178

179
                context.AddRange(jobEntities.Values);
5✔
180
                context.AddRange(edgeEntities);
5✔
181
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
182
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
5✔
183
        }
5✔
184

185
        /// <inheritdoc />
186
        public async ValueTask<IReadOnlyList<JobRecord>> AcquireDueJobsAsync(
187
                JobAcquisitionRequest request,
188
                CancellationToken cancellationToken = default
189
        )
190
        {
191
                ArgumentNullException.ThrowIfNull(request);
5✔
192
                ArgumentException.ThrowIfNullOrWhiteSpace(request.WorkerId);
5✔
193
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(request.Lease, TimeSpan.Zero);
5✔
194
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(request.BatchSize, 0);
5✔
195
                if (request.FairQueues is not null)
5✔
196
                        return await AcquireDueJobsFairAsync(request, cancellationToken).ConfigureAwait(false);
5✔
197

198
                var now = _timeProvider.GetUtcNow();
5✔
199
                var acquired = new List<JobRecord>(request.BatchSize);
5✔
200
                foreach (var queue in request.Queues)
5✔
201
                {
202
                        var queueCapacity = Math.Min(queue.Capacity, request.BatchSize - acquired.Count);
5✔
203
                        if (queueCapacity <= 0)
5✔
204
                                continue;
205

206
                        var jobCapacities = queue.JobCapacities.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal);
5✔
207
                        while (queueCapacity > 0)
5✔
208
                        {
209
                                var eligibleNames = jobCapacities.Where(static pair => pair.Value > 0).Select(static pair => pair.Key).ToArray();
5✔
210
                                if (eligibleNames.Length == 0)
5✔
211
                                        break;
212

213
                                await using var readContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
214
                                var candidates = await readContext.Set<ImmediateJobEntity>()
5✔
215
                                        .AsNoTracking()
5✔
216
                                        .Where(job => job.QueueName == queue.QueueName && eligibleNames.Contains(job.JobName) &&
5✔
217
                                                (((job.State == JobState.Scheduled || job.State == JobState.Pending) && job.DueAt <= now)
5✔
218
                                                        || (job.State == JobState.Active && job.LeaseExpiresAt <= now)))
5✔
219
                                        .OrderBy(job => job.DueAt)
5✔
220
                                        .ThenBy(job => job.CreatedAt)
5✔
221
                                        .ThenBy(job => job.Id)
5✔
222
                                        .Take(queueCapacity)
5✔
223
                                        .ToListAsync(cancellationToken)
5✔
224
                                        .ConfigureAwait(false);
5✔
225
                                if (candidates.Count == 0)
5✔
226
                                        break;
227

228
                                var selected = new List<ImmediateJobEntity>(candidates.Count);
5✔
229
                                var selectionCapacities = new Dictionary<string, int>(jobCapacities, StringComparer.Ordinal);
5✔
230
                                foreach (var candidate in candidates)
5✔
231
                                {
232
                                        if (selectionCapacities[candidate.JobName] <= 0)
5✔
233
                                                continue;
234
                                        selectionCapacities[candidate.JobName]--;
5✔
235
                                        selected.Add(candidate);
5✔
236
                                }
237

238
                                var claimed = await AcquireCandidatesAsync(
5✔
239
                                        selected,
5✔
240
                                        request.WorkerId,
5✔
241
                                        request.Lease,
5✔
242
                                        now,
5✔
243
                                        cancellationToken
5✔
244
                                ).ConfigureAwait(false);
5✔
245
                                foreach (var job in claimed)
5✔
246
                                {
247
                                        jobCapacities[job.JobName]--;
5✔
248
                                        queueCapacity--;
5✔
249
                                        acquired.Add(job);
5✔
250
                                }
251

252
                                if (claimed.Count == 0)
5✔
253
                                        break;
254
                        }
4✔
255
                }
4✔
256

257
                return acquired;
5✔
258
        }
4✔
259

260
        private async ValueTask<IReadOnlyList<JobRecord>> AcquireDueJobsFairAsync(
261
                JobAcquisitionRequest request,
262
                CancellationToken cancellationToken
263
        )
264
        {
265
                var now = _timeProvider.GetUtcNow();
5✔
266
                var acquired = new List<JobRecord>(request.BatchSize);
5✔
267
                foreach (var queue in request.Queues)
5✔
268
                {
269
                        var consecutiveFailedClaims = 0;
5✔
270
                        var queueCapacity = Math.Min(queue.Capacity, request.BatchSize - acquired.Count);
5✔
271
                        if (queueCapacity <= 0)
5✔
272
                                continue;
273

274
                        var jobCapacities = queue.JobCapacities.ToDictionary(
5✔
275
                                static pair => pair.Key,
5✔
276
                                static pair => pair.Value,
5✔
277
                                StringComparer.Ordinal
5✔
278
                        );
5✔
279
                        while (queueCapacity > 0)
5✔
280
                        {
281
                                var eligibleNames = jobCapacities
5✔
282
                                        .Where(static pair => pair.Value > 0)
5✔
283
                                        .Select(static pair => pair.Key)
5✔
284
                                        .ToArray();
5✔
285
                                if (eligibleNames.Length == 0)
5✔
286
                                        break;
287

288
                                await using var readContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
289
                                var eligibleQuery = readContext.Set<ImmediateJobEntity>()
5✔
290
                                        .AsNoTracking()
5✔
291
                                        .Where(job => job.QueueName == queue.QueueName && eligibleNames.Contains(job.JobName) &&
5✔
292
                                                (((job.State == JobState.Scheduled || job.State == JobState.Pending) && job.DueAt <= now)
5✔
293
                                                        || (job.State == JobState.Active && job.LeaseExpiresAt <= now)));
5✔
294
                                if (!await eligibleQuery
5✔
295
                                        .AnyAsync(static job => job.GroupId != null, cancellationToken)
5✔
296
                                        .ConfigureAwait(false))
5✔
297
                                {
298
                                        var claimed = await AcquireFairFastPathAsync(
4✔
299
                                                queue.QueueName,
4✔
300
                                                jobCapacities,
4✔
301
                                                queueCapacity,
4✔
302
                                                request.WorkerId,
4✔
303
                                                request.Lease,
4✔
304
                                                now,
4✔
305
                                                cancellationToken
4✔
306
                                        ).ConfigureAwait(false);
4✔
307
                                        queueCapacity -= claimed.Count;
4✔
308
                                        acquired.AddRange(claimed);
4✔
309
                                        break;
4✔
310
                                }
311

312
                                var groupedHeads = await eligibleQuery
5✔
313
                                        .Where(static job => job.GroupId != null)
5✔
314
                                        .GroupBy(static job => job.GroupId)
5✔
315
                                        .Select(static group => group
5✔
316
                                                .OrderBy(job => job.DueAt)
5✔
317
                                                .ThenBy(job => job.CreatedAt)
5✔
318
                                                .ThenBy(job => job.Id)
5✔
319
                                                .First())
5✔
320
                                        .ToListAsync(cancellationToken)
5✔
321
                                        .ConfigureAwait(false);
5✔
322
                                var ungroupedHead = await eligibleQuery
5✔
323
                                        .Where(static job => job.GroupId == null)
5✔
324
                                        .OrderBy(job => job.DueAt)
5✔
325
                                        .ThenBy(job => job.CreatedAt)
5✔
326
                                        .ThenBy(job => job.Id)
5✔
327
                                        .FirstOrDefaultAsync(cancellationToken)
5✔
328
                                        .ConfigureAwait(false);
5✔
329
                                if (groupedHeads.Count == 0)
5✔
330
                                {
331
                                        var claimed = await AcquireFairFastPathAsync(
×
332
                                                queue.QueueName,
×
333
                                                jobCapacities,
×
334
                                                queueCapacity,
×
335
                                                request.WorkerId,
×
336
                                                request.Lease,
×
337
                                                now,
×
338
                                                cancellationToken
×
339
                                        ).ConfigureAwait(false);
×
340
                                        queueCapacity -= claimed.Count;
×
341
                                        acquired.AddRange(claimed);
×
342
                                        break;
×
343
                                }
344

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

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

437
                                consecutiveFailedClaims = 0;
5✔
438
                                jobCapacities[claimedJob.JobName]--;
5✔
439
                                queueCapacity--;
5✔
440
                                acquired.Add(claimedJob);
5✔
441
                        }
4✔
442
                }
4✔
443

444
                return acquired;
5✔
445
        }
4✔
446

447
        private async ValueTask<IReadOnlyList<JobRecord>> AcquireFairFastPathAsync(
448
                string queueName,
449
                Dictionary<string, int> jobCapacities,
450
                int queueCapacity,
451
                string workerId,
452
                TimeSpan lease,
453
                DateTimeOffset now,
454
                CancellationToken cancellationToken
455
        )
456
        {
457
                var acquired = new List<JobRecord>(queueCapacity);
4✔
458
                while (queueCapacity > 0)
4✔
459
                {
460
                        var eligibleNames = jobCapacities
4✔
461
                                .Where(static pair => pair.Value > 0)
4✔
462
                                .Select(static pair => pair.Key)
4✔
463
                                .ToArray();
4✔
464
                        if (eligibleNames.Length == 0)
4✔
465
                                break;
466

467
                        await using var readContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
468
                        var candidates = await readContext.Set<ImmediateJobEntity>()
4✔
469
                                .AsNoTracking()
4✔
470
                                .Where(job => job.QueueName == queueName && eligibleNames.Contains(job.JobName) &&
4✔
471
                                        (((job.State == JobState.Scheduled || job.State == JobState.Pending) && job.DueAt <= now)
4✔
472
                                                || (job.State == JobState.Active && job.LeaseExpiresAt <= now)))
4✔
473
                                .OrderBy(job => job.DueAt)
4✔
474
                                .ThenBy(job => job.CreatedAt)
4✔
475
                                .ThenBy(job => job.Id)
4✔
476
                                .Take(queueCapacity)
4✔
477
                                .ToListAsync(cancellationToken)
4✔
478
                                .ConfigureAwait(false);
4✔
479
                        if (candidates.Count == 0)
4✔
480
                                break;
481

482
                        var selected = new List<ImmediateJobEntity>(candidates.Count);
4✔
483
                        var selectionCapacities = new Dictionary<string, int>(jobCapacities, StringComparer.Ordinal);
4✔
484
                        foreach (var candidate in candidates)
4✔
485
                        {
486
                                if (selectionCapacities[candidate.JobName] <= 0)
4✔
487
                                        continue;
488
                                selectionCapacities[candidate.JobName]--;
4✔
489
                                selected.Add(candidate);
4✔
490
                        }
491

492
                        var claimed = await AcquireCandidatesAsync(
4✔
493
                                selected,
4✔
494
                                workerId,
4✔
495
                                lease,
4✔
496
                                now,
4✔
497
                                cancellationToken
4✔
498
                        ).ConfigureAwait(false);
4✔
499
                        foreach (var job in claimed)
4✔
500
                        {
501
                                jobCapacities[job.JobName]--;
4✔
502
                                queueCapacity--;
4✔
503
                                acquired.Add(job);
4✔
504
                        }
505

506
                        if (claimed.Count == 0)
4✔
507
                                break;
508
                }
3✔
509

510
                return acquired;
4✔
511
        }
3✔
512

513
        private async ValueTask<JobRecord?> AcquireFairCandidateAsync(
514
                ImmediateJobEntity candidate,
515
                string workerId,
516
                TimeSpan lease,
517
                DateTimeOffset now,
518
                long nextSequence,
519
                CancellationToken cancellationToken
520
        )
521
        {
522
                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
523
                var strategy = strategyContext.Database.CreateExecutionStrategy();
5✔
524
                return await strategy.ExecuteAsync(
5✔
525
                        operationCancellationToken => AcquireFairCandidateCoreAsync(
5✔
526
                                candidate,
5✔
527
                                workerId,
5✔
528
                                lease,
5✔
529
                                now,
5✔
530
                                nextSequence,
5✔
531
                                operationCancellationToken
5✔
532
                        ),
5✔
533
                        cancellationToken
5✔
534
                ).ConfigureAwait(false);
5✔
535
        }
4✔
536

537
        private async Task<JobRecord?> AcquireFairCandidateCoreAsync(
538
                ImmediateJobEntity candidate,
539
                string workerId,
540
                TimeSpan lease,
541
                DateTimeOffset now,
542
                long nextSequence,
543
                CancellationToken cancellationToken
544
        )
545
        {
546
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
547
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
548
                var entity = Copy(candidate);
5✔
549
                _ = context.Attach(entity);
5✔
550
                entity.State = JobState.Active;
5✔
551
                entity.WorkerId = workerId;
5✔
552
                entity.LeaseExpiresAt = now + lease;
5✔
553
                entity.Attempt++;
5✔
554
                entity.CompletedAt = null;
5✔
555
                entity.ExecutionTraceId = null;
5✔
556
                entity.ExecutionSpanId = null;
5✔
557
                entity.ExecutionStartedAt = null;
5✔
558
                entity.ConcurrencyStamp = Guid.NewGuid();
5✔
559

560
                if (candidate.GroupId is { } groupId)
5✔
561
                {
562
                        var group = await context.Set<ImmediateFairQueueGroupEntity>()
5✔
563
                                .SingleOrDefaultAsync(
5✔
564
                                        item => item.QueueName == candidate.QueueName && item.GroupId == groupId,
5✔
565
                                        cancellationToken
5✔
566
                                )
5✔
567
                                .ConfigureAwait(false);
5✔
568
                        if (group is null)
5✔
569
                        {
570
                                _ = context.Add(new ImmediateFairQueueGroupEntity
5✔
571
                                {
5✔
572
                                        QueueName = candidate.QueueName,
5✔
573
                                        GroupId = groupId,
5✔
574
                                        LastServedSequence = nextSequence,
5✔
575
                                        ConcurrencyStamp = Guid.NewGuid(),
5✔
576
                                });
5✔
577
                        }
578
                        else if (group.LastServedSequence >= nextSequence)
4✔
579
                        {
580
                                // Selection observed an older cursor snapshot. Re-rank instead of moving this group backward.
581
                                return null;
4✔
582
                        }
583
                        else
584
                        {
585
                                group.LastServedSequence = nextSequence;
4✔
586
                                group.ConcurrencyStamp = Guid.NewGuid();
4✔
587
                        }
588
                }
589

590
                if (entity.BatchId is { } batchId)
5✔
591
                {
592
                        var batch = await context.Set<ImmediateJobBatchEntity>()
×
593
                                .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
×
594
                                .ConfigureAwait(false);
×
595
                        if (batch is not null && batch.StartedAt is null)
×
596
                        {
597
                                batch.StartedAt = now;
×
598
                                batch.ConcurrencyStamp = Guid.NewGuid();
×
599
                        }
600
                }
601

602
                try
603
                {
604
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
605
                        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
5✔
606
                        return ToRecord(entity);
5✔
607
                }
608
                catch (DbUpdateException)
4✔
609
                {
610
                        // The job or its group cursor changed after candidate selection.
611
                        return null;
5✔
612
                }
613
        }
4✔
614

615
        private static JobRecord? GetFirstOrDefault(IReadOnlyList<JobRecord> jobs) =>
616
                jobs.Count == 0 ? null : jobs[0];
4✔
617

618
        private static bool IsNoisy(
619
                string? groupId,
620
                int inflight,
621
                int totalInflight,
622
                FairQueuePolicy policy
623
        )
624
        {
625
                return groupId is not null
5✔
626
                        && totalInflight > 0
5✔
627
                        && inflight >= policy.MinInflightForNoisy
5✔
628
                        && (double)inflight / totalInflight > policy.ConcurrencyShareThreshold;
5✔
629
        }
630

631
        private sealed record FairQueueCandidateState(
5✔
632
                string JobId,
5✔
633
                int Inflight,
5✔
634
                long LastServedSequence
5✔
635
        );
5✔
636

637
        /// <inheritdoc />
638
        public async ValueTask<IReadOnlyList<JobRecord>> AcquireJobsAsync(
639
                IReadOnlyCollection<string> jobIds,
640
                string workerId,
641
                TimeSpan lease,
642
                CancellationToken cancellationToken = default
643
        )
644
        {
645
                ArgumentNullException.ThrowIfNull(jobIds);
5✔
646
                ArgumentException.ThrowIfNullOrWhiteSpace(workerId);
5✔
647
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lease, TimeSpan.Zero);
5✔
648
                if (jobIds.Count == 0)
5✔
649
                        return [];
×
650

651
                var now = _timeProvider.GetUtcNow();
5✔
652
                var ids = jobIds.ToArray();
5✔
653
                await using var readContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
654
                var candidates = await readContext.Set<ImmediateJobEntity>()
5✔
655
                        .AsNoTracking()
5✔
656
                        .Where(job => ids.Contains(job.Id) &&
5✔
657
                                (((job.State == JobState.Scheduled || job.State == JobState.Pending) && job.DueAt <= now)
5✔
658
                                || (job.State == JobState.Active && job.LeaseExpiresAt <= now)))
5✔
659
                        .ToListAsync(cancellationToken)
5✔
660
                        .ConfigureAwait(false);
5✔
661

662
                return await AcquireCandidatesAsync(candidates, workerId, lease, now, cancellationToken).ConfigureAwait(false);
5✔
663
        }
4✔
664

665
        private async ValueTask<IReadOnlyList<JobRecord>> AcquireCandidatesAsync(
666
                List<ImmediateJobEntity> candidates,
667
                string workerId,
668
                TimeSpan lease,
669
                DateTimeOffset now,
670
                CancellationToken cancellationToken
671
        )
672
        {
673
                var acquired = new List<JobRecord>(candidates.Count);
5✔
674
                foreach (var candidate in candidates)
5✔
675
                {
676
                        await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
677
                        var entity = Copy(candidate);
5✔
678
                        _ = context.Attach(entity);
5✔
679
                        entity.State = JobState.Active;
5✔
680
                        entity.WorkerId = workerId;
5✔
681
                        entity.LeaseExpiresAt = now + lease;
5✔
682
                        entity.Attempt++;
5✔
683
                        entity.CompletedAt = null;
5✔
684
                        entity.ExecutionTraceId = null;
5✔
685
                        entity.ExecutionSpanId = null;
5✔
686
                        entity.ExecutionStartedAt = null;
5✔
687
                        entity.ConcurrencyStamp = Guid.NewGuid();
5✔
688
                        if (entity.BatchId is { } batchId)
5✔
689
                        {
690
                                var batch = await context.Set<ImmediateJobBatchEntity>()
5✔
691
                                        .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
5✔
692
                                        .ConfigureAwait(false);
5✔
693
                                if (batch is not null && batch.StartedAt is null)
5✔
694
                                {
695
                                        batch.StartedAt = now;
5✔
696
                                        batch.ConcurrencyStamp = Guid.NewGuid();
5✔
697
                                }
698
                        }
699

700
                        try
701
                        {
702
                                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
703
                                acquired.Add(ToRecord(entity));
5✔
704
                        }
5✔
705
                        catch (DbUpdateConcurrencyException)
1✔
706
                        {
707
                                // Another scheduler claimed or changed this candidate first.
708
                        }
1✔
709
                }
4✔
710

711
                return acquired;
5✔
712
        }
4✔
713

714
        /// <inheritdoc />
715
        public ValueTask SetExecutionTelemetryAsync(
716
                string jobId,
717
                string workerId,
718
                string? traceId,
719
                string? spanId,
720
                DateTimeOffset startedAt,
721
                CancellationToken cancellationToken = default
722
        ) => MutateOwnedAsync(jobId, workerId, job =>
1✔
723
        {
1✔
724
                job.ExecutionTraceId = traceId;
1✔
725
                job.ExecutionSpanId = spanId;
1✔
726
                job.ExecutionStartedAt = startedAt;
1✔
727
        }, cancellationToken);
1✔
728

729
        /// <inheritdoc />
730
        public ValueTask RenewLeaseAsync(string jobId, string workerId, TimeSpan lease, CancellationToken cancellationToken = default)
731
        {
732
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lease, TimeSpan.Zero);
×
733
                return MutateOwnedAsync(jobId, workerId, job => job.LeaseExpiresAt = _timeProvider.GetUtcNow() + lease, cancellationToken);
×
734
        }
735

736
        /// <inheritdoc />
737
        public ValueTask CompleteAsync(string jobId, string workerId, CancellationToken cancellationToken = default) =>
738
                CompleteWithContinuationsAsync(jobId, workerId, [], cancellationToken);
5✔
739

740
        /// <inheritdoc />
741
        public ValueTask CompleteWithContinuationsAsync(
742
                string jobId,
743
                string workerId,
744
                IReadOnlyList<JobContinuationAddition> additions,
745
                CancellationToken cancellationToken = default
746
        )
747
        {
748
                ArgumentNullException.ThrowIfNull(additions);
5✔
749
                return MutateOwnedWithDependenciesAsync(
5✔
750
                        jobId,
5✔
751
                        workerId,
5✔
752
                        error: null,
5✔
753
                        nextRetryAt: null,
5✔
754
                        succeeded: true,
5✔
755
                        additions,
5✔
756
                        cancellationToken
5✔
757
                );
5✔
758
        }
759

760
        /// <inheritdoc />
761
        public async ValueTask AddBatchJobAsync(
762
                string currentJobId,
763
                JobRecord job,
764
                ContinuationOptions options,
765
                CancellationToken cancellationToken = default
766
        )
767
        {
768
                ArgumentNullException.ThrowIfNull(job);
5✔
769
                if (options == ContinuationOptions.Detached)
5✔
770
                        throw new ImmediateJobException("AddToBatchAsync cannot create a detached job.");
×
771
                const int MaxConcurrencyAttempts = 5;
772
                for (var attempt = 0; attempt < MaxConcurrencyAttempts; attempt++)
5✔
773
                {
774
                        try
775
                        {
776
                                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
777
                                var strategy = strategyContext.Database.CreateExecutionStrategy();
5✔
778
                                await strategy.ExecuteAsync(
5✔
779
                                        operationCancellationToken => AddBatchJobCoreAsync(
5✔
780
                                                currentJobId,
5✔
781
                                                job,
5✔
782
                                                options,
5✔
783
                                                operationCancellationToken
5✔
784
                                        ),
5✔
785
                                        cancellationToken
5✔
786
                                ).ConfigureAwait(false);
5✔
787
                                return;
×
788
                        }
×
789
                        catch (DbUpdateConcurrencyException) when (attempt + 1 < MaxConcurrencyAttempts)
5✔
790
                        {
791
                        }
×
792
                }
793
        }
×
794

795
        /// <inheritdoc />
796
        public ValueTask FailAsync(
797
                string jobId,
798
                string workerId,
799
                string error,
800
                DateTimeOffset? nextRetryAt,
801
                CancellationToken cancellationToken = default
802
        ) => MutateOwnedWithDependenciesAsync(jobId, workerId, error, nextRetryAt, succeeded: false, [], cancellationToken);
5✔
803

804
        /// <inheritdoc />
805
        public async ValueTask UpsertRecurringAsync(RecurringJobSchedule schedule, CancellationToken cancellationToken = default)
806
        {
807
                ArgumentNullException.ThrowIfNull(schedule);
5✔
808
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
809
                if (await UpdateRecurringAsync(context, schedule, cancellationToken).ConfigureAwait(false) != 0)
5✔
810
                        return;
811
                await ThrowIfReplacingCodeDefinedScheduleAsync(context, schedule, cancellationToken).ConfigureAwait(false);
5✔
812

813
                _ = context.Add(ToEntity(schedule));
5✔
814
                try
815
                {
816
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
817
                }
5✔
818
                catch (DbUpdateException)
819
                {
820
                        // A competing node inserted the same schedule after our update attempt.
821
                        await using var retryContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
822
                        if (await UpdateRecurringAsync(retryContext, schedule, cancellationToken).ConfigureAwait(false) != 0)
×
823
                                return;
824
                        await ThrowIfReplacingCodeDefinedScheduleAsync(retryContext, schedule, cancellationToken).ConfigureAwait(false);
×
825
                        throw;
×
826
                }
827
        }
4✔
828

829
        /// <inheritdoc />
830
        public async ValueTask RemoveObsoleteCodeDefinedRecurringAsync(
831
                IReadOnlyCollection<string> activeScheduleNames,
832
                CancellationToken cancellationToken = default
833
        )
834
        {
835
                ArgumentNullException.ThrowIfNull(activeScheduleNames);
4✔
836
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
837
                var schedules = context.Set<ImmediateRecurringJobEntity>()
4✔
838
                        .Where(schedule => schedule.IsCodeDefined);
4✔
839
                if (activeScheduleNames.Count != 0)
4✔
840
                        schedules = schedules.Where(schedule => !activeScheduleNames.Contains(schedule.Name));
4✔
841
                _ = await schedules.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
4✔
842
        }
4✔
843

844
        /// <inheritdoc />
845
        public async ValueTask RemoveRecurringAsync(string name, CancellationToken cancellationToken = default)
846
        {
847
                ArgumentException.ThrowIfNullOrWhiteSpace(name);
5✔
848
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
849
                var removed = await context.Set<ImmediateRecurringJobEntity>()
5✔
850
                        .Where(schedule => schedule.Name == name && !schedule.IsCodeDefined)
5✔
851
                        .ExecuteDeleteAsync(cancellationToken)
5✔
852
                        .ConfigureAwait(false);
5✔
853
                if (removed != 0)
5✔
854
                        return;
855
                if (await context.Set<ImmediateRecurringJobEntity>()
5✔
856
                        .AnyAsync(schedule => schedule.Name == name, cancellationToken)
5✔
857
                        .ConfigureAwait(false))
5✔
858
                {
859
                        throw new ImmediateJobException("Code-defined recurring schedules cannot be deleted.");
4✔
860
                }
861

862
                throw new KeyNotFoundException($"Recurring schedule '{name}' was not found.");
5✔
863
        }
3✔
864

865
        /// <inheritdoc />
866
        public ValueTask PauseRecurringAsync(string name, CancellationToken cancellationToken = default)
867
                => MutateRecurringAsync(name, schedule => schedule.IsPaused = true, cancellationToken);
×
868

869
        /// <inheritdoc />
870
        public ValueTask ResumeRecurringAsync(string name, CancellationToken cancellationToken = default)
871
                => MutateRecurringAsync(name, schedule => schedule.IsPaused = false, cancellationToken);
×
872

873
        /// <inheritdoc />
874
        public async ValueTask<IReadOnlyList<RecurringJobSchedule>> GetDueRecurringAsync(DateTimeOffset now, int batchSize, CancellationToken cancellationToken = default)
875
        {
876
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(batchSize, 0);
×
877
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
878
                return await context.Set<ImmediateRecurringJobEntity>()
×
879
                        .AsNoTracking()
×
880
                        .Where(schedule => !schedule.IsPaused && schedule.NextRunAt <= now)
×
881
                        .OrderBy(schedule => schedule.NextRunAt)
×
882
                        .Take(batchSize)
×
883
                        .Select(schedule => new RecurringJobSchedule
×
884
                        {
×
885
                                Name = schedule.Name,
×
886
                                JobName = schedule.JobName,
×
887
                                Cron = schedule.Cron,
×
888
                                TimeZone = schedule.TimeZone,
×
889
                                IsCodeDefined = schedule.IsCodeDefined,
×
890
                                IsPaused = schedule.IsPaused,
×
891
                                NextRunAt = schedule.NextRunAt,
×
892
                                LastRunAt = schedule.LastRunAt,
×
893
                        })
×
894
                        .ToListAsync(cancellationToken)
×
895
                        .ConfigureAwait(false);
×
896
        }
897

898
        /// <inheritdoc />
899
        public async ValueTask<bool> MaterializeRecurringAsync(
900
                RecurringJobSchedule schedule,
901
                JobRecord job,
902
                DateTimeOffset nextRunAt,
903
                CancellationToken cancellationToken = default
904
        )
905
        {
906
                ArgumentNullException.ThrowIfNull(schedule);
5✔
907
                ArgumentNullException.ThrowIfNull(job);
5✔
908
                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
909
                var strategy = strategyContext.Database.CreateExecutionStrategy();
5✔
910
                return await strategy.ExecuteAsync(
5✔
911
                        operationCancellationToken => MaterializeRecurringCoreAsync(
5✔
912
                                schedule,
5✔
913
                                job,
5✔
914
                                nextRunAt,
5✔
915
                                operationCancellationToken
5✔
916
                        ),
5✔
917
                        cancellationToken
5✔
918
                ).ConfigureAwait(false);
5✔
919
        }
4✔
920

921
        private async Task<bool> MaterializeRecurringCoreAsync(
922
                RecurringJobSchedule schedule,
923
                JobRecord job,
924
                DateTimeOffset nextRunAt,
925
                CancellationToken cancellationToken
926
        )
927
        {
928
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
929
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
930
                var entity = await context.Set<ImmediateRecurringJobEntity>()
5✔
931
                        .SingleOrDefaultAsync(item => item.Name == schedule.Name, cancellationToken)
5✔
932
                        .ConfigureAwait(false);
5✔
933
                if (entity is null || entity.IsPaused || entity.NextRunAt != schedule.NextRunAt)
5✔
934
                        return false;
1✔
935

936
                entity.LastRunAt = schedule.NextRunAt;
5✔
937
                entity.NextRunAt = nextRunAt;
5✔
938
                entity.ConcurrencyStamp = Guid.NewGuid();
5✔
939
                _ = context.Add(ToEntity(job));
5✔
940
                try
941
                {
942
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
943
                        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
5✔
944
                        return true;
5✔
945
                }
946
                catch (DbUpdateException)
947
                {
948
                        await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
×
949
                        return false;
×
950
                }
951
        }
4✔
952

953
        /// <inheritdoc />
954
        public async ValueTask<JobMonitoringSnapshot> GetMonitoringSnapshotAsync(CancellationToken cancellationToken = default)
955
        {
956
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
957
                var rawCounts = await context.Set<ImmediateJobEntity>()
5✔
958
                        .AsNoTracking()
5✔
959
                        .GroupBy(job => job.State)
5✔
960
                        .Select(group => new { State = group.Key, Count = group.LongCount() })
5✔
961
                        .ToListAsync(cancellationToken)
5✔
962
                        .ConfigureAwait(false);
5✔
963
                var counts = Enum.GetValues<JobState>().ToDictionary(static state => state, static _ => 0L);
5✔
964
                foreach (var item in rawCounts)
5✔
965
                        counts[item.State] = item.Count;
5✔
966

967
                var recurring = await context.Set<ImmediateRecurringJobEntity>()
5✔
968
                        .AsNoTracking()
5✔
969
                        .OrderBy(schedule => schedule.Name)
5✔
970
                        .Select(schedule => new RecurringJobSchedule
5✔
971
                        {
5✔
972
                                Name = schedule.Name,
5✔
973
                                JobName = schedule.JobName,
5✔
974
                                Cron = schedule.Cron,
5✔
975
                                TimeZone = schedule.TimeZone,
5✔
976
                                IsCodeDefined = schedule.IsCodeDefined,
5✔
977
                                IsPaused = schedule.IsPaused,
5✔
978
                                NextRunAt = schedule.NextRunAt,
5✔
979
                                LastRunAt = schedule.LastRunAt,
5✔
980
                        })
5✔
981
                        .ToListAsync(cancellationToken)
5✔
982
                        .ConfigureAwait(false);
5✔
983
                var cutoff = _timeProvider.GetUtcNow() - TimeSpan.FromMinutes(2);
5✔
984
                var servers = await context.Set<ImmediateJobServerEntity>()
5✔
985
                        .AsNoTracking()
5✔
986
                        .Where(server => server.LastHeartbeat >= cutoff)
5✔
987
                        .OrderBy(server => server.WorkerId)
5✔
988
                        .Select(server => new JobServerSnapshot(server.WorkerId, server.LastHeartbeat, server.ActiveWorkers, server.MaxWorkers))
5✔
989
                        .ToListAsync(cancellationToken)
5✔
990
                        .ConfigureAwait(false);
5✔
991
                return new(_timeProvider.GetUtcNow(), counts, recurring, servers)
5✔
992
                {
5✔
993
                        Capabilities = this.GetCapabilities(),
5✔
994
                };
5✔
995
        }
4✔
996

997
        /// <inheritdoc />
998
        public async ValueTask<IReadOnlyList<JobRecord>> QueryJobsAsync(JobQuery query, CancellationToken cancellationToken = default)
999
        {
1000
                ArgumentNullException.ThrowIfNull(query);
5✔
1001
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
5✔
1002
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
5✔
1003
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1004
                var jobs = context.Set<ImmediateJobEntity>().AsNoTracking();
5✔
1005
                if (query.Id is { } id)
5✔
1006
                        jobs = jobs.Where(job => job.Id == id);
5✔
1007
                if (query.State is { } state)
5✔
1008
                        jobs = jobs.Where(job => job.State == state);
4✔
1009
                if (!string.IsNullOrWhiteSpace(query.QueueName))
5✔
1010
                        jobs = jobs.Where(job => job.QueueName == query.QueueName);
×
1011
                if (!string.IsNullOrWhiteSpace(query.JobName))
5✔
1012
                        jobs = jobs.Where(job => job.JobName == query.JobName);
×
1013
                if (!string.IsNullOrWhiteSpace(query.Search))
5✔
1014
                {
1015
                        var search = query.Search.ToUpperInvariant();
×
1016
                        // The parameterless form is intentionally used because relational providers translate it to SQL.
1017
#pragma warning disable CA1304, CA1311, CA1862, MA0011
1018
                        jobs = jobs.Where(job => job.JobName.ToUpper().Contains(search));
×
1019
#pragma warning restore CA1304, CA1311, CA1862, MA0011
1020
                }
1021

1022
                var entities = await jobs.OrderByDescending(job => job.CreatedAt)
5✔
1023
                        .ThenBy(job => job.Id)
5✔
1024
                        .Skip(query.Skip)
5✔
1025
                        .Take(query.Take)
5✔
1026
                        .ToListAsync(cancellationToken)
5✔
1027
                        .ConfigureAwait(false);
5✔
1028
                return [.. entities.Select(ToRecord)];
5✔
1029
        }
4✔
1030

1031
        /// <inheritdoc />
1032
        public async ValueTask<BatchStatus?> GetBatchStatusAsync(
1033
                string batchId,
1034
                CancellationToken cancellationToken = default
1035
        )
1036
        {
1037
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
5✔
1038
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1039
                var batch = await context.Set<ImmediateJobBatchEntity>()
5✔
1040
                        .AsNoTracking()
5✔
1041
                        .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
5✔
1042
                        .ConfigureAwait(false);
5✔
1043
                return batch is null ? null : ToStatus(batch);
5✔
1044
        }
4✔
1045

1046
        /// <inheritdoc />
1047
        public async ValueTask<IReadOnlyList<JobContinuationEdge>> GetIncomingEdgesAsync(
1048
                IReadOnlyCollection<string> childJobIds,
1049
                CancellationToken cancellationToken = default
1050
        )
1051
        {
1052
                ArgumentNullException.ThrowIfNull(childJobIds);
5✔
1053
                foreach (var childJobId in childJobIds)
5✔
1054
                        ArgumentException.ThrowIfNullOrWhiteSpace(childJobId);
5✔
1055
                if (childJobIds.Count == 0)
5✔
1056
                        return [];
5✔
1057

1058
                var ids = childJobIds.Distinct(StringComparer.Ordinal).ToArray();
5✔
1059
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1060
                var edges = await context.Set<ImmediateJobContinuationEntity>()
5✔
1061
                        .AsNoTracking()
5✔
1062
                        .Where(edge => ids.Contains(edge.ChildJobId))
5✔
1063
                        .OrderBy(edge => edge.ChildJobId)
5✔
1064
                        .ThenBy(edge => edge.ParentKind)
5✔
1065
                        .ThenBy(edge => edge.ParentId)
5✔
1066
                        .ToListAsync(cancellationToken)
5✔
1067
                        .ConfigureAwait(false);
5✔
1068
                return [.. edges.Select(ToContinuationEdge)];
5✔
1069
        }
4✔
1070

1071
        /// <inheritdoc />
1072
        public async ValueTask<IReadOnlyList<BatchStatus>> QueryBatchesAsync(
1073
                JobBatchQuery query,
1074
                CancellationToken cancellationToken = default
1075
        )
1076
        {
1077
                ArgumentNullException.ThrowIfNull(query);
×
1078
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
×
1079
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
×
1080
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
1081
                var batches = context.Set<ImmediateJobBatchEntity>().AsNoTracking();
×
1082
                if (query.State is { } state)
×
1083
                        batches = batches.Where(batch => batch.State == state);
×
1084
                var entities = await batches.OrderByDescending(batch => batch.CreatedAt)
×
1085
                        .ThenBy(batch => batch.Id)
×
1086
                        .Skip(query.Skip)
×
1087
                        .Take(query.Take)
×
1088
                        .ToListAsync(cancellationToken)
×
1089
                        .ConfigureAwait(false);
×
1090
                return [.. entities.Select(ToStatus)];
×
1091
        }
1092

1093
        /// <inheritdoc />
1094
        public async ValueTask<IReadOnlyList<BatchMemberStatus>> QueryBatchMembersAsync(
1095
                string batchId,
1096
                BatchMemberQuery query,
1097
                CancellationToken cancellationToken = default
1098
        )
1099
        {
1100
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
1101
                ArgumentNullException.ThrowIfNull(query);
4✔
1102
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
4✔
1103
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
4✔
1104
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
1105
                var jobs = context.Set<ImmediateJobEntity>()
4✔
1106
                        .AsNoTracking()
4✔
1107
                        .Where(job => job.BatchId == batchId);
4✔
1108
                if (query.State is { } state)
4✔
1109
                        jobs = jobs.Where(job => job.State == state);
×
1110
                return await jobs.OrderBy(job => job.CreatedAt)
4✔
1111
                        .ThenBy(job => job.Id)
4✔
1112
                        .Skip(query.Skip)
4✔
1113
                        .Take(query.Take)
4✔
1114
                        .Select(job => new BatchMemberStatus(
4✔
1115
                                job.Id,
4✔
1116
                                job.JobName,
4✔
1117
                                job.QueueName,
4✔
1118
                                job.State,
4✔
1119
                                job.Attempt,
4✔
1120
                                job.CreatedAt,
4✔
1121
                                job.CompletedAt,
4✔
1122
                                job.LastError
4✔
1123
                        ))
4✔
1124
                        .ToListAsync(cancellationToken)
4✔
1125
                        .ConfigureAwait(false);
4✔
1126
        }
3✔
1127

1128
        /// <inheritdoc />
1129
        public async ValueTask<BatchGraph?> GetBatchGraphAsync(
1130
                string batchId,
1131
                CancellationToken cancellationToken = default
1132
        )
1133
        {
1134
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
1135
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
1136
                if (!await context.Set<ImmediateJobBatchEntity>()
4✔
1137
                        .AnyAsync(batch => batch.Id == batchId, cancellationToken)
4✔
1138
                        .ConfigureAwait(false))
4✔
1139
                {
1140
                        return null;
4✔
1141
                }
1142

1143
                var jobs = await context.Set<ImmediateJobEntity>()
×
1144
                        .AsNoTracking()
×
1145
                        .Where(job => job.BatchId == batchId)
×
1146
                        .OrderBy(job => job.CreatedAt)
×
1147
                        .ThenBy(job => job.Id)
×
1148
                        .Select(job => new BatchGraphNode(job.Id, job.JobName, job.State))
×
1149
                        .ToListAsync(cancellationToken)
×
1150
                        .ConfigureAwait(false);
×
1151
                var ids = jobs.Select(static job => job.JobId).ToArray();
×
1152
                var edges = ids.Length == 0
×
1153
                        ? []
×
1154
                        : await context.Set<ImmediateJobContinuationEntity>()
×
1155
                                .AsNoTracking()
×
1156
                                .Where(edge => ids.Contains(edge.ChildJobId))
×
1157
                                .OrderBy(edge => edge.ChildJobId)
×
1158
                                .ThenBy(edge => edge.ParentKind)
×
1159
                                .ThenBy(edge => edge.ParentId)
×
1160
                                .ToListAsync(cancellationToken)
×
1161
                                .ConfigureAwait(false);
×
1162
                return new(batchId, jobs, [.. edges.Select(ToGraphEdge)]);
4✔
1163
        }
3✔
1164

1165
        /// <inheritdoc />
1166
        public async ValueTask<JobStatus?> GetJobStatusAsync(
1167
                string jobId,
1168
                CancellationToken cancellationToken = default
1169
        )
1170
        {
1171
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
5✔
1172
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1173
                var job = await context.Set<ImmediateJobEntity>()
5✔
1174
                        .AsNoTracking()
5✔
1175
                        .SingleOrDefaultAsync(item => item.Id == jobId, cancellationToken)
5✔
1176
                        .ConfigureAwait(false);
5✔
1177
                if (job is null)
5✔
1178
                        return null;
5✔
1179
                var edges = await context.Set<ImmediateJobContinuationEntity>()
5✔
1180
                        .AsNoTracking()
5✔
1181
                        .Where(edge => edge.ChildJobId == jobId)
5✔
1182
                        .OrderBy(edge => edge.ParentKind)
5✔
1183
                        .ThenBy(edge => edge.ParentId)
5✔
1184
                        .ToListAsync(cancellationToken)
5✔
1185
                        .ConfigureAwait(false);
5✔
1186
                return new(
5✔
1187
                        job.Id,
5✔
1188
                        job.JobName,
5✔
1189
                        job.QueueName,
5✔
1190
                        job.State,
5✔
1191
                        job.Attempt,
5✔
1192
                        MaxAttempts: null,
5✔
1193
                        job.CreatedAt,
5✔
1194
                        job.DueAt,
5✔
1195
                        job.CompletedAt,
5✔
1196
                        job.LastError,
5✔
1197
                        job.BatchId,
5✔
1198
                        [.. edges.Select(ToGraphEdge)]
5✔
1199
                );
5✔
1200
        }
4✔
1201

1202
        /// <inheritdoc />
1203
        public ValueTask CancelBatchAsync(string batchId, CancellationToken cancellationToken = default)
1204
        {
1205
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
5✔
1206
                return ExecuteWithStrategyAsync(
5✔
1207
                        operationCancellationToken => CancelBatchCoreAsync(batchId, operationCancellationToken),
5✔
1208
                        cancellationToken
5✔
1209
                );
5✔
1210
        }
1211

1212
        private async Task CancelBatchCoreAsync(string batchId, CancellationToken cancellationToken)
1213
        {
1214
                var now = _timeProvider.GetUtcNow();
5✔
1215
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1216
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1217
                var batch = await context.Set<ImmediateJobBatchEntity>()
5✔
1218
                        .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
5✔
1219
                        .ConfigureAwait(false)
5✔
1220
                        ?? throw new KeyNotFoundException($"Batch '{batchId}' was not found.");
5✔
1221
                if (batch.State != BatchState.Executing)
5✔
1222
                        throw new ImmediateJobException("Only an executing batch can be cancelled.");
×
1223

1224
                var jobs = await context.Set<ImmediateJobEntity>()
5✔
1225
                        .Where(job => job.BatchId == batchId)
5✔
1226
                        .ToListAsync(cancellationToken)
5✔
1227
                        .ConfigureAwait(false);
5✔
1228
                foreach (var job in jobs)
5✔
1229
                {
1230
                        if (IsTerminal(job.State))
5✔
1231
                                continue;
1232
                        job.State = JobState.Cancelled;
5✔
1233
                        job.CompletedAt = now;
5✔
1234
                        job.WorkerId = null;
5✔
1235
                        job.LeaseExpiresAt = null;
5✔
1236
                        job.ConcurrencyStamp = Guid.NewGuid();
5✔
1237
                        await PropagateTerminalAsync(context, job, now, cancellationToken).ConfigureAwait(false);
5✔
1238
                }
1239

1240
                var terminalGroups = GetTerminalFairQueueGroups(context);
5✔
1241
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
1242
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
5✔
1243
                foreach (var (queueName, groupId) in terminalGroups)
5✔
1244
                {
1245
                        await TryRemoveFairQueueCursorAsync(queueName, groupId, CancellationToken.None).ConfigureAwait(false);
×
1246
                }
1247
        }
5✔
1248

1249
        /// <inheritdoc />
1250
        public ValueTask DeleteBatchAsync(string batchId, CancellationToken cancellationToken = default)
1251
        {
1252
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
5✔
1253
                return ExecuteWithStrategyAsync(
5✔
1254
                        operationCancellationToken => DeleteBatchCoreAsync(batchId, operationCancellationToken),
5✔
1255
                        cancellationToken
5✔
1256
                );
5✔
1257
        }
1258

1259
        private async Task DeleteBatchCoreAsync(string batchId, CancellationToken cancellationToken)
1260
        {
1261
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1262
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1263
                var batch = await context.Set<ImmediateJobBatchEntity>()
5✔
1264
                        .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
5✔
1265
                        .ConfigureAwait(false)
5✔
1266
                        ?? throw new KeyNotFoundException($"Batch '{batchId}' was not found.");
5✔
1267
                if (batch.State == BatchState.Executing)
4✔
1268
                        throw new ImmediateJobException("Only a terminal batch can be deleted.");
×
1269

1270
                var jobs = await context.Set<ImmediateJobEntity>()
4✔
1271
                        .Where(job => job.BatchId == batchId)
4✔
1272
                        .ToListAsync(cancellationToken)
4✔
1273
                        .ConfigureAwait(false);
4✔
1274
                var jobIds = jobs.Select(static job => job.Id).ToArray();
4✔
1275
                var edges = await context.Set<ImmediateJobContinuationEntity>()
4✔
1276
                        .Where(edge =>
4✔
1277
                                jobIds.Contains(edge.ChildJobId)
4✔
1278
                                || (edge.ParentKind == ContinuationParentKind.Job && jobIds.Contains(edge.ParentId))
4✔
1279
                                || (edge.ParentKind == ContinuationParentKind.Batch && edge.ParentId == batchId))
4✔
1280
                        .ToListAsync(cancellationToken)
4✔
1281
                        .ConfigureAwait(false);
4✔
1282
                context.RemoveRange(edges);
4✔
1283
                context.RemoveRange(jobs);
4✔
1284
                _ = context.Remove(batch);
4✔
1285
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
1286
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
4✔
1287
        }
4✔
1288

1289
        /// <inheritdoc />
1290
        public ValueTask RetryAsync(string jobId, CancellationToken cancellationToken = default)
1291
        {
1292
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
5✔
1293
                return ExecuteWithStrategyAsync(
5✔
1294
                        operationCancellationToken => RetryCoreAsync(jobId, operationCancellationToken),
5✔
1295
                        cancellationToken
5✔
1296
                );
5✔
1297
        }
1298

1299
        private async Task RetryCoreAsync(string jobId, CancellationToken cancellationToken)
1300
        {
1301
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1302
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1303
                var job = await context.Set<ImmediateJobEntity>()
5✔
1304
                        .SingleOrDefaultAsync(item => item.Id == jobId && item.State == JobState.Failed, cancellationToken)
5✔
1305
                        .ConfigureAwait(false);
5✔
1306
                if (job is null)
5✔
1307
                {
1308
                        if (await context.Set<ImmediateJobEntity>()
5✔
1309
                                .AnyAsync(item => item.Id == jobId, cancellationToken)
5✔
1310
                                .ConfigureAwait(false))
5✔
1311
                        {
1312
                                throw new ImmediateJobException("Only failed jobs can be retried.");
4✔
1313
                        }
1314

1315
                        throw new KeyNotFoundException($"Job '{jobId}' was not found.");
5✔
1316
                }
1317

1318
                if (job.BatchId is { } batchId)
1✔
1319
                {
1320
                        var batch = await context.Set<ImmediateJobBatchEntity>()
×
1321
                                .SingleAsync(item => item.Id == batchId, cancellationToken)
×
1322
                                .ConfigureAwait(false);
×
1323
                        batch.PendingCount++;
×
1324
                        batch.FailedCount = Math.Max(0, batch.FailedCount - 1);
×
1325
                        batch.State = BatchState.Executing;
×
1326
                        batch.CompletedAt = null;
×
1327
                        batch.ConcurrencyStamp = Guid.NewGuid();
×
1328
                }
1329

1330
                job.State = JobState.Pending;
1✔
1331
                job.DueAt = _timeProvider.GetUtcNow();
1✔
1332
                job.WorkerId = null;
1✔
1333
                job.LeaseExpiresAt = null;
1✔
1334
                job.CompletedAt = null;
1✔
1335
                job.LastError = null;
1✔
1336
                job.ConcurrencyStamp = Guid.NewGuid();
1✔
1337
                try
1338
                {
1339
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
1✔
1340
                }
1✔
1341
                catch (DbUpdateConcurrencyException)
1342
                {
1343
                        if (!await context.Set<ImmediateJobEntity>()
×
1344
                                .AsNoTracking()
×
1345
                                .AnyAsync(item => item.Id == jobId, cancellationToken)
×
1346
                                .ConfigureAwait(false))
×
1347
                        {
1348
                                throw new KeyNotFoundException($"Job '{jobId}' was not found.");
×
1349
                        }
1350

1351
                        throw new ImmediateJobException("Only failed jobs can be retried.");
×
1352
                }
1353

1354
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
1✔
1355
        }
1✔
1356

1357
        /// <inheritdoc />
1358
        public ValueTask DeleteAsync(string jobId, CancellationToken cancellationToken = default)
1359
        {
1360
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
5✔
1361
                return ExecuteWithStrategyAsync(
5✔
1362
                        operationCancellationToken => DeleteCoreAsync(jobId, operationCancellationToken),
5✔
1363
                        cancellationToken
5✔
1364
                );
5✔
1365
        }
1366

1367
        private async Task DeleteCoreAsync(string jobId, CancellationToken cancellationToken)
1368
        {
1369
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1370
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1371
                var job = await context.Set<ImmediateJobEntity>()
5✔
1372
                        .AsNoTracking()
5✔
1373
                        .SingleOrDefaultAsync(item => item.Id == jobId
5✔
1374
                                && (item.State == JobState.Succeeded || item.State == JobState.Failed || item.State == JobState.Cancelled), cancellationToken)
5✔
1375
                        .ConfigureAwait(false);
5✔
1376
                if (job is null)
5✔
1377
                {
1378
                        if (await context.Set<ImmediateJobEntity>()
5✔
1379
                                .AnyAsync(item => item.Id == jobId, cancellationToken)
5✔
1380
                                .ConfigureAwait(false))
5✔
1381
                        {
1382
                                throw new ImmediateJobException("Only terminal jobs can be deleted.");
4✔
1383
                        }
1384

1385
                        throw new KeyNotFoundException($"Job '{jobId}' was not found.");
5✔
1386
                }
1387

1388
                if (job.BatchId is not null)
1✔
1389
                        throw new ImmediateJobException("Batch members are deleted with their batch so the workflow remains coherent.");
×
1390
                _ = await context.Set<ImmediateJobContinuationEntity>()
1✔
1391
                        .Where(edge => edge.ChildJobId == jobId ||
1✔
1392
                                (edge.ParentKind == ContinuationParentKind.Job && edge.ParentId == jobId))
1✔
1393
                        .ExecuteDeleteAsync(cancellationToken)
1✔
1394
                        .ConfigureAwait(false);
1✔
1395
                var removed = await context.Set<ImmediateJobEntity>()
1✔
1396
                        .Where(item => item.Id == jobId &&
1✔
1397
                                (item.State == JobState.Succeeded || item.State == JobState.Failed || item.State == JobState.Cancelled))
1✔
1398
                        .ExecuteDeleteAsync(cancellationToken)
1✔
1399
                        .ConfigureAwait(false);
1✔
1400
                if (removed == 0)
1✔
1401
                {
1402
                        if (await context.Set<ImmediateJobEntity>()
×
1403
                                .AnyAsync(item => item.Id == jobId, cancellationToken)
×
1404
                                .ConfigureAwait(false))
×
1405
                        {
1406
                                throw new ImmediateJobException("Only terminal jobs can be deleted.");
×
1407
                        }
1408

1409
                        throw new KeyNotFoundException($"Job '{jobId}' was not found.");
×
1410
                }
1411

1412
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
1✔
1413
        }
1✔
1414

1415
        /// <inheritdoc />
1416
        public ValueTask PurgeJobsAsync(
1417
                TimeSpan succeededRetention,
1418
                TimeSpan failedRetention,
1419
                CancellationToken cancellationToken = default
1420
        )
1421
        {
1422
                var now = _timeProvider.GetUtcNow();
4✔
1423
                return ExecuteWithStrategyAsync(
4✔
1424
                        operationCancellationToken => PurgeJobsCoreAsync(
4✔
1425
                                now - succeededRetention,
4✔
1426
                                now - failedRetention,
4✔
1427
                                operationCancellationToken
4✔
1428
                        ),
4✔
1429
                        cancellationToken
4✔
1430
                );
4✔
1431
        }
1432

1433
        /// <inheritdoc />
1434
        public ValueTask PurgeBatchesAsync(
1435
                TimeSpan batchSucceededRetention,
1436
                TimeSpan batchFailedRetention,
1437
                CancellationToken cancellationToken = default
1438
        )
1439
        {
1440
                var now = _timeProvider.GetUtcNow();
4✔
1441
                return ExecuteWithStrategyAsync(
4✔
1442
                        operationCancellationToken => PurgeBatchesCoreAsync(
4✔
1443
                                now - batchSucceededRetention,
4✔
1444
                                now - batchFailedRetention,
4✔
1445
                                operationCancellationToken
4✔
1446
                        ),
4✔
1447
                        cancellationToken
4✔
1448
                );
4✔
1449
        }
1450

1451
        private async Task PurgeJobsCoreAsync(
1452
                DateTimeOffset succeededBefore,
1453
                DateTimeOffset failedBefore,
1454
                CancellationToken cancellationToken
1455
        )
1456
        {
1457
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
1458
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
4✔
1459
                var jobs = await context.Set<ImmediateJobEntity>()
4✔
1460
                        .Where(job => job.BatchId == null
4✔
1461
                                && ((job.State == JobState.Succeeded && job.CompletedAt < succeededBefore)
4✔
1462
                                || ((job.State == JobState.Failed || job.State == JobState.Cancelled) && job.CompletedAt < failedBefore))
4✔
1463
                        )
4✔
1464
                        .ToListAsync(cancellationToken)
4✔
1465
                        .ConfigureAwait(false);
4✔
1466
                if (jobs.Count != 0)
4✔
1467
                {
1468
                        var jobIds = jobs.Select(static job => job.Id).ToArray();
×
1469
                        var edges = await context.Set<ImmediateJobContinuationEntity>()
×
1470
                                .Where(edge =>
×
1471
                                        jobIds.Contains(edge.ChildJobId)
×
1472
                                        || (jobIds.Contains(edge.ParentId) && edge.ParentKind == ContinuationParentKind.Job)
×
1473
                                )
×
1474
                                .ToListAsync(cancellationToken)
×
1475
                                .ConfigureAwait(false);
×
1476
                        context.RemoveRange(edges);
×
1477
                }
1478

1479
                context.RemoveRange(jobs);
4✔
1480
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
1481
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
4✔
1482
        }
4✔
1483

1484
        private async Task PurgeBatchesCoreAsync(
1485
                DateTimeOffset batchSucceededBefore,
1486
                DateTimeOffset batchFailedBefore,
1487
                CancellationToken cancellationToken
1488
        )
1489
        {
1490
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
1491
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
4✔
1492
                var batches = await context.Set<ImmediateJobBatchEntity>()
4✔
1493
                        .Where(batch => (batch.State == BatchState.Succeeded && batch.CompletedAt < batchSucceededBefore)
4✔
1494
                                || ((batch.State == BatchState.Failed || batch.State == BatchState.Cancelled)
4✔
1495
                                        && batch.CompletedAt < batchFailedBefore))
4✔
1496
                        .ToListAsync(cancellationToken)
4✔
1497
                        .ConfigureAwait(false);
4✔
1498
                if (batches.Count != 0)
4✔
1499
                {
1500
                        var batchIds = batches.Select(static batch => batch.Id).ToArray();
×
1501
                        var memberIds = await context.Set<ImmediateJobEntity>()
×
1502
                                .Where(job => job.BatchId != null && batchIds.Contains(job.BatchId))
×
1503
                                .Select(job => job.Id)
×
1504
                                .ToListAsync(cancellationToken)
×
1505
                                .ConfigureAwait(false);
×
1506
                        var edges = await context.Set<ImmediateJobContinuationEntity>()
×
1507
                                .Where(edge =>
×
1508
                                        (batchIds.Contains(edge.ParentId) && edge.ParentKind == ContinuationParentKind.Batch)
×
1509
                                        || memberIds.Contains(edge.ChildJobId)
×
1510
                                        || (memberIds.Contains(edge.ParentId) && edge.ParentKind == ContinuationParentKind.Job)
×
1511
                                )
×
1512
                                .ToListAsync(cancellationToken)
×
1513
                                .ConfigureAwait(false);
×
1514
                        context.RemoveRange(edges);
×
1515
                        context.RemoveRange(batches);
×
1516
                }
1517

1518
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
1519
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
4✔
1520
        }
4✔
1521

1522
        /// <inheritdoc />
1523
        public async ValueTask HeartbeatAsync(JobServerSnapshot server, CancellationToken cancellationToken = default)
1524
        {
1525
                ArgumentNullException.ThrowIfNull(server);
1✔
1526
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
1✔
1527
                var cutoff = _timeProvider.GetUtcNow() - TimeSpan.FromMinutes(2);
1✔
1528
                _ = await context.Set<ImmediateJobServerEntity>()
1✔
1529
                        .Where(item => item.LastHeartbeat < cutoff)
1✔
1530
                        .ExecuteDeleteAsync(cancellationToken)
1✔
1531
                        .ConfigureAwait(false);
1✔
1532
                var entity = await context.Set<ImmediateJobServerEntity>().FindAsync([server.WorkerId], cancellationToken).ConfigureAwait(false);
1✔
1533
                if (entity is null)
1✔
1534
                {
1535
                        _ = context.Add(new ImmediateJobServerEntity
1✔
1536
                        {
1✔
1537
                                WorkerId = server.WorkerId,
1✔
1538
                                LastHeartbeat = server.LastHeartbeat,
1✔
1539
                                ActiveWorkers = server.ActiveWorkers,
1✔
1540
                                MaxWorkers = server.MaxWorkers,
1✔
1541
                        });
1✔
1542
                }
1543
                else
1544
                {
1545
                        entity.LastHeartbeat = server.LastHeartbeat;
×
1546
                        entity.ActiveWorkers = server.ActiveWorkers;
×
1547
                        entity.MaxWorkers = server.MaxWorkers;
×
1548
                }
1549

1550
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
1✔
1551
        }
1✔
1552

1553
        /// <inheritdoc />
1554
        public async ValueTask<bool> IsHealthyAsync(CancellationToken cancellationToken = default)
1555
        {
1556
                try
1557
                {
1558
                        await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
1559
                        return await context.Database.CanConnectAsync(cancellationToken).ConfigureAwait(false);
×
1560
                }
×
1561
                catch (Exception exception) when (exception is DbException or InvalidOperationException)
×
1562
                {
1563
                        return false;
×
1564
                }
1565
        }
×
1566

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

1606
        private async ValueTask ExecuteWithStrategyAsync(
1607
                Func<CancellationToken, Task> operation,
1608
                CancellationToken cancellationToken
1609
        )
1610
        {
1611
                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1612
                var strategy = strategyContext.Database.CreateExecutionStrategy();
5✔
1613
                await strategy.ExecuteAsync(operation, cancellationToken).ConfigureAwait(false);
5✔
1614
        }
5✔
1615

1616
        private async ValueTask MutateOwnedAsync(
1617
                string jobId,
1618
                string workerId,
1619
                Action<ImmediateJobEntity> mutate,
1620
                CancellationToken cancellationToken
1621
        )
1622
        {
1623
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
1✔
1624
                var job = await context.Set<ImmediateJobEntity>()
1✔
1625
                        .SingleOrDefaultAsync(item => item.Id == jobId && item.State == JobState.Active && item.WorkerId == workerId, cancellationToken)
1✔
1626
                        .ConfigureAwait(false);
1✔
1627
                if (job is null)
1✔
1628
                        return;
1629
                mutate(job);
1✔
1630
                job.ConcurrencyStamp = Guid.NewGuid();
1✔
1631
                try
1632
                {
1633
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
1✔
1634
                }
1✔
1635
                catch (DbUpdateConcurrencyException)
×
1636
                {
1637
                        // A stale worker must not update a lease that has been reclaimed.
1638
                }
1✔
1639
        }
1✔
1640

1641
        private async Task MutateOwnedCoreAsync(
1642
                string jobId,
1643
                string workerId,
1644
                string? error,
1645
                DateTimeOffset? nextRetryAt,
1646
                bool succeeded,
1647
                IReadOnlyList<JobContinuationAddition> additions,
1648
                CancellationToken cancellationToken
1649
        )
1650
        {
1651
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1652
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1653
                var job = await context.Set<ImmediateJobEntity>()
5✔
1654
                        .SingleOrDefaultAsync(item => item.Id == jobId && item.State == JobState.Active && item.WorkerId == workerId, cancellationToken)
5✔
1655
                        .ConfigureAwait(false);
5✔
1656
                if (job is null)
5✔
1657
                        throw new ImmediateJobException($"Worker '{workerId}' does not own active job '{jobId}'.");
1✔
1658

1659
                var now = _timeProvider.GetUtcNow();
5✔
1660
                job.WorkerId = null;
5✔
1661
                job.LeaseExpiresAt = null;
5✔
1662
                job.LastError = error;
5✔
1663
                job.ConcurrencyStamp = Guid.NewGuid();
5✔
1664
                if (!succeeded && nextRetryAt is { } retryAt)
5✔
1665
                {
1666
                        job.State = retryAt <= now ? JobState.Pending : JobState.Scheduled;
×
1667
                        job.DueAt = retryAt;
×
1668
                        job.CompletedAt = null;
×
1669
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1670
                        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
×
1671
                        return;
×
1672
                }
1673

1674
                if (succeeded && additions.Count != 0)
5✔
1675
                {
1676
                        await FlushContinuationAdditionsAsync(context, job, additions, cancellationToken).ConfigureAwait(false);
5✔
1677
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1678
                }
1679

1680
                job.State = succeeded ? JobState.Succeeded : JobState.Failed;
5✔
1681
                job.CompletedAt = now;
5✔
1682
                await PropagateTerminalAsync(context, job, now, cancellationToken).ConfigureAwait(false);
5✔
1683
                var terminalGroups = GetTerminalFairQueueGroups(context);
5✔
1684
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
1685
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
5✔
1686
                foreach (var (queueName, groupId) in terminalGroups)
5✔
1687
                        await TryRemoveFairQueueCursorAsync(queueName, groupId, CancellationToken.None).ConfigureAwait(false);
5✔
1688
        }
5✔
1689

1690
        private static (string QueueName, string GroupId)[] GetTerminalFairQueueGroups(TContext context) =>
1691
                [
5✔
1692
                        .. context.ChangeTracker
5✔
1693
                                .Entries<ImmediateJobEntity>()
5✔
1694
                                .Select(static entry => entry.Entity)
5✔
1695
                                .Where(static job => job.GroupId is not null && IsTerminal(job.State))
5✔
1696
                                .Select(static job => (job.QueueName, GroupId: job.GroupId!))
5✔
1697
                                .Distinct(),
5✔
1698
                ];
5✔
1699

1700
        private async ValueTask TryRemoveFairQueueCursorAsync(
1701
                string queueName,
1702
                string groupId,
1703
                CancellationToken cancellationToken
1704
        )
1705
        {
1706
                try
1707
                {
1708
                        await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1709
                        if (await HasLiveGroupJobsAsync(context, queueName, groupId, cancellationToken).ConfigureAwait(false))
5✔
1710
                        {
1711
                                return;
1712
                        }
1713

1714
                        var cursor = await context.Set<ImmediateFairQueueGroupEntity>()
4✔
1715
                                .SingleOrDefaultAsync(
4✔
1716
                                        group => group.QueueName == queueName && group.GroupId == groupId,
4✔
1717
                                        cancellationToken
4✔
1718
                                )
4✔
1719
                                .ConfigureAwait(false);
4✔
1720
                        if (cursor is null)
4✔
1721
                                return;
1722

1723
                        _ = context.Remove(cursor);
4✔
1724
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
1725
                }
4✔
1726
                catch (Exception exception) when (
×
1727
                        !cancellationToken.IsCancellationRequested
×
1728
                        && exception is DbException or DbUpdateException
×
1729
                )
×
1730
                {
1731
                        // Cleanup is best-effort metadata maintenance and must not invalidate a committed transition.
1732
                }
×
1733
        }
5✔
1734

1735
        private static Task<bool> HasLiveGroupJobsAsync(
1736
                TContext context,
1737
                string queueName,
1738
                string groupId,
1739
                CancellationToken cancellationToken
1740
        ) => context.Set<ImmediateJobEntity>().AnyAsync(
5✔
1741
                job => job.QueueName == queueName
5✔
1742
                        && job.GroupId == groupId
5✔
1743
                        && (job.State == JobState.Pending
5✔
1744
                                || job.State == JobState.Scheduled
5✔
1745
                                || job.State == JobState.Active),
5✔
1746
                cancellationToken
5✔
1747
        );
5✔
1748

1749
        private async Task AddBatchJobCoreAsync(
1750
                string currentJobId,
1751
                JobRecord record,
1752
                ContinuationOptions options,
1753
                CancellationToken cancellationToken
1754
        )
1755
        {
1756
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1757
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1758
                var current = await context.Set<ImmediateJobEntity>()
5✔
1759
                        .SingleOrDefaultAsync(job => job.Id == currentJobId && job.State == JobState.Active, cancellationToken)
5✔
1760
                        .ConfigureAwait(false)
5✔
1761
                        ?? throw new ImmediateJobException($"The current active job '{currentJobId}' was not found.");
5✔
1762
                if (current.BatchId is not { } batchId)
5✔
1763
                        throw new ImmediateJobException("The current job does not belong to a batch.");
×
1764
                if (options is not (ContinuationOptions.BesideContinuations or ContinuationOptions.BeforeContinuations))
5✔
1765
                        throw new ArgumentOutOfRangeException(nameof(options));
×
1766
                if (!string.Equals(record.BatchId, batchId, StringComparison.Ordinal))
5✔
1767
                        throw new ImmediateJobException("The new job must belong to the current job's batch.");
5✔
1768
                if (record.State is JobState.Active or JobState.AwaitingContinuation || IsTerminal(record.State))
×
1769
                        throw new ImmediateJobException($"Concurrent batch member '{record.Id}' has invalid state '{record.State}'.");
×
1770

1771
                var batch = await context.Set<ImmediateJobBatchEntity>()
×
1772
                        .SingleAsync(item => item.Id == batchId && item.State == BatchState.Executing, cancellationToken)
×
1773
                        .ConfigureAwait(false);
×
1774
                var job = ToEntity(record);
×
1775
                _ = context.Add(job);
×
1776
                batch.TotalJobs++;
×
1777
                batch.PendingCount++;
×
1778
                batch.ConcurrencyStamp = Guid.NewGuid();
×
1779

1780
                if (options == ContinuationOptions.BeforeContinuations)
×
1781
                {
1782
                        var waiters = await GetActiveWaitersAsync(context, currentJobId, cancellationToken).ConfigureAwait(false);
×
1783
                        foreach (var waiter in waiters)
×
1784
                        {
1785
                                _ = context.Add(new ImmediateJobContinuationEntity
×
1786
                                {
×
1787
                                        ChildJobId = waiter.Id,
×
1788
                                        ParentKind = ContinuationParentKind.Job,
×
1789
                                        ParentId = job.Id,
×
1790
                                        Trigger = ContinuationTrigger.Success,
×
1791
                                });
×
1792
                                waiter.RemainingDependencies++;
×
1793
                                waiter.ConcurrencyStamp = Guid.NewGuid();
×
1794
                        }
1795
                }
1796

1797
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1798
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
×
1799
        }
×
1800

1801
        private static async Task FlushContinuationAdditionsAsync(
1802
                TContext context,
1803
                ImmediateJobEntity current,
1804
                IReadOnlyList<JobContinuationAddition> additions,
1805
                CancellationToken cancellationToken
1806
        )
1807
        {
1808
                var ids = new HashSet<string>(StringComparer.Ordinal);
5✔
1809
                var trackedAdditions = 0;
5✔
1810
                foreach (var addition in additions)
5✔
1811
                {
1812
                        ArgumentNullException.ThrowIfNull(addition);
5✔
1813
                        ArgumentNullException.ThrowIfNull(addition.Job);
5✔
1814
                        if (!ids.Add(addition.Job.Id))
5✔
1815
                                throw new ImmediateJobException("Buffered continuations contain duplicate job identifiers.");
×
1816
                        if (!Enum.IsDefined(addition.Trigger))
5✔
1817
                                throw new ArgumentOutOfRangeException(nameof(additions), "Unknown continuation trigger.");
4✔
1818
                        if (addition.Job.State is not (JobState.Pending or JobState.Scheduled))
5✔
1819
                                throw new ImmediateJobException($"Dynamic continuation '{addition.Job.Id}' has invalid state '{addition.Job.State}'.");
×
1820

1821
                        if (addition.Options == ContinuationOptions.Detached)
5✔
1822
                        {
1823
                                if (addition.Job.BatchId is not null)
5✔
1824
                                        throw new ImmediateJobException("A detached continuation cannot belong to a batch.");
5✔
1825
                        }
1826
                        else if (addition.Options is ContinuationOptions.BesideContinuations or ContinuationOptions.BeforeContinuations)
5✔
1827
                        {
1828
                                if (current.BatchId is null || !string.Equals(addition.Job.BatchId, current.BatchId, StringComparison.Ordinal))
5✔
1829
                                        throw new ImmediateJobException("A batch-tracked continuation must belong to the current job's batch.");
5✔
1830
                                trackedAdditions++;
×
1831
                        }
1832
                        else
1833
                        {
1834
                                throw new ArgumentOutOfRangeException(nameof(additions), "Unknown continuation option.");
4✔
1835
                        }
1836
                }
1837

1838
                var waiters = additions.Any(static addition => addition.Options == ContinuationOptions.BeforeContinuations)
×
1839
                        ? await GetActiveWaitersAsync(context, current.Id, cancellationToken).ConfigureAwait(false)
×
1840
                        : [];
×
1841
                ImmediateJobBatchEntity? batch = null;
1842
                if (trackedAdditions != 0)
×
1843
                {
1844
                        if (current.BatchId is not { } batchId)
×
1845
                                throw new ImmediateJobException("The current job does not belong to a batch.");
×
1846
                        batch = await context.Set<ImmediateJobBatchEntity>()
×
1847
                                .SingleAsync(item => item.Id == batchId && item.State == BatchState.Executing, cancellationToken)
×
1848
                                .ConfigureAwait(false);
×
1849
                        batch.TotalJobs += trackedAdditions;
×
1850
                        batch.PendingCount += trackedAdditions;
×
1851
                        batch.ConcurrencyStamp = Guid.NewGuid();
×
1852
                }
1853

1854
                foreach (var addition in additions)
×
1855
                {
1856
                        var job = ToEntity(addition.Job with
×
1857
                        {
×
1858
                                State = JobState.AwaitingContinuation,
×
1859
                                RemainingDependencies = 1,
×
1860
                        });
×
1861
                        _ = context.Add(job);
×
1862
                        _ = context.Add(new ImmediateJobContinuationEntity
×
1863
                        {
×
1864
                                ChildJobId = job.Id,
×
1865
                                ParentKind = ContinuationParentKind.Job,
×
1866
                                ParentId = current.Id,
×
1867
                                Trigger = addition.Trigger,
×
1868
                        });
×
1869

1870
                        if (addition.Options != ContinuationOptions.BeforeContinuations)
×
1871
                                continue;
1872
                        foreach (var waiter in waiters)
×
1873
                        {
1874
                                _ = context.Add(new ImmediateJobContinuationEntity
×
1875
                                {
×
1876
                                        ChildJobId = waiter.Id,
×
1877
                                        ParentKind = ContinuationParentKind.Job,
×
1878
                                        ParentId = job.Id,
×
1879
                                        Trigger = ContinuationTrigger.Success,
×
1880
                                });
×
1881
                                waiter.RemainingDependencies++;
×
1882
                                waiter.ConcurrencyStamp = Guid.NewGuid();
×
1883
                        }
1884
                }
1885
        }
×
1886

1887
        private static async Task<List<ImmediateJobEntity>> GetActiveWaitersAsync(
1888
                TContext context,
1889
                string currentJobId,
1890
                CancellationToken cancellationToken
1891
        )
1892
        {
1893
                var waiterIds = await context.Set<ImmediateJobContinuationEntity>()
×
1894
                        .Where(edge => edge.ParentKind == ContinuationParentKind.Job && edge.ParentId == currentJobId)
×
1895
                        .Select(edge => edge.ChildJobId)
×
1896
                        .Distinct()
×
1897
                        .ToArrayAsync(cancellationToken)
×
1898
                        .ConfigureAwait(false);
×
1899
                return waiterIds.Length == 0
×
1900
                        ? []
×
1901
                        : await context.Set<ImmediateJobEntity>()
×
1902
                                .Where(job => waiterIds.Contains(job.Id) && job.State == JobState.AwaitingContinuation)
×
1903
                                .ToListAsync(cancellationToken)
×
1904
                                .ConfigureAwait(false);
×
1905
        }
1906

1907
        private static async Task PropagateTerminalAsync(
1908
                TContext context,
1909
                ImmediateJobEntity terminalJob,
1910
                DateTimeOffset now,
1911
                CancellationToken cancellationToken
1912
        )
1913
        {
1914
                var parents = new Queue<(ContinuationParentKind Kind, string Id, ContinuationParentOutcome Outcome)>();
5✔
1915
                var processed = new HashSet<(ContinuationParentKind Kind, string Id)>();
5✔
1916
                parents.Enqueue((
5✔
1917
                        ContinuationParentKind.Job,
5✔
1918
                        terminalJob.Id,
5✔
1919
                        GetParentOutcome(terminalJob.State)
5✔
1920
                ));
5✔
1921
                await UpdateBatchForTerminalJobAsync(context, terminalJob, now, parents, cancellationToken).ConfigureAwait(false);
5✔
1922

1923
                while (parents.TryDequeue(out var parent))
5✔
1924
                {
1925
                        if (!processed.Add((parent.Kind, parent.Id)))
5✔
1926
                                continue;
1927
                        var edges = await context.Set<ImmediateJobContinuationEntity>()
5✔
1928
                                .Where(edge => edge.ParentKind == parent.Kind
5✔
1929
                                        && edge.ParentId == parent.Id
5✔
1930
                                        && edge.ParentOutcome == ContinuationParentOutcome.Unsettled)
5✔
1931
                                .ToListAsync(cancellationToken)
5✔
1932
                                .ConfigureAwait(false);
5✔
1933
                        foreach (var edge in edges)
5✔
1934
                        {
1935
                                edge.ParentOutcome = parent.Outcome;
5✔
1936
                                var child = await context.Set<ImmediateJobEntity>()
5✔
1937
                                        .SingleOrDefaultAsync(job => job.Id == edge.ChildJobId, cancellationToken)
5✔
1938
                                        .ConfigureAwait(false);
5✔
1939
                                if (child is null || IsTerminal(child.State))
5✔
1940
                                        continue;
1941

1942
                                if (child.State != JobState.AwaitingContinuation || child.RemainingDependencies <= 0)
5✔
1943
                                        continue;
1944
                                child.RemainingDependencies--;
5✔
1945
                                if (parent.Outcome == ContinuationParentOutcome.Failed)
5✔
1946
                                        child.FailedDependencies++;
5✔
1947
                                if (child.RemainingDependencies == 0)
5✔
1948
                                {
1949
                                        var cancel = await ShouldCancelSettledContinuationAsync(
5✔
1950
                                                context,
5✔
1951
                                                child.Id,
5✔
1952
                                                cancellationToken
5✔
1953
                                        ).ConfigureAwait(false);
5✔
1954
                                        if (cancel)
5✔
1955
                                        {
1956
                                                child.State = JobState.Cancelled;
5✔
1957
                                                child.CompletedAt = now;
5✔
1958
                                                parents.Enqueue((ContinuationParentKind.Job, child.Id, ContinuationParentOutcome.Other));
5✔
1959
                                                await UpdateBatchForTerminalJobAsync(context, child, now, parents, cancellationToken).ConfigureAwait(false);
5✔
1960
                                        }
1961
                                        else
1962
                                        {
1963
                                                child.State = child.DueAt <= now ? JobState.Pending : JobState.Scheduled;
5✔
1964
                                        }
1965
                                }
1966

1967
                                child.ConcurrencyStamp = Guid.NewGuid();
5✔
1968
                        }
4✔
1969
                }
4✔
1970
        }
5✔
1971

1972
        private static async Task<bool> ShouldCancelSettledContinuationAsync(
1973
                TContext context,
1974
                string childJobId,
1975
                CancellationToken cancellationToken
1976
        )
1977
        {
1978
                var edges = await context.Set<ImmediateJobContinuationEntity>()
5✔
1979
                        .Where(edge => edge.ChildJobId == childJobId)
5✔
1980
                        .ToListAsync(cancellationToken)
5✔
1981
                        .ConfigureAwait(false);
5✔
1982
                var requiresFailure = false;
5✔
1983
                var anyFailed = false;
5✔
1984
                foreach (var edge in edges)
5✔
1985
                {
1986
                        if (edge.Trigger == ContinuationTrigger.Success
5✔
1987
                                && edge.ParentOutcome != ContinuationParentOutcome.Succeeded)
5✔
UNCOV
1988
                                return true;
×
1989
                        requiresFailure |= edge.Trigger == ContinuationTrigger.Failure;
5✔
1990
                        anyFailed |= edge.ParentOutcome == ContinuationParentOutcome.Failed;
5✔
1991
                }
1992

1993
                return requiresFailure && !anyFailed;
5✔
1994
        }
4✔
1995

1996
        private static async Task UpdateBatchForTerminalJobAsync(
1997
                TContext context,
1998
                ImmediateJobEntity job,
1999
                DateTimeOffset now,
2000
                Queue<(ContinuationParentKind Kind, string Id, ContinuationParentOutcome Outcome)> parents,
2001
                CancellationToken cancellationToken
2002
        )
2003
        {
2004
                if (job.BatchId is not { } batchId)
5✔
2005
                        return;
5✔
2006
                var batch = await context.Set<ImmediateJobBatchEntity>()
5✔
2007
                        .SingleAsync(item => item.Id == batchId, cancellationToken)
5✔
2008
                        .ConfigureAwait(false);
5✔
2009
                batch.PendingCount = Math.Max(0, batch.PendingCount - 1);
5✔
2010
                switch (job.State)
5✔
2011
                {
2012
                        case JobState.Succeeded:
2013
                                batch.SucceededCount++;
5✔
2014
                                break;
5✔
2015
                        case JobState.Failed:
2016
                                batch.FailedCount++;
×
2017
                                break;
×
2018
                        case JobState.Cancelled:
2019
                                batch.CancelledCount++;
5✔
2020
                                break;
5✔
2021
                        case JobState.AwaitingContinuation:
2022
                        case JobState.AwaitingParameters:
2023
                        case JobState.Scheduled:
2024
                        case JobState.Pending:
2025
                        case JobState.Active:
2026
                                throw new ImmediateJobException($"Job '{job.Id}' is not terminal.");
×
2027
                        default:
2028
                                throw new ArgumentOutOfRangeException(nameof(job), job.State, "Unknown job state.");
×
2029
                }
2030

2031
                batch.ConcurrencyStamp = Guid.NewGuid();
5✔
2032
                if (batch.PendingCount != 0)
5✔
2033
                        return;
5✔
2034
                batch.State = GetTerminalBatchState(batch.FailedCount, batch.CancelledCount);
5✔
2035
                batch.CompletedAt = now;
5✔
2036
                parents.Enqueue((
5✔
2037
                        ContinuationParentKind.Batch,
5✔
2038
                        batch.Id,
5✔
2039
                        GetParentOutcome(batch.State)
5✔
2040
                ));
5✔
2041
        }
5✔
2042

2043
        private static async Task EvaluateInitialDependenciesAsync(
2044
                TContext context,
2045
                Dictionary<string, ImmediateJobEntity> jobs,
2046
                ImmediateJobContinuationEntity[] edges,
2047
                DateTimeOffset now,
2048
                CancellationToken cancellationToken
2049
        )
2050
        {
2051
                var externalJobIds = edges
5✔
2052
                        .Where(edge => edge.ParentKind == ContinuationParentKind.Job && !jobs.ContainsKey(edge.ParentId))
5✔
2053
                        .Select(static edge => edge.ParentId)
5✔
2054
                        .Distinct(StringComparer.Ordinal)
5✔
2055
                        .Order(StringComparer.Ordinal)
5✔
2056
                        .ToArray();
5✔
2057
                var externalBatchIds = edges
5✔
2058
                        .Where(static edge => edge.ParentKind == ContinuationParentKind.Batch)
5✔
2059
                        .Select(static edge => edge.ParentId)
5✔
2060
                        .Distinct(StringComparer.Ordinal)
5✔
2061
                        .Order(StringComparer.Ordinal)
5✔
2062
                        .ToArray();
5✔
2063
                var externalJobEntities = externalJobIds.Length == 0
5✔
2064
                        ? []
5✔
2065
                        : await context.Set<ImmediateJobEntity>()
5✔
2066
                                .Where(job => externalJobIds.Contains(job.Id))
5✔
2067
                                .OrderBy(static job => job.Id)
5✔
2068
                                .ToListAsync(cancellationToken)
5✔
2069
                                .ConfigureAwait(false);
5✔
2070
                var externalBatchEntities = externalBatchIds.Length == 0
5✔
2071
                        ? []
5✔
2072
                        : await context.Set<ImmediateJobBatchEntity>()
5✔
2073
                                .Where(batch => externalBatchIds.Contains(batch.Id))
5✔
2074
                                .OrderBy(static batch => batch.Id)
5✔
2075
                                .ToListAsync(cancellationToken)
5✔
2076
                                .ConfigureAwait(false);
5✔
2077
                var externalJobs = externalJobEntities.ToDictionary(job => job.Id, StringComparer.Ordinal);
5✔
2078
                var externalBatches = externalBatchEntities.ToDictionary(batch => batch.Id, StringComparer.Ordinal);
5✔
2079
                if (externalJobs.Count != externalJobIds.Length || externalBatches.Count != externalBatchIds.Length)
5✔
2080
                        throw new ImmediateJobException("A continuation parent does not exist.");
5✔
2081
                foreach (var parent in externalJobEntities.Where(parent => !IsTerminal(parent.State)))
5✔
2082
                        parent.ConcurrencyStamp = Guid.NewGuid();
5✔
2083
                foreach (var parent in externalBatchEntities.Where(parent => parent.State == BatchState.Executing))
5✔
2084
                        parent.ConcurrencyStamp = Guid.NewGuid();
4✔
2085

2086
                var incoming = edges.ToLookup(static edge => edge.ChildJobId, StringComparer.Ordinal);
5✔
2087
                var changed = true;
5✔
2088
                while (changed)
5✔
2089
                {
2090
                        changed = false;
5✔
2091
                        foreach (var job in jobs.Values)
5✔
2092
                        {
2093
                                var dependencies = incoming[job.Id];
5✔
2094
                                if (!dependencies.Any() || IsTerminal(job.State))
5✔
2095
                                        continue;
2096
                                var remaining = 0;
5✔
2097
                                var failedDependencies = 0;
5✔
2098
                                var requiresFailure = false;
5✔
2099
                                var violated = false;
5✔
2100
                                foreach (var edge in dependencies)
5✔
2101
                                {
2102
                                        var (terminal, parentSucceeded, parentFailed) = GetParentState(
5✔
2103
                                                edge,
5✔
2104
                                                jobs,
5✔
2105
                                                externalJobs,
5✔
2106
                                                externalBatches
5✔
2107
                                        );
5✔
2108
                                        requiresFailure |= edge.Trigger == ContinuationTrigger.Failure;
5✔
2109
                                        if (!terminal)
5✔
2110
                                        {
2111
                                                remaining++;
5✔
2112
                                                continue;
5✔
2113
                                        }
2114

2115
                                        edge.ParentOutcome = GetParentOutcome(parentSucceeded, parentFailed);
1✔
2116

2117
                                        if (parentFailed)
1✔
2118
                                                failedDependencies++;
×
2119
                                        if (edge.Trigger == ContinuationTrigger.Success && !parentSucceeded)
1✔
2120
                                                violated = true;
×
2121
                                }
2122

2123
                                job.FailedDependencies = failedDependencies;
5✔
2124
                                if (violated || (remaining == 0 && requiresFailure && failedDependencies == 0))
5✔
2125
                                {
2126
                                        job.State = JobState.Cancelled;
×
2127
                                        job.RemainingDependencies = 0;
×
2128
                                        job.CompletedAt = now;
×
2129
                                        changed = true;
×
2130
                                }
2131
                                else if (remaining == 0)
5✔
2132
                                {
2133
                                        job.State = job.DueAt <= now ? JobState.Pending : JobState.Scheduled;
1✔
2134
                                        job.RemainingDependencies = 0;
1✔
2135
                                }
2136
                                else
2137
                                {
2138
                                        job.State = JobState.AwaitingContinuation;
5✔
2139
                                        job.RemainingDependencies = remaining;
5✔
2140
                                }
2141
                        }
2142
                }
2143
        }
5✔
2144

2145
        private static (bool Terminal, bool Succeeded, bool Failed) GetParentState(
2146
                ImmediateJobContinuationEntity edge,
2147
                Dictionary<string, ImmediateJobEntity> jobs,
2148
                Dictionary<string, ImmediateJobEntity> externalJobs,
2149
                Dictionary<string, ImmediateJobBatchEntity> externalBatches
2150
        )
2151
        {
2152
                if (edge.ParentKind == ContinuationParentKind.Batch)
5✔
2153
                {
2154
                        var state = externalBatches[edge.ParentId].State;
5✔
2155
                        return (state != BatchState.Executing, state == BatchState.Succeeded, state == BatchState.Failed);
5✔
2156
                }
2157

2158
                var jobState = jobs.TryGetValue(edge.ParentId, out var job) ? job.State : externalJobs[edge.ParentId].State;
5✔
2159
                return (IsTerminal(jobState), jobState == JobState.Succeeded, jobState == JobState.Failed);
5✔
2160
        }
2161

2162
        private static void ThrowIfCyclic(
2163
                HashSet<string> jobIds,
2164
                IReadOnlyList<ImmediateJobContinuationEntity> edges
2165
        )
2166
        {
2167
                var indegree = jobIds.ToDictionary(static id => id, static _ => 0, StringComparer.Ordinal);
5✔
2168
                var children = new Dictionary<string, List<string>>(StringComparer.Ordinal);
5✔
2169
                foreach (var edge in edges.Where(edge =>
5✔
2170
                        edge.ParentKind == ContinuationParentKind.Job && jobIds.Contains(edge.ParentId)))
5✔
2171
                {
2172
                        indegree[edge.ChildJobId]++;
5✔
2173
                        if (!children.TryGetValue(edge.ParentId, out var values))
5✔
2174
                                children[edge.ParentId] = values = [];
5✔
2175
                        values.Add(edge.ChildJobId);
5✔
2176
                }
2177

2178
                var ready = new Queue<string>(indegree.Where(static pair => pair.Value == 0).Select(static pair => pair.Key));
5✔
2179
                var visited = 0;
5✔
2180
                while (ready.TryDequeue(out var parent))
5✔
2181
                {
2182
                        visited++;
5✔
2183
                        if (!children.TryGetValue(parent, out var values))
5✔
2184
                                continue;
2185
                        foreach (var child in values)
5✔
2186
                        {
2187
                                if (--indegree[child] == 0)
5✔
2188
                                        ready.Enqueue(child);
5✔
2189
                        }
2190
                }
2191

2192
                if (visited != jobIds.Count)
5✔
2193
                        throw new ImmediateJobException("The continuation graph contains a dependency cycle.");
×
2194
        }
5✔
2195

2196
        private static bool IsTerminal(JobState state) =>
2197
                state is JobState.Succeeded or JobState.Failed or JobState.Cancelled;
5✔
2198

2199
        private static ContinuationParentOutcome GetParentOutcome(JobState state) => state switch
5✔
2200
        {
5✔
2201
                JobState.Succeeded => ContinuationParentOutcome.Succeeded,
5✔
2202
                JobState.Failed => ContinuationParentOutcome.Failed,
5✔
2203
                JobState.AwaitingContinuation or
5✔
2204
                JobState.AwaitingParameters or
5✔
2205
                JobState.Scheduled or
5✔
2206
                JobState.Pending or
5✔
2207
                JobState.Active or
5✔
2208
                JobState.Cancelled => ContinuationParentOutcome.Other,
5✔
NEW
2209
                _ => throw new ArgumentOutOfRangeException(nameof(state), state, "Unknown job state."),
×
2210
        };
5✔
2211

2212
        private static ContinuationParentOutcome GetParentOutcome(BatchState state) => state switch
5✔
2213
        {
5✔
2214
                BatchState.Succeeded => ContinuationParentOutcome.Succeeded,
5✔
NEW
2215
                BatchState.Failed => ContinuationParentOutcome.Failed,
×
2216
                BatchState.Executing or BatchState.Cancelled => ContinuationParentOutcome.Other,
5✔
NEW
2217
                _ => throw new ArgumentOutOfRangeException(nameof(state), state, "Unknown batch state."),
×
2218
        };
5✔
2219

2220
        private static ContinuationParentOutcome GetParentOutcome(bool succeeded, bool failed) =>
2221
                (succeeded, failed) switch
1✔
2222
                {
1✔
2223
                        (true, _) => ContinuationParentOutcome.Succeeded,
1✔
NEW
2224
                        (_, true) => ContinuationParentOutcome.Failed,
×
NEW
2225
                        _ => ContinuationParentOutcome.Other,
×
2226
                };
1✔
2227

2228
        private static BatchState GetTerminalBatchState(int failed, int cancelled) =>
2229
                failed != 0 ? BatchState.Failed : cancelled != 0 ? BatchState.Cancelled : BatchState.Succeeded;
5✔
2230

2231
        private async ValueTask MutateRecurringAsync(string name, Action<ImmediateRecurringJobEntity> mutate, CancellationToken cancellationToken)
2232
        {
2233
                ArgumentException.ThrowIfNullOrWhiteSpace(name);
×
2234
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
2235
                var schedule = await context.Set<ImmediateRecurringJobEntity>().FindAsync([name], cancellationToken).ConfigureAwait(false);
×
2236
                if (schedule is null)
×
2237
                        return;
2238
                mutate(schedule);
×
2239
                schedule.ConcurrencyStamp = Guid.NewGuid();
×
2240
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
2241
        }
×
2242

2243
        private static Task<int> UpdateRecurringAsync(
2244
                TContext context,
2245
                RecurringJobSchedule schedule,
2246
                CancellationToken cancellationToken
2247
        )
2248
        {
2249
                var concurrencyStamp = Guid.NewGuid();
5✔
2250
                return context.Set<ImmediateRecurringJobEntity>()
5✔
2251
                        .Where(entity => entity.Name == schedule.Name && (schedule.IsCodeDefined || !entity.IsCodeDefined))
5✔
2252
                        .ExecuteUpdateAsync(setters => setters
5✔
2253
                                .SetProperty(entity => entity.JobName, schedule.JobName)
5✔
2254
                                .SetProperty(entity => entity.Cron, schedule.Cron)
5✔
2255
                                .SetProperty(entity => entity.TimeZone, schedule.TimeZone)
5✔
2256
                                .SetProperty(entity => entity.IsCodeDefined, schedule.IsCodeDefined)
5✔
2257
                                .SetProperty(entity => entity.NextRunAt, schedule.NextRunAt)
5✔
2258
                                .SetProperty(entity => entity.ConcurrencyStamp, concurrencyStamp),
5✔
2259
                                cancellationToken);
5✔
2260
        }
2261

2262
        private static async Task ThrowIfReplacingCodeDefinedScheduleAsync(
2263
                TContext context,
2264
                RecurringJobSchedule schedule,
2265
                CancellationToken cancellationToken
2266
        )
2267
        {
2268
                if (!schedule.IsCodeDefined && await context.Set<ImmediateRecurringJobEntity>()
5✔
2269
                        .AnyAsync(entity => entity.Name == schedule.Name && entity.IsCodeDefined, cancellationToken)
5✔
2270
                        .ConfigureAwait(false))
5✔
2271
                {
2272
                        throw new ImmediateJobException("Code-defined recurring schedules cannot be replaced by dynamic schedules.");
4✔
2273
                }
2274
        }
5✔
2275

2276
        private static ImmediateJobEntity ToEntity(JobRecord job) => new()
5✔
2277
        {
5✔
2278
                Id = job.Id,
5✔
2279
                QueueName = job.QueueName,
5✔
2280
                JobName = job.JobName,
5✔
2281
                GroupId = job.GroupId,
5✔
2282
                Payload = job.Payload,
5✔
2283
                Context = job.Context,
5✔
2284
                State = job.State,
5✔
2285
                DueAt = job.DueAt,
5✔
2286
                CreatedAt = job.CreatedAt,
5✔
2287
                Attempt = job.Attempt,
5✔
2288
                WorkerId = job.WorkerId,
5✔
2289
                LeaseExpiresAt = job.LeaseExpiresAt,
5✔
2290
                LastError = job.LastError,
5✔
2291
                CompletedAt = job.CompletedAt,
5✔
2292
                RecurringKey = job.RecurringKey,
5✔
2293
                TraceParent = job.TraceParent,
5✔
2294
                TraceState = job.TraceState,
5✔
2295
                ExecutionTraceId = job.ExecutionTraceId,
5✔
2296
                ExecutionSpanId = job.ExecutionSpanId,
5✔
2297
                ExecutionStartedAt = job.ExecutionStartedAt,
5✔
2298
                BatchId = job.BatchId,
5✔
2299
                RemainingDependencies = job.RemainingDependencies,
5✔
2300
                FailedDependencies = job.FailedDependencies,
5✔
2301
                ConcurrencyStamp = Guid.NewGuid(),
5✔
2302
        };
5✔
2303

2304
        private static ImmediateJobContinuationEntity ToEntity(JobContinuationEdge edge)
2305
        {
2306
                var hasJobParent = edge.ParentJobId is not null;
5✔
2307
                var hasBatchParent = edge.ParentBatchId is not null;
5✔
2308
                if (hasJobParent == hasBatchParent)
5✔
2309
                        throw new ImmediateJobException("A continuation edge must identify exactly one parent job or batch.");
×
2310
                return new()
5✔
2311
                {
5✔
2312
                        ChildJobId = edge.ChildJobId,
5✔
2313
                        ParentKind = hasJobParent ? ContinuationParentKind.Job : ContinuationParentKind.Batch,
5✔
2314
                        ParentId = edge.ParentJobId ?? edge.ParentBatchId!,
5✔
2315
                        Trigger = edge.Trigger,
5✔
2316
                };
5✔
2317
        }
2318

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

2347
        private static JobRecord ToRecord(ImmediateJobEntity job) => new()
5✔
2348
        {
5✔
2349
                Id = job.Id,
5✔
2350
                QueueName = job.QueueName,
5✔
2351
                JobName = job.JobName,
5✔
2352
                GroupId = job.GroupId,
5✔
2353
                Payload = job.Payload,
5✔
2354
                Context = job.Context,
5✔
2355
                State = job.State,
5✔
2356
                DueAt = job.DueAt,
5✔
2357
                CreatedAt = job.CreatedAt,
5✔
2358
                Attempt = job.Attempt,
5✔
2359
                WorkerId = job.WorkerId,
5✔
2360
                LeaseExpiresAt = job.LeaseExpiresAt,
5✔
2361
                LastError = job.LastError,
5✔
2362
                CompletedAt = job.CompletedAt,
5✔
2363
                RecurringKey = job.RecurringKey,
5✔
2364
                TraceParent = job.TraceParent,
5✔
2365
                TraceState = job.TraceState,
5✔
2366
                ExecutionTraceId = job.ExecutionTraceId,
5✔
2367
                ExecutionSpanId = job.ExecutionSpanId,
5✔
2368
                ExecutionStartedAt = job.ExecutionStartedAt,
5✔
2369
                BatchId = job.BatchId,
5✔
2370
                RemainingDependencies = job.RemainingDependencies,
5✔
2371
                FailedDependencies = job.FailedDependencies,
5✔
2372
        };
5✔
2373

2374
        private static BatchStatus ToStatus(ImmediateJobBatchEntity batch) => new(
5✔
2375
                batch.Id,
5✔
2376
                batch.State,
5✔
2377
                batch.TotalJobs,
5✔
2378
                batch.SucceededCount,
5✔
2379
                batch.FailedCount,
5✔
2380
                batch.CancelledCount,
5✔
2381
                batch.PendingCount,
5✔
2382
                batch.CreatedAt,
5✔
2383
                batch.StartedAt,
5✔
2384
                batch.CompletedAt,
5✔
2385
                BatchStatus.CalculateFractionSettled(batch.TotalJobs, batch.PendingCount)
5✔
2386
        );
5✔
2387

2388
        private static JobContinuationEdge ToContinuationEdge(ImmediateJobContinuationEntity edge) => new()
5✔
2389
        {
5✔
2390
                ChildJobId = edge.ChildJobId,
5✔
2391
                ParentJobId = edge.ParentKind == ContinuationParentKind.Job ? edge.ParentId : null,
5✔
2392
                ParentBatchId = edge.ParentKind == ContinuationParentKind.Batch ? edge.ParentId : null,
5✔
2393
                Trigger = edge.Trigger,
5✔
2394
        };
5✔
2395

2396
        private static BatchGraphEdge ToGraphEdge(ImmediateJobContinuationEntity edge) => new(
5✔
2397
                edge.ChildJobId,
5✔
2398
                edge.ParentKind == ContinuationParentKind.Job ? edge.ParentId : null,
5✔
2399
                edge.ParentKind == ContinuationParentKind.Batch ? edge.ParentId : null,
5✔
2400
                edge.Trigger
5✔
2401
        );
5✔
2402

2403
        private static ImmediateRecurringJobEntity ToEntity(RecurringJobSchedule schedule) => new()
5✔
2404
        {
5✔
2405
                Name = schedule.Name,
5✔
2406
                JobName = schedule.JobName,
5✔
2407
                Cron = schedule.Cron,
5✔
2408
                TimeZone = schedule.TimeZone,
5✔
2409
                IsCodeDefined = schedule.IsCodeDefined,
5✔
2410
                IsPaused = schedule.IsPaused,
5✔
2411
                NextRunAt = schedule.NextRunAt,
5✔
2412
                LastRunAt = schedule.LastRunAt,
5✔
2413
                ConcurrencyStamp = Guid.NewGuid(),
5✔
2414
        };
5✔
2415
}
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