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

ImmediatePlatform / Immediate.Jobs / 30669186804

31 Jul 2026 10:12PM UTC coverage: 83.933% (+0.1%) from 83.82%
30669186804

Pull #86

github

web-flow
Merge 29013d26d into c8caa3f4e
Pull Request #86: Add job cancellation APIs and dashboard action

101 of 114 new or added lines in 9 files covered. (88.6%)

5 existing lines in 2 files now uncovered.

8207 of 9778 relevant lines covered (83.93%)

2.71 hits per line

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

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

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

7
namespace Immediate.Jobs.EntityFrameworkCore;
8

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

242
                return acquired;
5✔
243
        }
4✔
244

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

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

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

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

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

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

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

429
                return acquired;
5✔
430
        }
4✔
431

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

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

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

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

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

495
                return acquired;
4✔
496
        }
3✔
497

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

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

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

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

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

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

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

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

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

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

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

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

687
                        try
688
                        {
689
                                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
690
                                acquired.Add(ToRecord(entity));
5✔
691
                        }
5✔
692
                        catch (DbUpdateException)
1✔
693
                        {
694
                                // Suppress only an expected optimistic-claim race; genuine provider failures remain visible.
695
                                if (!await CandidateWasClaimedAsync(candidate, cancellationToken).ConfigureAwait(false))
1✔
696
                                        throw;
×
697
                        }
698
                }
4✔
699

700
                return acquired;
5✔
701
        }
4✔
702

703
        private async ValueTask<bool> CandidateWasClaimedAsync(
704
                ImmediateJobEntity candidate,
705
                CancellationToken cancellationToken
706
        )
707
        {
708
                try
709
                {
710
                        await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
1✔
711
                        var currentStamp = await context.Set<ImmediateJobEntity>()
1✔
712
                                .AsNoTracking()
1✔
713
                                .Where(job => job.Id == candidate.Id)
1✔
714
                                .Select(static job => (Guid?)job.ConcurrencyStamp)
1✔
715
                                .SingleOrDefaultAsync(cancellationToken)
1✔
716
                                .ConfigureAwait(false);
1✔
717
                        return currentStamp != candidate.ConcurrencyStamp;
1✔
718
                }
×
719
                catch (Exception exception) when (exception is DbException or InvalidOperationException)
×
720
                {
721
                        return false;
×
722
                }
723
        }
1✔
724

725
        /// <inheritdoc />
726
        public ValueTask SetExecutionTelemetryAsync(
727
                string jobId,
728
                int executionNumber,
729
                string workerId,
730
                string? traceId,
731
                string? spanId,
732
                DateTimeOffset startedAt,
733
                CancellationToken cancellationToken = default
734
        ) => MutateOwnedAsync(jobId, executionNumber, workerId, (job, execution) =>
1✔
735
        {
1✔
736
                job.ExecutionTraceId = traceId;
1✔
737
                job.ExecutionSpanId = spanId;
1✔
738
                job.ExecutionStartedAt = startedAt;
1✔
739
                execution.ExecutionTraceId = traceId;
1✔
740
                execution.ExecutionSpanId = spanId;
1✔
741
                execution.ExecutionStartedAt = startedAt;
1✔
742
        }, cancellationToken);
1✔
743

744
        /// <inheritdoc />
745
        public ValueTask RenewLeaseAsync(
746
                string jobId,
747
                int executionNumber,
748
                string workerId,
749
                TimeSpan lease,
750
                CancellationToken cancellationToken = default
751
        )
752
        {
753
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lease, TimeSpan.Zero);
1✔
754
                return MutateOwnedAsync(
1✔
755
                        jobId,
1✔
756
                        executionNumber,
1✔
757
                        workerId,
1✔
758
                        (job, _) => job.LeaseExpiresAt = _timeProvider.GetUtcNow() + lease,
×
759
                        cancellationToken
1✔
760
                );
1✔
761
        }
762

763
        /// <inheritdoc />
764
        public ValueTask CompleteAsync(
765
                string jobId,
766
                int executionNumber,
767
                string workerId,
768
                CancellationToken cancellationToken = default
769
        ) => CompleteWithContinuationsAsync(jobId, executionNumber, workerId, [], cancellationToken);
5✔
770

771
        /// <inheritdoc />
772
        public ValueTask CompleteWithContinuationsAsync(
773
                string jobId,
774
                int executionNumber,
775
                string workerId,
776
                IReadOnlyList<JobContinuationAddition> additions,
777
                CancellationToken cancellationToken = default
778
        )
779
        {
780
                ArgumentNullException.ThrowIfNull(additions);
5✔
781
                return MutateOwnedWithDependenciesAsync(
5✔
782
                        jobId,
5✔
783
                        executionNumber,
5✔
784
                        workerId,
5✔
785
                        error: null,
5✔
786
                        nextRetryAt: null,
5✔
787
                        succeeded: true,
5✔
788
                        additions,
5✔
789
                        cancellationToken
5✔
790
                );
5✔
791
        }
792

793
        /// <inheritdoc />
794
        public ValueTask AddBatchJobAsync(
795
                string currentJobId,
796
                int executionNumber,
797
                JobRecord job,
798
                ContinuationOptions options,
799
                CancellationToken cancellationToken = default
800
        )
801
        {
802
                ArgumentNullException.ThrowIfNull(job);
5✔
803
                if (options == ContinuationOptions.Detached)
5✔
804
                        throw new ImmediateJobException("AddToBatchAsync cannot create a detached job.");
×
805
                return RetryConcurrencyAsync(
5✔
806
                        operationCancellationToken => AddBatchJobCoreAsync(
5✔
807
                                currentJobId,
5✔
808
                                executionNumber,
5✔
809
                                job,
5✔
810
                                options,
5✔
811
                                operationCancellationToken
5✔
812
                        ),
5✔
813
                        cancellationToken
5✔
814
                );
5✔
815
        }
816

817
        /// <inheritdoc />
818
        public ValueTask FailAsync(
819
                string jobId,
820
                int executionNumber,
821
                string workerId,
822
                string error,
823
                DateTimeOffset? nextRetryAt,
824
                CancellationToken cancellationToken = default
825
        ) => MutateOwnedWithDependenciesAsync(
5✔
826
                jobId,
5✔
827
                executionNumber,
5✔
828
                workerId,
5✔
829
                error,
5✔
830
                nextRetryAt,
5✔
831
                succeeded: false,
5✔
832
                [],
5✔
833
                cancellationToken
5✔
834
        );
5✔
835

836
        /// <inheritdoc />
837
        public async ValueTask UpsertRecurringAsync(RecurringJobSchedule schedule, CancellationToken cancellationToken = default)
838
        {
839
                ArgumentNullException.ThrowIfNull(schedule);
5✔
840
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
841
                if (await UpdateRecurringAsync(context, schedule, cancellationToken).ConfigureAwait(false) != 0)
5✔
842
                        return;
843
                await ThrowIfReplacingCodeDefinedScheduleAsync(context, schedule, cancellationToken).ConfigureAwait(false);
5✔
844

845
                _ = context.Add(ToEntity(schedule));
5✔
846
                try
847
                {
848
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
849
                }
5✔
850
                catch (DbUpdateException)
851
                {
852
                        // A competing node inserted the same schedule after our update attempt.
853
                        await using var retryContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
854
                        if (await UpdateRecurringAsync(retryContext, schedule, cancellationToken).ConfigureAwait(false) != 0)
×
855
                                return;
856
                        await ThrowIfReplacingCodeDefinedScheduleAsync(retryContext, schedule, cancellationToken).ConfigureAwait(false);
×
857
                        throw;
×
858
                }
859
        }
4✔
860

861
        /// <inheritdoc />
862
        public async ValueTask RemoveObsoleteCodeDefinedRecurringAsync(
863
                IReadOnlyCollection<string> activeScheduleNames,
864
                CancellationToken cancellationToken = default
865
        )
866
        {
867
                ArgumentNullException.ThrowIfNull(activeScheduleNames);
4✔
868
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
869
                var schedules = context.Set<ImmediateRecurringJobEntity>()
4✔
870
                        .Where(schedule => schedule.IsCodeDefined);
4✔
871
                if (activeScheduleNames.Count != 0)
4✔
872
                        schedules = schedules.Where(schedule => !activeScheduleNames.Contains(schedule.Name));
4✔
873
                _ = await schedules.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
4✔
874
        }
4✔
875

876
        /// <inheritdoc />
877
        public async ValueTask RemoveRecurringAsync(string name, CancellationToken cancellationToken = default)
878
        {
879
                ArgumentException.ThrowIfNullOrWhiteSpace(name);
5✔
880
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
881
                var removed = await context.Set<ImmediateRecurringJobEntity>()
5✔
882
                        .Where(schedule => schedule.Name == name && !schedule.IsCodeDefined)
5✔
883
                        .ExecuteDeleteAsync(cancellationToken)
5✔
884
                        .ConfigureAwait(false);
5✔
885
                if (removed != 0)
5✔
886
                        return;
887
                if (await context.Set<ImmediateRecurringJobEntity>()
5✔
888
                        .AnyAsync(schedule => schedule.Name == name, cancellationToken)
5✔
889
                        .ConfigureAwait(false))
5✔
890
                {
891
                        throw new ImmediateJobException("Code-defined recurring schedules cannot be deleted.");
4✔
892
                }
893

894
                throw new KeyNotFoundException($"Recurring schedule '{name}' was not found.");
5✔
895
        }
3✔
896

897
        /// <inheritdoc />
898
        public ValueTask PauseRecurringAsync(string name, CancellationToken cancellationToken = default)
899
                => MutateRecurringAsync(name, schedule => schedule.IsPaused = true, cancellationToken);
×
900

901
        /// <inheritdoc />
902
        public ValueTask ResumeRecurringAsync(string name, CancellationToken cancellationToken = default)
903
                => MutateRecurringAsync(name, schedule => schedule.IsPaused = false, cancellationToken);
×
904

905
        /// <inheritdoc />
906
        public async ValueTask<IReadOnlyList<RecurringJobSchedule>> GetDueRecurringAsync(DateTimeOffset now, int batchSize, CancellationToken cancellationToken = default)
907
        {
908
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(batchSize, 0);
×
909
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
910
                return await context.Set<ImmediateRecurringJobEntity>()
×
911
                        .AsNoTracking()
×
912
                        .Where(schedule => !schedule.IsPaused && schedule.NextRunAt <= now)
×
913
                        .OrderBy(schedule => schedule.NextRunAt)
×
914
                        .Take(batchSize)
×
915
                        .Select(schedule => new RecurringJobSchedule
×
916
                        {
×
917
                                Name = schedule.Name,
×
918
                                JobName = schedule.JobName,
×
919
                                Cron = schedule.Cron,
×
920
                                TimeZone = schedule.TimeZone,
×
921
                                IsCodeDefined = schedule.IsCodeDefined,
×
922
                                IsPaused = schedule.IsPaused,
×
923
                                NextRunAt = schedule.NextRunAt,
×
924
                                LastRunAt = schedule.LastRunAt,
×
925
                        })
×
926
                        .ToListAsync(cancellationToken)
×
927
                        .ConfigureAwait(false);
×
928
        }
929

930
        /// <inheritdoc />
931
        public async ValueTask<bool> MaterializeRecurringAsync(
932
                RecurringJobSchedule schedule,
933
                JobRecord job,
934
                DateTimeOffset nextRunAt,
935
                CancellationToken cancellationToken = default
936
        )
937
        {
938
                ArgumentNullException.ThrowIfNull(schedule);
5✔
939
                ArgumentNullException.ThrowIfNull(job);
5✔
940
                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
941
                var strategy = strategyContext.Database.CreateExecutionStrategy();
5✔
942
                return await strategy.ExecuteAsync(
5✔
943
                        operationCancellationToken => MaterializeRecurringCoreAsync(
5✔
944
                                schedule,
5✔
945
                                job,
5✔
946
                                nextRunAt,
5✔
947
                                operationCancellationToken
5✔
948
                        ),
5✔
949
                        cancellationToken
5✔
950
                ).ConfigureAwait(false);
5✔
951
        }
4✔
952

953
        private async Task<bool> MaterializeRecurringCoreAsync(
954
                RecurringJobSchedule schedule,
955
                JobRecord job,
956
                DateTimeOffset nextRunAt,
957
                CancellationToken cancellationToken
958
        )
