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

ImmediatePlatform / Immediate.Jobs / 30306869135

27 Jul 2026 09:26PM UTC coverage: 70.257%. First build
30306869135

Pull #34

github

web-flow
Merge dbd96d86d into c4ed190c8
Pull Request #34: Fair queues

782 of 864 new or added lines in 10 files covered. (90.51%)

5159 of 7343 relevant lines covered (70.26%)

2.31 hits per line

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

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

4
namespace Immediate.Jobs.EntityFrameworkCore;
5

6
/// <summary>An optimistic-concurrency EF Core implementation of <see cref="IJobStorage"/>.</summary>
7
/// <typeparam name="TContext">The application context containing the Immediate.Jobs model.</typeparam>
8
public sealed class EntityFrameworkCoreJobStorage<TContext>(
5✔
9
        IDbContextFactory<TContext> contextFactory,
5✔
10
        TimeProvider? timeProvider = null
5✔
11
) : IRecurringJobStorage, IJobGraphStorage, IJobStorageReplica
5✔
12
        where TContext : DbContext
13
{
14
        private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
5✔
15

16
        /// <inheritdoc />
17
        public ValueTask DisposeAsync() => ValueTask.CompletedTask;
5✔
18

19
        /// <inheritdoc />
20
        public async ValueTask InitializeAsync(CancellationToken cancellationToken = default)
21
        {
22
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
23
                _ = context.Model.FindEntityType(typeof(ImmediateJobEntity))
4✔
24
                        ?? throw new ImmediateJobException("Immediate.Jobs entities are not configured. Call modelBuilder.AddImmediateJobs() from OnModelCreating.");
4✔
25
        }
4✔
26

27
        /// <inheritdoc />
28
        public async ValueTask EnqueueAsync(JobRecord job, CancellationToken cancellationToken = default)
29
        {
30
                ArgumentNullException.ThrowIfNull(job);
5✔
31
                if (job.GroupId is { } groupId)
5✔
32
                        await TryRemoveFairQueueCursorAsync(job.QueueName, groupId, cancellationToken).ConfigureAwait(false);
5✔
33
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
34
                _ = context.Set<ImmediateJobEntity>().Add(ToEntity(job));
5✔
35
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
36
        }
5✔
37

38
        /// <inheritdoc />
39
        public async ValueTask EnqueueContinuationAsync(
40
                JobRecord job,
41
                IReadOnlyList<JobContinuationEdge> edges,
42
                CancellationToken cancellationToken = default
43
        )
44
        {
45
                ArgumentNullException.ThrowIfNull(job);
5✔
46
                ArgumentNullException.ThrowIfNull(edges);
5✔
47
                await ExecuteGraphInsertAsync(batch: null, [job], edges, cancellationToken).ConfigureAwait(false);
5✔
48
        }
4✔
49

50
        /// <inheritdoc />
51
        public async ValueTask EnqueueBatchAsync(
52
                JobBatchRecord batch,
53
                IReadOnlyList<JobRecord> jobs,
54
                IReadOnlyList<JobContinuationEdge> edges,
55
                CancellationToken cancellationToken = default
56
        )
57
        {
58
                ArgumentNullException.ThrowIfNull(batch);
5✔
59
                ArgumentNullException.ThrowIfNull(jobs);
5✔
60
                ArgumentNullException.ThrowIfNull(edges);
5✔
61
                if (jobs.Count == 0)
5✔
62
                        throw new ImmediateJobException("IJOB016: An atomic batch cannot be committed without jobs.");
×
63
                await ExecuteGraphInsertAsync(batch, jobs, edges, cancellationToken).ConfigureAwait(false);
5✔
64
        }
5✔
65

66
        private async ValueTask ExecuteGraphInsertAsync(
67
                JobBatchRecord? batch,
68
                IReadOnlyList<JobRecord> jobs,
69
                IReadOnlyList<JobContinuationEdge> edges,
70
                CancellationToken cancellationToken
71
        )
72
        {
73
                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
74
                var strategy = strategyContext.Database.CreateExecutionStrategy();
5✔
75
                await strategy.ExecuteAsync(
5✔
76
                        operationCancellationToken => InsertGraphCoreAsync(batch, jobs, edges, operationCancellationToken),
5✔
77
                        cancellationToken
5✔
78
                ).ConfigureAwait(false);
5✔
79
        }
5✔
80

81
        private async Task InsertGraphCoreAsync(
82
                JobBatchRecord? batch,
83
                IReadOnlyList<JobRecord> jobs,
84
                IReadOnlyList<JobContinuationEdge> edges,
85
                CancellationToken cancellationToken
86
        )
87
        {
88
                var jobIds = jobs.Select(static job => job.Id).ToHashSet(StringComparer.Ordinal);
5✔
89
                if (jobIds.Count != jobs.Count)
5✔
90
                        throw new ImmediateJobException("A batch or continuation insert contains duplicate job identifiers.");
×
91
                if (batch is not null && jobs.Any(job => job.BatchId != batch.Id))
5✔
92
                        throw new ImmediateJobException("Every atomic batch member must carry the committed batch identifier.");
×
93

94
                var edgeEntities = edges.Select(ToEntity).ToArray();
5✔
95
                if (edgeEntities.Any(edge => !jobIds.Contains(edge.ChildJobId)))
5✔
96
                        throw new ImmediateJobException("Every continuation edge must target a job inserted by the same operation.");
×
97
                if (edgeEntities.DistinctBy(static edge => (edge.ChildJobId, edge.ParentKind, edge.ParentId)).Count() != edgeEntities.Length)
5✔
98
                        throw new ImmediateJobException("Duplicate continuation edges are not allowed.");
×
99
                ThrowIfCyclic(jobIds, edgeEntities);
5✔
100

101
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
102
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
103
                var jobEntities = jobs.Select(ToEntity).ToDictionary(static job => job.Id, StringComparer.Ordinal);
5✔
104
                await EvaluateInitialDependenciesAsync(
5✔
105
                        context,
5✔
106
                        jobEntities,
5✔
107
                        edgeEntities,
5✔
108
                        _timeProvider.GetUtcNow(),
5✔
109
                        cancellationToken
5✔
110
                ).ConfigureAwait(false);
5✔
111

112
                if (batch is not null)
5✔
113
                {
114
                        var terminal = jobEntities.Values.Where(static job => IsTerminal(job.State)).ToArray();
5✔
115
                        var pending = jobEntities.Count - terminal.Length;
5✔
116
                        var succeeded = terminal.Count(static job => job.State == JobState.Succeeded);
5✔
117
                        var failed = terminal.Count(static job => job.State == JobState.Failed);
5✔
118
                        var cancelled = terminal.Count(static job => job.State == JobState.Cancelled);
5✔
119
                        _ = context.Add(new ImmediateJobBatchEntity
5✔
120
                        {
5✔
121
                                Id = batch.Id,
5✔
122
                                CreatedAt = batch.CreatedAt,
5✔
123
                                TotalJobs = jobEntities.Count,
5✔
124
                                PendingCount = pending,
5✔
125
                                SucceededCount = succeeded,
5✔
126
                                FailedCount = failed,
5✔
127
                                CancelledCount = cancelled,
5✔
128
                                StartedAt = batch.StartedAt,
5✔
129
                                CompletedAt = pending == 0 ? batch.CompletedAt ?? _timeProvider.GetUtcNow() : null,
5✔
130
                                State = pending == 0 ? GetTerminalBatchState(failed, cancelled) : BatchState.Executing,
5✔
131
                                ConcurrencyStamp = Guid.NewGuid(),
5✔
132
                        });
5✔
133
                }
134

135
                context.AddRange(jobEntities.Values);
5✔
136
                context.AddRange(edgeEntities);
5✔
137
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
138
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
5✔
139
        }
5✔
140

141
        /// <inheritdoc />
142
        public async ValueTask<IReadOnlyList<JobRecord>> AcquireDueJobsAsync(
143
                JobAcquisitionRequest request,
144
                CancellationToken cancellationToken = default
145
        )
146
        {
147
                ArgumentNullException.ThrowIfNull(request);
5✔
148
                ArgumentException.ThrowIfNullOrWhiteSpace(request.WorkerId);
5✔
149
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(request.Lease, TimeSpan.Zero);
5✔
150
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(request.BatchSize, 0);
5✔
151
                if (request.FairQueues is not null)
5✔
152
                        return await AcquireDueJobsFairAsync(request, cancellationToken).ConfigureAwait(false);
5✔
153

154
                var now = _timeProvider.GetUtcNow();
5✔
155
                var acquired = new List<JobRecord>(request.BatchSize);
5✔
156
                foreach (var queue in request.Queues)
5✔
157
                {
158
                        var queueCapacity = Math.Min(queue.Capacity, request.BatchSize - acquired.Count);
5✔
159
                        if (queueCapacity <= 0)
5✔
160
                                continue;
161

162
                        var jobCapacities = queue.JobCapacities.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal);
5✔
163
                        while (queueCapacity > 0)
5✔
164
                        {
165
                                var eligibleNames = jobCapacities.Where(static pair => pair.Value > 0).Select(static pair => pair.Key).ToArray();
5✔
166
                                if (eligibleNames.Length == 0)
5✔
167
                                        break;
168

169
                                await using var readContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
170
                                var candidates = await readContext.Set<ImmediateJobEntity>()
5✔
171
                                        .AsNoTracking()
5✔
172
                                        .Where(job => job.QueueName == queue.QueueName && eligibleNames.Contains(job.JobName) &&
5✔
173
                                                (((job.State == JobState.Scheduled || job.State == JobState.Pending) && job.DueAt <= now)
5✔
174
                                                        || (job.State == JobState.Active && job.LeaseExpiresAt <= now)))
5✔
175
                                        .OrderBy(job => job.DueAt)
5✔
176
                                        .ThenBy(job => job.CreatedAt)
5✔
177
                                        .ThenBy(job => job.Id)
5✔
178
                                        .Take(queueCapacity)
5✔
179
                                        .ToListAsync(cancellationToken)
5✔
180
                                        .ConfigureAwait(false);
5✔
181
                                if (candidates.Count == 0)
5✔
182
                                        break;
183

184
                                var selected = new List<ImmediateJobEntity>(candidates.Count);
5✔
185
                                var selectionCapacities = new Dictionary<string, int>(jobCapacities, StringComparer.Ordinal);
5✔
186
                                foreach (var candidate in candidates)
5✔
187
                                {
188
                                        if (selectionCapacities[candidate.JobName] <= 0)
5✔
189
                                                continue;
190
                                        selectionCapacities[candidate.JobName]--;
5✔
191
                                        selected.Add(candidate);
5✔
192
                                }
193

194
                                var claimed = await AcquireCandidatesAsync(
5✔
195
                                        selected,
5✔
196
                                        request.WorkerId,
5✔
197
                                        request.Lease,
5✔
198
                                        now,
5✔
199
                                        cancellationToken
5✔
200
                                ).ConfigureAwait(false);
5✔
201
                                foreach (var job in claimed)
5✔
202
                                {
203
                                        jobCapacities[job.JobName]--;
5✔
204
                                        queueCapacity--;
5✔
205
                                        acquired.Add(job);
5✔
206
                                }
207

208
                                if (claimed.Count == 0)
5✔
209
                                        break;
210
                        }
5✔
211
                }
5✔
212

213
                return acquired;
5✔
214
        }
5✔
215

216
        private async ValueTask<IReadOnlyList<JobRecord>> AcquireDueJobsFairAsync(
217
                JobAcquisitionRequest request,
218
                CancellationToken cancellationToken
219
        )
220
        {
221
                var now = _timeProvider.GetUtcNow();
5✔
222
                var acquired = new List<JobRecord>(request.BatchSize);
5✔
223
                foreach (var queue in request.Queues)
5✔
224
                {
225
                        var queueCapacity = Math.Min(queue.Capacity, request.BatchSize - acquired.Count);
5✔
226
                        if (queueCapacity <= 0)
5✔
227
                                continue;
228

229
                        var jobCapacities = queue.JobCapacities.ToDictionary(
5✔
230
                                static pair => pair.Key,
5✔
231
                                static pair => pair.Value,
5✔
232
                                StringComparer.Ordinal
5✔
233
                        );
5✔
234
                        while (queueCapacity > 0)
5✔
235
                        {
236
                                var eligibleNames = jobCapacities
5✔
237
                                        .Where(static pair => pair.Value > 0)
5✔
238
                                        .Select(static pair => pair.Key)
5✔
239
                                        .ToArray();
5✔
240
                                if (eligibleNames.Length == 0)
5✔
241
                                        break;
242

243
                                await using var readContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
244
                                var eligibleQuery = readContext.Set<ImmediateJobEntity>()
5✔
245
                                        .AsNoTracking()
5✔
246
                                        .Where(job => job.QueueName == queue.QueueName && eligibleNames.Contains(job.JobName) &&
5✔
247
                                                (((job.State == JobState.Scheduled || job.State == JobState.Pending) && job.DueAt <= now)
5✔
248
                                                        || (job.State == JobState.Active && job.LeaseExpiresAt <= now)));
5✔
249
                                if (!await eligibleQuery
5✔
250
                                        .AnyAsync(static job => job.GroupId != null, cancellationToken)
5✔
251
                                        .ConfigureAwait(false))
5✔
252
                                {
253
                                        var claimed = await AcquireFairFastPathAsync(
4✔
254
                                                queue.QueueName,
4✔
255
                                                jobCapacities,
4✔
256
                                                queueCapacity,
4✔
257
                                                request.WorkerId,
4✔
258
                                                request.Lease,
4✔
259
                                                now,
4✔
260
                                                cancellationToken
4✔
261
                                        ).ConfigureAwait(false);
4✔
262
                                        queueCapacity -= claimed.Count;
4✔
263
                                        acquired.AddRange(claimed);
4✔
264
                                        break;
4✔
265
                                }
266

267
                                var groupedHeads = await eligibleQuery
5✔
268
                                        .Where(static job => job.GroupId != null)
5✔
269
                                        .GroupBy(static job => job.GroupId)
5✔
270
                                        .Select(static group => group
5✔
271
                                                .OrderBy(job => job.DueAt)
5✔
272
                                                .ThenBy(job => job.CreatedAt)
5✔
273
                                                .ThenBy(job => job.Id)
5✔
274
                                                .First())
5✔
275
                                        .ToListAsync(cancellationToken)
5✔
276
                                        .ConfigureAwait(false);
5✔
277
                                var ungroupedHead = await eligibleQuery
5✔
278
                                        .Where(static job => job.GroupId == null)
5✔
279
                                        .OrderBy(job => job.DueAt)
5✔
280
                                        .ThenBy(job => job.CreatedAt)
5✔
281
                                        .ThenBy(job => job.Id)
5✔
282
                                        .FirstOrDefaultAsync(cancellationToken)
5✔
283
                                        .ConfigureAwait(false);
5✔
284
                                if (groupedHeads.Count == 0)
5✔
285
                                {
NEW
286
                                        var claimed = await AcquireFairFastPathAsync(
×
NEW
287
                                                queue.QueueName,
×
NEW
288
                                                jobCapacities,
×
NEW
289
                                                queueCapacity,
×
NEW
290
                                                request.WorkerId,
×
NEW
291
                                                request.Lease,
×
NEW
292
                                                now,
×
NEW
293
                                                cancellationToken
×
NEW
294
                                        ).ConfigureAwait(false);
×
NEW
295
                                        queueCapacity -= claimed.Count;
×
NEW
296
                                        acquired.AddRange(claimed);
×
NEW
297
                                        break;
×
298
                                }
299

300
                                var activeQuery = readContext.Set<ImmediateJobEntity>()
5✔
301
                                        .AsNoTracking()
5✔
302
                                        .Where(job => job.QueueName == queue.QueueName
5✔
303
                                                && job.State == JobState.Active
5✔
304
                                                && job.LeaseExpiresAt > now);
5✔
305
                                var totalInflight = await activeQuery
5✔
306
                                        .CountAsync(cancellationToken)
5✔
307
                                        .ConfigureAwait(false);
5✔
308
                                var groupedHeadIds = groupedHeads.Select(static job => job.Id).ToArray();
5✔
309
                                var cursorQuery = readContext.Set<ImmediateFairQueueGroupEntity>()
5✔
310
                                        .AsNoTracking()
5✔
311
                                        .Where(group => group.QueueName == queue.QueueName);
5✔
312
                                var groupStateQuery = eligibleQuery
5✔
313
                                        .Where(job => groupedHeadIds.Contains(job.Id));
5✔
314
                                var groupStates = request.FairQueues!.GroupRoundRobin
5✔
315
                                        ? await groupStateQuery
5✔
316
                                                .Select(job => new FairQueueCandidateState(
5✔
317
                                                        job.Id,
5✔
318
                                                        activeQuery.Count(active => active.GroupId == job.GroupId),
5✔
319
                                                        cursorQuery
5✔
320
                                                                .Where(cursor => cursor.GroupId == job.GroupId)
5✔
321
                                                                .Select(static cursor => cursor.LastServedSequence)
5✔
322
                                                                .FirstOrDefault()
5✔
323
                                                ))
5✔
324
                                                .ToDictionaryAsync(static state => state.JobId, cancellationToken)
5✔
325
                                                .ConfigureAwait(false)
5✔
326
                                        : await groupStateQuery
5✔
327
                                                .Select(job => new FairQueueCandidateState(
5✔
328
                                                        job.Id,
5✔
329
                                                        activeQuery.Count(active => active.GroupId == job.GroupId),
5✔
330
                                                        0
5✔
331
                                                ))
5✔
332
                                                .ToDictionaryAsync(static state => state.JobId, cancellationToken)
4✔
333
                                                .ConfigureAwait(false);
5✔
334
                                var nextSequence = 0L;
5✔
335
                                if (request.FairQueues.GroupRoundRobin)
5✔
336
                                {
337
                                        var maxSequence = await cursorQuery
5✔
338
                                                .MaxAsync(static group => (long?)group.LastServedSequence, cancellationToken)
5✔
339
                                                .ConfigureAwait(false);
5✔
340
                                        nextSequence = (maxSequence ?? 0) + 1;
5✔
341
                                }
342

343
                                var candidates = ungroupedHead is null ? groupedHeads : [.. groupedHeads, ungroupedHead];
5✔
344
                                var ranked = candidates.Select(job =>
5✔
345
                                {
5✔
346
                                        var state = job.GroupId is null ? null : groupStates[job.Id];
5✔
347
                                        var noisy = IsNoisy(job.GroupId, state?.Inflight ?? 0, totalInflight, request.FairQueues);
5✔
348
                                        return new
5✔
349
                                        {
5✔
350
                                                Job = job,
5✔
351
                                                Noisy = noisy,
5✔
352
                                                NoisyInflight = noisy ? state!.Inflight : 0,
5✔
353
                                                LastServedSequence = state?.LastServedSequence ?? 0,
5✔
354
                                        };
5✔
355
                                });
5✔
356
                                var selected = ranked
5✔
357
                                        .OrderBy(static candidate => candidate.Noisy)
5✔
358
                                        .ThenBy(static candidate => candidate.NoisyInflight)
5✔
359
                                        .ThenBy(candidate => request.FairQueues.GroupRoundRobin
5✔
360
                                                ? candidate.LastServedSequence
5✔
361
                                                : 0)
5✔
362
                                        .ThenBy(static candidate => candidate.Job.DueAt)
5✔
363
                                        .ThenBy(static candidate => candidate.Job.CreatedAt)
5✔
364
                                        .ThenBy(static candidate => candidate.Job.Id, StringComparer.Ordinal)
5✔
365
                                        .First()
5✔
366
                                        .Job;
5✔
367
                                var claimedJob = request.FairQueues.GroupRoundRobin
5✔
368
                                        ? await AcquireFairCandidateAsync(
5✔
369
                                                selected,
5✔
370
                                                request.WorkerId,
5✔
371
                                                request.Lease,
5✔
372
                                                now,
5✔
373
                                                nextSequence,
5✔
374
                                                cancellationToken
5✔
375
                                        ).ConfigureAwait(false)
5✔
376
                                        : GetFirstOrDefault(await AcquireCandidatesAsync(
5✔
377
                                                        [selected],
5✔
378
                                                        request.WorkerId,
5✔
379
                                                        request.Lease,
5✔
380
                                                        now,
5✔
381
                                                        cancellationToken
5✔
382
                                                ).ConfigureAwait(false));
5✔
383
                                if (claimedJob is null)
5✔
384
                                        continue;
385

386
                                jobCapacities[claimedJob.JobName]--;
5✔
387
                                queueCapacity--;
5✔
388
                                acquired.Add(claimedJob);
5✔
389
                        }
5✔
390
                }
5✔
391

392
                return acquired;
5✔
393
        }
5✔
394

395
        private async ValueTask<IReadOnlyList<JobRecord>> AcquireFairFastPathAsync(
396
                string queueName,
397
                Dictionary<string, int> jobCapacities,
398
                int queueCapacity,
399
                string workerId,
400
                TimeSpan lease,
401
                DateTimeOffset now,
402
                CancellationToken cancellationToken
403
        )
404
        {
405
                var acquired = new List<JobRecord>(queueCapacity);
4✔
406
                while (queueCapacity > 0)
4✔
407
                {
408
                        var eligibleNames = jobCapacities
4✔
409
                                .Where(static pair => pair.Value > 0)
4✔
410
                                .Select(static pair => pair.Key)
4✔
411
                                .ToArray();
4✔
412
                        if (eligibleNames.Length == 0)
4✔
413
                                break;
414

415
                        await using var readContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
416
                        var candidates = await readContext.Set<ImmediateJobEntity>()
4✔
417
                                .AsNoTracking()
4✔
418
                                .Where(job => job.QueueName == queueName && eligibleNames.Contains(job.JobName) &&
4✔
419
                                        (((job.State == JobState.Scheduled || job.State == JobState.Pending) && job.DueAt <= now)
4✔
420
                                                || (job.State == JobState.Active && job.LeaseExpiresAt <= now)))
4✔
421
                                .OrderBy(job => job.DueAt)
4✔
422
                                .ThenBy(job => job.CreatedAt)
4✔
423
                                .ThenBy(job => job.Id)
4✔
424
                                .Take(queueCapacity)
4✔
425
                                .ToListAsync(cancellationToken)
4✔
426
                                .ConfigureAwait(false);
4✔
427
                        if (candidates.Count == 0)
4✔
428
                                break;
429

430
                        var selected = new List<ImmediateJobEntity>(candidates.Count);
4✔
431
                        var selectionCapacities = new Dictionary<string, int>(jobCapacities, StringComparer.Ordinal);
4✔
432
                        foreach (var candidate in candidates)
4✔
433
                        {
434
                                if (selectionCapacities[candidate.JobName] <= 0)
4✔
435
                                        continue;
436
                                selectionCapacities[candidate.JobName]--;
4✔
437
                                selected.Add(candidate);
4✔
438
                        }
439

440
                        var claimed = await AcquireCandidatesAsync(
4✔
441
                                selected,
4✔
442
                                workerId,
4✔
443
                                lease,
4✔
444
                                now,
4✔
445
                                cancellationToken
4✔
446
                        ).ConfigureAwait(false);
4✔
447
                        foreach (var job in claimed)
4✔
448
                        {
449
                                jobCapacities[job.JobName]--;
4✔
450
                                queueCapacity--;
4✔
451
                                acquired.Add(job);
4✔
452
                        }
453

454
                        if (claimed.Count == 0)
4✔
455
                                break;
456
                }
4✔
457

458
                return acquired;
4✔
459
        }
4✔
460

461
        private async ValueTask<JobRecord?> AcquireFairCandidateAsync(
462
                ImmediateJobEntity candidate,
463
                string workerId,
464
                TimeSpan lease,
465
                DateTimeOffset now,
466
                long nextSequence,
467
                CancellationToken cancellationToken
468
        )
469
        {
470
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
471
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
472
                var entity = Copy(candidate);
5✔
473
                _ = context.Attach(entity);
5✔
474
                entity.State = JobState.Active;
5✔
475
                entity.WorkerId = workerId;
5✔
476
                entity.LeaseExpiresAt = now + lease;
5✔
477
                entity.Attempt++;
5✔
478
                entity.CompletedAt = null;
5✔
479
                entity.ExecutionTraceId = null;
5✔
480
                entity.ExecutionSpanId = null;
5✔
481
                entity.ExecutionStartedAt = null;
5✔
482
                entity.ConcurrencyStamp = Guid.NewGuid();
5✔
483

484
                if (candidate.GroupId is { } groupId)
5✔
485
                {
486
                        var group = await context.Set<ImmediateFairQueueGroupEntity>()
5✔
487
                                .SingleOrDefaultAsync(
5✔
488
                                        item => item.QueueName == candidate.QueueName && item.GroupId == groupId,
5✔
489
                                        cancellationToken
5✔
490
                                )
5✔
491
                                .ConfigureAwait(false);
5✔
492
                        if (group is null)
5✔
493
                        {
494
                                _ = context.Add(new ImmediateFairQueueGroupEntity
5✔
495
                                {
5✔
496
                                        QueueName = candidate.QueueName,
5✔
497
                                        GroupId = groupId,
5✔
498
                                        LastServedSequence = nextSequence,
5✔
499
                                        ConcurrencyStamp = Guid.NewGuid(),
5✔
500
                                });
5✔
501
                        }
502
                        else if (group.LastServedSequence >= nextSequence)
4✔
503
                        {
504
                                // Selection observed an older cursor snapshot. Re-rank instead of moving this group backward.
NEW
505
                                return null;
×
506
                        }
507
                        else
508
                        {
509
                                group.LastServedSequence = nextSequence;
4✔
510
                                group.ConcurrencyStamp = Guid.NewGuid();
4✔
511
                        }
512
                }
513

514
                if (entity.BatchId is { } batchId)
5✔
515
                {
NEW
516
                        var batch = await context.Set<ImmediateJobBatchEntity>()
×
NEW
517
                                .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
×
NEW
518
                                .ConfigureAwait(false);
×
NEW
519
                        if (batch is not null && batch.StartedAt is null)
×
520
                        {
NEW
521
                                batch.StartedAt = now;
×
NEW
522
                                batch.ConcurrencyStamp = Guid.NewGuid();
×
523
                        }
524
                }
525

526
                try
527
                {
528
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
529
                        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
5✔
530
                        return ToRecord(entity);
5✔
531
                }
NEW
532
                catch (DbUpdateException)
×
533
                {
534
                        // The job or its group cursor changed after candidate selection.
535
                        return null;
5✔
536
                }
537
        }