959
        {
960
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
961
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
962
                var entity = await context.Set<ImmediateRecurringJobEntity>()
5✔
963
                        .SingleOrDefaultAsync(item => item.Name == schedule.Name, cancellationToken)
5✔
964
                        .ConfigureAwait(false);
5✔
965
                if (entity is null || entity.IsPaused || entity.NextRunAt != schedule.NextRunAt)
5✔
966
                        return false;
1✔
967

968
                entity.LastRunAt = schedule.NextRunAt;
5✔
969
                entity.NextRunAt = nextRunAt;
5✔
970
                entity.ConcurrencyStamp = Guid.NewGuid();
5✔
971
                _ = context.Add(ToEntity(job));
5✔
972
                try
973
                {
974
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
975
                        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
5✔
976
                        return true;
5✔
977
                }
978
                catch (DbUpdateException)
979
                {
980
                        await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
×
981
                        return false;
×
982
                }
983
        }
4✔
984

985
        /// <inheritdoc />
986
        public async ValueTask<JobMonitoringSnapshot> GetMonitoringSnapshotAsync(CancellationToken cancellationToken = default)
987
        {
988
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
989
                var rawCounts = await context.Set<ImmediateJobEntity>()
5✔
990
                        .AsNoTracking()
5✔
991
                        .GroupBy(job => job.State)
5✔
992
                        .Select(group => new { State = group.Key, Count = group.LongCount() })
5✔
993
                        .ToListAsync(cancellationToken)
5✔
994
                        .ConfigureAwait(false);
5✔
995
                var counts = Enum.GetValues<JobState>().ToDictionary(static state => state, static _ => 0L);
5✔
996
                foreach (var item in rawCounts)
5✔
997
                        counts[item.State] = item.Count;
5✔
998

999
                var recurring = await context.Set<ImmediateRecurringJobEntity>()
5✔
1000
                        .AsNoTracking()
5✔
1001
                        .OrderBy(schedule => schedule.Name)
5✔
1002
                        .Select(schedule => new RecurringJobSchedule
5✔
1003
                        {
5✔
1004
                                Name = schedule.Name,
5✔
1005
                                JobName = schedule.JobName,
5✔
1006
                                Cron = schedule.Cron,
5✔
1007
                                TimeZone = schedule.TimeZone,
5✔
1008
                                IsCodeDefined = schedule.IsCodeDefined,
5✔
1009
                                IsPaused = schedule.IsPaused,
5✔
1010
                                NextRunAt = schedule.NextRunAt,
5✔
1011
                                LastRunAt = schedule.LastRunAt,
5✔
1012
                        })
5✔
1013
                        .ToListAsync(cancellationToken)
5✔
1014
                        .ConfigureAwait(false);
5✔
1015
                var cutoff = _timeProvider.GetUtcNow() - TimeSpan.FromMinutes(2);
5✔
1016
                var servers = await context.Set<ImmediateJobServerEntity>()
5✔
1017
                        .AsNoTracking()
5✔
1018
                        .Where(server => server.LastHeartbeat >= cutoff)
5✔
1019
                        .OrderBy(server => server.WorkerId)
5✔
1020
                        .Select(server => new JobServerSnapshot(server.WorkerId, server.LastHeartbeat, server.ActiveWorkers, server.MaxWorkers))
5✔
1021
                        .ToListAsync(cancellationToken)
5✔
1022
                        .ConfigureAwait(false);
5✔
1023
                return new(_timeProvider.GetUtcNow(), counts, recurring, servers)
5✔
1024
                {
5✔
1025
                        Capabilities = this.GetCapabilities(),
5✔
1026
                };
5✔
1027
        }
4✔
1028

1029
        /// <inheritdoc />
1030
        public async ValueTask<IReadOnlyList<JobRecord>> QueryJobsAsync(JobQuery query, CancellationToken cancellationToken = default)
1031
        {
1032
                ArgumentNullException.ThrowIfNull(query);
5✔
1033
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
5✔
1034
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
5✔
1035
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1036
                var jobs = context.Set<ImmediateJobEntity>().AsNoTracking();
5✔
1037
                if (query.Id is { } id)
5✔
1038
                        jobs = jobs.Where(job => job.Id == id);
5✔
1039
                if (query.State is { } state)
5✔
1040
                        jobs = jobs.Where(job => job.State == state);
4✔
1041
                if (!string.IsNullOrWhiteSpace(query.QueueName))
5✔
1042
                        jobs = jobs.Where(job => job.QueueName == query.QueueName);
×
1043
                if (!string.IsNullOrWhiteSpace(query.JobName))
5✔
1044
                        jobs = jobs.Where(job => job.JobName == query.JobName);
×
1045
                if (!string.IsNullOrWhiteSpace(query.Search))
5✔
1046
                {
1047
                        var search = query.Search.ToUpperInvariant();
×
1048
                        // The parameterless form is intentionally used because relational providers translate it to SQL.
1049
#pragma warning disable CA1304, CA1311, CA1862, MA0011
1050
                        jobs = jobs.Where(job => job.JobName.ToUpper().Contains(search));
×
1051
#pragma warning restore CA1304, CA1311, CA1862, MA0011
1052
                }
1053

1054
                var entities = await jobs.OrderByDescending(job => job.CreatedAt)
5✔
1055
                        .ThenBy(job => job.Id)
5✔
1056
                        .Skip(query.Skip)
5✔
1057
                        .Take(query.Take)
5✔
1058
                        .ToListAsync(cancellationToken)
5✔
1059
                        .ConfigureAwait(false);
5✔
1060
                return [.. entities.Select(ToRecord)];
5✔
1061
        }
4✔
1062

1063
        /// <inheritdoc />
1064
        public async ValueTask<IReadOnlyList<JobExecutionRecord>> QueryJobExecutionsAsync(
1065
                JobExecutionQuery query,
1066
                CancellationToken cancellationToken = default
1067
        )
1068
        {
1069
                ArgumentNullException.ThrowIfNull(query);
1✔
1070
                query.Validate();
1✔
1071
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
1✔
1072
                var job = await context.Set<ImmediateJobEntity>()
1✔
1073
                        .AsNoTracking()
1✔
1074
                        .SingleOrDefaultAsync(item => item.Id == query.JobId, cancellationToken)
1✔
1075
                        .ConfigureAwait(false);
1✔
1076
                if (job is null)
1✔
1077
                        return [];
1✔
1078

1079
                var executions = context.Set<ImmediateJobExecutionEntity>()
1✔
1080
                        .AsNoTracking()
1✔
1081
                        .Where(execution => execution.JobId == query.JobId);
1✔
1082
                if (query.Attempt is { } attempt)
1✔
1083
                        executions = executions.Where(execution => execution.Attempt == attempt);
×
1084

1085
                var synthetic = JobExecutionRecord.CreateSynthetic(ToRecord(job));
1✔
1086
                var syntheticMissing = synthetic is not null
1✔
1087
                        && (query.Attempt is null || query.Attempt == synthetic.Attempt)
1✔
1088
                        && !await executions.AnyAsync(
1✔
1089
                                execution => execution.Attempt == synthetic.Attempt,
1✔
1090
                                cancellationToken
1✔
1091
                        ).ConfigureAwait(false);
1✔
1092
                var skip = query.Skip;
1✔
1093
                var take = Math.Min(query.Take, JobExecutionQuery.MaximumTake);
1✔
1094
                var result = new List<JobExecutionRecord>(take);
1✔
1095
                if (syntheticMissing && skip == 0 && take != 0)
1✔
1096
                {
1097
                        result.Add(synthetic!);
×
1098
                        take--;
×
1099
                }
1100
                else if (syntheticMissing)
1✔
1101
                {
1102
                        skip--;
×
1103
                }
1104

1105
                if (take != 0 && skip >= 0)
1✔
1106
                {
1107
                        var persisted = await executions
1✔
1108
                                .OrderByDescending(execution => execution.Attempt)
1✔
1109
                                .Skip(skip)
1✔
1110
                                .Take(take)
1✔
1111
                                .ToListAsync(cancellationToken)
1✔
1112
                                .ConfigureAwait(false);
1✔
1113
                        result.AddRange(persisted.Select(ToRecord));
1✔
1114
                }
1115

1116
                return result;
1✔
1117
        }
1✔
1118

1119
        /// <inheritdoc />
1120
        public async ValueTask<BatchStatus?> GetBatchStatusAsync(
1121
                string batchId,
1122
                CancellationToken cancellationToken = default
1123
        )
1124
        {
1125
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
5✔
1126
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1127
                var batch = await context.Set<ImmediateJobBatchEntity>()
5✔
1128
                        .AsNoTracking()
5✔
1129
                        .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
5✔
1130
                        .ConfigureAwait(false);
5✔
1131
                return batch is null ? null : ToStatus(batch);
5✔
1132
        }
4✔
1133

1134
        /// <inheritdoc />
1135
        public async ValueTask<IReadOnlyList<JobContinuationEdge>> GetIncomingEdgesAsync(
1136
                IReadOnlyCollection<string> childJobIds,
1137
                CancellationToken cancellationToken = default
1138
        )
1139
        {
1140
                ArgumentNullException.ThrowIfNull(childJobIds);
5✔
1141
                foreach (var childJobId in childJobIds)
5✔
1142
                        ArgumentException.ThrowIfNullOrWhiteSpace(childJobId);
5✔
1143
                if (childJobIds.Count == 0)
5✔
1144
                        return [];
5✔
1145

1146
                var ids = childJobIds.Distinct(StringComparer.Ordinal).ToArray();
5✔
1147
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1148
                var edges = await context.Set<ImmediateJobContinuationEntity>()
5✔
1149
                        .AsNoTracking()
5✔
1150
                        .Where(edge => ids.Contains(edge.ChildJobId))
5✔
1151
                        .OrderBy(edge => edge.ChildJobId)
5✔
1152
                        .ThenBy(edge => edge.ParentKind)
5✔
1153
                        .ThenBy(edge => edge.ParentId)
5✔
1154
                        .ToListAsync(cancellationToken)
5✔
1155
                        .ConfigureAwait(false);
5✔
1156
                return [.. edges.Select(ToContinuationEdge)];
5✔
1157
        }
4✔
1158

1159
        /// <inheritdoc />
1160
        public async ValueTask<IReadOnlyList<BatchStatus>> QueryBatchesAsync(
1161
                JobBatchQuery query,
1162
                CancellationToken cancellationToken = default
1163
        )
1164
        {
1165
                ArgumentNullException.ThrowIfNull(query);
×
1166
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
×
1167
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
×
1168
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
1169
                var batches = context.Set<ImmediateJobBatchEntity>().AsNoTracking();
×
1170
                if (query.State is { } state)
×
1171
                        batches = batches.Where(batch => batch.State == state);
×
1172
                var entities = await batches.OrderByDescending(batch => batch.CreatedAt)
×
1173
                        .ThenBy(batch => batch.Id)
×
1174
                        .Skip(query.Skip)
×
1175
                        .Take(query.Take)
×
1176
                        .ToListAsync(cancellationToken)
×
1177
                        .ConfigureAwait(false);
×
1178
                return [.. entities.Select(ToStatus)];
×
1179
        }
1180

1181
        /// <inheritdoc />
1182
        public async ValueTask<IReadOnlyList<BatchMemberStatus>> QueryBatchMembersAsync(
1183
                string batchId,
1184
                BatchMemberQuery query,
1185
                CancellationToken cancellationToken = default
1186
        )
1187
        {
1188
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
1189
                ArgumentNullException.ThrowIfNull(query);
4✔
1190
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
4✔
1191
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
4✔
1192
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
1193
                var jobs = context.Set<ImmediateJobEntity>()
4✔
1194
                        .AsNoTracking()
4✔
1195
                        .Where(job => job.BatchId == batchId);
4✔
1196
                if (query.State is { } state)
4✔
1197
                        jobs = jobs.Where(job => job.State == state);
×
1198
                return await jobs.OrderBy(job => job.CreatedAt)
4✔
1199
                        .ThenBy(job => job.Id)
4✔
1200
                        .Skip(query.Skip)
4✔
1201
                        .Take(query.Take)
4✔
1202
                        .Select(job => new BatchMemberStatus(
4✔
1203
                                job.Id,
4✔
1204
                                job.JobName,
4✔
1205
                                job.QueueName,
4✔
1206
                                job.State,
4✔
1207
                                job.Attempt,
4✔
1208
                                job.CreatedAt,
4✔
1209
                                job.CompletedAt,
4✔
1210
                                job.LastError
4✔
1211
                        ))
4✔
1212
                        .ToListAsync(cancellationToken)
4✔
1213
                        .ConfigureAwait(false);
4✔
1214
        }
3✔
1215

1216
        /// <inheritdoc />
1217
        public async ValueTask<BatchGraph?> GetBatchGraphAsync(
1218
                string batchId,
1219
                CancellationToken cancellationToken = default
1220
        )
1221
        {
1222
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
1223
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
1224
                if (!await context.Set<ImmediateJobBatchEntity>()
4✔
1225
                        .AnyAsync(batch => batch.Id == batchId, cancellationToken)
4✔
1226
                        .ConfigureAwait(false))
4✔
1227
                {
1228
                        return null;
4✔
1229
                }
1230

1231
                var jobs = await context.Set<ImmediateJobEntity>()
×
1232
                        .AsNoTracking()
×
1233
                        .Where(job => job.BatchId == batchId)
×
1234
                        .OrderBy(job => job.CreatedAt)
×
1235
                        .ThenBy(job => job.Id)
×
1236
                        .Select(job => new BatchGraphNode(job.Id, job.JobName, job.State))
×
1237
                        .ToListAsync(cancellationToken)
×
1238
                        .ConfigureAwait(false);
×
1239
                var ids = jobs.Select(static job => job.JobId).ToArray();
×
1240
                var edges = ids.Length == 0
×
1241
                        ? []
×
1242
                        : await context.Set<ImmediateJobContinuationEntity>()
×
1243
                                .AsNoTracking()
×
1244
                                .Where(edge => ids.Contains(edge.ChildJobId))
×
1245
                                .OrderBy(edge => edge.ChildJobId)
×
1246
                                .ThenBy(edge => edge.ParentKind)
×
1247
                                .ThenBy(edge => edge.ParentId)
×
1248
                                .ToListAsync(cancellationToken)
×
1249
                                .ConfigureAwait(false);
×
1250
                return new(batchId, jobs, [.. edges.Select(ToGraphEdge)]);
4✔
1251
        }
3✔
1252

1253
        /// <inheritdoc />
1254
        public async ValueTask<JobStatus?> GetJobStatusAsync(
1255
                string jobId,
1256
                CancellationToken cancellationToken = default
1257
        )
1258
        {
1259
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
5✔
1260
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1261
                var job = await context.Set<ImmediateJobEntity>()
5✔
1262
                        .AsNoTracking()
5✔
1263
                        .SingleOrDefaultAsync(item => item.Id == jobId, cancellationToken)
5✔
1264
                        .ConfigureAwait(false);
5✔
1265
                if (job is null)
5✔
1266
                        return null;
5✔
1267
                var edges = await context.Set<ImmediateJobContinuationEntity>()
5✔
1268
                        .AsNoTracking()
5✔
1269
                        .Where(edge => edge.ChildJobId == jobId)
5✔
1270
                        .OrderBy(edge => edge.ParentKind)
5✔
1271
                        .ThenBy(edge => edge.ParentId)
5✔
1272
                        .ToListAsync(cancellationToken)
5✔
1273
                        .ConfigureAwait(false);
5✔
1274
                return new(
5✔
1275
                        job.Id,
5✔
1276
                        job.JobName,
5✔
1277
                        job.QueueName,
5✔
1278
                        job.State,
5✔
1279
                        job.Attempt,
5✔
1280
                        MaxAttempts: null,
5✔
1281
                        job.CreatedAt,
5✔
1282
                        job.DueAt,
5✔
1283
                        job.CompletedAt,
5✔
1284
                        job.LastError,
5✔
1285
                        job.BatchId,
5✔
1286
                        [.. edges.Select(ToGraphEdge)]
5✔
1287
                );
5✔
1288
        }
4✔
1289

1290
        /// <inheritdoc />
1291
        public ValueTask CancelBatchAsync(string batchId, CancellationToken cancellationToken = default)
1292
        {
1293
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
5✔
1294
                return RetryConcurrencyAsync(
5✔
1295
                        operationCancellationToken => CancelBatchCoreAsync(batchId, operationCancellationToken),
5✔
1296
                        cancellationToken
5✔
1297
                );
5✔
1298
        }
1299

1300
        private async Task CancelBatchCoreAsync(string batchId, CancellationToken cancellationToken)
1301
        {
1302
                var now = _timeProvider.GetUtcNow();
5✔
1303
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1304
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1305
                var batch = await context.Set<ImmediateJobBatchEntity>()
5✔
1306
                        .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
5✔
1307
                        .ConfigureAwait(false)
5✔
1308
                        ?? throw new KeyNotFoundException($"Batch '{batchId}' was not found.");
5✔
1309
                if (batch.State != BatchState.Executing)
5✔
1310
                        throw new ImmediateJobException("Only an executing batch can be cancelled.");
×
1311

1312
                var jobs = await context.Set<ImmediateJobEntity>()
5✔
1313
                        .Where(job => job.BatchId == batchId)
5✔
1314
                        .ToListAsync(cancellationToken)
5✔
1315
                        .ConfigureAwait(false);
5✔
1316
                var jobsToCancel = jobs.Where(job => !IsTerminal(job.State)).ToArray();
5✔
1317
                foreach (var job in jobsToCancel)
5✔
1318
                {
1319
                        if (job.State == JobState.Active)
5✔
1320
                        {
1321
                                var execution = await GetOrMaterializeExecutionAsync(context, job, cancellationToken).ConfigureAwait(false)
×
1322
                                        ?? throw new ImmediateJobException($"Active job '{job.Id}' has no execution ordinal.");
×
1323
                                execution.State = JobExecutionState.Cancelled;
×
1324
                                execution.CompletedAt = now;
×
1325
                                execution.Error = null;
×
1326
                        }
1327

1328
                        job.State = JobState.Cancelled;
5✔
1329
                        job.CompletedAt = now;
5✔
1330
                        job.WorkerId = null;
5✔
1331
                        job.LeaseExpiresAt = null;
5✔
1332
                        job.ConcurrencyStamp = Guid.NewGuid();
5✔
1333
                }
4✔
1334

1335
                foreach (var job in jobsToCancel)
5✔
1336
                        await PropagateTerminalAsync(context, job, now, cancellationToken).ConfigureAwait(false);
5✔
1337

1338
                var terminalGroups = GetTerminalFairQueueGroups(context);
5✔
1339
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
1340
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
5✔
1341
                foreach (var (queueName, groupId) in terminalGroups)
5✔
1342
                {
1343
                        await TryRemoveFairQueueCursorAsync(queueName, groupId, CancellationToken.None).ConfigureAwait(false);
×
1344
                }
1345
        }
5✔
1346

1347
        /// <inheritdoc />
1348
        public ValueTask DeleteBatchAsync(string batchId, CancellationToken cancellationToken = default)
1349
        {
1350
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
5✔
1351
                return ExecuteWithStrategyAsync(
5✔
1352
                        operationCancellationToken => DeleteBatchCoreAsync(batchId, operationCancellationToken),
5✔
1353
                        cancellationToken
5✔
1354
                );
5✔
1355
        }
1356

1357
        private async Task DeleteBatchCoreAsync(string batchId, CancellationToken cancellationToken)
1358
        {
1359
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1360
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1361
                var batch = await context.Set<ImmediateJobBatchEntity>()
5✔
1362
                        .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
5✔
1363
                        .ConfigureAwait(false)
5✔
1364
                        ?? throw new KeyNotFoundException($"Batch '{batchId}' was not found.");
5✔
1365
                if (batch.State == BatchState.Executing)
5✔
1366
                        throw new ImmediateJobException("Only a terminal batch can be deleted.");
×
1367

1368
                var jobs = await context.Set<ImmediateJobEntity>()
5✔
1369
                        .Where(job => job.BatchId == batchId)
5✔
1370
                        .ToListAsync(cancellationToken)
5✔
1371
                        .ConfigureAwait(false);
5✔
1372
                var jobIds = jobs.Select(static job => job.Id).ToArray();
5✔
1373
                var edges = await context.Set<ImmediateJobContinuationEntity>()
5✔
1374
                        .Where(edge =>
5✔
1375
                                jobIds.Contains(edge.ChildJobId)
5✔
1376
                                || (edge.ParentKind == ContinuationParentKind.Job && jobIds.Contains(edge.ParentId))
5✔
1377
                                || (edge.ParentKind == ContinuationParentKind.Batch && edge.ParentId == batchId))
5✔
1378
                        .ToListAsync(cancellationToken)
5✔
1379
                        .ConfigureAwait(false);
5✔
1380
                context.RemoveRange(edges);
5✔
1381
                context.RemoveRange(jobs);
5✔
1382
                _ = context.Remove(batch);
5✔
1383
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
1384
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
5✔
1385
        }
5✔
1386

1387
        /// <inheritdoc />
1388
        public ValueTask CancelAsync(string jobId, CancellationToken cancellationToken = default)
1389
        {
1390
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
5✔
1391
                return RetryConcurrencyAsync(
5✔
1392
                        operationCancellationToken => CancelCoreAsync(jobId, operationCancellationToken),
5✔
1393
                        cancellationToken
5✔
1394
                );
5✔
1395
        }
1396

1397
        private async Task CancelCoreAsync(string jobId, CancellationToken cancellationToken)
1398
        {
1399
                var now = _timeProvider.GetUtcNow();
5✔
1400
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1401
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1402
                var job = await context.Set<ImmediateJobEntity>()
5✔
1403
                        .SingleOrDefaultAsync(item => item.Id == jobId, cancellationToken)
5✔
1404
                        .ConfigureAwait(false)
5✔
1405
                        ?? throw new KeyNotFoundException($"Job '{jobId}' was not found.");
5✔
1406
                if (IsTerminal(job.State))
5✔
1407
                        throw new ImmediateJobException("Only a non-terminal job can be cancelled.");
1✔
1408

1409
                if (job.State == JobState.Active)
5✔
1410
                {
1411
                        var execution = await GetOrMaterializeExecutionAsync(context, job, cancellationToken).ConfigureAwait(false)
1✔
1412
                                ?? throw new ImmediateJobException($"Active job '{job.Id}' has no execution ordinal.");
1✔
1413
                        execution.State = JobExecutionState.Cancelled;
1✔
1414
                        execution.CompletedAt = now;
1✔
1415
                        execution.Error = null;
1✔
1416
                }
1417

1418
                job.State = JobState.Cancelled;
5✔
1419
                job.CompletedAt = now;
5✔
1420
                job.WorkerId = null;
5✔
1421
                job.LeaseExpiresAt = null;
5✔
1422
                job.ConcurrencyStamp = Guid.NewGuid();
5✔
1423
                await PropagateTerminalAsync(context, job, now, cancellationToken).ConfigureAwait(false);
5✔
1424

1425
                var terminalGroups = GetTerminalFairQueueGroups(context);
5✔
1426
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
1427
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
5✔
1428
                foreach (var (queueName, groupId) in terminalGroups)
5✔
1429
                {
NEW
1430
                        await TryRemoveFairQueueCursorAsync(queueName, groupId, CancellationToken.None).ConfigureAwait(false);
×
1431
                }
1432
        }
5✔
1433

1434
        /// <inheritdoc />
1435
        public ValueTask RetryAsync(string jobId, CancellationToken cancellationToken = default)
1436
        {
1437
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
5✔
1438
                return RetryConcurrencyAsync(
5✔
1439
                        operationCancellationToken => RetryCoreAsync(jobId, operationCancellationToken),
5✔
1440
                        cancellationToken
5✔
1441
                );
5✔
1442
        }
1443

1444
        private async Task RetryCoreAsync(string jobId, CancellationToken cancellationToken)
1445
        {
1446
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1447
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1448
                var job = await context.Set<ImmediateJobEntity>()
5✔
1449
                        .SingleOrDefaultAsync(item => item.Id == jobId &&
5✔
1450
                                (item.State == JobState.Failed || item.State == JobState.Scheduled), cancellationToken)
5✔
1451
                        .ConfigureAwait(false);
5✔
1452
                if (job is null)
5✔
1453
                {
1454
                        if (await context.Set<ImmediateJobEntity>()
5✔
1455
                                .AnyAsync(item => item.Id == jobId, cancellationToken)
5✔
1456
                                .ConfigureAwait(false))
5✔
1457
                        {
1458
                                throw new ImmediateJobException("Only failed or scheduled jobs can be retried.");
5✔
1459
                        }
1460

1461
                        throw new KeyNotFoundException($"Job '{jobId}' was not found.");
5✔
1462
                }
1463

1464
                var wasFailed = job.State == JobState.Failed;
1✔
1465
                _ = await GetOrMaterializeExecutionAsync(context, job, cancellationToken).ConfigureAwait(false);
1✔
1466
                if (wasFailed && job.BatchId is { } batchId)
1✔
1467
                {
1468
                        var batch = await context.Set<ImmediateJobBatchEntity>()
×
1469
                                .SingleAsync(item => item.Id == batchId, cancellationToken)
×
1470
                                .ConfigureAwait(false);
×
1471
                        batch.PendingCount++;
×
1472
                        batch.FailedCount = Math.Max(0, batch.FailedCount - 1);
×
1473
                        batch.State = BatchState.Executing;
×
1474
                        batch.CompletedAt = null;
×
1475
                        batch.ConcurrencyStamp = Guid.NewGuid();
×
1476
                }
1477

1478
                job.State = JobState.Pending;
1✔
1479
                job.DueAt = _timeProvider.GetUtcNow();
1✔
1480
                job.WorkerId = null;
1✔
1481
                job.LeaseExpiresAt = null;
1✔
1482
                if (wasFailed)
1✔
1483
                {
1484
                        job.CompletedAt = null;
1✔
1485
                        job.LastError = null;
1✔
1486
                }
1487

1488
                job.ConcurrencyStamp = Guid.NewGuid();
1✔
1489
                try
1490
                {
1491
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
1✔
1492
                }
1✔
1493
                catch (DbUpdateConcurrencyException)
1494
                {
1495
                        if (!await context.Set<ImmediateJobEntity>()
×
1496
                                .AsNoTracking()
×
1497
                                .AnyAsync(item => item.Id == jobId, cancellationToken)
×
1498
                                .ConfigureAwait(false))
×
1499
                        {
1500
                                throw new KeyNotFoundException($"Job '{jobId}' was not found.");
×
1501
                        }
1502

1503
                        throw new ImmediateJobException("Only failed or scheduled jobs can be retried.");
×
1504
                }
1505

1506
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
1✔
1507
        }