5✔
538

539
        private static JobRecord? GetFirstOrDefault(IReadOnlyList<JobRecord> jobs) =>
540
                jobs.Count == 0 ? null : jobs[0];
4✔
541

542
        private static bool IsNoisy(
543
                string? groupId,
544
                int inflight,
545
                int totalInflight,
546
                FairQueuePolicy policy
547
        )
548
        {
549
                return groupId is not null
5✔
550
                        && totalInflight > 0
5✔
551
                        && inflight >= policy.MinInflightForNoisy
5✔
552
                        && (double)inflight / totalInflight > policy.ConcurrencyShareThreshold;
5✔
553
        }
554

555
        private sealed record FairQueueCandidateState(
5✔
556
                string JobId,
5✔
557
                int Inflight,
5✔
558
                long LastServedSequence
5✔
559
        );
5✔
560

561
        /// <inheritdoc />
562
        public async ValueTask<IReadOnlyList<JobRecord>> AcquireJobsAsync(
563
                IReadOnlyCollection<string> jobIds,
564
                string workerId,
565
                TimeSpan lease,
566
                CancellationToken cancellationToken = default
567
        )
568
        {
569
                ArgumentNullException.ThrowIfNull(jobIds);
4✔
570
                ArgumentException.ThrowIfNullOrWhiteSpace(workerId);
4✔
571
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lease, TimeSpan.Zero);
4✔
572
                if (jobIds.Count == 0)
4✔
573
                        return [];
×
574

575
                var now = _timeProvider.GetUtcNow();
4✔
576
                var ids = jobIds.ToArray();
4✔
577
                await using var readContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
578
                var candidates = await readContext.Set<ImmediateJobEntity>()
4✔
579
                        .AsNoTracking()
4✔
580
                        .Where(job => ids.Contains(job.Id) &&
4✔
581
                                (((job.State == JobState.Scheduled || job.State == JobState.Pending) && job.DueAt <= now)
4✔
582
                                || (job.State == JobState.Active && job.LeaseExpiresAt <= now)))
4✔
583
                        .ToListAsync(cancellationToken)
4✔
584
                        .ConfigureAwait(false);
4✔
585

586
                return await AcquireCandidatesAsync(candidates, workerId, lease, now, cancellationToken).ConfigureAwait(false);
4✔
587
        }
4✔
588

589
        private async ValueTask<IReadOnlyList<JobRecord>> AcquireCandidatesAsync(
590
                List<ImmediateJobEntity> candidates,
591
                string workerId,
592
                TimeSpan lease,
593
                DateTimeOffset now,
594
                CancellationToken cancellationToken
595
        )
596
        {
597
                var acquired = new List<JobRecord>(candidates.Count);
5✔
598
                foreach (var candidate in candidates)
5✔
599
                {
600
                        await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
601
                        var entity = Copy(candidate);
5✔
602
                        _ = context.Attach(entity);
5✔
603
                        entity.State = JobState.Active;
5✔
604
                        entity.WorkerId = workerId;
5✔
605
                        entity.LeaseExpiresAt = now + lease;
5✔
606
                        entity.Attempt++;
5✔
607
                        entity.CompletedAt = null;
5✔
608
                        entity.ExecutionTraceId = null;
5✔
609
                        entity.ExecutionSpanId = null;
5✔
610
                        entity.ExecutionStartedAt = null;
5✔
611
                        entity.ConcurrencyStamp = Guid.NewGuid();
5✔
612
                        if (entity.BatchId is { } batchId)
5✔
613
                        {
614
                                var batch = await context.Set<ImmediateJobBatchEntity>()
5✔
615
                                        .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
5✔
616
                                        .ConfigureAwait(false);
5✔
617
                                if (batch is not null && batch.StartedAt is null)
5✔
618
                                {
619
                                        batch.StartedAt = now;
5✔
620
                                        batch.ConcurrencyStamp = Guid.NewGuid();
5✔
621
                                }
622
                        }
623

624
                        try
625
                        {
626
                                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
627
                                acquired.Add(ToRecord(entity));
5✔
628
                        }
5✔
629
                        catch (DbUpdateConcurrencyException)
1✔
630
                        {
631
                                // Another scheduler claimed or changed this candidate first.
632
                        }
1✔
633
                }
5✔
634

635
                return acquired;
5✔
636
        }
5✔
637

638
        /// <inheritdoc />
639
        public ValueTask SetExecutionTelemetryAsync(
640
                string jobId,
641
                string workerId,
642
                string? traceId,
643
                string? spanId,
644
                DateTimeOffset startedAt,
645
                CancellationToken cancellationToken = default
646
        ) => MutateOwnedAsync(jobId, workerId, job =>
1✔
647
        {
1✔
648
                job.ExecutionTraceId = traceId;
1✔
649
                job.ExecutionSpanId = spanId;
1✔
650
                job.ExecutionStartedAt = startedAt;
1✔
651
        }, cancellationToken);
1✔
652

653
        /// <inheritdoc />
654
        public ValueTask RenewLeaseAsync(string jobId, string workerId, TimeSpan lease, CancellationToken cancellationToken = default)
655
        {
656
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lease, TimeSpan.Zero);
×
657
                return MutateOwnedAsync(jobId, workerId, job => job.LeaseExpiresAt = _timeProvider.GetUtcNow() + lease, cancellationToken);
×
658
        }
659

660
        /// <inheritdoc />
661
        public ValueTask CompleteAsync(string jobId, string workerId, CancellationToken cancellationToken = default) =>
662
                CompleteWithContinuationsAsync(jobId, workerId, [], cancellationToken);
5✔
663

664
        /// <inheritdoc />
665
        public ValueTask CompleteWithContinuationsAsync(
666
                string jobId,
667
                string workerId,
668
                IReadOnlyList<JobContinuationAddition> additions,
669
                CancellationToken cancellationToken = default
670
        )
671
        {
672
                ArgumentNullException.ThrowIfNull(additions);
5✔
673
                return MutateOwnedWithDependenciesAsync(
5✔
674
                        jobId,
5✔
675
                        workerId,
5✔
676
                        error: null,
5✔
677
                        nextRetryAt: null,
5✔
678
                        succeeded: true,
5✔
679
                        additions,
5✔
680
                        cancellationToken
5✔
681
                );
5✔
682
        }
683

684
        /// <inheritdoc />
685
        public async ValueTask AddBatchJobAsync(
686
                string currentJobId,
687
                JobRecord job,
688
                ContinuationOptions options,
689
                CancellationToken cancellationToken = default
690
        )
691
        {
692
                ArgumentNullException.ThrowIfNull(job);
×
693
                if (options == ContinuationOptions.Detached)
×
694
                        throw new ImmediateJobException("IJOB020: AddToBatchAsync cannot create a detached job.");
×
695
                const int MaxConcurrencyAttempts = 5;
696
                for (var attempt = 0; attempt < MaxConcurrencyAttempts; attempt++)
×
697
                {
698
                        try
699
                        {
700
                                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
701
                                var strategy = strategyContext.Database.CreateExecutionStrategy();
×
702
                                await strategy.ExecuteAsync(
×
703
                                        operationCancellationToken => AddBatchJobCoreAsync(
×
704
                                                currentJobId,
×
705
                                                job,
×
706
                                                options,
×
707
                                                operationCancellationToken
×
708
                                        ),
×
709
                                        cancellationToken
×
710
                                ).ConfigureAwait(false);
×
711
                                return;
×
712
                        }
×
713
                        catch (DbUpdateConcurrencyException) when (attempt + 1 < MaxConcurrencyAttempts)
×
714
                        {
715
                        }
×
716
                }
717
        }
×
718

719
        /// <inheritdoc />
720
        public ValueTask FailAsync(
721
                string jobId,
722
                string workerId,
723
                string error,
724
                DateTimeOffset? nextRetryAt,
725
                CancellationToken cancellationToken = default
726
        ) => MutateOwnedWithDependenciesAsync(jobId, workerId, error, nextRetryAt, succeeded: false, [], cancellationToken);
4✔
727

728
        /// <inheritdoc />
729
        public async ValueTask UpsertRecurringAsync(RecurringJobSchedule schedule, CancellationToken cancellationToken = default)
730
        {
731
                ArgumentNullException.ThrowIfNull(schedule);
5✔
732
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
733
                if (await UpdateRecurringAsync(context, schedule, cancellationToken).ConfigureAwait(false) != 0)
5✔
734
                        return;
735
                await ThrowIfReplacingCodeDefinedScheduleAsync(context, schedule, cancellationToken).ConfigureAwait(false);
5✔
736

737
                _ = context.Add(ToEntity(schedule));
5✔
738
                try
739
                {
740
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
741
                }
5✔
742
                catch (DbUpdateException)
×
743
                {
744
                        // A competing node inserted the same schedule after our update attempt.
745
                        await using var retryContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
746
                        if (await UpdateRecurringAsync(retryContext, schedule, cancellationToken).ConfigureAwait(false) != 0)
×
747
                                return;
748
                        await ThrowIfReplacingCodeDefinedScheduleAsync(retryContext, schedule, cancellationToken).ConfigureAwait(false);
×
749
                        throw;
×
750
                }
×
751
        }
5✔
752

753
        /// <inheritdoc />
754
        public async ValueTask RemoveObsoleteCodeDefinedRecurringAsync(
755
                IReadOnlyCollection<string> activeScheduleNames,
756
                CancellationToken cancellationToken = default
757
        )
758
        {
759
                ArgumentNullException.ThrowIfNull(activeScheduleNames);
4✔
760
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
761
                var schedules = context.Set<ImmediateRecurringJobEntity>()
4✔
762
                        .Where(schedule => schedule.IsCodeDefined);
4✔
763
                if (activeScheduleNames.Count != 0)
4✔
764
                        schedules = schedules.Where(schedule => !activeScheduleNames.Contains(schedule.Name));
4✔
765
                _ = await schedules.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
4✔
766
        }
4✔
767

768
        /// <inheritdoc />
769
        public async ValueTask RemoveRecurringAsync(string name, CancellationToken cancellationToken = default)
770
        {
771
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
772
                var entity = await context.Set<ImmediateRecurringJobEntity>()
×
773
                        .SingleOrDefaultAsync(schedule => schedule.Name == name && !schedule.IsCodeDefined, cancellationToken)
×
774
                        .ConfigureAwait(false);
×
775
                if (entity is not null)
×
776
                {
777
                        _ = context.Remove(entity);
×
778
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
779
                }
780
        }
×
781

782
        /// <inheritdoc />
783
        public ValueTask PauseRecurringAsync(string name, CancellationToken cancellationToken = default)
784
                => MutateRecurringAsync(name, schedule => schedule.IsPaused = true, cancellationToken);
×
785

786
        /// <inheritdoc />
787
        public ValueTask ResumeRecurringAsync(string name, CancellationToken cancellationToken = default)
788
                => MutateRecurringAsync(name, schedule => schedule.IsPaused = false, cancellationToken);
×
789

790
        /// <inheritdoc />
791
        public async ValueTask<IReadOnlyList<RecurringJobSchedule>> GetDueRecurringAsync(DateTimeOffset now, int batchSize, CancellationToken cancellationToken = default)
792
        {
793
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(batchSize, 0);
×
794
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
795
                return await context.Set<ImmediateRecurringJobEntity>()
×
796
                        .AsNoTracking()
×
797
                        .Where(schedule => !schedule.IsPaused && schedule.NextRunAt <= now)
×
798
                        .OrderBy(schedule => schedule.NextRunAt)
×
799
                        .Take(batchSize)
×
800
                        .Select(schedule => new RecurringJobSchedule
×
801
                        {
×
802
                                Name = schedule.Name,
×
803
                                JobName = schedule.JobName,
×
804
                                Cron = schedule.Cron,
×
805
                                TimeZone = schedule.TimeZone,
×
806
                                IsCodeDefined = schedule.IsCodeDefined,
×
807
                                IsPaused = schedule.IsPaused,
×
808
                                NextRunAt = schedule.NextRunAt,
×
809
                                LastRunAt = schedule.LastRunAt,
×
810
                        })
×
811
                        .ToListAsync(cancellationToken)
×
812
                        .ConfigureAwait(false);
×
813
        }
×
814

815
        /// <inheritdoc />
816
        public async ValueTask<bool> MaterializeRecurringAsync(
817
                RecurringJobSchedule schedule,
818
                JobRecord job,
819
                DateTimeOffset nextRunAt,
820
                CancellationToken cancellationToken = default
821
        )
822
        {
823
                ArgumentNullException.ThrowIfNull(schedule);
5✔
824
                ArgumentNullException.ThrowIfNull(job);
5✔
825
                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
826
                var strategy = strategyContext.Database.CreateExecutionStrategy();
5✔
827
                return await strategy.ExecuteAsync(
5✔
828
                        operationCancellationToken => MaterializeRecurringCoreAsync(
5✔
829
                                schedule,
5✔
830
                                job,
5✔
831
                                nextRunAt,
5✔
832
                                operationCancellationToken
5✔
833
                        ),
5✔
834
                        cancellationToken
5✔
835
                ).ConfigureAwait(false);
5✔
836
        }
5✔
837

838
        private async Task<bool> MaterializeRecurringCoreAsync(
839
                RecurringJobSchedule schedule,
840
                JobRecord job,
841
                DateTimeOffset nextRunAt,
842
                CancellationToken cancellationToken
843
        )
844
        {
845
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
846
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
847
                var entity = await context.Set<ImmediateRecurringJobEntity>()
5✔
848
                        .SingleOrDefaultAsync(item => item.Name == schedule.Name, cancellationToken)
5✔
849
                        .ConfigureAwait(false);
5✔
850
                if (entity is null || entity.IsPaused || entity.NextRunAt != schedule.NextRunAt)
5✔
851
                        return false;
1✔
852

853
                entity.LastRunAt = schedule.NextRunAt;
5✔
854
                entity.NextRunAt = nextRunAt;
5✔
855
                entity.ConcurrencyStamp = Guid.NewGuid();
5✔
856
                _ = context.Add(ToEntity(job));
5✔
857
                try
858
                {
859
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
860
                        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
5✔
861
                        return true;
5✔
862
                }
863
                catch (DbUpdateException)
864
                {
865
                        await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
×
866
                        return false;
×
867
                }
868
        }
5✔
869

870
        /// <inheritdoc />
871
        public async ValueTask<JobMonitoringSnapshot> GetMonitoringSnapshotAsync(CancellationToken cancellationToken = default)
872
        {
873
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
874
                var rawCounts = await context.Set<ImmediateJobEntity>()
5✔
875
                        .AsNoTracking()
5✔
876
                        .GroupBy(job => job.State)
5✔
877
                        .Select(group => new { State = group.Key, Count = group.LongCount() })
5✔
878
                        .ToListAsync(cancellationToken)
5✔
879
                        .ConfigureAwait(false);
5✔
880
                var counts = Enum.GetValues<JobState>().ToDictionary(static state => state, static _ => 0L);
5✔
881
                foreach (var item in rawCounts)
5✔
882
                        counts[item.State] = item.Count;
5✔
883

884
                var recurring = await context.Set<ImmediateRecurringJobEntity>()
5✔
885
                        .AsNoTracking()
5✔
886
                        .OrderBy(schedule => schedule.Name)
5✔
887
                        .Select(schedule => new RecurringJobSchedule
5✔
888
                        {
5✔
889
                                Name = schedule.Name,
5✔
890
                                JobName = schedule.JobName,
5✔
891
                                Cron = schedule.Cron,
5✔
892
                                TimeZone = schedule.TimeZone,
5✔
893
                                IsCodeDefined = schedule.IsCodeDefined,
5✔
894
                                IsPaused = schedule.IsPaused,
5✔
895
                                NextRunAt = schedule.NextRunAt,
5✔
896
                                LastRunAt = schedule.LastRunAt,
5✔
897
                        })
5✔
898
                        .ToListAsync(cancellationToken)
5✔
899
                        .ConfigureAwait(false);
5✔
900
                var servers = await context.Set<ImmediateJobServerEntity>()
5✔
901
                        .AsNoTracking()
5✔
902
                        .OrderBy(server => server.WorkerId)
5✔
903
                        .Select(server => new JobServerSnapshot(server.WorkerId, server.LastHeartbeat, server.ActiveWorkers, server.MaxWorkers))
5✔
904
                        .ToListAsync(cancellationToken)
5✔
905
                        .ConfigureAwait(false);
5✔
906
                return new(_timeProvider.GetUtcNow(), counts, recurring, servers)
5✔
907
                {
5✔
908
                        Capabilities = this.GetCapabilities(),
5✔
909
                };
5✔
910
        }
5✔
911

912
        /// <inheritdoc />
913
        public async ValueTask<IReadOnlyList<JobRecord>> QueryJobsAsync(JobQuery query, CancellationToken cancellationToken = default)
914
        {
915
                ArgumentNullException.ThrowIfNull(query);
5✔
916
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
5✔
917
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
5✔
918
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
919
                IQueryable<ImmediateJobEntity> jobs = context.Set<ImmediateJobEntity>().AsNoTracking();
5✔
920
                if (query.Id is { } id)
5✔
921
                        jobs = jobs.Where(job => job.Id == id);
5✔
922
                if (query.State is { } state)
5✔
923
                        jobs = jobs.Where(job => job.State == state);
4✔
924
                if (!string.IsNullOrWhiteSpace(query.QueueName))
5✔
925
                        jobs = jobs.Where(job => job.QueueName == query.QueueName);
×
926
                if (!string.IsNullOrWhiteSpace(query.Search))
5✔
927
                {
928
                        var search = query.Search.ToUpperInvariant();
×
929
                        // The parameterless form is intentionally used because relational providers translate it to SQL.
930
#pragma warning disable CA1304, CA1311, CA1862
931
                        jobs = jobs.Where(job => job.JobName.ToUpper().Contains(search));
×
932
#pragma warning restore CA1304, CA1311, CA1862
933
                }
934

935
                var entities = await jobs.OrderByDescending(job => job.CreatedAt)
5✔
936
                        .Skip(query.Skip)
5✔
937
                        .Take(query.Take)
5✔
938
                        .ToListAsync(cancellationToken)
5✔
939
                        .ConfigureAwait(false);
5✔
940
                return [.. entities.Select(ToRecord)];
5✔
941
        }
5✔
942

943
        /// <inheritdoc />
944
        public async ValueTask<BatchStatus?> GetBatchStatusAsync(
945
                string batchId,
946
                CancellationToken cancellationToken = default
947
        )
948
        {
949
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
5✔
950
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
951
                var batch = await context.Set<ImmediateJobBatchEntity>()
5✔
952
                        .AsNoTracking()
5✔
953
                        .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
5✔
954
                        .ConfigureAwait(false);
5✔
955
                return batch is null ? null : ToStatus(batch);
5✔
956
        }
5✔
957

958
        /// <inheritdoc />
959
        public async ValueTask<IReadOnlyList<BatchStatus>> QueryBatchesAsync(
960
                JobBatchQuery query,
961
                CancellationToken cancellationToken = default
962
        )
963
        {
964
                ArgumentNullException.ThrowIfNull(query);
×
965
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
×
966
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
×
967
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
968
                IQueryable<ImmediateJobBatchEntity> batches = context.Set<ImmediateJobBatchEntity>().AsNoTracking();
×
969
                if (query.State is { } state)
×
970
                        batches = batches.Where(batch => batch.State == state);
×
971
                var entities = await batches.OrderByDescending(batch => batch.CreatedAt)
×
972
                        .ThenBy(batch => batch.Id)
×
973
                        .Skip(query.Skip)
×
974
                        .Take(query.Take)
×
975
                        .ToListAsync(cancellationToken)
×
976
                        .ConfigureAwait(false);
×
977
                return [.. entities.Select(ToStatus)];
×
978
        }
×
979

980
        /// <inheritdoc />
981
        public async ValueTask<IReadOnlyList<BatchMemberStatus>> QueryBatchMembersAsync(
982
                string batchId,
983
                BatchMemberQuery query,
984
                CancellationToken cancellationToken = default
985
        )
986
        {
987
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
988
                ArgumentNullException.ThrowIfNull(query);
4✔
989
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
4✔
990
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
4✔
991
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
992
                IQueryable<ImmediateJobEntity> jobs = context.Set<ImmediateJobEntity>()
4✔
993
                        .AsNoTracking()
4✔
994
                        .Where(job => job.BatchId == batchId);
4✔
995
                if (query.State is { } state)
4✔
996
                        jobs = jobs.Where(job => job.State == state);
×
997
                return await jobs.OrderBy(job => job.CreatedAt)
4✔
998
                        .ThenBy(job => job.Id)
4✔
999
                        .Skip(query.Skip)
4✔
1000
                        .Take(query.Take)
4✔
1001
                        .Select(job => new BatchMemberStatus(
4✔
1002
                                job.Id,
4✔
1003
                                job.JobName,
4✔
1004
                                job.QueueName,
4✔
1005
                                job.State,
4✔
1006
                                job.Attempt,
4✔
1007
                                job.CreatedAt,
4✔
1008
                                job.CompletedAt,
4✔
1009
                                job.LastError
4✔
1010
                        ))
4✔
1011
                        .ToListAsync(cancellationToken)
4✔
1012
                        .ConfigureAwait(false);
4✔
1013
        }
4✔
1014

1015
        /// <inheritdoc />
1016
        public async ValueTask<BatchGraph?> GetBatchGraphAsync(
1017
                string batchId,
1018
                CancellationToken cancellationToken = default
1019
        )
1020
        {
1021
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
1022
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
1023
                if (!await context.Set<ImmediateJobBatchEntity>()
4✔
1024
                        .AnyAsync(batch => batch.Id == batchId, cancellationToken)
4✔
1025
                        .ConfigureAwait(false))
4✔
1026
                {
1027
                        return null;
4✔
1028
                }
1029

1030
                var jobs = await context.Set<ImmediateJobEntity>()
×
1031
                        .AsNoTracking()
×
1032
                        .Where(job => job.BatchId == batchId)
×
1033
                        .OrderBy(job => job.CreatedAt)
×
1034
                        .ThenBy(job => job.Id)
×
1035
                        .Select(job => new BatchGraphNode(job.Id, job.JobName, job.State))
×
1036
                        .ToListAsync(cancellationToken)
×
1037
                        .ConfigureAwait(false);
×
1038
                var ids = jobs.Select(static job => job.JobId).ToArray();
×
1039
                var edges = ids.Length == 0
×
1040
                        ? []
×
1041
                        : await context.Set<ImmediateJobContinuationEntity>()
×
1042
                                .AsNoTracking()
×
1043
                                .Where(edge => ids.Contains(edge.ChildJobId))
×
1044
                                .OrderBy(edge => edge.ChildJobId)
×
1045
                                .ThenBy(edge => edge.ParentKind)
×
1046
                                .ThenBy(edge => edge.ParentId)
×
1047
                                .ToListAsync(cancellationToken)
×
1048
                                .ConfigureAwait(false);
×
1049
                return new(batchId, jobs, [.. edges.Select(ToGraphEdge)]);
4✔
1050
        }
4✔
1051

1052
        /// <inheritdoc />
1053
        public async ValueTask<JobStatus?> GetJobStatusAsync(
1054
                string jobId,
1055
                CancellationToken cancellationToken = default
1056
        )
1057
        {
1058
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
5✔
1059
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1060
                var job = await context.Set<ImmediateJobEntity>()
5✔
1061
                        .AsNoTracking()
5✔
1062
                        .SingleOrDefaultAsync(item => item.Id == jobId, cancellationToken)
5✔
1063
                        .ConfigureAwait(false);
5✔
1064
                if (job is null)
5✔
1065
                        return null;
1✔
1066
                var edges = await context.Set<ImmediateJobContinuationEntity>()
4✔
1067
                        .AsNoTracking()
4✔
1068
                        .Where(edge => edge.ChildJobId == jobId)
4✔
1069
                        .OrderBy(edge => edge.ParentKind)
4✔
1070
                        .ThenBy(edge => edge.ParentId)
4✔
1071
                        .ToListAsync(cancellationToken)
4✔
1072
                        .ConfigureAwait(false);
4✔
1073
                return new(
5✔
1074
                        job.Id,
5✔
1075
                        job.JobName,
5✔
1076
                        job.QueueName,
5✔
1077
                        job.State,
5✔
1078
                        job.Attempt,
5✔
1079
                        MaxAttempts: 0,
5✔
1080
                        job.CreatedAt,
5✔
1081
                        job.DueAt,
5✔
1082
                        job.CompletedAt,
5✔
1083
                        job.LastError,
5✔
1084
                        job.BatchId,
5✔
1085
                        [.. edges.Select(ToGraphEdge)]
5✔
1086
                );
5✔
1087
        }
5✔
1088