1✔
1508

1509
        /// <inheritdoc />
1510
        public ValueTask DeleteAsync(string jobId, CancellationToken cancellationToken = default)
1511
        {
1512
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
5✔
1513
                return ExecuteWithStrategyAsync(
5✔
1514
                        operationCancellationToken => DeleteCoreAsync(jobId, operationCancellationToken),
5✔
1515
                        cancellationToken
5✔
1516
                );
5✔
1517
        }
1518

1519
        private async Task DeleteCoreAsync(string jobId, CancellationToken cancellationToken)
1520
        {
1521
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1522
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1523
                var job = await context.Set<ImmediateJobEntity>()
5✔
1524
                        .AsNoTracking()
5✔
1525
                        .SingleOrDefaultAsync(item => item.Id == jobId
5✔
1526
                                && (item.State == JobState.Succeeded || item.State == JobState.Failed || item.State == JobState.Cancelled || item.State == JobState.Skipped), cancellationToken)
5✔
1527
                        .ConfigureAwait(false);
5✔
1528
                if (job is null)
5✔
1529
                {
1530
                        if (await context.Set<ImmediateJobEntity>()
5✔
1531
                                .AnyAsync(item => item.Id == jobId, cancellationToken)
5✔
1532
                                .ConfigureAwait(false))
5✔
1533
                        {
1534
                                throw new ImmediateJobException("Only terminal jobs can be deleted.");
4✔
1535
                        }
1536

1537
                        throw new KeyNotFoundException($"Job '{jobId}' was not found.");
5✔
1538
                }
1539

1540
                if (job.BatchId is not null)
1✔
1541
                        throw new ImmediateJobException("Batch members are deleted with their batch so the workflow remains coherent.");
×
1542
                _ = await context.Set<ImmediateJobContinuationEntity>()
1✔
1543
                        .Where(edge => edge.ChildJobId == jobId ||
1✔
1544
                                (edge.ParentKind == ContinuationParentKind.Job && edge.ParentId == jobId))
1✔
1545
                        .ExecuteDeleteAsync(cancellationToken)
1✔
1546
                        .ConfigureAwait(false);
1✔
1547
                var removed = await context.Set<ImmediateJobEntity>()
1✔
1548
                        .Where(item => item.Id == jobId &&
1✔
1549
                                (item.State == JobState.Succeeded || item.State == JobState.Failed || item.State == JobState.Cancelled || item.State == JobState.Skipped))
1✔
1550
                        .ExecuteDeleteAsync(cancellationToken)
1✔
1551
                        .ConfigureAwait(false);
1✔
1552
                if (removed == 0)
1✔
1553
                {
1554
                        if (await context.Set<ImmediateJobEntity>()
×
1555
                                .AnyAsync(item => item.Id == jobId, cancellationToken)
×
1556
                                .ConfigureAwait(false))
×
1557
                        {
1558
                                throw new ImmediateJobException("Only terminal jobs can be deleted.");
×
1559
                        }
1560

1561
                        throw new KeyNotFoundException($"Job '{jobId}' was not found.");
×
1562
                }
1563

1564
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
1✔
1565
        }
1✔
1566

1567
        /// <inheritdoc />
1568
        public ValueTask PurgeJobsAsync(
1569
                TimeSpan succeededRetention,
1570
                TimeSpan failedRetention,
1571
                CancellationToken cancellationToken = default
1572
        )
1573
        {
1574
                var now = _timeProvider.GetUtcNow();
4✔
1575
                return ExecuteWithStrategyAsync(
4✔
1576
                        operationCancellationToken => PurgeJobsCoreAsync(
4✔
1577
                                now - succeededRetention,
4✔
1578
                                now - failedRetention,
4✔
1579
                                operationCancellationToken
4✔
1580
                        ),
4✔
1581
                        cancellationToken
4✔
1582
                );
4✔
1583
        }
1584

1585
        /// <inheritdoc />
1586
        public ValueTask PurgeBatchesAsync(
1587
                TimeSpan batchSucceededRetention,
1588
                TimeSpan batchFailedRetention,
1589
                CancellationToken cancellationToken = default
1590
        )
1591
        {
1592
                var now = _timeProvider.GetUtcNow();
4✔
1593
                return ExecuteWithStrategyAsync(
4✔
1594
                        operationCancellationToken => PurgeBatchesCoreAsync(
4✔
1595
                                now - batchSucceededRetention,
4✔
1596
                                now - batchFailedRetention,
4✔
1597
                                operationCancellationToken
4✔
1598
                        ),
4✔
1599
                        cancellationToken
4✔
1600
                );
4✔
1601
        }
1602

1603
        private async Task PurgeJobsCoreAsync(
1604
                DateTimeOffset succeededBefore,
1605
                DateTimeOffset failedBefore,
1606
                CancellationToken cancellationToken
1607
        )
1608
        {
1609
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
1610
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
4✔
1611
                var jobs = await context.Set<ImmediateJobEntity>()
4✔
1612
                        .Where(job => job.BatchId == null
4✔
1613
                                && ((job.State == JobState.Succeeded && job.CompletedAt < succeededBefore)
4✔
1614
                                || ((job.State == JobState.Failed || job.State == JobState.Cancelled || job.State == JobState.Skipped) && job.CompletedAt < failedBefore))
4✔
1615
                        )
4✔
1616
                        .ToListAsync(cancellationToken)
4✔
1617
                        .ConfigureAwait(false);
4✔
1618
                if (jobs.Count != 0)
4✔
1619
                {
1620
                        var jobIds = jobs.Select(static job => job.Id).ToArray();
×
1621
                        var edges = await context.Set<ImmediateJobContinuationEntity>()
×
1622
                                .Where(edge =>
×
1623
                                        jobIds.Contains(edge.ChildJobId)
×
1624
                                        || (jobIds.Contains(edge.ParentId) && edge.ParentKind == ContinuationParentKind.Job)
×
1625
                                )
×
1626
                                .ToListAsync(cancellationToken)
×
1627
                                .ConfigureAwait(false);
×
1628
                        context.RemoveRange(edges);
×
1629
                }
1630

1631
                context.RemoveRange(jobs);
4✔
1632
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
1633
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
4✔
1634
        }
4✔
1635

1636
        private async Task PurgeBatchesCoreAsync(
1637
                DateTimeOffset batchSucceededBefore,
1638
                DateTimeOffset batchFailedBefore,
1639
                CancellationToken cancellationToken
1640
        )
1641
        {
1642
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
1643
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
4✔
1644
                var batches = await context.Set<ImmediateJobBatchEntity>()
4✔
1645
                        .Where(batch => (batch.State == BatchState.Succeeded && batch.CompletedAt < batchSucceededBefore)
4✔
1646
                                || ((batch.State == BatchState.Failed || batch.State == BatchState.Cancelled)
4✔
1647
                                        && batch.CompletedAt < batchFailedBefore))
4✔
1648
                        .ToListAsync(cancellationToken)
4✔
1649
                        .ConfigureAwait(false);
4✔
1650
                if (batches.Count != 0)
4✔
1651
                {
1652
                        var batchIds = batches.Select(static batch => batch.Id).ToArray();
×
1653
                        var memberIds = await context.Set<ImmediateJobEntity>()
×
1654
                                .Where(job => job.BatchId != null && batchIds.Contains(job.BatchId))
×
1655
                                .Select(job => job.Id)
×
1656
                                .ToListAsync(cancellationToken)
×
1657
                                .ConfigureAwait(false);
×
1658
                        var edges = await context.Set<ImmediateJobContinuationEntity>()
×
1659
                                .Where(edge =>
×
1660
                                        (batchIds.Contains(edge.ParentId) && edge.ParentKind == ContinuationParentKind.Batch)
×
1661
                                        || memberIds.Contains(edge.ChildJobId)
×
1662
                                        || (memberIds.Contains(edge.ParentId) && edge.ParentKind == ContinuationParentKind.Job)
×
1663
                                )
×
1664
                                .ToListAsync(cancellationToken)
×
1665
                                .ConfigureAwait(false);
×
1666
                        context.RemoveRange(edges);
×
1667
                        context.RemoveRange(batches);
×
1668
                }
1669

1670
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
1671
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
4✔
1672
        }
4✔
1673

1674
        /// <inheritdoc />
1675
        public async ValueTask HeartbeatAsync(JobServerSnapshot server, CancellationToken cancellationToken = default)
1676
        {
1677
                ArgumentNullException.ThrowIfNull(server);
1✔
1678
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
1✔
1679
                var cutoff = _timeProvider.GetUtcNow() - TimeSpan.FromMinutes(2);
1✔
1680
                _ = await context.Set<ImmediateJobServerEntity>()
1✔
1681
                        .Where(item => item.LastHeartbeat < cutoff)
1✔
1682
                        .ExecuteDeleteAsync(cancellationToken)
1✔
1683
                        .ConfigureAwait(false);
1✔
1684
                var entity = await context.Set<ImmediateJobServerEntity>().FindAsync([server.WorkerId], cancellationToken).ConfigureAwait(false);
1✔
1685
                if (entity is null)
1✔
1686
                {
1687
                        _ = context.Add(new ImmediateJobServerEntity
1✔
1688
                        {
1✔
1689
                                WorkerId = server.WorkerId,
1✔
1690
                                LastHeartbeat = server.LastHeartbeat,
1✔
1691
                                ActiveWorkers = server.ActiveWorkers,
1✔
1692
                                MaxWorkers = server.MaxWorkers,
1✔
1693
                        });
1✔
1694
                }
1695
                else
1696
                {
1697
                        entity.LastHeartbeat = server.LastHeartbeat;
×
1698
                        entity.ActiveWorkers = server.ActiveWorkers;
×
1699
                        entity.MaxWorkers = server.MaxWorkers;
×
1700
                }
1701

1702
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
1✔
1703
        }
1✔
1704

1705
        /// <inheritdoc />
1706
        public async ValueTask<bool> IsHealthyAsync(CancellationToken cancellationToken = default)
1707
        {
1708
                try
1709
                {
1710
                        await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
1711
                        return await context.Database.CanConnectAsync(cancellationToken).ConfigureAwait(false);
×
1712
                }
×
1713
                catch (Exception exception) when (exception is DbException or InvalidOperationException)
×
1714
                {
1715
                        return false;
×
1716
                }
1717
        }
×
1718

1719
        private ValueTask MutateOwnedWithDependenciesAsync(
1720
                string jobId,
1721
                int executionNumber,
1722
                string workerId,
1723
                string? error,
1724
                DateTimeOffset? nextRetryAt,
1725
                bool succeeded,
1726
                IReadOnlyList<JobContinuationAddition> additions,
1727
                CancellationToken cancellationToken
1728
        ) => RetryConcurrencyAsync(
5✔
1729
                operationCancellationToken => MutateOwnedCoreAsync(
5✔
1730
                        jobId,
5✔
1731
                        executionNumber,
5✔
1732
                        workerId,
5✔
1733
                        error,
5✔
1734
                        nextRetryAt,
5✔
1735
                        succeeded,
5✔
1736
                        additions,
5✔
1737
                        operationCancellationToken
5✔
1738
                ),
5✔
1739
                cancellationToken
5✔
1740
        );
5✔
1741

1742
        private async ValueTask RetryConcurrencyAsync(
1743
                Func<CancellationToken, Task> operation,
1744
                CancellationToken cancellationToken
1745
        )