1089
        /// <inheritdoc />
1090
        public ValueTask CancelBatchAsync(string batchId, CancellationToken cancellationToken = default)
1091
        {
1092
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
1093
                return ExecuteWithStrategyAsync(
4✔
1094
                        operationCancellationToken => CancelBatchCoreAsync(batchId, operationCancellationToken),
4✔
1095
                        cancellationToken
4✔
1096
                );
4✔
1097
        }
1098

1099
        private async Task CancelBatchCoreAsync(string batchId, CancellationToken cancellationToken)
1100
        {
1101
                var now = _timeProvider.GetUtcNow();
4✔
1102
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
1103
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
4✔
1104
                var batch = await context.Set<ImmediateJobBatchEntity>()
4✔
1105
                        .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
4✔
1106
                        .ConfigureAwait(false)
4✔
1107
                        ?? throw new KeyNotFoundException($"Batch '{batchId}' was not found.");
4✔
1108
                if (batch.State != BatchState.Executing)
4✔
1109
                        throw new ImmediateJobException("Only an executing batch can be cancelled.");
×
1110

1111
                var jobs = await context.Set<ImmediateJobEntity>()
4✔
1112
                        .Where(job => job.BatchId == batchId)
4✔
1113
                        .ToListAsync(cancellationToken)
4✔
1114
                        .ConfigureAwait(false);
4✔
1115
                foreach (var job in jobs)
4✔
1116
                {
1117
                        if (IsTerminal(job.State))
4✔
1118
                                continue;
1119
                        job.State = JobState.Cancelled;
4✔
1120
                        job.CompletedAt = now;
4✔
1121
                        job.WorkerId = null;
4✔
1122
                        job.LeaseExpiresAt = null;
4✔
1123
                        job.ConcurrencyStamp = Guid.NewGuid();
4✔
1124
                        await PropagateTerminalAsync(context, job, now, cancellationToken).ConfigureAwait(false);
4✔
1125
                }
1126

1127
                var terminalGroups = GetTerminalFairQueueGroups(context);
4✔
1128
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
1129
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
4✔
1130
                foreach (var (queueName, groupId) in terminalGroups)
4✔
1131
                {
NEW
1132
                        await TryRemoveFairQueueCursorAsync(queueName, groupId, CancellationToken.None).ConfigureAwait(false);
×
1133
                }
1134
        }
4✔
1135

1136
        /// <inheritdoc />
1137
        public ValueTask DeleteBatchAsync(string batchId, CancellationToken cancellationToken = default)
1138
        {
1139
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
1140
                return ExecuteWithStrategyAsync(
4✔
1141
                        operationCancellationToken => DeleteBatchCoreAsync(batchId, operationCancellationToken),
4✔
1142
                        cancellationToken
4✔
1143
                );
4✔
1144
        }
1145

1146
        private async Task DeleteBatchCoreAsync(string batchId, CancellationToken cancellationToken)
1147
        {
1148
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
1149
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
4✔
1150
                var batch = await context.Set<ImmediateJobBatchEntity>()
4✔
1151
                        .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
4✔
1152
                        .ConfigureAwait(false)
4✔
1153
                        ?? throw new KeyNotFoundException($"Batch '{batchId}' was not found.");
4✔
1154
                if (batch.State == BatchState.Executing)
4✔
1155
                        throw new ImmediateJobException("Only a terminal batch can be deleted.");
×
1156

1157
                var jobs = await context.Set<ImmediateJobEntity>()
4✔
1158
                        .Where(job => job.BatchId == batchId)
4✔
1159
                        .ToListAsync(cancellationToken)
4✔
1160
                        .ConfigureAwait(false);
4✔
1161
                var jobIds = jobs.Select(static job => job.Id).ToArray();
4✔
1162
                var edges = await context.Set<ImmediateJobContinuationEntity>()
4✔
1163
                        .Where(edge => jobIds.Contains(edge.ChildJobId)
4✔
1164
                                || edge.ParentKind == ContinuationParentKind.Job && jobIds.Contains(edge.ParentId)
4✔
1165
                                || edge.ParentKind == ContinuationParentKind.Batch && edge.ParentId == batchId)
4✔
1166
                        .ToListAsync(cancellationToken)
4✔
1167
                        .ConfigureAwait(false);
4✔
1168
                context.RemoveRange(edges);
4✔
1169
                context.RemoveRange(jobs);
4✔
1170
                _ = context.Remove(batch);
4✔
1171
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
1172
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
4✔
1173
        }
4✔
1174

1175
        /// <inheritdoc />
1176
        public ValueTask RetryAsync(string jobId, CancellationToken cancellationToken = default)
1177
        {
1178
                return ExecuteWithStrategyAsync(
×
1179
                        operationCancellationToken => RetryCoreAsync(jobId, operationCancellationToken),
×
1180
                        cancellationToken
×
1181
                );
×
1182
        }
1183

1184
        private async Task RetryCoreAsync(string jobId, CancellationToken cancellationToken)
1185
        {
1186
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
1187
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
×
1188
                var job = await context.Set<ImmediateJobEntity>()
×
1189
                        .SingleOrDefaultAsync(item => item.Id == jobId && item.State == JobState.Failed, cancellationToken)
×
1190
                        .ConfigureAwait(false);
×
1191
                if (job is null)
×
1192
                        return;
1193
                if (job.BatchId is { } batchId)
×
1194
                {
1195
                        var batch = await context.Set<ImmediateJobBatchEntity>()
×
1196
                                .SingleAsync(item => item.Id == batchId, cancellationToken)
×
1197
                                .ConfigureAwait(false);
×
1198
                        batch.PendingCount++;
×
1199
                        batch.FailedCount = Math.Max(0, batch.FailedCount - 1);
×
1200
                        batch.State = BatchState.Executing;
×
1201
                        batch.CompletedAt = null;
×
1202
                        batch.ConcurrencyStamp = Guid.NewGuid();
×
1203
                }
1204

1205
                job.State = JobState.Pending;
×
1206
                job.DueAt = _timeProvider.GetUtcNow();
×
1207
                job.WorkerId = null;
×
1208
                job.LeaseExpiresAt = null;
×
1209
                job.CompletedAt = null;
×
1210
                job.LastError = null;
×
1211
                job.ConcurrencyStamp = Guid.NewGuid();
×
1212
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1213
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
×
1214
        }
×
1215

1216
        /// <inheritdoc />
1217
        public async ValueTask DeleteAsync(string jobId, CancellationToken cancellationToken = default)
1218
        {
1219
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
1220
                var job = await context.Set<ImmediateJobEntity>()
×
1221
                        .SingleOrDefaultAsync(item => item.Id == jobId
×
1222
                                && (item.State == JobState.Succeeded || item.State == JobState.Failed || item.State == JobState.Cancelled), cancellationToken)
×
1223
                        .ConfigureAwait(false);
×
1224
                if (job is not null)
×
1225
                {
1226
                        if (job.BatchId is not null)
×
1227
                                throw new ImmediateJobException("Batch members are deleted with their batch so the workflow remains coherent.");
×
1228
                        var outgoing = await context.Set<ImmediateJobContinuationEntity>()
×
1229
                                .Where(edge => edge.ParentKind == ContinuationParentKind.Job && edge.ParentId == jobId)
×
1230
                                .ToListAsync(cancellationToken)
×
1231
                                .ConfigureAwait(false);
×
1232
                        context.RemoveRange(outgoing);
×
1233
                        _ = context.Remove(job);
×
1234
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1235
                }
1236
        }
×
1237

1238
        /// <inheritdoc />
1239
        public ValueTask PurgeJobsAsync(
1240
                TimeSpan succeededRetention,
1241
                TimeSpan failedRetention,
1242
                CancellationToken cancellationToken = default
1243
        )
1244
        {
1245
                var now = _timeProvider.GetUtcNow();
4✔
1246
                return ExecuteWithStrategyAsync(
4✔
1247
                        operationCancellationToken => PurgeJobsCoreAsync(
4✔
1248
                                now - succeededRetention,
4✔
1249
                                now - failedRetention,
4✔
1250
                                operationCancellationToken
4✔
1251
                        ),
4✔
1252
                        cancellationToken
4✔
1253
                );
4✔
1254
        }
1255

1256
        /// <inheritdoc />
1257
        public ValueTask PurgeBatchesAsync(
1258
                TimeSpan batchSucceededRetention,
1259
                TimeSpan batchFailedRetention,
1260
                CancellationToken cancellationToken = default
1261
        )
1262
        {
1263
                var now = _timeProvider.GetUtcNow();
4✔
1264
                return ExecuteWithStrategyAsync(
4✔
1265
                        operationCancellationToken => PurgeBatchesCoreAsync(
4✔
1266
                                now - batchSucceededRetention,
4✔
1267
                                now - batchFailedRetention,
4✔
1268
                                operationCancellationToken
4✔
1269
                        ),
4✔
1270
                        cancellationToken
4✔
1271
                );
4✔
1272
        }
1273

1274
        private async Task PurgeJobsCoreAsync(
1275
                DateTimeOffset succeededBefore,
1276
                DateTimeOffset failedBefore,
1277
                CancellationToken cancellationToken
1278
        )
1279
        {
1280
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
1281
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
4✔
1282
                var jobs = await context.Set<ImmediateJobEntity>()
4✔
1283
                        .Where(job => job.BatchId == null
4✔
1284
                                && ((job.State == JobState.Succeeded && job.CompletedAt < succeededBefore)
4✔
1285
                                || ((job.State == JobState.Failed || job.State == JobState.Cancelled) && job.CompletedAt < failedBefore))
4✔
1286
                        )
4✔
1287
                        .ToListAsync(cancellationToken)
4✔
1288
                        .ConfigureAwait(false);
4✔
1289
                if (jobs.Count != 0)
4✔
1290
                {
1291
                        var jobIds = jobs.Select(static job => job.Id).ToArray();
×
1292
                        var edges = await context.Set<ImmediateJobContinuationEntity>()
×
1293
                                .Where(edge => jobIds.Contains(edge.ChildJobId)
×
1294
                                        || jobIds.Contains(edge.ParentId) && edge.ParentKind == ContinuationParentKind.Job)
×
1295
                                .ToListAsync(cancellationToken)
×
1296
                                .ConfigureAwait(false);
×
1297
                        context.RemoveRange(edges);
×
1298
                }
1299

1300
                context.RemoveRange(jobs);
4✔
1301
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
1302
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
4✔
1303
        }
4✔
1304

1305
        private async Task PurgeBatchesCoreAsync(
1306
                DateTimeOffset batchSucceededBefore,
1307
                DateTimeOffset batchFailedBefore,
1308
                CancellationToken cancellationToken
1309
        )
1310
        {
1311
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
1312
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
4✔
1313
                var batches = await context.Set<ImmediateJobBatchEntity>()
4✔
1314
                        .Where(batch => (batch.State == BatchState.Succeeded && batch.CompletedAt < batchSucceededBefore)
4✔
1315
                                || ((batch.State == BatchState.Failed || batch.State == BatchState.Cancelled)
4✔
1316
                                        && batch.CompletedAt < batchFailedBefore))
4✔
1317
                        .ToListAsync(cancellationToken)
4✔
1318
                        .ConfigureAwait(false);
4✔
1319
                if (batches.Count != 0)
4✔
1320
                {
1321
                        var batchIds = batches.Select(static batch => batch.Id).ToArray();
×
1322
                        var memberIds = await context.Set<ImmediateJobEntity>()
×
1323
                                .Where(job => job.BatchId != null && batchIds.Contains(job.BatchId))
×
1324
                                .Select(job => job.Id)
×
1325
                                .ToListAsync(cancellationToken)
×
1326
                                .ConfigureAwait(false);
×
1327
                        var edges = await context.Set<ImmediateJobContinuationEntity>()
×
1328
                                .Where(edge => batchIds.Contains(edge.ParentId) && edge.ParentKind == ContinuationParentKind.Batch
×
1329
                                        || memberIds.Contains(edge.ChildJobId)
×
1330
                                        || memberIds.Contains(edge.ParentId) && edge.ParentKind == ContinuationParentKind.Job)
×
1331
                                .ToListAsync(cancellationToken)
×
1332
                                .ConfigureAwait(false);
×
1333
                        context.RemoveRange(edges);
×
1334
                        context.RemoveRange(batches);
×
1335
                }
×
1336

1337
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
1338
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
4✔
1339
        }
4✔
1340

1341
        /// <inheritdoc />
1342
        public async ValueTask HeartbeatAsync(JobServerSnapshot server, CancellationToken cancellationToken = default)