1746
        {
1747
                var concurrencyAttempt = 0;
5✔
1748
                while (true)
4✔
1749
                {
1750
                        try
1751
                        {
1752
                                await ExecuteWithStrategyAsync(operation, cancellationToken).ConfigureAwait(false);
5✔
1753
                                return;
5✔
1754
                        }
1755
                        catch (DbUpdateConcurrencyException) when (++concurrencyAttempt < MaxConcurrencyAttempts)
5✔
1756
                        {
1757
                                // The transaction rolled back, so the next attempt re-evaluates the graph from durable state.
1758
                        }
5✔
1759
                        catch (DbUpdateException exception) when (++concurrencyAttempt < MaxConcurrencyAttempts)
5✔
1760
                        {
1761
                                if (!await IsSyntheticExecutionInsertRaceAsync(exception, cancellationToken).ConfigureAwait(false))
1✔
1762
                                        throw;
×
1763
                                // The failed context is disposed by the operation; retry with the execution inserted by the winner.
1764
                        }
1765
                }
1766
        }
5✔
1767

1768
        private async ValueTask ExecuteWithStrategyAsync(
1769
                Func<CancellationToken, Task> operation,
1770
                CancellationToken cancellationToken
1771
        )
1772
        {
1773
                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1774
                var strategy = strategyContext.Database.CreateExecutionStrategy();
5✔
1775
                await strategy.ExecuteAsync(operation, cancellationToken).ConfigureAwait(false);
5✔
1776
        }
5✔
1777

1778
        private async ValueTask<bool> IsSyntheticExecutionInsertRaceAsync(
1779
                DbUpdateException exception,
1780
                CancellationToken cancellationToken
1781
        )
1782
        {
1783
                var syntheticExecutions = exception.Entries
1✔
1784
                        .Select(static entry => entry.Entity)
1✔
1785
                        .OfType<ImmediateJobExecutionEntity>()
1✔
1786
                        .Where(static execution => execution.IsSynthetic)
1✔
1787
                        .DistinctBy(static execution => (execution.JobId, execution.Attempt))
1✔
1788
                        .ToArray();
1✔
1789
                if (syntheticExecutions.Length == 0)
1✔
1790
                        return false;
×
1791

1792
                try
1793
                {
1794
                        await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
1✔
1795
                        foreach (var execution in syntheticExecutions)
1✔
1796
                        {
1797
                                if (!await context.Set<ImmediateJobExecutionEntity>()
1✔
1798
                                        .AsNoTracking()
1✔
1799
                                        .AnyAsync(
1✔
1800
                                                item => item.JobId == execution.JobId && item.Attempt == execution.Attempt,
1✔
1801
                                                cancellationToken
1✔
1802
                                        )
1✔
1803
                                        .ConfigureAwait(false))
1✔
1804
                                {
1805
                                        return false;
×
1806
                                }
1807
                        }
1808

1809
                        return true;
1✔
1810
                }
×
1811
                catch (Exception verificationException) when (
×
1812
                        verificationException is DbException or InvalidOperationException
×
1813
                )
×
1814
                {
1815
                        return false;
×
1816
                }
1817
        }
1✔
1818

1819
        private ValueTask MutateOwnedAsync(
1820
                string jobId,
1821
                int executionNumber,
1822
                string workerId,
1823
                Action<ImmediateJobEntity, ImmediateJobExecutionEntity> mutate,
1824
                CancellationToken cancellationToken
1825
        ) => RetryConcurrencyAsync(
1✔
1826
                operationCancellationToken => MutateOwnedOnceAsync(
1✔
1827
                        jobId,
1✔
1828
                        executionNumber,
1✔
1829
                        workerId,
1✔
1830
                        mutate,
1✔
1831
                        operationCancellationToken
1✔
1832
                ),
1✔
1833
                cancellationToken
1✔
1834
        );
1✔
1835

1836
        private async Task MutateOwnedOnceAsync(
1837
                string jobId,
1838
                int executionNumber,
1839
                string workerId,
1840
                Action<ImmediateJobEntity, ImmediateJobExecutionEntity> mutate,
1841
                CancellationToken cancellationToken
1842
        )
1843
        {
1844
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
1✔
1845
                var job = await context.Set<ImmediateJobEntity>()
1✔
1846
                        .SingleOrDefaultAsync(item => item.Id == jobId && item.Attempt == executionNumber && item.State == JobState.Active && item.WorkerId == workerId, cancellationToken)
1✔
1847
                        .ConfigureAwait(false) ?? throw new ImmediateJobException($"Worker '{workerId}' does not own active job '{jobId}'.");
1✔
1848
                var execution = await GetOrMaterializeExecutionAsync(context, job, cancellationToken).ConfigureAwait(false)
1✔
1849
                        ?? throw new ImmediateJobException($"Active job '{job.Id}' has no execution ordinal.");
1✔
1850
                mutate(job, execution);
1✔
1851
                job.ConcurrencyStamp = Guid.NewGuid();
1✔
1852
                try
1853
                {
1854
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
1✔
1855
                }
1✔
1856
                catch (DbUpdateConcurrencyException exception)
×
1857
                {
1858
                        throw new ImmediateJobException(
1✔
1859
                                $"Worker '{workerId}' does not own active job '{jobId}'.",
1✔
1860
                                exception
1✔
1861
                        );
1✔
1862
                }
1863
        }
1✔
1864

1865
        private async Task MutateOwnedCoreAsync(
1866
                string jobId,
1867
                int executionNumber,
1868
                string workerId,
1869
                string? error,
1870
                DateTimeOffset? nextRetryAt,
1871
                bool succeeded,
1872
                IReadOnlyList<JobContinuationAddition> additions,
1873
                CancellationToken cancellationToken
1874
        )
1875
        {
1876
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1877
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1878
                var job = await context.Set<ImmediateJobEntity>()
5✔
1879
                        .SingleOrDefaultAsync(item => item.Id == jobId && item.Attempt == executionNumber && item.State == JobState.Active && item.WorkerId == workerId, cancellationToken)
5✔
1880
                        .ConfigureAwait(false) ?? throw new ImmediateJobException($"Worker '{workerId}' does not own active job '{jobId}'.");
5✔
1881
                var now = _timeProvider.GetUtcNow();
5✔
1882
                var execution = await GetOrMaterializeExecutionAsync(context, job, cancellationToken).ConfigureAwait(false)
5✔
1883
                        ?? throw new ImmediateJobException($"Active job '{job.Id}' has no execution ordinal.");
5✔
1884
                execution.State = succeeded ? JobExecutionState.Succeeded : JobExecutionState.Failed;
5✔
1885
                execution.CompletedAt = now;
5✔
1886
                execution.Error = error;
5✔
1887
                job.WorkerId = null;
5✔
1888
                job.LeaseExpiresAt = null;
5✔
1889
                job.LastError = error;
5✔
1890
                job.ConcurrencyStamp = Guid.NewGuid();
5✔
1891
                if (!succeeded && nextRetryAt is { } retryAt)
5✔
1892
                {
1893
                        job.State = retryAt <= now ? JobState.Pending : JobState.Scheduled;
×
1894
                        job.DueAt = retryAt;
×
1895
                        job.CompletedAt = null;
×
1896
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1897
                        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
×
1898
                        return;
×
1899
                }
1900

1901
                if (succeeded && additions.Count != 0)
5✔
1902
                {
1903
                        await FlushContinuationAdditionsAsync(context, job, additions, cancellationToken).ConfigureAwait(false);
5✔
1904
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1905
                }
1906

1907
                job.State = succeeded ? JobState.Succeeded : JobState.Failed;
5✔
1908
                job.CompletedAt = now;
5✔
1909
                await PropagateTerminalAsync(context, job, now, cancellationToken).ConfigureAwait(false);
5✔
1910
                var terminalGroups = GetTerminalFairQueueGroups(context);
5✔
1911
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
1912
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
5✔
1913
                foreach (var (queueName, groupId) in terminalGroups)
5✔
1914
                        await TryRemoveFairQueueCursorAsync(queueName, groupId, CancellationToken.None).ConfigureAwait(false);
5✔
1915
        }
5✔
1916

1917
        private static (string QueueName, string GroupId)[] GetTerminalFairQueueGroups(TContext context) =>
1918
                [
5✔
1919
                        .. context.ChangeTracker
5✔
1920
                                .Entries<ImmediateJobEntity>()
5✔
1921
                                .Select(static entry => entry.Entity)
5✔
1922
                                .Where(static job => job.GroupId is not null && IsTerminal(job.State))
5✔
1923
                                .Select(static job => (job.QueueName, GroupId: job.GroupId!))
5✔
1924
                                .Distinct(),
5✔
1925
                ];
5✔
1926

1927
        private async ValueTask TryRemoveFairQueueCursorAsync(
1928
                string queueName,
1929
                string groupId,
1930
                CancellationToken cancellationToken
1931
        )
1932
        {
1933
                try
1934
                {
1935
                        await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1936
                        if (await HasLiveGroupJobsAsync(context, queueName, groupId, cancellationToken).ConfigureAwait(false))
5✔
1937
                        {
1938
                                return;
1939
                        }
1940

1941
                        var cursor = await context.Set<ImmediateFairQueueGroupEntity>()
4✔
1942
                                .SingleOrDefaultAsync(
4✔
1943
                                        group => group.QueueName == queueName && group.GroupId == groupId,
4✔
1944
                                        cancellationToken
4✔
1945
                                )
4✔
1946
                                .ConfigureAwait(false);
4✔
1947
                        if (cursor is null)
4✔
1948
                                return;
1949

1950
                        _ = context.Remove(cursor);
4✔
1951
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
1952
                }
4✔
1953
                catch (Exception exception) when (
×
1954
                        !cancellationToken.IsCancellationRequested
×
1955
                        && exception is DbException or DbUpdateException
×
1956
                )
×
1957
                {
1958
                        // Cleanup is best-effort metadata maintenance and must not invalidate a committed transition.
1959
                }
×
1960
        }
5✔
1961

1962
        private static Task<bool> HasLiveGroupJobsAsync(
1963
                TContext context,
1964
                string queueName,
1965
                string groupId,
1966
                CancellationToken cancellationToken
1967
        ) => context.Set<ImmediateJobEntity>().AnyAsync(
5✔
1968
                job => job.QueueName == queueName
5✔
1969
                        && job.GroupId == groupId
5✔
1970
                        && (job.State == JobState.Pending
5✔
1971
                                || job.State == JobState.Scheduled
5✔
1972
                                || job.State == JobState.Active),
5✔
1973
                cancellationToken
5✔
1974
        );
5✔
1975

1976
        private async Task AddBatchJobCoreAsync(
1977
                string currentJobId,
1978
                int executionNumber,
1979
                JobRecord record,
1980
                ContinuationOptions options,
1981
                CancellationToken cancellationToken
1982
        )
1983
        {
1984
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1985
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1986
                var current = await context.Set<ImmediateJobEntity>()
5✔
1987
                        .SingleOrDefaultAsync(job => job.Id == currentJobId && job.Attempt == executionNumber && job.State == JobState.Active, cancellationToken)
5✔
1988
                        .ConfigureAwait(false)
5✔
1989
                        ?? throw new ImmediateJobException($"The current active job '{currentJobId}' was not found.");
5✔
1990
                if (current.BatchId is not { } batchId)
5✔
1991
                        throw new ImmediateJobException("The current job does not belong to a batch.");
×
1992
                if (options is not (ContinuationOptions.BesideContinuations or ContinuationOptions.BeforeContinuations))
5✔
1993
                        throw new ArgumentOutOfRangeException(nameof(options));
×
1994
                if (!string.Equals(record.BatchId, batchId, StringComparison.Ordinal))
5✔
1995
                        throw new ImmediateJobException("The new job must belong to the current job's batch.");
5✔
1996
                if (record.State is JobState.Active or JobState.AwaitingContinuation || IsTerminal(record.State))
×
1997
                        throw new ImmediateJobException($"Concurrent batch member '{record.Id}' has invalid state '{record.State}'.");
×
1998

1999
                var batch = await context.Set<ImmediateJobBatchEntity>()
×
2000
                        .SingleAsync(item => item.Id == batchId && item.State == BatchState.Executing, cancellationToken)
×
2001
                        .ConfigureAwait(false);
×
2002
                var job = ToEntity(record);
×
2003
                _ = context.Add(job);
×
2004
                batch.TotalJobs++;
×
2005
                batch.PendingCount++;
×
2006
                batch.ConcurrencyStamp = Guid.NewGuid();
×
2007

2008
                if (options == ContinuationOptions.BeforeContinuations)
×
2009
                {
2010
                        var waiters = await GetActiveWaitersAsync(context, currentJobId, cancellationToken).ConfigureAwait(false);
×
2011
                        foreach (var waiter in waiters)
×
2012
                        {
2013
                                _ = context.Add(new ImmediateJobContinuationEntity
×
2014
                                {
×
2015
                                        ChildJobId = waiter.Id,
×
2016
                                        ParentKind = ContinuationParentKind.Job,
×
2017
                                        ParentId = job.Id,
×
2018
                                        Trigger = ContinuationTrigger.Success,
×
2019
                                });
×
2020
                                waiter.RemainingDependencies++;
×
2021
                                waiter.ConcurrencyStamp = Guid.NewGuid();
×
2022
                        }
2023
                }
2024

2025
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
2026
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
×
2027
        }