1343
        {
1344
                ArgumentNullException.ThrowIfNull(server);
×
1345
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
1346
                var entity = await context.Set<ImmediateJobServerEntity>().FindAsync([server.WorkerId], cancellationToken).ConfigureAwait(false);
×
1347
                if (entity is null)
×
1348
                {
1349
                        _ = context.Add(new ImmediateJobServerEntity
×
1350
                        {
×
1351
                                WorkerId = server.WorkerId,
×
1352
                                LastHeartbeat = server.LastHeartbeat,
×
1353
                                ActiveWorkers = server.ActiveWorkers,
×
1354
                                MaxWorkers = server.MaxWorkers,
×
1355
                        });
×
1356
                }
1357
                else
1358
                {
1359
                        entity.LastHeartbeat = server.LastHeartbeat;
×
1360
                        entity.ActiveWorkers = server.ActiveWorkers;
×
1361
                        entity.MaxWorkers = server.MaxWorkers;
×
1362
                }
1363

1364
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1365
        }
×
1366

1367
        /// <inheritdoc />
1368
        public async ValueTask<bool> IsHealthyAsync(CancellationToken cancellationToken = default)
1369
        {
1370
                try
1371
                {
1372
                        await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
1373
                        return await context.Database.CanConnectAsync(cancellationToken).ConfigureAwait(false);
×
1374
                }
×
1375
                catch (Exception exception) when (exception is DbException or InvalidOperationException)
×
1376
                {
1377
                        return false;
×
1378
                }
1379
        }
×
1380

1381
        private async ValueTask MutateOwnedWithDependenciesAsync(
1382
                string jobId,
1383
                string workerId,
1384
                string? error,
1385
                DateTimeOffset? nextRetryAt,
1386
                bool succeeded,
1387
                IReadOnlyList<JobContinuationAddition> additions,
1388
                CancellationToken cancellationToken
1389
        )
1390
        {
1391
                const int MaxConcurrencyAttempts = 5;
1392
                for (var attempt = 0; attempt < MaxConcurrencyAttempts; attempt++)
5✔
1393
                {
1394
                        try
1395
                        {
1396
                                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1397
                                var strategy = strategyContext.Database.CreateExecutionStrategy();
5✔
1398
                                await strategy.ExecuteAsync(
5✔
1399
                                        operationCancellationToken => MutateOwnedCoreAsync(
5✔
1400
                                                jobId,
5✔
1401
                                                workerId,
5✔
1402
                                                error,
5✔
1403
                                                nextRetryAt,
5✔
1404
                                                succeeded,
5✔
1405
                                                additions,
5✔
1406
                                                operationCancellationToken
5✔
1407
                                        ),
5✔
1408
                                        cancellationToken
5✔
1409
                                ).ConfigureAwait(false);
5✔
1410
                                return;
5✔
1411
                        }
×
1412
                        catch (DbUpdateConcurrencyException) when (attempt + 1 < MaxConcurrencyAttempts)
×
1413
                        {
1414
                                // A competing parent completion changed a shared child or batch counter. The whole
1415
                                // transaction rolled back, so retrying re-evaluates the graph from durable state.
1416
                        }
×
1417
                }
1418
        }
5✔
1419

1420
        private async ValueTask ExecuteWithStrategyAsync(
1421
                Func<CancellationToken, Task> operation,
1422
                CancellationToken cancellationToken
1423
        )
1424
        {
1425
                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
1426
                var strategy = strategyContext.Database.CreateExecutionStrategy();
4✔
1427
                await strategy.ExecuteAsync(operation, cancellationToken).ConfigureAwait(false);
4✔
1428
        }
4✔
1429

1430
        private async ValueTask MutateOwnedAsync(
1431
                string jobId,
1432
                string workerId,
1433
                Action<ImmediateJobEntity> mutate,
1434
                CancellationToken cancellationToken
1435
        )
1436
        {
1437
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
1✔
1438
                var job = await context.Set<ImmediateJobEntity>()
1✔
1439
                        .SingleOrDefaultAsync(item => item.Id == jobId && item.State == JobState.Active && item.WorkerId == workerId, cancellationToken)
1✔
1440
                        .ConfigureAwait(false);
1✔
1441
                if (job is null)
1✔
1442
                        return;
1443
                mutate(job);
1✔
1444
                job.ConcurrencyStamp = Guid.NewGuid();
1✔
1445
                try
1446
                {
1447
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
1✔
1448
                }
1✔
1449
                catch (DbUpdateConcurrencyException)
×
1450
                {
1451
                        // A stale worker must not update a lease that has been reclaimed.
1452
                }
1✔
1453
        }
1✔
1454

1455
        private async Task MutateOwnedCoreAsync(
1456
                string jobId,
1457
                string workerId,
1458
                string? error,
1459
                DateTimeOffset? nextRetryAt,
1460
                bool succeeded,
1461
                IReadOnlyList<JobContinuationAddition> additions,
1462
                CancellationToken cancellationToken
1463
        )
1464
        {
1465
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1466
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1467
                var job = await context.Set<ImmediateJobEntity>()
5✔
1468
                        .SingleOrDefaultAsync(item => item.Id == jobId && item.State == JobState.Active && item.WorkerId == workerId, cancellationToken)
5✔
1469
                        .ConfigureAwait(false);
5✔
1470
                if (job is null)
5✔
1471
                        return;
1472

1473
                var now = _timeProvider.GetUtcNow();
5✔
1474
                job.WorkerId = null;
5✔
1475
                job.LeaseExpiresAt = null;
5✔
1476
                job.LastError = error;
5✔
1477
                job.ConcurrencyStamp = Guid.NewGuid();
5✔
1478
                if (!succeeded && nextRetryAt is { } retryAt)
5✔
1479
                {
1480
                        job.State = retryAt <= now ? JobState.Pending : JobState.Scheduled;
×
1481
                        job.DueAt = retryAt;
×
1482
                        job.CompletedAt = null;
×
1483
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1484
                        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
×
1485
                        return;
×
1486
                }
1487

1488
                if (succeeded && additions.Count != 0)
5✔
1489
                {
1490
                        await FlushContinuationAdditionsAsync(context, job, additions, cancellationToken).ConfigureAwait(false);
×
1491
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1492
                }
1493

1494
                job.State = succeeded ? JobState.Succeeded : JobState.Failed;
5✔
1495
                job.CompletedAt = now;
5✔
1496
                await PropagateTerminalAsync(context, job, now, cancellationToken).ConfigureAwait(false);
5✔
1497
                var terminalGroups = GetTerminalFairQueueGroups(context);
5✔
1498
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
1499
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
5✔
1500
                foreach (var (queueName, groupId) in terminalGroups)
5✔
1501
                        await TryRemoveFairQueueCursorAsync(queueName, groupId, CancellationToken.None).ConfigureAwait(false);
5✔
1502
        }
5✔
1503

1504
        private static (string QueueName, string GroupId)[] GetTerminalFairQueueGroups(TContext context) =>
1505
                [
5✔
1506
                        .. context.ChangeTracker
5✔
1507
                                .Entries<ImmediateJobEntity>()
5✔
1508
                                .Select(static entry => entry.Entity)
5✔
1509
                                .Where(static job => job.GroupId is not null && IsTerminal(job.State))
5✔
1510
                                .Select(static job => (job.QueueName, GroupId: job.GroupId!))
5✔
1511
                                .Distinct(),
5✔
1512
                ];
5✔
1513

1514
        private async ValueTask TryRemoveFairQueueCursorAsync(
1515
                string queueName,
1516
                string groupId,
1517
                CancellationToken cancellationToken
1518
        )
1519
        {
1520
                try
1521
                {
1522
                        await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1523
                        if (await context.Set<ImmediateJobEntity>()
5✔
1524
                                .AnyAsync(
5✔
1525
                                        job => job.QueueName == queueName
5✔
1526
                                                && job.GroupId == groupId
5✔
1527
                                                && (job.State == JobState.Pending
5✔
1528
                                                        || job.State == JobState.Scheduled
5✔
1529
                                                        || job.State == JobState.Active),
5✔
1530
                                        cancellationToken
5✔
1531
                                )
5✔
1532
                                .ConfigureAwait(false))
5✔
1533
                        {
1534
                                return;
1535
                        }
1536

1537
                        var cursor = await context.Set<ImmediateFairQueueGroupEntity>()
5✔
1538
                                .SingleOrDefaultAsync(
5✔
1539
                                        group => group.QueueName == queueName && group.GroupId == groupId,
5✔
1540
                                        cancellationToken
5✔
1541
                                )
5✔
1542
                                .ConfigureAwait(false);
5✔
1543
                        if (cursor is null)
5✔
1544
                                return;
1545

1546
                        _ = context.Remove(cursor);
4✔
1547
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
1548
                }
4✔
NEW
1549
                catch (Exception exception) when (
×
NEW
1550
                        !cancellationToken.IsCancellationRequested
×
NEW
1551
                        && exception is DbException or DbUpdateException
×
NEW
1552
                )
×
1553
                {
1554
                        // Cleanup is best-effort metadata maintenance and must not invalidate a committed transition.
NEW
1555
                }
×
1556
        }
5✔
1557

1558
        private async Task AddBatchJobCoreAsync(
1559
                string currentJobId,
1560
                JobRecord record,
1561
                ContinuationOptions options,
1562
                CancellationToken cancellationToken
1563
        )
1564
        {
1565
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
1566
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
×
1567
                var current = await context.Set<ImmediateJobEntity>()
×
1568
                        .SingleOrDefaultAsync(job => job.Id == currentJobId && job.State == JobState.Active, cancellationToken)
×
1569
                        .ConfigureAwait(false)
×
1570
                        ?? throw new ImmediateJobException($"The current active job '{currentJobId}' was not found.");
×
1571
                if (current.BatchId is not { } batchId)
×
1572
                        throw new ImmediateJobException("The current job does not belong to a batch.");
×
1573
                var batch = await context.Set<ImmediateJobBatchEntity>()
×
1574
                        .SingleAsync(item => item.Id == batchId && item.State == BatchState.Executing, cancellationToken)
×
1575
                        .ConfigureAwait(false);
×
1576
                var job = ToEntity(record with { BatchId = batchId });
×
1577
                _ = context.Add(job);
×
1578
                batch.TotalJobs++;
×
1579
                batch.PendingCount++;
×
1580
                batch.ConcurrencyStamp = Guid.NewGuid();
×
1581

1582
                if (options == ContinuationOptions.BeforeContinuations)
×
1583
                {
1584
                        var waiters = await GetActiveWaitersAsync(context, currentJobId, cancellationToken).ConfigureAwait(false);
×
1585
                        foreach (var waiter in waiters)
×
1586
                        {
1587
                                _ = context.Add(new ImmediateJobContinuationEntity
×
1588
                                {
×
1589
                                        ChildJobId = waiter.Id,
×
1590
                                        ParentKind = ContinuationParentKind.Job,
×
1591
                                        ParentId = job.Id,
×
1592
                                        Trigger = ContinuationTrigger.Success,
×
1593
                                });
×
1594
                                waiter.RemainingDependencies++;
×
1595
                                waiter.ConcurrencyStamp = Guid.NewGuid();
×
1596
                        }
1597
                }
1598

1599
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1600
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
×
1601
        }
×
1602

1603
        private static async Task FlushContinuationAdditionsAsync(
1604
                TContext context,
1605
                ImmediateJobEntity current,
1606
                IReadOnlyList<JobContinuationAddition> additions,
1607
                CancellationToken cancellationToken
1608
        )
1609
        {
1610
                var ids = additions.Select(static addition => addition.Job.Id).ToHashSet(StringComparer.Ordinal);
×
1611
                if (ids.Count != additions.Count)
×
1612
                        throw new ImmediateJobException("Buffered continuations contain duplicate job identifiers.");
×
1613
                var waiters = additions.Any(static addition => addition.Options == ContinuationOptions.BeforeContinuations)
×
1614
                        ? await GetActiveWaitersAsync(context, current.Id, cancellationToken).ConfigureAwait(false)
×
1615
                        : [];
×
1616
                ImmediateJobBatchEntity? batch = null;
1617
                var trackedAdditions = additions.Count(static addition => addition.Options != ContinuationOptions.Detached);
×
1618
                if (trackedAdditions != 0)
×
1619
                {
1620
                        if (current.BatchId is not { } batchId)
×
1621
                                throw new ImmediateJobException("The current job does not belong to a batch.");
×
1622
                        batch = await context.Set<ImmediateJobBatchEntity>()
×
1623
                                .SingleAsync(item => item.Id == batchId && item.State == BatchState.Executing, cancellationToken)
×
1624
                                .ConfigureAwait(false);
×
1625
                        batch.TotalJobs += trackedAdditions;
×
1626
                        batch.PendingCount += trackedAdditions;
×
1627
                        batch.ConcurrencyStamp = Guid.NewGuid();
×
1628
                }
1629

1630
                foreach (var addition in additions)
×
1631
                {
1632
                        var inBatch = addition.Options != ContinuationOptions.Detached;
×
1633
                        var job = ToEntity(addition.Job with
×
1634
                        {
×
1635
                                BatchId = inBatch ? current.BatchId : null,
×
1636
                                State = JobState.AwaitingContinuation,
×
1637
                                RemainingDependencies = 1,
×
1638
                        });
×
1639
                        _ = context.Add(job);
×
1640
                        _ = context.Add(new ImmediateJobContinuationEntity
×
1641
                        {
×
1642
                                ChildJobId = job.Id,
×
1643
                                ParentKind = ContinuationParentKind.Job,
×
1644
                                ParentId = current.Id,
×
1645
                                Trigger = addition.Trigger,
×
1646
                        });
×
1647

1648
                        if (addition.Options != ContinuationOptions.BeforeContinuations)
×
1649
                                continue;
1650
                        foreach (var waiter in waiters)
×
1651
                        {
1652
                                _ = context.Add(new ImmediateJobContinuationEntity
×
1653
                                {
×
1654
                                        ChildJobId = waiter.Id,
×
1655
                                        ParentKind = ContinuationParentKind.Job,
×
1656
                                        ParentId = job.Id,
×
1657
                                        Trigger = ContinuationTrigger.Success,
×
1658
                                });
×
1659
                                waiter.RemainingDependencies++;
×
1660
                                waiter.ConcurrencyStamp = Guid.NewGuid();
×
1661
                        }
1662
                }
1663
        }