×
2028

2029
        private static async Task FlushContinuationAdditionsAsync(
2030
                TContext context,
2031
                ImmediateJobEntity current,
2032
                IReadOnlyList<JobContinuationAddition> additions,
2033
                CancellationToken cancellationToken
2034
        )
2035
        {
2036
                var ids = new HashSet<string>(StringComparer.Ordinal);
5✔
2037
                var trackedAdditions = 0;
5✔
2038
                foreach (var addition in additions)
5✔
2039
                {
2040
                        ArgumentNullException.ThrowIfNull(addition);
5✔
2041
                        ArgumentNullException.ThrowIfNull(addition.Job);
5✔
2042
                        if (!ids.Add(addition.Job.Id))
5✔
2043
                                throw new ImmediateJobException("Buffered continuations contain duplicate job identifiers.");
×
2044
                        if (!Enum.IsDefined(addition.Trigger))
5✔
2045
                                throw new ArgumentOutOfRangeException(nameof(additions), "Unknown continuation trigger.");
4✔
2046
                        if (addition.Job.State is not (JobState.Pending or JobState.Scheduled))
5✔
2047
                                throw new ImmediateJobException($"Dynamic continuation '{addition.Job.Id}' has invalid state '{addition.Job.State}'.");
×
2048

2049
                        if (addition.Options == ContinuationOptions.Detached)
5✔
2050
                        {
2051
                                if (addition.Job.BatchId is not null)
5✔
2052
                                        throw new ImmediateJobException("A detached continuation cannot belong to a batch.");
5✔
2053
                        }
2054
                        else if (addition.Options is ContinuationOptions.BesideContinuations or ContinuationOptions.BeforeContinuations)
5✔
2055
                        {
2056
                                if (current.BatchId is null || !string.Equals(addition.Job.BatchId, current.BatchId, StringComparison.Ordinal))
5✔
2057
                                        throw new ImmediateJobException("A batch-tracked continuation must belong to the current job's batch.");
5✔
2058
                                trackedAdditions++;
×
2059
                        }
2060
                        else
2061
                        {
2062
                                throw new ArgumentOutOfRangeException(nameof(additions), "Unknown continuation option.");
4✔
2063
                        }
2064
                }
2065

2066
                var waiters = additions.Any(static addition => addition.Options == ContinuationOptions.BeforeContinuations)
×
2067
                        ? await GetActiveWaitersAsync(context, current.Id, cancellationToken).ConfigureAwait(false)
×
2068
                        : [];
×
2069
                ImmediateJobBatchEntity? batch = null;
2070
                if (trackedAdditions != 0)
×
2071
                {
2072
                        if (current.BatchId is not { } batchId)
×
2073
                                throw new ImmediateJobException("The current job does not belong to a batch.");
×
2074
                        batch = await context.Set<ImmediateJobBatchEntity>()
×
2075
                                .SingleAsync(item => item.Id == batchId && item.State == BatchState.Executing, cancellationToken)
×
2076
                                .ConfigureAwait(false);
×
2077
                        batch.TotalJobs += trackedAdditions;
×
2078
                        batch.PendingCount += trackedAdditions;
×
2079
                        batch.ConcurrencyStamp = Guid.NewGuid();
×
2080
                }
2081

2082
                foreach (var addition in additions)
×
2083
                {
2084
                        var job = ToEntity(addition.Job with
×
2085
                        {
×
2086
                                State = JobState.AwaitingContinuation,
×
2087
                                RemainingDependencies = 1,
×
2088
                        });
×
2089
                        _ = context.Add(job);
×
2090
                        _ = context.Add(new ImmediateJobContinuationEntity
×
2091
                        {
×
2092
                                ChildJobId = job.Id,
×
2093
                                ParentKind = ContinuationParentKind.Job,
×
2094
                                ParentId = current.Id,
×
2095
                                Trigger = addition.Trigger,
×
2096
                        });
×
2097

2098
                        if (addition.Options != ContinuationOptions.BeforeContinuations)
×
2099
                                continue;
2100
                        foreach (var waiter in waiters)
×
2101
                        {
2102
                                _ = context.Add(new ImmediateJobContinuationEntity
×
2103
                                {
×
2104
                                        ChildJobId = waiter.Id,
×
2105
                                        ParentKind = ContinuationParentKind.Job,
×
2106
                                        ParentId = job.Id,
×
2107
                                        Trigger = ContinuationTrigger.Success,
×
2108
                                });
×
2109
                                waiter.RemainingDependencies++;
×
2110
                                waiter.ConcurrencyStamp = Guid.NewGuid();
×
2111
                        }
2112
                }
2113
        }
×
2114

2115
        private static async Task<List<ImmediateJobEntity>> GetActiveWaitersAsync(
2116
                TContext context,
2117
                string currentJobId,
2118
                CancellationToken cancellationToken
2119
        )
2120
        {
2121
                var waiterIds = await context.Set<ImmediateJobContinuationEntity>()
×
2122
                        .Where(edge => edge.ParentKind == ContinuationParentKind.Job && edge.ParentId == currentJobId)
×
2123
                        .Select(edge => edge.ChildJobId)
×
2124
                        .Distinct()
×
2125
                        .ToArrayAsync(cancellationToken)
×
2126
                        .ConfigureAwait(false);
×
2127
                return waiterIds.Length == 0
×
2128
                        ? []
×
2129
                        : await context.Set<ImmediateJobEntity>()
×
2130
                                .Where(job => waiterIds.Contains(job.Id) && job.State == JobState.AwaitingContinuation)
×
2131
                                .ToListAsync(cancellationToken)
×
2132
                                .ConfigureAwait(false);
×
2133
        }
2134

2135
        private static async Task PropagateTerminalAsync(
2136
                TContext context,
2137
                ImmediateJobEntity terminalJob,
2138
                DateTimeOffset now,
2139
                CancellationToken cancellationToken
2140
        )
2141
        {
2142
                var parents = new Queue<(ContinuationParentKind Kind, string Id, ContinuationParentOutcome Outcome)>();
5✔
2143
                var processed = new HashSet<(ContinuationParentKind Kind, string Id)>();
5✔
2144
                parents.Enqueue((
5✔
2145
                        ContinuationParentKind.Job,
5✔
2146
                        terminalJob.Id,
5✔
2147
                        GetParentOutcome(terminalJob.State)
5✔
2148
                ));
5✔
2149
                await UpdateBatchForTerminalJobAsync(context, terminalJob, now, parents, cancellationToken).ConfigureAwait(false);
5✔
2150

2151
                while (parents.TryDequeue(out var parent))
5✔
2152
                {
2153
                        if (!processed.Add((parent.Kind, parent.Id)))
5✔
2154
                                continue;
2155
                        var edges = await context.Set<ImmediateJobContinuationEntity>()
5✔
2156
                                .Where(edge => edge.ParentKind == parent.Kind
5✔
2157
                                        && edge.ParentId == parent.Id
5✔
2158
                                        && edge.ParentOutcome == ContinuationParentOutcome.Unsettled)
5✔
2159
                                .ToListAsync(cancellationToken)
5✔
2160
                                .ConfigureAwait(false);
5✔
2161
                        foreach (var edge in edges)
5✔
2162
                        {
2163
                                edge.ParentOutcome = parent.Outcome;
5✔
2164
                                var child = await context.Set<ImmediateJobEntity>()
5✔
2165
                                        .SingleOrDefaultAsync(job => job.Id == edge.ChildJobId, cancellationToken)
5✔
2166
                                        .ConfigureAwait(false);
5✔
2167
                                if (child is null || IsTerminal(child.State))
5✔
2168
                                        continue;
2169

2170
                                if (child.State != JobState.AwaitingContinuation || child.RemainingDependencies <= 0)
5✔
2171
                                        continue;
2172
                                child.RemainingDependencies--;
5✔
2173
                                if (parent.Outcome == ContinuationParentOutcome.Failed)
5✔
2174
                                        child.FailedDependencies++;
5✔
2175
                                if (child.RemainingDependencies == 0)
5✔
2176
                                {
2177
                                        var skip = await ShouldSkipSettledContinuationAsync(
5✔
2178
                                                context,
5✔
2179
                                                child.Id,
5✔
2180
                                                cancellationToken
5✔
2181
                                        ).ConfigureAwait(false);
5✔
2182
                                        if (skip)
5✔
2183
                                        {
2184
                                                child.State = JobState.Skipped;
5✔
2185
                                                child.CompletedAt = now;
5✔
2186
                                                parents.Enqueue((ContinuationParentKind.Job, child.Id, ContinuationParentOutcome.Other));
5✔
2187
                                                await UpdateBatchForTerminalJobAsync(context, child, now, parents, cancellationToken).ConfigureAwait(false);
5✔
2188
                                        }
2189
                                        else
2190
                                        {
2191
                                                child.State = child.DueAt <= now ? JobState.Pending : JobState.Scheduled;
5✔
2192
                                        }
2193
                                }
2194

2195
                                child.ConcurrencyStamp = Guid.NewGuid();
5✔
2196
                        }
4✔
2197
                }
4✔
2198
        }
5✔
2199

2200
        private static async Task<bool> ShouldSkipSettledContinuationAsync(
2201
                TContext context,
2202
                string childJobId,
2203
                CancellationToken cancellationToken
2204
        )
2205
        {
2206
                var edges = await context.Set<ImmediateJobContinuationEntity>()
5✔
2207
                        .Where(edge => edge.ChildJobId == childJobId)
5✔
2208
                        .ToListAsync(cancellationToken)
5✔
2209
                        .ConfigureAwait(false);
5✔
2210
                var requiresFailure = false;
5✔
2211
                var anyFailed = false;
5✔
2212
                foreach (var edge in edges)
5✔
2213
                {
2214
                        if (edge.Trigger == ContinuationTrigger.Success
5✔
2215
                                && edge.ParentOutcome != ContinuationParentOutcome.Succeeded)
5✔
2216
                        {
2217
                                return true;
1✔
2218
                        }
2219

2220
                        requiresFailure |= edge.Trigger == ContinuationTrigger.Failure;
5✔
2221
                        anyFailed |= edge.ParentOutcome == ContinuationParentOutcome.Failed;
5✔
2222
                }
2223

2224
                return requiresFailure && !anyFailed;
5✔
2225
        }
4✔
2226

2227
        private static async Task UpdateBatchForTerminalJobAsync(
2228
                TContext context,
2229
                ImmediateJobEntity job,
2230
                DateTimeOffset now,
2231
                Queue<(ContinuationParentKind Kind, string Id, ContinuationParentOutcome Outcome)> parents,
2232
                CancellationToken cancellationToken
2233
        )
2234
        {
2235
                if (job.BatchId is not { } batchId)
5✔
2236
                        return;
5✔
2237
                var batch = await context.Set<ImmediateJobBatchEntity>()
5✔
2238
                        .SingleAsync(item => item.Id == batchId, cancellationToken)
5✔
2239
                        .ConfigureAwait(false);
5✔
2240
                batch.PendingCount = Math.Max(0, batch.PendingCount - 1);
5✔
2241
                switch (job.State)
5✔
2242
                {
2243
                        case JobState.Succeeded:
2244
                                batch.SucceededCount++;
5✔
2245
                                break;
5✔
2246
                        case JobState.Failed:
2247
                                batch.FailedCount++;
×
2248
                                break;
×
2249
                        case JobState.Cancelled:
2250
                                batch.CancelledCount++;
5✔
2251
                                break;
5✔
2252
                        case JobState.Skipped:
2253
                                batch.SkippedCount++;
1✔
2254
                                break;
1✔
2255
                        case JobState.AwaitingContinuation:
2256
                        case JobState.AwaitingParameters:
2257
                        case JobState.Scheduled:
2258
                        case JobState.Pending:
2259
                        case JobState.Active:
2260
                                throw new ImmediateJobException($"Job '{job.Id}' is not terminal.");
×
2261
                        default:
2262
                                throw new ArgumentOutOfRangeException(nameof(job), job.State, "Unknown job state.");
×
2263
                }
2264

2265
                batch.ConcurrencyStamp = Guid.NewGuid();
5✔
2266
                if (batch.PendingCount != 0)
5✔
2267
                        return;
5✔
2268
                batch.State = GetTerminalBatchState(batch.FailedCount, batch.CancelledCount);
5✔
2269
                batch.CompletedAt = now;
5✔
2270
                parents.Enqueue((
5✔
2271
                        ContinuationParentKind.Batch,
5✔
2272
                        batch.Id,
5✔
2273
                        GetParentOutcome(batch.State)
5✔
2274
                ));
5✔
2275
        }
5✔
2276

2277
        private static async Task EvaluateInitialDependenciesAsync(
2278
                TContext context,
2279
                Dictionary<string, ImmediateJobEntity> jobs,
2280
                ImmediateJobContinuationEntity[] edges,
2281
                DateTimeOffset now,
2282
                CancellationToken cancellationToken
2283
        )
2284
        {
2285
                var externalJobIds = edges
5✔
2286
                        .Where(edge => edge.ParentKind == ContinuationParentKind.Job && !jobs.ContainsKey(edge.ParentId))
5✔
2287
                        .Select(static edge => edge.ParentId)
5✔
2288
                        .Distinct(StringComparer.Ordinal)
5✔
2289
                        .Order(StringComparer.Ordinal)
5✔
2290
                        .ToArray();
5✔
2291
                var externalBatchIds = edges
5✔
2292
                        .Where(static edge => edge.ParentKind == ContinuationParentKind.Batch)
5✔
2293
                        .Select(static edge => edge.ParentId)
5✔
2294
                        .Distinct(StringComparer.Ordinal)
5✔
2295
                        .Order(StringComparer.Ordinal)
5✔
2296
                        .ToArray();
5✔
2297
                var externalJobEntities = externalJobIds.Length == 0
5✔
2298
                        ? []
5✔
2299
                        : await context.Set<ImmediateJobEntity>()
5✔
2300
                                .Where(job => externalJobIds.Contains(job.Id))
5✔
2301
                                .OrderBy(static job => job.Id)
5✔
2302
                                .ToListAsync(cancellationToken)
5✔
2303
                                .ConfigureAwait(false);
5✔
2304
                var externalBatchEntities = externalBatchIds.Length == 0
5✔
2305
                        ? []
5✔
2306
                        : await context.Set<ImmediateJobBatchEntity>()
5✔
2307
                                .Where(batch => externalBatchIds.Contains(batch.Id))
5✔
2308
                                .OrderBy(static batch => batch.Id)
5✔
2309
                                .ToListAsync(cancellationToken)
5✔
2310
                                .ConfigureAwait(false);
5✔
2311
                var externalJobs = externalJobEntities.ToDictionary(job => job.Id, StringComparer.Ordinal);
5✔
2312
                var externalBatches = externalBatchEntities.ToDictionary(batch => batch.Id, StringComparer.Ordinal);
5✔
2313
                if (externalJobs.Count != externalJobIds.Length || externalBatches.Count != externalBatchIds.Length)
5✔
2314
                        throw new ImmediateJobException("A continuation parent does not exist.");
5✔
2315
                foreach (var parent in externalJobEntities.Where(parent => !IsTerminal(parent.State)))
5✔
2316
                        parent.ConcurrencyStamp = Guid.NewGuid();
5✔
2317
                foreach (var parent in externalBatchEntities.Where(parent => parent.State == BatchState.Executing))
5✔
2318
                        parent.ConcurrencyStamp = Guid.NewGuid();
4✔
2319

2320
                var incoming = edges.ToLookup(static edge => edge.ChildJobId, StringComparer.Ordinal);
5✔
2321
                var changed = true;
5✔
2322
                while (changed)
5✔
2323
                {
2324
                        changed = false;
5✔
2325
                        foreach (var job in jobs.Values)
5✔
2326
                        {
2327
                                var dependencies = incoming[job.Id];
5✔
2328
                                if (!dependencies.Any() || IsTerminal(job.State))
5✔
2329
                                        continue;
2330
                                var remaining = 0;
5✔
2331
                                var failedDependencies = 0;
5✔
2332
                                var requiresFailure = false;
5✔
2333
                                var violated = false;
5✔
2334
                                foreach (var edge in dependencies)
5✔
2335
                                {
2336
                                        var (terminal, parentSucceeded, parentFailed) = GetParentState(
5✔
2337
                                                edge,
5✔
2338
                                                jobs,
5✔
2339
                                                externalJobs,
5✔
2340
                                                externalBatches
5✔
2341
                                        );
5✔
2342
                                        requiresFailure |= edge.Trigger == ContinuationTrigger.Failure;
5✔
2343
                                        if (!terminal)
5✔
2344
                                        {
2345
                                                remaining++;
5✔
2346
                                                continue;
5✔
2347
                                        }
2348

2349
                                        edge.ParentOutcome = GetParentOutcome(parentSucceeded, parentFailed);
1✔
2350

2351
                                        if (parentFailed)
1✔
2352
                                                failedDependencies++;
×
2353
                                        if (edge.Trigger == ContinuationTrigger.Success && !parentSucceeded)
1✔
2354
                                                violated = true;
×
2355
                                }
2356

2357
                                job.FailedDependencies = failedDependencies;
5✔
2358
                                if (violated || (remaining == 0 && requiresFailure && failedDependencies == 0))
5✔
2359
                                {
2360
                                        job.State = JobState.Skipped;
×
2361
                                        job.RemainingDependencies = 0;
×
2362
                                        job.CompletedAt = now;
×
2363
                                        changed = true;
×
2364
                                }
2365
                                else if (remaining == 0)
5✔
2366
                                {
2367
                                        job.State = job.DueAt <= now ? JobState.Pending : JobState.Scheduled;
1✔
2368
                                        job.RemainingDependencies = 0;
1✔
2369
                                }
2370
                                else
2371
                                {
2372
                                        job.State = JobState.AwaitingContinuation;
5✔
2373
                                        job.RemainingDependencies = remaining;
5✔
2374
                                }
2375
                        }
2376
                }
2377
        }
5✔
2378

2379
        private static (bool Terminal, bool Succeeded, bool Failed) GetParentState(
2380
                ImmediateJobContinuationEntity edge,
2381
                Dictionary<string, ImmediateJobEntity> jobs,
2382
                Dictionary<string, ImmediateJobEntity> externalJobs,
2383
                Dictionary<string, ImmediateJobBatchEntity> externalBatches
2384
        )
2385
        {
2386
                if (edge.ParentKind == ContinuationParentKind.Batch)
5✔
2387
                {
2388
                        var state = externalBatches[edge.ParentId].State;
5✔
2389
                        return (state != BatchState.Executing, state == BatchState.Succeeded, state == BatchState.Failed);
5✔
2390
                }
2391

2392
                var jobState = jobs.TryGetValue(edge.ParentId, out var job) ? job.State : externalJobs[edge.ParentId].State;
5✔
2393
                return (IsTerminal(jobState), jobState == JobState.Succeeded, jobState == JobState.Failed);
5✔
2394
        }
2395

2396
        private static void ThrowIfCyclic(
2397
                HashSet<string> jobIds,
2398
                IReadOnlyList<ImmediateJobContinuationEntity> edges
2399
        )
2400
        {
2401
                var indegree = jobIds.ToDictionary(static id => id, static _ => 0, StringComparer.Ordinal);
5✔
2402
                var children = new Dictionary<string, List<string>>(StringComparer.Ordinal);
5✔
2403
                foreach (var edge in edges.Where(edge =>
5✔
2404
                        edge.ParentKind == ContinuationParentKind.Job && jobIds.Contains(edge.ParentId)))
5✔
2405
                {
2406
                        indegree[edge.ChildJobId]++;
5✔
2407
                        if (!children.TryGetValue(edge.ParentId, out var values))
5✔
2408
                                children[edge.ParentId] = values = [];
5✔
2409
                        values.Add(edge.ChildJobId);
5✔
2410
                }
2411

2412
                var ready = new Queue<string>(indegree.Where(static pair => pair.Value == 0).Select(static pair => pair.Key));
5✔
2413
                var visited = 0;
5✔
2414
                while (ready.TryDequeue(out var parent))
5✔
2415
                {
2416
                        visited++;
5✔
2417
                        if (!children.TryGetValue(parent, out var values))
5✔
2418
                                continue;
2419
                        foreach (var child in values)
5✔
2420
                        {
2421
                                if (--indegree[child] == 0)
5✔
2422
                                        ready.Enqueue(child);
5✔
2423
                        }
2424
                }
2425

2426
                if (visited != jobIds.Count)
5✔
2427
                        throw new ImmediateJobException("The continuation graph contains a dependency cycle.");
×
2428
        }
5✔
2429

2430
        private static bool IsTerminal(JobState state) =>
2431
                state is JobState.Succeeded or JobState.Failed or JobState.Cancelled or JobState.Skipped;
5✔
2432

2433
        private static ContinuationParentOutcome GetParentOutcome(JobState state) => state switch
5✔
2434
        {
5✔
2435
                JobState.Succeeded => ContinuationParentOutcome.Succeeded,
5✔
2436
                JobState.Failed => ContinuationParentOutcome.Failed,
5✔
2437
                JobState.AwaitingContinuation or
5✔
2438
                JobState.AwaitingParameters or
5✔
2439
                JobState.Scheduled or
5✔
2440
                JobState.Pending or
5✔
2441
                JobState.Active or
5✔
2442
                JobState.Cancelled or
5✔
2443
                JobState.Skipped => ContinuationParentOutcome.Other,
5✔
2444
                _ => throw new ArgumentOutOfRangeException(nameof(state), state, "Unknown job state."),
×
2445
        };
5✔
2446

2447
        private static ContinuationParentOutcome GetParentOutcome(BatchState state) => state switch
5✔
2448
        {
5✔
2449
                BatchState.Succeeded => ContinuationParentOutcome.Succeeded,
5✔
2450
                BatchState.Failed => ContinuationParentOutcome.Failed,
×
2451
                BatchState.Executing or BatchState.Cancelled => ContinuationParentOutcome.Other,
5✔
2452
                _ => throw new ArgumentOutOfRangeException(nameof(state), state, "Unknown batch state."),
×
2453
        };
5✔
2454

2455
        private static ContinuationParentOutcome GetParentOutcome(bool succeeded, bool failed) =>
2456
                (succeeded, failed) switch
1✔
2457
                {
1✔
2458
                        (true, _) => ContinuationParentOutcome.Succeeded,
1✔
2459
                        (_, true) => ContinuationParentOutcome.Failed,
×
2460
                        _ => ContinuationParentOutcome.Other,
×
2461
                };
1✔
2462

2463
        private static BatchState GetTerminalBatchState(int failed, int cancelled) =>
2464
                failed != 0 ? BatchState.Failed : cancelled != 0 ? BatchState.Cancelled : BatchState.Succeeded;
5✔
2465

2466
        private async ValueTask MutateRecurringAsync(string name, Action<ImmediateRecurringJobEntity> mutate, CancellationToken cancellationToken)
2467
        {
2468
                ArgumentException.ThrowIfNullOrWhiteSpace(name);
1✔
2469
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
1✔
2470
                var schedule = await context.Set<ImmediateRecurringJobEntity>().FindAsync([name], cancellationToken).ConfigureAwait(false) ?? throw new KeyNotFoundException($"Recurring schedule '{name}' was not found.");
1✔
2471
                mutate(schedule);
×
2472
                schedule.ConcurrencyStamp = Guid.NewGuid();
×
2473
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
2474
        }
×
2475

2476
        private static Task<int> UpdateRecurringAsync(
2477
                TContext context,
2478
                RecurringJobSchedule schedule,
2479
                CancellationToken cancellationToken
2480
        )
2481
        {
2482
                var concurrencyStamp = Guid.NewGuid();
5✔
2483
                return context.Set<ImmediateRecurringJobEntity>()
5✔
2484
                        .Where(entity => entity.Name == schedule.Name && (schedule.IsCodeDefined || !entity.IsCodeDefined))
5✔
2485
                        .ExecuteUpdateAsync(setters => setters
5✔
2486
                                .SetProperty(entity => entity.JobName, schedule.JobName)
5✔
2487
                                .SetProperty(entity => entity.Cron, schedule.Cron)
5✔
2488
                                .SetProperty(entity => entity.TimeZone, schedule.TimeZone)
5✔
2489
                                .SetProperty(entity => entity.IsCodeDefined, schedule.IsCodeDefined)
5✔
2490
                                .SetProperty(entity => entity.NextRunAt, schedule.NextRunAt)
5✔
2491
                                .SetProperty(entity => entity.ConcurrencyStamp, concurrencyStamp),
5✔
2492
                                cancellationToken);
5✔
2493
        }