×
1664

1665
        private static async Task<List<ImmediateJobEntity>> GetActiveWaitersAsync(
1666
                TContext context,
1667
                string currentJobId,
1668
                CancellationToken cancellationToken
1669
        )
1670
        {
1671
                var waiterIds = await context.Set<ImmediateJobContinuationEntity>()
×
1672
                        .Where(edge => edge.ParentKind == ContinuationParentKind.Job && edge.ParentId == currentJobId)
×
1673
                        .Select(edge => edge.ChildJobId)
×
1674
                        .Distinct()
×
1675
                        .ToArrayAsync(cancellationToken)
×
1676
                        .ConfigureAwait(false);
×
1677
                return waiterIds.Length == 0
×
1678
                        ? []
×
1679
                        : await context.Set<ImmediateJobEntity>()
×
1680
                                .Where(job => waiterIds.Contains(job.Id) && job.State == JobState.AwaitingContinuation)
×
1681
                                .ToListAsync(cancellationToken)
×
1682
                                .ConfigureAwait(false);
×
1683
        }
×
1684

1685
        private static async Task PropagateTerminalAsync(
1686
                TContext context,
1687
                ImmediateJobEntity terminalJob,
1688
                DateTimeOffset now,
1689
                CancellationToken cancellationToken
1690
        )
1691
        {
1692
                var parents = new Queue<(ContinuationParentKind Kind, string Id, bool Succeeded, bool Failed)>();
5✔
1693
                var processed = new HashSet<(ContinuationParentKind Kind, string Id)>();
5✔
1694
                parents.Enqueue((
5✔
1695
                        ContinuationParentKind.Job,
5✔
1696
                        terminalJob.Id,
5✔
1697
                        terminalJob.State == JobState.Succeeded,
5✔
1698
                        terminalJob.State == JobState.Failed
5✔
1699
                ));
5✔
1700
                await UpdateBatchForTerminalJobAsync(context, terminalJob, now, parents, cancellationToken).ConfigureAwait(false);
5✔
1701

1702
                while (parents.TryDequeue(out var parent))
5✔
1703
                {
1704
                        if (!processed.Add((parent.Kind, parent.Id)))
5✔
1705
                                continue;
1706
                        var edges = await context.Set<ImmediateJobContinuationEntity>()
5✔
1707
                                .Where(edge => edge.ParentKind == parent.Kind && edge.ParentId == parent.Id)
5✔
1708
                                .ToListAsync(cancellationToken)
5✔
1709
                                .ConfigureAwait(false);
5✔
1710
                        foreach (var edge in edges)
5✔
1711
                        {
1712
                                var child = await context.Set<ImmediateJobEntity>()
5✔
1713
                                        .SingleOrDefaultAsync(job => job.Id == edge.ChildJobId, cancellationToken)
5✔
1714
                                        .ConfigureAwait(false);
5✔
1715
                                if (child is null || IsTerminal(child.State))
5✔
1716
                                        continue;
1717

1718
                                if (edge.Trigger == ContinuationTrigger.Success && !parent.Succeeded)
5✔
1719
                                {
1720
                                        child.State = JobState.Cancelled;
×
1721
                                        child.RemainingDependencies = 0;
×
1722
                                        child.CompletedAt = now;
×
1723
                                        child.WorkerId = null;
×
1724
                                        child.LeaseExpiresAt = null;
×
1725
                                        child.ConcurrencyStamp = Guid.NewGuid();
×
1726
                                        parents.Enqueue((ContinuationParentKind.Job, child.Id, Succeeded: false, Failed: false));
×
1727
                                        await UpdateBatchForTerminalJobAsync(context, child, now, parents, cancellationToken).ConfigureAwait(false);
×
1728
                                        continue;
×
1729
                                }
1730

1731
                                if (child.State != JobState.AwaitingContinuation || child.RemainingDependencies <= 0)
5✔
1732
                                        continue;
1733
                                child.RemainingDependencies--;
5✔
1734
                                if (parent.Failed)
5✔
1735
                                        child.FailedDependencies++;
4✔
1736
                                if (child.RemainingDependencies == 0)
5✔
1737
                                {
1738
                                        if (edge.Trigger == ContinuationTrigger.Failure && child.FailedDependencies == 0)
5✔
1739
                                        {
1740
                                                child.State = JobState.Cancelled;
4✔
1741
                                                child.CompletedAt = now;
4✔
1742
                                                parents.Enqueue((ContinuationParentKind.Job, child.Id, Succeeded: false, Failed: false));
4✔
1743
                                                await UpdateBatchForTerminalJobAsync(context, child, now, parents, cancellationToken).ConfigureAwait(false);
4✔
1744
                                        }
1745
                                        else
1746
                                        {
1747
                                                child.State = child.DueAt <= now ? JobState.Pending : JobState.Scheduled;
5✔
1748
                                        }
1749
                                }
1750

1751
                                child.ConcurrencyStamp = Guid.NewGuid();
5✔
1752
                        }
5✔
1753
                }
5✔
1754
        }
5✔
1755

1756
        private static async Task UpdateBatchForTerminalJobAsync(
1757
                TContext context,
1758
                ImmediateJobEntity job,
1759
                DateTimeOffset now,
1760
                Queue<(ContinuationParentKind Kind, string Id, bool Succeeded, bool Failed)> parents,
1761
                CancellationToken cancellationToken
1762
        )
1763
        {
1764
                if (job.BatchId is not { } batchId)
5✔
1765
                        return;
5✔
1766
                var batch = await context.Set<ImmediateJobBatchEntity>()
5✔
1767
                        .SingleAsync(item => item.Id == batchId, cancellationToken)
5✔
1768
                        .ConfigureAwait(false);
5✔
1769
                batch.PendingCount = Math.Max(0, batch.PendingCount - 1);
5✔
1770
                switch (job.State)
5✔
1771
                {
1772
                        case JobState.Succeeded:
1773
                                batch.SucceededCount++;
5✔
1774
                                break;
5✔
1775
                        case JobState.Failed:
1776
                                batch.FailedCount++;
×
1777
                                break;
×
1778
                        case JobState.Cancelled:
1779
                                batch.CancelledCount++;
4✔
1780
                                break;
4✔
1781
                        case JobState.AwaitingContinuation:
1782
                        case JobState.AwaitingParameters:
1783
                        case JobState.Scheduled:
1784
                        case JobState.Pending:
1785
                        case JobState.Active:
1786
                                throw new ImmediateJobException($"Job '{job.Id}' is not terminal.");
×
1787
                        default:
1788
                                throw new ArgumentOutOfRangeException(nameof(job), job.State, "Unknown job state.");
×
1789
                }
1790

1791
                batch.ConcurrencyStamp = Guid.NewGuid();
5✔
1792
                if (batch.PendingCount != 0)
5✔
1793
                        return;
5✔
1794
                batch.State = GetTerminalBatchState(batch.FailedCount, batch.CancelledCount);
5✔
1795
                batch.CompletedAt = now;
5✔
1796
                parents.Enqueue((
5✔
1797
                        ContinuationParentKind.Batch,
5✔
1798
                        batch.Id,
5✔
1799
                        batch.State == BatchState.Succeeded,
5✔
1800
                        batch.State == BatchState.Failed
5✔
1801
                ));
5✔
1802
        }
5✔
1803

1804
        private static async Task EvaluateInitialDependenciesAsync(
1805
                TContext context,
1806
                Dictionary<string, ImmediateJobEntity> jobs,
1807
                ImmediateJobContinuationEntity[] edges,
1808
                DateTimeOffset now,
1809
                CancellationToken cancellationToken
1810
        )
1811
        {
1812
                var externalJobIds = edges
5✔
1813
                        .Where(edge => edge.ParentKind == ContinuationParentKind.Job && !jobs.ContainsKey(edge.ParentId))
5✔
1814
                        .Select(static edge => edge.ParentId)
5✔
1815
                        .Distinct(StringComparer.Ordinal)
5✔
1816
                        .ToArray();
5✔
1817
                var externalBatchIds = edges
5✔
1818
                        .Where(static edge => edge.ParentKind == ContinuationParentKind.Batch)
5✔
1819
                        .Select(static edge => edge.ParentId)
×
1820
                        .Distinct(StringComparer.Ordinal)
5✔
1821
                        .ToArray();
5✔
1822
                var externalJobs = externalJobIds.Length == 0
5✔
1823
                        ? []
5✔
1824
                        : await context.Set<ImmediateJobEntity>()
5✔
1825
                                .AsNoTracking()
5✔
1826
                                .Where(job => externalJobIds.Contains(job.Id))
5✔
1827
                                .ToDictionaryAsync(job => job.Id, job => job.State, StringComparer.Ordinal, cancellationToken)
4✔
1828
                                .ConfigureAwait(false);
5✔
1829
                var externalBatches = externalBatchIds.Length == 0
5✔
1830
                        ? []
5✔
1831
                        : await context.Set<ImmediateJobBatchEntity>()
5✔
1832
                                .AsNoTracking()
5✔
1833
                                .Where(batch => externalBatchIds.Contains(batch.Id))
5✔
1834
                                .ToDictionaryAsync(batch => batch.Id, batch => batch.State, StringComparer.Ordinal, cancellationToken)
×
1835
                                .ConfigureAwait(false);
5✔
1836
                if (externalJobs.Count != externalJobIds.Length || externalBatches.Count != externalBatchIds.Length)
5✔
1837
                        throw new ImmediateJobException("A continuation parent does not exist.");
5✔
1838

1839
                var incoming = edges.ToLookup(static edge => edge.ChildJobId, StringComparer.Ordinal);
5✔
1840
                var changed = true;
5✔
1841
                while (changed)
5✔
1842
                {
1843
                        changed = false;
5✔
1844
                        foreach (var job in jobs.Values)
5✔
1845
                        {
1846
                                var dependencies = incoming[job.Id];
5✔
1847
                                if (!dependencies.Any() || IsTerminal(job.State))
5✔
1848
                                        continue;
1849
                                var remaining = 0;
5✔
1850
                                var failedDependencies = 0;
5✔
1851
                                var requiresFailure = false;
5✔
1852
                                var violated = false;
5✔
1853
                                foreach (var edge in dependencies)
5✔
1854
                                {
1855
                                        var (terminal, parentSucceeded, parentFailed) = GetParentState(
5✔
1856
                                                edge,
5✔
1857
                                                jobs,
5✔
1858
                                                externalJobs,
5✔
1859
                                                externalBatches
5✔
1860
                                        );
5✔
1861
                                        requiresFailure |= edge.Trigger == ContinuationTrigger.Failure;
5✔
1862
                                        if (!terminal)
5✔
1863
                                        {
1864
                                                remaining++;
5✔
1865
                                                continue;
5✔
1866
                                        }
1867

1868
                                        if (parentFailed)
×
1869
                                                failedDependencies++;
×
1870
                                        if (edge.Trigger == ContinuationTrigger.Success && !parentSucceeded)
×
1871
                                                violated = true;
×
1872
                                }
1873

1874
                                job.FailedDependencies = failedDependencies;
5✔
1875
                                if (violated || remaining == 0 && requiresFailure && failedDependencies == 0)
5✔
1876
                                {
1877
                                        job.State = JobState.Cancelled;
×
1878
                                        job.RemainingDependencies = 0;
×
1879
                                        job.CompletedAt = now;
×
1880
                                        changed = true;
×
1881
                                }
1882
                                else if (remaining == 0)
5✔
1883
                                {
1884
                                        job.State = job.DueAt <= now ? JobState.Pending : JobState.Scheduled;
×
1885
                                        job.RemainingDependencies = 0;
×
1886
                                }
1887
                                else
1888
                                {
1889
                                        job.State = JobState.AwaitingContinuation;
5✔
1890
                                        job.RemainingDependencies = remaining;
5✔
1891
                                }
1892
                        }
1893
                }
1894
        }
5✔
1895

1896
        private static (bool Terminal, bool Succeeded, bool Failed) GetParentState(
1897
                ImmediateJobContinuationEntity edge,
1898
                Dictionary<string, ImmediateJobEntity> jobs,
1899
                Dictionary<string, JobState> externalJobs,
1900
                Dictionary<string, BatchState> externalBatches
1901
        )
1902
        {
1903
                if (edge.ParentKind == ContinuationParentKind.Batch)
5✔
1904
                {
1905
                        var state = externalBatches[edge.ParentId];
×
1906
                        return (state != BatchState.Executing, state == BatchState.Succeeded, state == BatchState.Failed);
×
1907
                }
1908

1909
                var jobState = jobs.TryGetValue(edge.ParentId, out var job) ? job.State : externalJobs[edge.ParentId];
5✔
1910
                return (IsTerminal(jobState), jobState == JobState.Succeeded, jobState == JobState.Failed);
5✔
1911
        }
1912

1913
        private static void ThrowIfCyclic(
1914
                HashSet<string> jobIds,
1915
                IReadOnlyList<ImmediateJobContinuationEntity> edges
1916
        )
1917
        {
1918
                var indegree = jobIds.ToDictionary(static id => id, static _ => 0, StringComparer.Ordinal);
5✔
1919
                var children = new Dictionary<string, List<string>>(StringComparer.Ordinal);
5✔
1920
                foreach (var edge in edges.Where(edge =>
5✔
1921
                        edge.ParentKind == ContinuationParentKind.Job && jobIds.Contains(edge.ParentId)))
5✔
1922
                {
1923
                        indegree[edge.ChildJobId]++;
5✔
1924
                        if (!children.TryGetValue(edge.ParentId, out var values))
5✔
1925
                                children[edge.ParentId] = values = [];
5✔
1926
                        values.Add(edge.ChildJobId);
5✔
1927
                }
1928

1929
                var ready = new Queue<string>(indegree.Where(static pair => pair.Value == 0).Select(static pair => pair.Key));
5✔
1930
                var visited = 0;
5✔
1931
                while (ready.TryDequeue(out var parent))
5✔
1932
                {
1933
                        visited++;
5✔
1934
                        if (!children.TryGetValue(parent, out var values))
5✔
1935
                                continue;
1936
                        foreach (var child in values)
5✔
1937
                        {
1938
                                if (--indegree[child] == 0)
5✔
1939
                                        ready.Enqueue(child);
5✔
1940
                        }
1941
                }
1942

1943
                if (visited != jobIds.Count)
5✔
1944
                        throw new ImmediateJobException("IJOB018: The continuation graph contains a dependency cycle.");
×
1945
        }
5✔
1946

1947
        private static bool IsTerminal(JobState state) =>
1948
                state is JobState.Succeeded or JobState.Failed or JobState.Cancelled;
5✔
1949

1950
        private static BatchState GetTerminalBatchState(int failed, int cancelled) =>
1951
                failed != 0 ? BatchState.Failed : cancelled != 0 ? BatchState.Cancelled : BatchState.Succeeded;
5✔
1952

1953
        private async ValueTask MutateRecurringAsync(string name, Action<ImmediateRecurringJobEntity> mutate, CancellationToken cancellationToken)
1954
        {
1955
                ArgumentException.ThrowIfNullOrWhiteSpace(name);
×
1956
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
1957
                var schedule = await context.Set<ImmediateRecurringJobEntity>().FindAsync([name], cancellationToken).ConfigureAwait(false);
×
1958
                if (schedule is null)
×
1959
                        return;
1960
                mutate(schedule);
×
1961
                schedule.ConcurrencyStamp = Guid.NewGuid();
×
1962
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1963
        }
×
1964

1965
        private static Task<int> UpdateRecurringAsync(
1966
                TContext context,
1967
                RecurringJobSchedule schedule,
1968
                CancellationToken cancellationToken
1969
        )
1970
        {
1971
                var concurrencyStamp = Guid.NewGuid();
5✔
1972
                return context.Set<ImmediateRecurringJobEntity>()
5✔
1973
                        .Where(entity => entity.Name == schedule.Name && (schedule.IsCodeDefined || !entity.IsCodeDefined))
5✔
1974
                        .ExecuteUpdateAsync(setters => setters
5✔
1975
                                .SetProperty(entity => entity.JobName, schedule.JobName)
5✔
1976
                                .SetProperty(entity => entity.Cron, schedule.Cron)
5✔
1977
                                .SetProperty(entity => entity.TimeZone, schedule.TimeZone)
5✔
1978
                                .SetProperty(entity => entity.IsCodeDefined, schedule.IsCodeDefined)
5✔
1979
                                .SetProperty(entity => entity.NextRunAt, schedule.NextRunAt)
5✔
1980
                                .SetProperty(entity => entity.ConcurrencyStamp, concurrencyStamp),
5✔
1981
                                cancellationToken);
5✔
1982
        }
1983

1984
        private static async Task ThrowIfReplacingCodeDefinedScheduleAsync(
1985
                TContext context,
1986
                RecurringJobSchedule schedule,
1987
                CancellationToken cancellationToken
1988
        )
1989
        {
1990
                if (!schedule.IsCodeDefined && await context.Set<ImmediateRecurringJobEntity>()
5✔
1991
                        .AnyAsync(entity => entity.Name == schedule.Name && entity.IsCodeDefined, cancellationToken)
5✔
1992
                        .ConfigureAwait(false))
5✔
1993
                {
1994
                        throw new ImmediateJobException("Code-defined recurring schedules cannot be replaced by dynamic schedules.");
4✔
1995
                }
1996
        }
5✔
1997

1998
        private static ImmediateJobEntity ToEntity(JobRecord job) => new()
5✔
1999
        {
5✔
2000
                Id = job.Id,
5✔
2001
                QueueName = job.QueueName,
5✔
2002
                JobName = job.JobName,
5✔
2003
                GroupId = job.GroupId,
5✔
2004
                Payload = job.Payload,
5✔
2005
                Context = job.Context,
5✔
2006
                State = job.State,
5✔
2007
                DueAt = job.DueAt,
5✔
2008
                CreatedAt = job.CreatedAt,
5✔
2009
                Attempt = job.Attempt,
5✔
2010
                WorkerId = job.WorkerId,
5✔
2011
                LeaseExpiresAt = job.LeaseExpiresAt,
5✔
2012
                LastError = job.LastError,
5✔
2013
                CompletedAt = job.CompletedAt,
5✔
2014
                RecurringKey = job.RecurringKey,
5✔
2015
                TraceParent = job.TraceParent,
5✔
2016
                TraceState = job.TraceState,
5✔
2017
                ExecutionTraceId = job.ExecutionTraceId,
5✔
2018
                ExecutionSpanId = job.ExecutionSpanId,
5✔
2019
                ExecutionStartedAt = job.ExecutionStartedAt,
5✔
2020
                BatchId = job.BatchId,
5✔
2021
                RemainingDependencies = job.RemainingDependencies,
5✔
2022
                FailedDependencies = job.FailedDependencies,
5✔
2023
                ConcurrencyStamp = Guid.NewGuid(),
5✔
2024
        };
5✔
2025

2026
        private static ImmediateJobContinuationEntity ToEntity(JobContinuationEdge edge)
2027
        {
2028
                var hasJobParent = edge.ParentJobId is not null;
5✔
2029
                var hasBatchParent = edge.ParentBatchId is not null;
5✔
2030
                if (hasJobParent == hasBatchParent)
5✔
2031
                        throw new ImmediateJobException("A continuation edge must identify exactly one parent job or batch.");
×
2032
                return new()
5✔
2033
                {
5✔
2034
                        ChildJobId = edge.ChildJobId,
5✔
2035
                        ParentKind = hasJobParent ? ContinuationParentKind.Job : ContinuationParentKind.Batch,
5✔
2036
                        ParentId = edge.ParentJobId ?? edge.ParentBatchId!,
5✔
2037
                        Trigger = edge.Trigger,
5✔
2038
                };
5✔
2039
        }
2040

2041
        private static ImmediateJobEntity Copy(ImmediateJobEntity job) => new()
5✔
2042
        {
5✔
2043
                Id = job.Id,
5✔
2044
                QueueName = job.QueueName,
5✔
2045
                JobName = job.JobName,
5✔
2046
                GroupId = job.GroupId,
5✔
2047
                Payload = job.Payload,
5✔
2048
                Context = job.Context,
5✔
2049
                State = job.State,
5✔
2050
                DueAt = job.DueAt,
5✔
2051
                CreatedAt = job.CreatedAt,
5✔
2052
                Attempt = job.Attempt,
5✔
2053
                WorkerId = job.WorkerId,
5✔
2054
                LeaseExpiresAt = job.LeaseExpiresAt,
5✔
2055
                LastError = job.LastError,
5✔
2056
                CompletedAt = job.CompletedAt,
5✔
2057
                RecurringKey = job.RecurringKey,
5✔
2058
                TraceParent = job.TraceParent,
5✔
2059
                TraceState = job.TraceState,
5✔
2060
                ExecutionTraceId = job.ExecutionTraceId,
5✔
2061
                ExecutionSpanId = job.ExecutionSpanId,
5✔
2062
                ExecutionStartedAt = job.ExecutionStartedAt,
5✔
2063
                BatchId = job.BatchId,
5✔
2064
                RemainingDependencies = job.RemainingDependencies,
5✔
2065
                FailedDependencies = job.FailedDependencies,
5✔
2066
                ConcurrencyStamp = job.ConcurrencyStamp,
5✔
2067
        };
5✔
2068

2069
        private static JobRecord ToRecord(ImmediateJobEntity job) => new()
5✔
2070
        {
5✔
2071
                Id = job.Id,
5✔
2072
                QueueName = job.QueueName,
5✔
2073
                JobName = job.JobName,
5✔
2074
                GroupId = job.GroupId,
5✔
2075
                Payload = job.Payload,
5✔
2076
                Context = job.Context,
5✔
2077
                State = job.State,
5✔
2078
                DueAt = job.DueAt,
5✔
2079
                CreatedAt = job.CreatedAt,
5✔
2080
                Attempt = job.Attempt,
5✔
2081
                WorkerId = job.WorkerId,
5✔
2082
                LeaseExpiresAt = job.LeaseExpiresAt,
5✔
2083
                LastError = job.LastError,
5✔
2084
                CompletedAt = job.CompletedAt,
5✔
2085
                RecurringKey = job.RecurringKey,
5✔
2086
                TraceParent = job.TraceParent,
5✔
2087
                TraceState = job.TraceState,
5✔
2088
                ExecutionTraceId = job.ExecutionTraceId,
5✔
2089
                ExecutionSpanId = job.ExecutionSpanId,
5✔
2090
                ExecutionStartedAt = job.ExecutionStartedAt,
5✔
2091
                BatchId = job.BatchId,
5✔
2092
                RemainingDependencies = job.RemainingDependencies,
5✔
2093
                FailedDependencies = job.FailedDependencies,
5✔
2094
        };
5✔
2095

2096
        private static BatchStatus ToStatus(ImmediateJobBatchEntity batch) => new(
5✔
2097
                batch.Id,
5✔
2098
                batch.State,
5✔
2099
                batch.TotalJobs,
5✔
2100
                batch.SucceededCount,
5✔
2101
                batch.FailedCount,
5✔
2102
                batch.CancelledCount,
5✔
2103
                batch.PendingCount,
5✔
2104
                batch.CreatedAt,
5✔
2105
                batch.StartedAt,
5✔
2106
                batch.CompletedAt,
5✔
2107
                batch.TotalJobs == 0 ? 1 : (double)(batch.TotalJobs - batch.PendingCount) / batch.TotalJobs
5✔
2108
        );
5✔
2109

2110
        private static BatchGraphEdge ToGraphEdge(ImmediateJobContinuationEntity edge) => new(
4✔
2111
                edge.ChildJobId,
4✔
2112
                edge.ParentKind == ContinuationParentKind.Job ? edge.ParentId : null,
4✔
2113
                edge.ParentKind == ContinuationParentKind.Batch ? edge.ParentId : null,
4✔
2114
                edge.Trigger
4✔
2115
        );
4✔
2116

2117
        private static ImmediateRecurringJobEntity ToEntity(RecurringJobSchedule schedule) => new()
5✔
2118
        {
5✔
2119
                Name = schedule.Name,
5✔
2120
                JobName = schedule.JobName,
5✔
2121
                Cron = schedule.Cron,
5✔
2122
                TimeZone = schedule.TimeZone,
5✔
2123
                IsCodeDefined = schedule.IsCodeDefined,
5✔
2124
                IsPaused = schedule.IsPaused,
5✔
2125
                NextRunAt = schedule.NextRunAt,
5✔
2126
                LastRunAt = schedule.LastRunAt,
5✔
2127
                ConcurrencyStamp = Guid.NewGuid(),
5✔
2128
        };
5✔
2129
}
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