2494

2495
        private static async Task ThrowIfReplacingCodeDefinedScheduleAsync(
2496
                TContext context,
2497
                RecurringJobSchedule schedule,
2498
                CancellationToken cancellationToken
2499
        )
2500
        {
2501
                if (!schedule.IsCodeDefined && await context.Set<ImmediateRecurringJobEntity>()
5✔
2502
                        .AnyAsync(entity => entity.Name == schedule.Name && entity.IsCodeDefined, cancellationToken)
5✔
2503
                        .ConfigureAwait(false))
5✔
2504
                {
2505
                        throw new ImmediateJobException("Code-defined recurring schedules cannot be replaced by dynamic schedules.");
4✔
2506
                }
2507
        }
5✔
2508

2509
        private static ImmediateJobEntity ToEntity(JobRecord job) => new()
5✔
2510
        {
5✔
2511
                Id = job.Id,
5✔
2512
                QueueName = job.QueueName,
5✔
2513
                JobName = job.JobName,
5✔
2514
                GroupId = job.GroupId,
5✔
2515
                Payload = job.Payload,
5✔
2516
                Context = job.Context,
5✔
2517
                State = job.State,
5✔
2518
                DueAt = job.DueAt,
5✔
2519
                CreatedAt = job.CreatedAt,
5✔
2520
                Attempt = job.Attempt,
5✔
2521
                WorkerId = job.WorkerId,
5✔
2522
                LeaseExpiresAt = job.LeaseExpiresAt,
5✔
2523
                LastError = job.LastError,
5✔
2524
                CompletedAt = job.CompletedAt,
5✔
2525
                RecurringKey = job.RecurringKey,
5✔
2526
                TraceParent = job.TraceParent,
5✔
2527
                TraceState = job.TraceState,
5✔
2528
                ExecutionTraceId = job.ExecutionTraceId,
5✔
2529
                ExecutionSpanId = job.ExecutionSpanId,
5✔
2530
                ExecutionStartedAt = job.ExecutionStartedAt,
5✔
2531
                BatchId = job.BatchId,
5✔
2532
                RemainingDependencies = job.RemainingDependencies,
5✔
2533
                FailedDependencies = job.FailedDependencies,
5✔
2534
                ConcurrencyStamp = Guid.NewGuid(),
5✔
2535
        };
5✔
2536

2537
        private static async Task PrepareAcquisitionExecutionsAsync(
2538
                TContext context,
2539
                ImmediateJobEntity candidate,
2540
                string workerId,
2541
                DateTimeOffset acquiredAt,
2542
                CancellationToken cancellationToken
2543
        )
2544
        {
2545
                var previous = await GetOrMaterializeExecutionAsync(context, candidate, cancellationToken).ConfigureAwait(false);
5✔
2546
                if (candidate.State == JobState.Active && previous is not null)
5✔
2547
                {
2548
                        previous.State = JobExecutionState.Interrupted;
5✔
2549
                        previous.CompletedAt = candidate.LeaseExpiresAt;
5✔
2550
                        previous.Error = null;
5✔
2551
                }
2552

2553
                _ = context.Add(new ImmediateJobExecutionEntity
5✔
2554
                {
5✔
2555
                        JobId = candidate.Id,
5✔
2556
                        Attempt = candidate.Attempt + 1,
5✔
2557
                        State = JobExecutionState.Active,
5✔
2558
                        WorkerId = workerId,
5✔
2559
                        AcquiredAt = acquiredAt,
5✔
2560
                });
5✔
2561
        }
5✔
2562

2563
        private static async Task<ImmediateJobExecutionEntity?> GetOrMaterializeExecutionAsync(
2564
                TContext context,
2565
                ImmediateJobEntity job,
2566
                CancellationToken cancellationToken
2567
        )
2568
        {
2569
                if (job.Attempt <= 0)
5✔
2570
                        return null;
5✔
2571
                var execution = await context.Set<ImmediateJobExecutionEntity>()
5✔
2572
                        .SingleOrDefaultAsync(
5✔
2573
                                item => item.JobId == job.Id && item.Attempt == job.Attempt,
5✔
2574
                                cancellationToken
5✔
2575
                        )
5✔
2576
                        .ConfigureAwait(false);
5✔
2577
                if (execution is not null)
5✔
2578
                        return execution;
5✔
2579

2580
                var synthetic = JobExecutionRecord.CreateSynthetic(ToRecord(job));
1✔
2581
                if (synthetic is null)
1✔
2582
                        return null;
×
2583
                execution = ToEntity(synthetic);
1✔
2584
                _ = context.Add(execution);
1✔
2585
                return execution;
1✔
2586
        }
4✔
2587

2588
        private static ImmediateJobExecutionEntity ToEntity(JobExecutionRecord execution) => new()
1✔
2589
        {
1✔
2590
                JobId = execution.JobId,
1✔
2591
                Attempt = execution.Attempt,
1✔
2592
                State = execution.State,
1✔
2593
                WorkerId = execution.WorkerId,
1✔
2594
                AcquiredAt = execution.AcquiredAt,
1✔
2595
                ExecutionStartedAt = execution.ExecutionStartedAt,
1✔
2596
                CompletedAt = execution.CompletedAt,
1✔
2597
                ExecutionTraceId = execution.ExecutionTraceId,
1✔
2598
                ExecutionSpanId = execution.ExecutionSpanId,
1✔
2599
                Error = execution.Error,
1✔
2600
                IsSynthetic = execution.IsSynthetic,
1✔
2601
        };
1✔
2602

2603
        private static JobExecutionRecord ToRecord(ImmediateJobExecutionEntity execution) => new()
1✔
2604
        {
1✔
2605
                JobId = execution.JobId,
1✔
2606
                Attempt = execution.Attempt,
1✔
2607
                State = execution.State,
1✔
2608
                WorkerId = execution.WorkerId,
1✔
2609
                AcquiredAt = execution.AcquiredAt,
1✔
2610
                ExecutionStartedAt = execution.ExecutionStartedAt,
1✔
2611
                CompletedAt = execution.CompletedAt,
1✔
2612
                ExecutionTraceId = execution.ExecutionTraceId,
1✔
2613
                ExecutionSpanId = execution.ExecutionSpanId,
1✔
2614
                Error = execution.Error,
1✔
2615
                IsSynthetic = execution.IsSynthetic,
1✔
2616
        };
1✔
2617

2618
        private static ImmediateJobContinuationEntity ToEntity(JobContinuationEdge edge)
2619
        {
2620
                var hasJobParent = edge.ParentJobId is not null;
5✔
2621
                var hasBatchParent = edge.ParentBatchId is not null;
5✔
2622
                if (hasJobParent == hasBatchParent)
5✔
2623
                        throw new ImmediateJobException("A continuation edge must identify exactly one parent job or batch.");
×
2624
                return new()
5✔
2625
                {
5✔
2626
                        ChildJobId = edge.ChildJobId,
5✔
2627
                        ParentKind = hasJobParent ? ContinuationParentKind.Job : ContinuationParentKind.Batch,
5✔
2628
                        ParentId = edge.ParentJobId ?? edge.ParentBatchId!,
5✔
2629
                        Trigger = edge.Trigger,
5✔
2630
                };
5✔
2631
        }
2632

2633
        private static ImmediateJobEntity Copy(ImmediateJobEntity job) => new()
5✔
2634
        {
5✔
2635
                Id = job.Id,
5✔
2636
                QueueName = job.QueueName,
5✔
2637
                JobName = job.JobName,
5✔
2638
                GroupId = job.GroupId,
5✔
2639
                Payload = job.Payload,
5✔
2640
                Context = job.Context,
5✔
2641
                State = job.State,
5✔
2642
                DueAt = job.DueAt,
5✔
2643
                CreatedAt = job.CreatedAt,
5✔
2644
                Attempt = job.Attempt,
5✔
2645
                WorkerId = job.WorkerId,
5✔
2646
                LeaseExpiresAt = job.LeaseExpiresAt,
5✔
2647
                LastError = job.LastError,
5✔
2648
                CompletedAt = job.CompletedAt,
5✔
2649
                RecurringKey = job.RecurringKey,
5✔
2650
                TraceParent = job.TraceParent,
5✔
2651
                TraceState = job.TraceState,
5✔
2652
                ExecutionTraceId = job.ExecutionTraceId,
5✔
2653
                ExecutionSpanId = job.ExecutionSpanId,
5✔
2654
                ExecutionStartedAt = job.ExecutionStartedAt,
5✔
2655
                BatchId = job.BatchId,
5✔
2656
                RemainingDependencies = job.RemainingDependencies,
5✔
2657
                FailedDependencies = job.FailedDependencies,
5✔
2658
                ConcurrencyStamp = job.ConcurrencyStamp,
5✔
2659
        };
5✔
2660

2661
        private static JobRecord ToRecord(ImmediateJobEntity job) => new()
5✔
2662
        {
5✔
2663
                Id = job.Id,
5✔
2664
                QueueName = job.QueueName,
5✔
2665
                JobName = job.JobName,
5✔
2666
                GroupId = job.GroupId,
5✔
2667
                Payload = job.Payload,
5✔
2668
                Context = job.Context,
5✔
2669
                State = job.State,
5✔
2670
                DueAt = job.DueAt,
5✔
2671
                CreatedAt = job.CreatedAt,
5✔
2672
                Attempt = job.Attempt,
5✔
2673
                WorkerId = job.WorkerId,
5✔
2674
                LeaseExpiresAt = job.LeaseExpiresAt,
5✔
2675
                LastError = job.LastError,
5✔
2676
                CompletedAt = job.CompletedAt,
5✔
2677
                RecurringKey = job.RecurringKey,
5✔
2678
                TraceParent = job.TraceParent,
5✔
2679
                TraceState = job.TraceState,
5✔
2680
                ExecutionTraceId = job.ExecutionTraceId,
5✔
2681
                ExecutionSpanId = job.ExecutionSpanId,
5✔
2682
                ExecutionStartedAt = job.ExecutionStartedAt,
5✔
2683
                BatchId = job.BatchId,
5✔
2684
                RemainingDependencies = job.RemainingDependencies,
5✔
2685
                FailedDependencies = job.FailedDependencies,
5✔
2686
        };
5✔
2687

2688
        private static BatchStatus ToStatus(ImmediateJobBatchEntity batch) => new(
5✔
2689
                batch.Id,
5✔
2690
                batch.State,
5✔
2691
                batch.TotalJobs,
5✔
2692
                batch.SucceededCount,
5✔
2693
                batch.FailedCount,
5✔
2694
                batch.CancelledCount,
5✔
2695
                batch.SkippedCount,
5✔
2696
                batch.PendingCount,
5✔
2697
                batch.CreatedAt,
5✔
2698
                batch.StartedAt,
5✔
2699
                batch.CompletedAt,
5✔
2700
                BatchStatus.CalculateFractionSettled(batch.TotalJobs, batch.PendingCount)
5✔
2701
        );
5✔
2702

2703
        private static JobContinuationEdge ToContinuationEdge(ImmediateJobContinuationEntity edge) => new()
5✔
2704
        {
5✔
2705
                ChildJobId = edge.ChildJobId,
5✔
2706
                ParentJobId = edge.ParentKind == ContinuationParentKind.Job ? edge.ParentId : null,
5✔
2707
                ParentBatchId = edge.ParentKind == ContinuationParentKind.Batch ? edge.ParentId : null,
5✔
2708
                Trigger = edge.Trigger,
5✔
2709
        };
5✔
2710

2711
        private static BatchGraphEdge ToGraphEdge(ImmediateJobContinuationEntity edge) => new(
5✔
2712
                edge.ChildJobId,
5✔
2713
                edge.ParentKind == ContinuationParentKind.Job ? edge.ParentId : null,
5✔
2714
                edge.ParentKind == ContinuationParentKind.Batch ? edge.ParentId : null,
5✔
2715
                edge.Trigger
5✔
2716
        );
5✔
2717

2718
        private static ImmediateRecurringJobEntity ToEntity(RecurringJobSchedule schedule) => new()
5✔
2719
        {
5✔
2720
                Name = schedule.Name,
5✔
2721
                JobName = schedule.JobName,
5✔
2722
                Cron = schedule.Cron,
5✔
2723
                TimeZone = schedule.TimeZone,
5✔
2724
                IsCodeDefined = schedule.IsCodeDefined,
5✔
2725
                IsPaused = schedule.IsPaused,
5✔
2726
                NextRunAt = schedule.NextRunAt,
5✔
2727
                LastRunAt = schedule.LastRunAt,
5✔
2728
                ConcurrencyStamp = Guid.NewGuid(),
5✔
2729
        };
5✔
2730
}
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