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

ImmediatePlatform / Immediate.Jobs / 30394116826

28 Jul 2026 07:56PM UTC coverage: 74.382%. First build
30394116826

Pull #46

github

web-flow
Merge a20371fc5 into c324de21a
Pull Request #46: Address PR comments

570 of 684 new or added lines in 10 files covered. (83.33%)

5807 of 7807 relevant lines covered (74.38%)

2.4 hits per line

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

80.78
/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 const int MaxConsecutiveFailedFairClaims = 5;
15
        private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
5✔
16

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

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

28
        /// <inheritdoc />
29
        public async ValueTask EnqueueAsync(JobRecord job, CancellationToken cancellationToken = default)
30
        {
31
                ArgumentNullException.ThrowIfNull(job);
5✔
32
                await ExecuteWithStrategyAsync(
5✔
33
                        operationCancellationToken => EnqueueCoreAsync(job, operationCancellationToken),
5✔
34
                        cancellationToken
5✔
35
                ).ConfigureAwait(false);
5✔
36
        }
5✔
37

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

59
                _ = context.Set<ImmediateJobEntity>().Add(ToEntity(job));
5✔
60
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
61
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
5✔
62
        }
5✔
63

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

239
                return acquired;
5✔
240
        }
4✔
241

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

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

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

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

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

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

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

426
                return acquired;
5✔
427
        }
4✔
428

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

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

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

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

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

492
                return acquired;
4✔
493
        }
3✔
494

495
        private async ValueTask<JobRecord?> AcquireFairCandidateAsync(
496
                ImmediateJobEntity candidate,
497
                string workerId,
498
                TimeSpan lease,
499
                DateTimeOffset now,
500
                long nextSequence,
501
                CancellationToken cancellationToken
502
        )
503
        {
504
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
505
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
506
                var entity = Copy(candidate);
5✔
507
                _ = context.Attach(entity);
5✔
508
                entity.State = JobState.Active;
5✔
509
                entity.WorkerId = workerId;
5✔
510
                entity.LeaseExpiresAt = now + lease;
5✔
511
                entity.Attempt++;
5✔
512
                entity.CompletedAt = null;
5✔
513
                entity.ExecutionTraceId = null;
5✔
514
                entity.ExecutionSpanId = null;
5✔
515
                entity.ExecutionStartedAt = null;
5✔
516
                entity.ConcurrencyStamp = Guid.NewGuid();
5✔
517

518
                if (candidate.GroupId is { } groupId)
5✔
519
                {
520
                        var group = await context.Set<ImmediateFairQueueGroupEntity>()
5✔
521
                                .SingleOrDefaultAsync(
5✔
522
                                        item => item.QueueName == candidate.QueueName && item.GroupId == groupId,
5✔
523
                                        cancellationToken
5✔
524
                                )
5✔
525
                                .ConfigureAwait(false);
5✔
526
                        if (group is null)
5✔
527
                        {
528
                                _ = context.Add(new ImmediateFairQueueGroupEntity
5✔
529
                                {
5✔
530
                                        QueueName = candidate.QueueName,
5✔
531
                                        GroupId = groupId,
5✔
532
                                        LastServedSequence = nextSequence,
5✔
533
                                        ConcurrencyStamp = Guid.NewGuid(),
5✔
534
                                });
5✔
535
                        }
536
                        else if (group.LastServedSequence >= nextSequence)
4✔
537
                        {
538
                                // Selection observed an older cursor snapshot. Re-rank instead of moving this group backward.
539
                                return null;
4✔
540
                        }
541
                        else
542
                        {
543
                                group.LastServedSequence = nextSequence;
4✔
544
                                group.ConcurrencyStamp = Guid.NewGuid();
4✔
545
                        }
546
                }
547

548
                if (entity.BatchId is { } batchId)
5✔
549
                {
550
                        var batch = await context.Set<ImmediateJobBatchEntity>()
×
551
                                .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
×
552
                                .ConfigureAwait(false);
×
553
                        if (batch is not null && batch.StartedAt is null)
×
554
                        {
555
                                batch.StartedAt = now;
×
556
                                batch.ConcurrencyStamp = Guid.NewGuid();
×
557
                        }
558
                }
559

560
                try
561
                {
562
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
563
                        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
5✔
564
                        return ToRecord(entity);
5✔
565
                }
566
                catch (DbUpdateException)
4✔
567
                {
568
                        // The job or its group cursor changed after candidate selection.
569
                        return null;
5✔
570
                }
571
        }
4✔
572

573
        private static JobRecord? GetFirstOrDefault(IReadOnlyList<JobRecord> jobs) =>
574
                jobs.Count == 0 ? null : jobs[0];
4✔
575

576
        private static bool IsNoisy(
577
                string? groupId,
578
                int inflight,
579
                int totalInflight,
580
                FairQueuePolicy policy
581
        )
582
        {
583
                return groupId is not null
5✔
584
                        && totalInflight > 0
5✔
585
                        && inflight >= policy.MinInflightForNoisy
5✔
586
                        && (double)inflight / totalInflight > policy.ConcurrencyShareThreshold;
5✔
587
        }
588

589
        private sealed record FairQueueCandidateState(
5✔
590
                string JobId,
5✔
591
                int Inflight,
5✔
592
                long LastServedSequence
5✔
593
        );
5✔
594

595
        /// <inheritdoc />
596
        public async ValueTask<IReadOnlyList<JobRecord>> AcquireJobsAsync(
597
                IReadOnlyCollection<string> jobIds,
598
                string workerId,
599
                TimeSpan lease,
600
                CancellationToken cancellationToken = default
601
        )
602
        {
603
                ArgumentNullException.ThrowIfNull(jobIds);
5✔
604
                ArgumentException.ThrowIfNullOrWhiteSpace(workerId);
5✔
605
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lease, TimeSpan.Zero);
5✔
606
                if (jobIds.Count == 0)
5✔
607
                        return [];
×
608

609
                var now = _timeProvider.GetUtcNow();
5✔
610
                var ids = jobIds.ToArray();
5✔
611
                await using var readContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
612
                var candidates = await readContext.Set<ImmediateJobEntity>()
5✔
613
                        .AsNoTracking()
5✔
614
                        .Where(job => ids.Contains(job.Id) &&
5✔
615
                                (((job.State == JobState.Scheduled || job.State == JobState.Pending) && job.DueAt <= now)
5✔
616
                                || (job.State == JobState.Active && job.LeaseExpiresAt <= now)))
5✔
617
                        .ToListAsync(cancellationToken)
5✔
618
                        .ConfigureAwait(false);
5✔
619

620
                return await AcquireCandidatesAsync(candidates, workerId, lease, now, cancellationToken).ConfigureAwait(false);
5✔
621
        }
4✔
622

623
        private async ValueTask<IReadOnlyList<JobRecord>> AcquireCandidatesAsync(
624
                List<ImmediateJobEntity> candidates,
625
                string workerId,
626
                TimeSpan lease,
627
                DateTimeOffset now,
628
                CancellationToken cancellationToken
629
        )
630
        {
631
                var acquired = new List<JobRecord>(candidates.Count);
5✔
632
                foreach (var candidate in candidates)
5✔
633
                {
634
                        await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
635
                        var entity = Copy(candidate);
5✔
636
                        _ = context.Attach(entity);
5✔
637
                        entity.State = JobState.Active;
5✔
638
                        entity.WorkerId = workerId;
5✔
639
                        entity.LeaseExpiresAt = now + lease;
5✔
640
                        entity.Attempt++;
5✔
641
                        entity.CompletedAt = null;
5✔
642
                        entity.ExecutionTraceId = null;
5✔
643
                        entity.ExecutionSpanId = null;
5✔
644
                        entity.ExecutionStartedAt = null;
5✔
645
                        entity.ConcurrencyStamp = Guid.NewGuid();
5✔
646
                        if (entity.BatchId is { } batchId)
5✔
647
                        {
648
                                var batch = await context.Set<ImmediateJobBatchEntity>()
5✔
649
                                        .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
5✔
650
                                        .ConfigureAwait(false);
5✔
651
                                if (batch is not null && batch.StartedAt is null)
5✔
652
                                {
653
                                        batch.StartedAt = now;
5✔
654
                                        batch.ConcurrencyStamp = Guid.NewGuid();
5✔
655
                                }
656
                        }
657

658
                        try
659
                        {
660
                                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
661
                                acquired.Add(ToRecord(entity));
5✔
662
                        }
5✔
663
                        catch (DbUpdateConcurrencyException)
1✔
664
                        {
665
                                // Another scheduler claimed or changed this candidate first.
666
                        }
1✔
667
                }
4✔
668

669
                return acquired;
5✔
670
        }
4✔
671

672
        /// <inheritdoc />
673
        public ValueTask SetExecutionTelemetryAsync(
674
                string jobId,
675
                string workerId,
676
                string? traceId,
677
                string? spanId,
678
                DateTimeOffset startedAt,
679
                CancellationToken cancellationToken = default
680
        ) => MutateOwnedAsync(jobId, workerId, job =>
1✔
681
        {
1✔
682
                job.ExecutionTraceId = traceId;
1✔
683
                job.ExecutionSpanId = spanId;
1✔
684
                job.ExecutionStartedAt = startedAt;
1✔
685
        }, cancellationToken);
1✔
686

687
        /// <inheritdoc />
688
        public ValueTask RenewLeaseAsync(string jobId, string workerId, TimeSpan lease, CancellationToken cancellationToken = default)
689
        {
690
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lease, TimeSpan.Zero);
×
691
                return MutateOwnedAsync(jobId, workerId, job => job.LeaseExpiresAt = _timeProvider.GetUtcNow() + lease, cancellationToken);
×
692
        }
693

694
        /// <inheritdoc />
695
        public ValueTask CompleteAsync(string jobId, string workerId, CancellationToken cancellationToken = default) =>
696
                CompleteWithContinuationsAsync(jobId, workerId, [], cancellationToken);
5✔
697

698
        /// <inheritdoc />
699
        public ValueTask CompleteWithContinuationsAsync(
700
                string jobId,
701
                string workerId,
702
                IReadOnlyList<JobContinuationAddition> additions,
703
                CancellationToken cancellationToken = default
704
        )
705
        {
706
                ArgumentNullException.ThrowIfNull(additions);
5✔
707
                return MutateOwnedWithDependenciesAsync(
5✔
708
                        jobId,
5✔
709
                        workerId,
5✔
710
                        error: null,
5✔
711
                        nextRetryAt: null,
5✔
712
                        succeeded: true,
5✔
713
                        additions,
5✔
714
                        cancellationToken
5✔
715
                );
5✔
716
        }
717

718
        /// <inheritdoc />
719
        public async ValueTask AddBatchJobAsync(
720
                string currentJobId,
721
                JobRecord job,
722
                ContinuationOptions options,
723
                CancellationToken cancellationToken = default
724
        )
725
        {
726
                ArgumentNullException.ThrowIfNull(job);
5✔
727
                if (options == ContinuationOptions.Detached)
5✔
728
                        throw new ImmediateJobException("AddToBatchAsync cannot create a detached job.");
×
729
                const int MaxConcurrencyAttempts = 5;
730
                for (var attempt = 0; attempt < MaxConcurrencyAttempts; attempt++)
5✔
731
                {
732
                        try
733
                        {
734
                                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
735
                                var strategy = strategyContext.Database.CreateExecutionStrategy();
5✔
736
                                await strategy.ExecuteAsync(
5✔
737
                                        operationCancellationToken => AddBatchJobCoreAsync(
5✔
738
                                                currentJobId,
5✔
739
                                                job,
5✔
740
                                                options,
5✔
741
                                                operationCancellationToken
5✔
742
                                        ),
5✔
743
                                        cancellationToken
5✔
744
                                ).ConfigureAwait(false);
5✔
745
                                return;
×
746
                        }
×
747
                        catch (DbUpdateConcurrencyException) when (attempt + 1 < MaxConcurrencyAttempts)
5✔
748
                        {
749
                        }
×
750
                }
751
        }
×
752

753
        /// <inheritdoc />
754
        public ValueTask FailAsync(
755
                string jobId,
756
                string workerId,
757
                string error,
758
                DateTimeOffset? nextRetryAt,
759
                CancellationToken cancellationToken = default
760
        ) => MutateOwnedWithDependenciesAsync(jobId, workerId, error, nextRetryAt, succeeded: false, [], cancellationToken);
5✔
761

762
        /// <inheritdoc />
763
        public async ValueTask UpsertRecurringAsync(RecurringJobSchedule schedule, CancellationToken cancellationToken = default)
764
        {
765
                ArgumentNullException.ThrowIfNull(schedule);
5✔
766
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
767
                if (await UpdateRecurringAsync(context, schedule, cancellationToken).ConfigureAwait(false) != 0)
5✔
768
                        return;
769
                await ThrowIfReplacingCodeDefinedScheduleAsync(context, schedule, cancellationToken).ConfigureAwait(false);
5✔
770

771
                _ = context.Add(ToEntity(schedule));
5✔
772
                try
773
                {
774
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
775
                }
5✔
776
                catch (DbUpdateException)
777
                {
778
                        // A competing node inserted the same schedule after our update attempt.
779
                        await using var retryContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
780
                        if (await UpdateRecurringAsync(retryContext, schedule, cancellationToken).ConfigureAwait(false) != 0)
×
781
                                return;
782
                        await ThrowIfReplacingCodeDefinedScheduleAsync(retryContext, schedule, cancellationToken).ConfigureAwait(false);
×
783
                        throw;
×
784
                }
785
        }
4✔
786

787
        /// <inheritdoc />
788
        public async ValueTask RemoveObsoleteCodeDefinedRecurringAsync(
789
                IReadOnlyCollection<string> activeScheduleNames,
790
                CancellationToken cancellationToken = default
791
        )
792
        {
793
                ArgumentNullException.ThrowIfNull(activeScheduleNames);
4✔
794
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
795
                var schedules = context.Set<ImmediateRecurringJobEntity>()
4✔
796
                        .Where(schedule => schedule.IsCodeDefined);
4✔
797
                if (activeScheduleNames.Count != 0)
4✔
798
                        schedules = schedules.Where(schedule => !activeScheduleNames.Contains(schedule.Name));
4✔
799
                _ = await schedules.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
4✔
800
        }
4✔
801

802
        /// <inheritdoc />
803
        public async ValueTask RemoveRecurringAsync(string name, CancellationToken cancellationToken = default)
804
        {
805
                ArgumentException.ThrowIfNullOrWhiteSpace(name);
5✔
806
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
807
                var removed = await context.Set<ImmediateRecurringJobEntity>()
5✔
808
                        .Where(schedule => schedule.Name == name && !schedule.IsCodeDefined)
5✔
809
                        .ExecuteDeleteAsync(cancellationToken)
5✔
810
                        .ConfigureAwait(false);
5✔
811
                if (removed != 0)
5✔
812
                        return;
813
                if (await context.Set<ImmediateRecurringJobEntity>()
5✔
814
                        .AnyAsync(schedule => schedule.Name == name, cancellationToken)
5✔
815
                        .ConfigureAwait(false))
5✔
816
                {
817
                        throw new ImmediateJobException("Code-defined recurring schedules cannot be deleted.");
4✔
818
                }
819

820
                throw new KeyNotFoundException($"Recurring schedule '{name}' was not found.");
5✔
821
        }
3✔
822

823
        /// <inheritdoc />
824
        public ValueTask PauseRecurringAsync(string name, CancellationToken cancellationToken = default)
825
                => MutateRecurringAsync(name, schedule => schedule.IsPaused = true, cancellationToken);
×
826

827
        /// <inheritdoc />
828
        public ValueTask ResumeRecurringAsync(string name, CancellationToken cancellationToken = default)
829
                => MutateRecurringAsync(name, schedule => schedule.IsPaused = false, cancellationToken);
×
830

831
        /// <inheritdoc />
832
        public async ValueTask<IReadOnlyList<RecurringJobSchedule>> GetDueRecurringAsync(DateTimeOffset now, int batchSize, CancellationToken cancellationToken = default)
833
        {
834
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(batchSize, 0);
×
835
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
836
                return await context.Set<ImmediateRecurringJobEntity>()
×
837
                        .AsNoTracking()
×
838
                        .Where(schedule => !schedule.IsPaused && schedule.NextRunAt <= now)
×
839
                        .OrderBy(schedule => schedule.NextRunAt)
×
840
                        .Take(batchSize)
×
841
                        .Select(schedule => new RecurringJobSchedule
×
842
                        {
×
843
                                Name = schedule.Name,
×
844
                                JobName = schedule.JobName,
×
845
                                Cron = schedule.Cron,
×
846
                                TimeZone = schedule.TimeZone,
×
847
                                IsCodeDefined = schedule.IsCodeDefined,
×
848
                                IsPaused = schedule.IsPaused,
×
849
                                NextRunAt = schedule.NextRunAt,
×
850
                                LastRunAt = schedule.LastRunAt,
×
851
                        })
×
852
                        .ToListAsync(cancellationToken)
×
853
                        .ConfigureAwait(false);
×
854
        }
855

856
        /// <inheritdoc />
857
        public async ValueTask<bool> MaterializeRecurringAsync(
858
                RecurringJobSchedule schedule,
859
                JobRecord job,
860
                DateTimeOffset nextRunAt,
861
                CancellationToken cancellationToken = default
862
        )
863
        {
864
                ArgumentNullException.ThrowIfNull(schedule);
5✔
865
                ArgumentNullException.ThrowIfNull(job);
5✔
866
                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
867
                var strategy = strategyContext.Database.CreateExecutionStrategy();
5✔
868
                return await strategy.ExecuteAsync(
5✔
869
                        operationCancellationToken => MaterializeRecurringCoreAsync(
5✔
870
                                schedule,
5✔
871
                                job,
5✔
872
                                nextRunAt,
5✔
873
                                operationCancellationToken
5✔
874
                        ),
5✔
875
                        cancellationToken
5✔
876
                ).ConfigureAwait(false);
5✔
877
        }
4✔
878

879
        private async Task<bool> MaterializeRecurringCoreAsync(
880
                RecurringJobSchedule schedule,
881
                JobRecord job,
882
                DateTimeOffset nextRunAt,
883
                CancellationToken cancellationToken
884
        )
885
        {
886
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
887
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
888
                var entity = await context.Set<ImmediateRecurringJobEntity>()
5✔
889
                        .SingleOrDefaultAsync(item => item.Name == schedule.Name, cancellationToken)
5✔
890
                        .ConfigureAwait(false);
5✔
891
                if (entity is null || entity.IsPaused || entity.NextRunAt != schedule.NextRunAt)
5✔
892
                        return false;
1✔
893

894
                entity.LastRunAt = schedule.NextRunAt;
5✔
895
                entity.NextRunAt = nextRunAt;
5✔
896
                entity.ConcurrencyStamp = Guid.NewGuid();
5✔
897
                _ = context.Add(ToEntity(job));
5✔
898
                try
899
                {
900
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
901
                        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
5✔
902
                        return true;
5✔
903
                }
904
                catch (DbUpdateException)
905
                {
906
                        await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
×
907
                        return false;
×
908
                }
909
        }
4✔
910

911
        /// <inheritdoc />
912
        public async ValueTask<JobMonitoringSnapshot> GetMonitoringSnapshotAsync(CancellationToken cancellationToken = default)
913
        {
914
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
915
                var rawCounts = await context.Set<ImmediateJobEntity>()
5✔
916
                        .AsNoTracking()
5✔
917
                        .GroupBy(job => job.State)
5✔
918
                        .Select(group => new { State = group.Key, Count = group.LongCount() })
5✔
919
                        .ToListAsync(cancellationToken)
5✔
920
                        .ConfigureAwait(false);
5✔
921
                var counts = Enum.GetValues<JobState>().ToDictionary(static state => state, static _ => 0L);
5✔
922
                foreach (var item in rawCounts)
5✔
923
                        counts[item.State] = item.Count;
5✔
924

925
                var recurring = await context.Set<ImmediateRecurringJobEntity>()
5✔
926
                        .AsNoTracking()
5✔
927
                        .OrderBy(schedule => schedule.Name)
5✔
928
                        .Select(schedule => new RecurringJobSchedule
5✔
929
                        {
5✔
930
                                Name = schedule.Name,
5✔
931
                                JobName = schedule.JobName,
5✔
932
                                Cron = schedule.Cron,
5✔
933
                                TimeZone = schedule.TimeZone,
5✔
934
                                IsCodeDefined = schedule.IsCodeDefined,
5✔
935
                                IsPaused = schedule.IsPaused,
5✔
936
                                NextRunAt = schedule.NextRunAt,
5✔
937
                                LastRunAt = schedule.LastRunAt,
5✔
938
                        })
5✔
939
                        .ToListAsync(cancellationToken)
5✔
940
                        .ConfigureAwait(false);
5✔
941
                var servers = await context.Set<ImmediateJobServerEntity>()
5✔
942
                        .AsNoTracking()
5✔
943
                        .OrderBy(server => server.WorkerId)
5✔
944
                        .Select(server => new JobServerSnapshot(server.WorkerId, server.LastHeartbeat, server.ActiveWorkers, server.MaxWorkers))
5✔
945
                        .ToListAsync(cancellationToken)
5✔
946
                        .ConfigureAwait(false);
5✔
947
                return new(_timeProvider.GetUtcNow(), counts, recurring, servers)
5✔
948
                {
5✔
949
                        Capabilities = this.GetCapabilities(),
5✔
950
                };
5✔
951
        }
4✔
952

953
        /// <inheritdoc />
954
        public async ValueTask<IReadOnlyList<JobRecord>> QueryJobsAsync(JobQuery query, CancellationToken cancellationToken = default)
955
        {
956
                ArgumentNullException.ThrowIfNull(query);
5✔
957
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
5✔
958
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
5✔
959
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
960
                IQueryable<ImmediateJobEntity> jobs = context.Set<ImmediateJobEntity>().AsNoTracking();
5✔
961
                if (query.Id is { } id)
5✔
962
                        jobs = jobs.Where(job => job.Id == id);
5✔
963
                if (query.State is { } state)
5✔
964
                        jobs = jobs.Where(job => job.State == state);
4✔
965
                if (!string.IsNullOrWhiteSpace(query.QueueName))
5✔
966
                        jobs = jobs.Where(job => job.QueueName == query.QueueName);
×
967
                if (!string.IsNullOrWhiteSpace(query.Search))
5✔
968
                {
969
                        var search = query.Search.ToUpperInvariant();
×
970
                        // The parameterless form is intentionally used because relational providers translate it to SQL.
971
#pragma warning disable CA1304, CA1311, CA1862
972
                        jobs = jobs.Where(job => job.JobName.ToUpper().Contains(search));
×
973
#pragma warning restore CA1304, CA1311, CA1862
974
                }
975

976
                var entities = await jobs.OrderByDescending(job => job.CreatedAt)
5✔
977
                        .Skip(query.Skip)
5✔
978
                        .Take(query.Take)
5✔
979
                        .ToListAsync(cancellationToken)
5✔
980
                        .ConfigureAwait(false);
5✔
981
                return [.. entities.Select(ToRecord)];
5✔
982
        }
4✔
983

984
        /// <inheritdoc />
985
        public async ValueTask<BatchStatus?> GetBatchStatusAsync(
986
                string batchId,
987
                CancellationToken cancellationToken = default
988
        )
989
        {
990
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
5✔
991
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
992
                var batch = await context.Set<ImmediateJobBatchEntity>()
5✔
993
                        .AsNoTracking()
5✔
994
                        .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
5✔
995
                        .ConfigureAwait(false);
5✔
996
                return batch is null ? null : ToStatus(batch);
5✔
997
        }
4✔
998

999
        /// <inheritdoc />
1000
        public async ValueTask<IReadOnlyList<JobContinuationEdge>> GetIncomingEdgesAsync(
1001
                IReadOnlyCollection<string> childJobIds,
1002
                CancellationToken cancellationToken = default
1003
        )
1004
        {
1005
                ArgumentNullException.ThrowIfNull(childJobIds);
5✔
1006
                foreach (var childJobId in childJobIds)
5✔
1007
                        ArgumentException.ThrowIfNullOrWhiteSpace(childJobId);
5✔
1008
                if (childJobIds.Count == 0)
5✔
1009
                        return [];
5✔
1010

1011
                var ids = childJobIds.Distinct(StringComparer.Ordinal).ToArray();
5✔
1012
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1013
                var edges = await context.Set<ImmediateJobContinuationEntity>()
5✔
1014
                        .AsNoTracking()
5✔
1015
                        .Where(edge => ids.Contains(edge.ChildJobId))
5✔
1016
                        .OrderBy(edge => edge.ChildJobId)
5✔
1017
                        .ThenBy(edge => edge.ParentKind)
5✔
1018
                        .ThenBy(edge => edge.ParentId)
5✔
1019
                        .ToListAsync(cancellationToken)
5✔
1020
                        .ConfigureAwait(false);
5✔
1021
                return [.. edges.Select(ToContinuationEdge)];
5✔
1022
        }
4✔
1023

1024
        /// <inheritdoc />
1025
        public async ValueTask<IReadOnlyList<BatchStatus>> QueryBatchesAsync(
1026
                JobBatchQuery query,
1027
                CancellationToken cancellationToken = default
1028
        )
1029
        {
1030
                ArgumentNullException.ThrowIfNull(query);
×
1031
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
×
1032
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
×
1033
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
1034
                IQueryable<ImmediateJobBatchEntity> batches = context.Set<ImmediateJobBatchEntity>().AsNoTracking();
×
1035
                if (query.State is { } state)
×
1036
                        batches = batches.Where(batch => batch.State == state);
×
1037
                var entities = await batches.OrderByDescending(batch => batch.CreatedAt)
×
1038
                        .ThenBy(batch => batch.Id)
×
1039
                        .Skip(query.Skip)
×
1040
                        .Take(query.Take)
×
1041
                        .ToListAsync(cancellationToken)
×
1042
                        .ConfigureAwait(false);
×
1043
                return [.. entities.Select(ToStatus)];
×
1044
        }
1045

1046
        /// <inheritdoc />
1047
        public async ValueTask<IReadOnlyList<BatchMemberStatus>> QueryBatchMembersAsync(
1048
                string batchId,
1049
                BatchMemberQuery query,
1050
                CancellationToken cancellationToken = default
1051
        )
1052
        {
1053
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
1054
                ArgumentNullException.ThrowIfNull(query);
4✔
1055
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
4✔
1056
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
4✔
1057
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
1058
                IQueryable<ImmediateJobEntity> jobs = context.Set<ImmediateJobEntity>()
4✔
1059
                        .AsNoTracking()
4✔
1060
                        .Where(job => job.BatchId == batchId);
4✔
1061
                if (query.State is { } state)
4✔
1062
                        jobs = jobs.Where(job => job.State == state);
×
1063
                return await jobs.OrderBy(job => job.CreatedAt)
4✔
1064
                        .ThenBy(job => job.Id)
4✔
1065
                        .Skip(query.Skip)
4✔
1066
                        .Take(query.Take)
4✔
1067
                        .Select(job => new BatchMemberStatus(
4✔
1068
                                job.Id,
4✔
1069
                                job.JobName,
4✔
1070
                                job.QueueName,
4✔
1071
                                job.State,
4✔
1072
                                job.Attempt,
4✔
1073
                                job.CreatedAt,
4✔
1074
                                job.CompletedAt,
4✔
1075
                                job.LastError
4✔
1076
                        ))
4✔
1077
                        .ToListAsync(cancellationToken)
4✔
1078
                        .ConfigureAwait(false);
4✔
1079
        }
3✔
1080

1081
        /// <inheritdoc />
1082
        public async ValueTask<BatchGraph?> GetBatchGraphAsync(
1083
                string batchId,
1084
                CancellationToken cancellationToken = default
1085
        )
1086
        {
1087
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
1088
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
1089
                if (!await context.Set<ImmediateJobBatchEntity>()
4✔
1090
                        .AnyAsync(batch => batch.Id == batchId, cancellationToken)
4✔
1091
                        .ConfigureAwait(false))
4✔
1092
                {
1093
                        return null;
4✔
1094
                }
1095

1096
                var jobs = await context.Set<ImmediateJobEntity>()
×
1097
                        .AsNoTracking()
×
1098
                        .Where(job => job.BatchId == batchId)
×
1099
                        .OrderBy(job => job.CreatedAt)
×
1100
                        .ThenBy(job => job.Id)
×
1101
                        .Select(job => new BatchGraphNode(job.Id, job.JobName, job.State))
×
1102
                        .ToListAsync(cancellationToken)
×
1103
                        .ConfigureAwait(false);
×
1104
                var ids = jobs.Select(static job => job.JobId).ToArray();
×
1105
                var edges = ids.Length == 0
×
1106
                        ? []
×
1107
                        : await context.Set<ImmediateJobContinuationEntity>()
×
1108
                                .AsNoTracking()
×
1109
                                .Where(edge => ids.Contains(edge.ChildJobId))
×
1110
                                .OrderBy(edge => edge.ChildJobId)
×
1111
                                .ThenBy(edge => edge.ParentKind)
×
1112
                                .ThenBy(edge => edge.ParentId)
×
1113
                                .ToListAsync(cancellationToken)
×
1114
                                .ConfigureAwait(false);
×
1115
                return new(batchId, jobs, [.. edges.Select(ToGraphEdge)]);
4✔
1116
        }
3✔
1117

1118
        /// <inheritdoc />
1119
        public async ValueTask<JobStatus?> GetJobStatusAsync(
1120
                string jobId,
1121
                CancellationToken cancellationToken = default
1122
        )
1123
        {
1124
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
5✔
1125
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1126
                var job = await context.Set<ImmediateJobEntity>()
5✔
1127
                        .AsNoTracking()
5✔
1128
                        .SingleOrDefaultAsync(item => item.Id == jobId, cancellationToken)
5✔
1129
                        .ConfigureAwait(false);
5✔
1130
                if (job is null)
5✔
1131
                        return null;
5✔
1132
                var edges = await context.Set<ImmediateJobContinuationEntity>()
5✔
1133
                        .AsNoTracking()
5✔
1134
                        .Where(edge => edge.ChildJobId == jobId)
5✔
1135
                        .OrderBy(edge => edge.ParentKind)
5✔
1136
                        .ThenBy(edge => edge.ParentId)
5✔
1137
                        .ToListAsync(cancellationToken)
5✔
1138
                        .ConfigureAwait(false);
5✔
1139
                return new(
5✔
1140
                        job.Id,
5✔
1141
                        job.JobName,
5✔
1142
                        job.QueueName,
5✔
1143
                        job.State,
5✔
1144
                        job.Attempt,
5✔
1145
                        MaxAttempts: null,
5✔
1146
                        job.CreatedAt,
5✔
1147
                        job.DueAt,
5✔
1148
                        job.CompletedAt,
5✔
1149
                        job.LastError,
5✔
1150
                        job.BatchId,
5✔
1151
                        [.. edges.Select(ToGraphEdge)]
5✔
1152
                );
5✔
1153
        }
4✔
1154

1155
        /// <inheritdoc />
1156
        public ValueTask CancelBatchAsync(string batchId, CancellationToken cancellationToken = default)
1157
        {
1158
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
5✔
1159
                return ExecuteWithStrategyAsync(
5✔
1160
                        operationCancellationToken => CancelBatchCoreAsync(batchId, operationCancellationToken),
5✔
1161
                        cancellationToken
5✔
1162
                );
5✔
1163
        }
1164

1165
        private async Task CancelBatchCoreAsync(string batchId, CancellationToken cancellationToken)
1166
        {
1167
                var now = _timeProvider.GetUtcNow();
5✔
1168
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1169
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1170
                var batch = await context.Set<ImmediateJobBatchEntity>()
5✔
1171
                        .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
5✔
1172
                        .ConfigureAwait(false)
5✔
1173
                        ?? throw new KeyNotFoundException($"Batch '{batchId}' was not found.");
5✔
1174
                if (batch.State != BatchState.Executing)
4✔
1175
                        throw new ImmediateJobException("Only an executing batch can be cancelled.");
×
1176

1177
                var jobs = await context.Set<ImmediateJobEntity>()
4✔
1178
                        .Where(job => job.BatchId == batchId)
4✔
1179
                        .ToListAsync(cancellationToken)
4✔
1180
                        .ConfigureAwait(false);
4✔
1181
                foreach (var job in jobs)
4✔
1182
                {
1183
                        if (IsTerminal(job.State))
4✔
1184
                                continue;
1185
                        job.State = JobState.Cancelled;
4✔
1186
                        job.CompletedAt = now;
4✔
1187
                        job.WorkerId = null;
4✔
1188
                        job.LeaseExpiresAt = null;
4✔
1189
                        job.ConcurrencyStamp = Guid.NewGuid();
4✔
1190
                        await PropagateTerminalAsync(context, job, now, cancellationToken).ConfigureAwait(false);
4✔
1191
                }
1192

1193
                var terminalGroups = GetTerminalFairQueueGroups(context);
4✔
1194
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
1195
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
4✔
1196
                foreach (var (queueName, groupId) in terminalGroups)
4✔
1197
                {
1198
                        await TryRemoveFairQueueCursorAsync(queueName, groupId, CancellationToken.None).ConfigureAwait(false);
×
1199
                }
1200
        }
4✔
1201

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

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

1223
                var jobs = await context.Set<ImmediateJobEntity>()
4✔
1224
                        .Where(job => job.BatchId == batchId)
4✔
1225
                        .ToListAsync(cancellationToken)
4✔
1226
                        .ConfigureAwait(false);
4✔
1227
                var jobIds = jobs.Select(static job => job.Id).ToArray();
4✔
1228
                var edges = await context.Set<ImmediateJobContinuationEntity>()
4✔
1229
                        .Where(edge => jobIds.Contains(edge.ChildJobId)
4✔
1230
                                || edge.ParentKind == ContinuationParentKind.Job && jobIds.Contains(edge.ParentId)
4✔
1231
                                || edge.ParentKind == ContinuationParentKind.Batch && edge.ParentId == batchId)
4✔
1232
                        .ToListAsync(cancellationToken)
4✔
1233
                        .ConfigureAwait(false);
4✔
1234
                context.RemoveRange(edges);
4✔
1235
                context.RemoveRange(jobs);
4✔
1236
                _ = context.Remove(batch);
4✔
1237
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
1238
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
4✔
1239
        }
4✔
1240

1241
        /// <inheritdoc />
1242
        public ValueTask RetryAsync(string jobId, CancellationToken cancellationToken = default)
1243
        {
1244
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
5✔
1245
                return ExecuteWithStrategyAsync(
5✔
1246
                        operationCancellationToken => RetryCoreAsync(jobId, operationCancellationToken),
5✔
1247
                        cancellationToken
5✔
1248
                );
5✔
1249
        }
1250

1251
        private async Task RetryCoreAsync(string jobId, CancellationToken cancellationToken)
1252
        {
1253
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1254
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1255
                var job = await context.Set<ImmediateJobEntity>()
5✔
1256
                        .SingleOrDefaultAsync(item => item.Id == jobId && item.State == JobState.Failed, cancellationToken)
5✔
1257
                        .ConfigureAwait(false);
5✔
1258
                if (job is null)
5✔
1259
                {
1260
                        if (await context.Set<ImmediateJobEntity>()
5✔
1261
                                .AnyAsync(item => item.Id == jobId, cancellationToken)
5✔
1262
                                .ConfigureAwait(false))
5✔
1263
                        {
1264
                                throw new ImmediateJobException("Only failed jobs can be retried.");
4✔
1265
                        }
1266

1267
                        throw new KeyNotFoundException($"Job '{jobId}' was not found.");
5✔
1268
                }
1269

1270
                if (job.BatchId is { } batchId)
×
1271
                {
1272
                        var batch = await context.Set<ImmediateJobBatchEntity>()
×
1273
                                .SingleAsync(item => item.Id == batchId, cancellationToken)
×
1274
                                .ConfigureAwait(false);
×
1275
                        batch.PendingCount++;
×
1276
                        batch.FailedCount = Math.Max(0, batch.FailedCount - 1);
×
1277
                        batch.State = BatchState.Executing;
×
1278
                        batch.CompletedAt = null;
×
1279
                        batch.ConcurrencyStamp = Guid.NewGuid();
×
1280
                }
1281

1282
                job.State = JobState.Pending;
×
1283
                job.DueAt = _timeProvider.GetUtcNow();
×
1284
                job.WorkerId = null;
×
1285
                job.LeaseExpiresAt = null;
×
1286
                job.CompletedAt = null;
×
1287
                job.LastError = null;
×
1288
                job.ConcurrencyStamp = Guid.NewGuid();
×
1289
                try
1290
                {
NEW
1291
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
NEW
1292
                }
×
1293
                catch (DbUpdateConcurrencyException)
1294
                {
NEW
1295
                        if (!await context.Set<ImmediateJobEntity>()
×
NEW
1296
                                .AsNoTracking()
×
NEW
1297
                                .AnyAsync(item => item.Id == jobId, cancellationToken)
×
NEW
1298
                                .ConfigureAwait(false))
×
1299
                        {
NEW
1300
                                throw new KeyNotFoundException($"Job '{jobId}' was not found.");
×
1301
                        }
1302

NEW
1303
                        throw new ImmediateJobException("Only failed jobs can be retried.");
×
1304
                }
1305

1306
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
×
1307
        }
×
1308

1309
        /// <inheritdoc />
1310
        public ValueTask DeleteAsync(string jobId, CancellationToken cancellationToken = default)
1311
        {
1312
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
5✔
1313
                return ExecuteWithStrategyAsync(
5✔
1314
                        operationCancellationToken => DeleteCoreAsync(jobId, operationCancellationToken),
5✔
1315
                        cancellationToken
5✔
1316
                );
5✔
1317
        }
1318

1319
        private async Task DeleteCoreAsync(string jobId, CancellationToken cancellationToken)
1320
        {
1321
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1322
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1323
                var job = await context.Set<ImmediateJobEntity>()
5✔
1324
                        .AsNoTracking()
5✔
1325
                        .SingleOrDefaultAsync(item => item.Id == jobId
5✔
1326
                                && (item.State == JobState.Succeeded || item.State == JobState.Failed || item.State == JobState.Cancelled), cancellationToken)
5✔
1327
                        .ConfigureAwait(false);
5✔
1328
                if (job is null)
5✔
1329
                {
1330
                        if (await context.Set<ImmediateJobEntity>()
5✔
1331
                                .AnyAsync(item => item.Id == jobId, cancellationToken)
5✔
1332
                                .ConfigureAwait(false))
5✔
1333
                        {
1334
                                throw new ImmediateJobException("Only terminal jobs can be deleted.");
4✔
1335
                        }
1336

1337
                        throw new KeyNotFoundException($"Job '{jobId}' was not found.");
5✔
1338
                }
1339

1340
                if (job.BatchId is not null)
1✔
NEW
1341
                        throw new ImmediateJobException("Batch members are deleted with their batch so the workflow remains coherent.");
×
1342
                _ = await context.Set<ImmediateJobContinuationEntity>()
1✔
1343
                        .Where(edge => edge.ChildJobId == jobId ||
1✔
1344
                                (edge.ParentKind == ContinuationParentKind.Job && edge.ParentId == jobId))
1✔
1345
                        .ExecuteDeleteAsync(cancellationToken)
1✔
1346
                        .ConfigureAwait(false);
1✔
1347
                var removed = await context.Set<ImmediateJobEntity>()
1✔
1348
                        .Where(item => item.Id == jobId &&
1✔
1349
                                (item.State == JobState.Succeeded || item.State == JobState.Failed || item.State == JobState.Cancelled))
1✔
1350
                        .ExecuteDeleteAsync(cancellationToken)
1✔
1351
                        .ConfigureAwait(false);
1✔
1352
                if (removed == 0)
1✔
1353
                {
NEW
1354
                        if (await context.Set<ImmediateJobEntity>()
×
NEW
1355
                                .AnyAsync(item => item.Id == jobId, cancellationToken)
×
NEW
1356
                                .ConfigureAwait(false))
×
1357
                        {
NEW
1358
                                throw new ImmediateJobException("Only terminal jobs can be deleted.");
×
1359
                        }
1360

NEW
1361
                        throw new KeyNotFoundException($"Job '{jobId}' was not found.");
×
1362
                }
1363

1364
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
1✔
1365
        }
1✔
1366

1367
        /// <inheritdoc />
1368
        public ValueTask PurgeJobsAsync(
1369
                TimeSpan succeededRetention,
1370
                TimeSpan failedRetention,
1371
                CancellationToken cancellationToken = default
1372
        )
1373
        {
1374
                var now = _timeProvider.GetUtcNow();
4✔
1375
                return ExecuteWithStrategyAsync(
4✔
1376
                        operationCancellationToken => PurgeJobsCoreAsync(
4✔
1377
                                now - succeededRetention,
4✔
1378
                                now - failedRetention,
4✔
1379
                                operationCancellationToken
4✔
1380
                        ),
4✔
1381
                        cancellationToken
4✔
1382
                );
4✔
1383
        }
1384

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

1403
        private async Task PurgeJobsCoreAsync(
1404
                DateTimeOffset succeededBefore,
1405
                DateTimeOffset failedBefore,
1406
                CancellationToken cancellationToken
1407
        )
1408
        {
1409
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
1410
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
4✔
1411
                var jobs = await context.Set<ImmediateJobEntity>()
4✔
1412
                        .Where(job => job.BatchId == null
4✔
1413
                                && ((job.State == JobState.Succeeded && job.CompletedAt < succeededBefore)
4✔
1414
                                || ((job.State == JobState.Failed || job.State == JobState.Cancelled) && job.CompletedAt < failedBefore))
4✔
1415
                        )
4✔
1416
                        .ToListAsync(cancellationToken)
4✔
1417
                        .ConfigureAwait(false);
4✔
1418
                if (jobs.Count != 0)
4✔
1419
                {
1420
                        var jobIds = jobs.Select(static job => job.Id).ToArray();
×
1421
                        var edges = await context.Set<ImmediateJobContinuationEntity>()
×
1422
                                .Where(edge => jobIds.Contains(edge.ChildJobId)
×
1423
                                        || jobIds.Contains(edge.ParentId) && edge.ParentKind == ContinuationParentKind.Job)
×
1424
                                .ToListAsync(cancellationToken)
×
1425
                                .ConfigureAwait(false);
×
1426
                        context.RemoveRange(edges);
×
1427
                }
1428

1429
                context.RemoveRange(jobs);
4✔
1430
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
1431
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
4✔
1432
        }
4✔
1433

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

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

1470
        /// <inheritdoc />
1471
        public async ValueTask HeartbeatAsync(JobServerSnapshot server, CancellationToken cancellationToken = default)
1472
        {
1473
                ArgumentNullException.ThrowIfNull(server);
×
1474
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
1475
                var entity = await context.Set<ImmediateJobServerEntity>().FindAsync([server.WorkerId], cancellationToken).ConfigureAwait(false);
×
1476
                if (entity is null)
×
1477
                {
1478
                        _ = context.Add(new ImmediateJobServerEntity
×
1479
                        {
×
1480
                                WorkerId = server.WorkerId,
×
1481
                                LastHeartbeat = server.LastHeartbeat,
×
1482
                                ActiveWorkers = server.ActiveWorkers,
×
1483
                                MaxWorkers = server.MaxWorkers,
×
1484
                        });
×
1485
                }
1486
                else
1487
                {
1488
                        entity.LastHeartbeat = server.LastHeartbeat;
×
1489
                        entity.ActiveWorkers = server.ActiveWorkers;
×
1490
                        entity.MaxWorkers = server.MaxWorkers;
×
1491
                }
1492

1493
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1494
        }
×
1495

1496
        /// <inheritdoc />
1497
        public async ValueTask<bool> IsHealthyAsync(CancellationToken cancellationToken = default)
1498
        {
1499
                try
1500
                {
1501
                        await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
1502
                        return await context.Database.CanConnectAsync(cancellationToken).ConfigureAwait(false);
×
1503
                }
×
1504
                catch (Exception exception) when (exception is DbException or InvalidOperationException)
×
1505
                {
1506
                        return false;
×
1507
                }
1508
        }
×
1509

1510
        private async ValueTask MutateOwnedWithDependenciesAsync(
1511
                string jobId,
1512
                string workerId,
1513
                string? error,
1514
                DateTimeOffset? nextRetryAt,
1515
                bool succeeded,
1516
                IReadOnlyList<JobContinuationAddition> additions,
1517
                CancellationToken cancellationToken
1518
        )
1519
        {
1520
                const int MaxConcurrencyAttempts = 5;
1521
                for (var attempt = 0; attempt < MaxConcurrencyAttempts; attempt++)
5✔
1522
                {
1523
                        try
1524
                        {
1525
                                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1526
                                var strategy = strategyContext.Database.CreateExecutionStrategy();
5✔
1527
                                await strategy.ExecuteAsync(
5✔
1528
                                        operationCancellationToken => MutateOwnedCoreAsync(
5✔
1529
                                                jobId,
5✔
1530
                                                workerId,
5✔
1531
                                                error,
5✔
1532
                                                nextRetryAt,
5✔
1533
                                                succeeded,
5✔
1534
                                                additions,
5✔
1535
                                                operationCancellationToken
5✔
1536
                                        ),
5✔
1537
                                        cancellationToken
5✔
1538
                                ).ConfigureAwait(false);
5✔
1539
                                return;
5✔
1540
                        }
×
1541
                        catch (DbUpdateConcurrencyException) when (attempt + 1 < MaxConcurrencyAttempts)
5✔
1542
                        {
1543
                                // A competing parent completion changed a shared child or batch counter. The whole
1544
                                // transaction rolled back, so retrying re-evaluates the graph from durable state.
1545
                        }
×
1546
                }
1547
        }
5✔
1548

1549
        private async ValueTask ExecuteWithStrategyAsync(
1550
                Func<CancellationToken, Task> operation,
1551
                CancellationToken cancellationToken
1552
        )
1553
        {
1554
                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1555
                var strategy = strategyContext.Database.CreateExecutionStrategy();
5✔
1556
                await strategy.ExecuteAsync(operation, cancellationToken).ConfigureAwait(false);
5✔
1557
        }
5✔
1558

1559
        private async ValueTask MutateOwnedAsync(
1560
                string jobId,
1561
                string workerId,
1562
                Action<ImmediateJobEntity> mutate,
1563
                CancellationToken cancellationToken
1564
        )
1565
        {
1566
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
1✔
1567
                var job = await context.Set<ImmediateJobEntity>()
1✔
1568
                        .SingleOrDefaultAsync(item => item.Id == jobId && item.State == JobState.Active && item.WorkerId == workerId, cancellationToken)
1✔
1569
                        .ConfigureAwait(false);
1✔
1570
                if (job is null)
1✔
1571
                        return;
1572
                mutate(job);
1✔
1573
                job.ConcurrencyStamp = Guid.NewGuid();
1✔
1574
                try
1575
                {
1576
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
1✔
1577
                }
1✔
1578
                catch (DbUpdateConcurrencyException)
×
1579
                {
1580
                        // A stale worker must not update a lease that has been reclaimed.
1581
                }
1✔
1582
        }
1✔
1583

1584
        private async Task MutateOwnedCoreAsync(
1585
                string jobId,
1586
                string workerId,
1587
                string? error,
1588
                DateTimeOffset? nextRetryAt,
1589
                bool succeeded,
1590
                IReadOnlyList<JobContinuationAddition> additions,
1591
                CancellationToken cancellationToken
1592
        )
1593
        {
1594
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1595
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1596
                var job = await context.Set<ImmediateJobEntity>()
5✔
1597
                        .SingleOrDefaultAsync(item => item.Id == jobId && item.State == JobState.Active && item.WorkerId == workerId, cancellationToken)
5✔
1598
                        .ConfigureAwait(false);
5✔
1599
                if (job is null)
5✔
1600
                        return;
1601

1602
                var now = _timeProvider.GetUtcNow();
5✔
1603
                job.WorkerId = null;
5✔
1604
                job.LeaseExpiresAt = null;
5✔
1605
                job.LastError = error;
5✔
1606
                job.ConcurrencyStamp = Guid.NewGuid();
5✔
1607
                if (!succeeded && nextRetryAt is { } retryAt)
5✔
1608
                {
1609
                        job.State = retryAt <= now ? JobState.Pending : JobState.Scheduled;
×
1610
                        job.DueAt = retryAt;
×
1611
                        job.CompletedAt = null;
×
1612
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1613
                        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
×
1614
                        return;
×
1615
                }
1616

1617
                if (succeeded && additions.Count != 0)
5✔
1618
                {
1619
                        await FlushContinuationAdditionsAsync(context, job, additions, cancellationToken).ConfigureAwait(false);
5✔
1620
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1621
                }
1622

1623
                job.State = succeeded ? JobState.Succeeded : JobState.Failed;
5✔
1624
                job.CompletedAt = now;
5✔
1625
                await PropagateTerminalAsync(context, job, now, cancellationToken).ConfigureAwait(false);
5✔
1626
                var terminalGroups = GetTerminalFairQueueGroups(context);
5✔
1627
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
1628
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
5✔
1629
                foreach (var (queueName, groupId) in terminalGroups)
5✔
1630
                        await TryRemoveFairQueueCursorAsync(queueName, groupId, CancellationToken.None).ConfigureAwait(false);
5✔
1631
        }
5✔
1632

1633
        private static (string QueueName, string GroupId)[] GetTerminalFairQueueGroups(TContext context) =>
1634
                [
5✔
1635
                        .. context.ChangeTracker
5✔
1636
                                .Entries<ImmediateJobEntity>()
5✔
1637
                                .Select(static entry => entry.Entity)
5✔
1638
                                .Where(static job => job.GroupId is not null && IsTerminal(job.State))
5✔
1639
                                .Select(static job => (job.QueueName, GroupId: job.GroupId!))
5✔
1640
                                .Distinct(),
5✔
1641
                ];
5✔
1642

1643
        private async ValueTask TryRemoveFairQueueCursorAsync(
1644
                string queueName,
1645
                string groupId,
1646
                CancellationToken cancellationToken
1647
        )
1648
        {
1649
                try
1650
                {
1651
                        await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1652
                        if (await HasLiveGroupJobsAsync(context, queueName, groupId, cancellationToken).ConfigureAwait(false))
5✔
1653
                        {
1654
                                return;
1655
                        }
1656

1657
                        var cursor = await context.Set<ImmediateFairQueueGroupEntity>()
4✔
1658
                                .SingleOrDefaultAsync(
4✔
1659
                                        group => group.QueueName == queueName && group.GroupId == groupId,
4✔
1660
                                        cancellationToken
4✔
1661
                                )
4✔
1662
                                .ConfigureAwait(false);
4✔
1663
                        if (cursor is null)
4✔
1664
                                return;
1665

1666
                        _ = context.Remove(cursor);
4✔
1667
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
1668
                }
4✔
1669
                catch (Exception exception) when (
×
1670
                        !cancellationToken.IsCancellationRequested
×
1671
                        && exception is DbException or DbUpdateException
×
1672
                )
×
1673
                {
1674
                        // Cleanup is best-effort metadata maintenance and must not invalidate a committed transition.
1675
                }
×
1676
        }
5✔
1677

1678
        private static Task<bool> HasLiveGroupJobsAsync(
1679
                TContext context,
1680
                string queueName,
1681
                string groupId,
1682
                CancellationToken cancellationToken
1683
        ) => context.Set<ImmediateJobEntity>().AnyAsync(
5✔
1684
                job => job.QueueName == queueName
5✔
1685
                        && job.GroupId == groupId
5✔
1686
                        && (job.State == JobState.Pending
5✔
1687
                                || job.State == JobState.Scheduled
5✔
1688
                                || job.State == JobState.Active),
5✔
1689
                cancellationToken
5✔
1690
        );
5✔
1691

1692
        private async Task AddBatchJobCoreAsync(
1693
                string currentJobId,
1694
                JobRecord record,
1695
                ContinuationOptions options,
1696
                CancellationToken cancellationToken
1697
        )
1698
        {
1699
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1700
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1701
                var current = await context.Set<ImmediateJobEntity>()
5✔
1702
                        .SingleOrDefaultAsync(job => job.Id == currentJobId && job.State == JobState.Active, cancellationToken)
5✔
1703
                        .ConfigureAwait(false)
5✔
1704
                        ?? throw new ImmediateJobException($"The current active job '{currentJobId}' was not found.");
5✔
1705
                if (current.BatchId is not { } batchId)
5✔
1706
                        throw new ImmediateJobException("The current job does not belong to a batch.");
×
1707
                if (options is not (ContinuationOptions.BesideContinuations or ContinuationOptions.BeforeContinuations))
5✔
NEW
1708
                        throw new ArgumentOutOfRangeException(nameof(options));
×
1709
                if (record.BatchId != batchId)
5✔
1710
                        throw new ImmediateJobException("The new job must belong to the current job's batch.");
5✔
NEW
1711
                if (record.State is JobState.Active or JobState.AwaitingContinuation || IsTerminal(record.State))
×
NEW
1712
                        throw new ImmediateJobException($"Concurrent batch member '{record.Id}' has invalid state '{record.State}'.");
×
1713

1714
                var batch = await context.Set<ImmediateJobBatchEntity>()
×
1715
                        .SingleAsync(item => item.Id == batchId && item.State == BatchState.Executing, cancellationToken)
×
1716
                        .ConfigureAwait(false);
×
NEW
1717
                var job = ToEntity(record);
×
1718
                _ = context.Add(job);
×
1719
                batch.TotalJobs++;
×
1720
                batch.PendingCount++;
×
1721
                batch.ConcurrencyStamp = Guid.NewGuid();
×
1722

1723
                if (options == ContinuationOptions.BeforeContinuations)
×
1724
                {
1725
                        var waiters = await GetActiveWaitersAsync(context, currentJobId, cancellationToken).ConfigureAwait(false);
×
1726
                        foreach (var waiter in waiters)
×
1727
                        {
1728
                                _ = context.Add(new ImmediateJobContinuationEntity
×
1729
                                {
×
1730
                                        ChildJobId = waiter.Id,
×
1731
                                        ParentKind = ContinuationParentKind.Job,
×
1732
                                        ParentId = job.Id,
×
1733
                                        Trigger = ContinuationTrigger.Success,
×
1734
                                });
×
1735
                                waiter.RemainingDependencies++;
×
1736
                                waiter.ConcurrencyStamp = Guid.NewGuid();
×
1737
                        }
1738
                }
1739

1740
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1741
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
×
1742
        }
×
1743

1744
        private static async Task FlushContinuationAdditionsAsync(
1745
                TContext context,
1746
                ImmediateJobEntity current,
1747
                IReadOnlyList<JobContinuationAddition> additions,
1748
                CancellationToken cancellationToken
1749
        )
1750
        {
1751
                var ids = new HashSet<string>(StringComparer.Ordinal);
5✔
1752
                var trackedAdditions = 0;
5✔
1753
                foreach (var addition in additions)
5✔
1754
                {
1755
                        ArgumentNullException.ThrowIfNull(addition);
5✔
1756
                        ArgumentNullException.ThrowIfNull(addition.Job);
5✔
1757
                        if (!ids.Add(addition.Job.Id))
5✔
NEW
1758
                                throw new ImmediateJobException("Buffered continuations contain duplicate job identifiers.");
×
1759
                        if (!Enum.IsDefined(addition.Trigger))
5✔
1760
                                throw new ArgumentOutOfRangeException(nameof(additions), "Unknown continuation trigger.");
4✔
1761
                        if (addition.Job.State is not (JobState.Pending or JobState.Scheduled))
5✔
NEW
1762
                                throw new ImmediateJobException($"Dynamic continuation '{addition.Job.Id}' has invalid state '{addition.Job.State}'.");
×
1763

1764
                        if (addition.Options == ContinuationOptions.Detached)
5✔
1765
                        {
1766
                                if (addition.Job.BatchId is not null)
5✔
1767
                                        throw new ImmediateJobException("A detached continuation cannot belong to a batch.");
5✔
1768
                        }
1769
                        else if (addition.Options is ContinuationOptions.BesideContinuations or ContinuationOptions.BeforeContinuations)
5✔
1770
                        {
1771
                                if (current.BatchId is null || addition.Job.BatchId != current.BatchId)
5✔
1772
                                        throw new ImmediateJobException("A batch-tracked continuation must belong to the current job's batch.");
5✔
NEW
1773
                                trackedAdditions++;
×
1774
                        }
1775
                        else
1776
                        {
1777
                                throw new ArgumentOutOfRangeException(nameof(additions), "Unknown continuation option.");
4✔
1778
                        }
1779
                }
1780

1781
                var waiters = additions.Any(static addition => addition.Options == ContinuationOptions.BeforeContinuations)
×
1782
                        ? await GetActiveWaitersAsync(context, current.Id, cancellationToken).ConfigureAwait(false)
×
1783
                        : [];
×
1784
                ImmediateJobBatchEntity? batch = null;
1785
                if (trackedAdditions != 0)
×
1786
                {
1787
                        if (current.BatchId is not { } batchId)
×
1788
                                throw new ImmediateJobException("The current job does not belong to a batch.");
×
1789
                        batch = await context.Set<ImmediateJobBatchEntity>()
×
1790
                                .SingleAsync(item => item.Id == batchId && item.State == BatchState.Executing, cancellationToken)
×
1791
                                .ConfigureAwait(false);
×
1792
                        batch.TotalJobs += trackedAdditions;
×
1793
                        batch.PendingCount += trackedAdditions;
×
1794
                        batch.ConcurrencyStamp = Guid.NewGuid();
×
1795
                }
1796

1797
                foreach (var addition in additions)
×
1798
                {
1799
                        var job = ToEntity(addition.Job with
×
1800
                        {
×
1801
                                State = JobState.AwaitingContinuation,
×
1802
                                RemainingDependencies = 1,
×
1803
                        });
×
1804
                        _ = context.Add(job);
×
1805
                        _ = context.Add(new ImmediateJobContinuationEntity
×
1806
                        {
×
1807
                                ChildJobId = job.Id,
×
1808
                                ParentKind = ContinuationParentKind.Job,
×
1809
                                ParentId = current.Id,
×
1810
                                Trigger = addition.Trigger,
×
1811
                        });
×
1812

1813
                        if (addition.Options != ContinuationOptions.BeforeContinuations)
×
1814
                                continue;
1815
                        foreach (var waiter in waiters)
×
1816
                        {
1817
                                _ = context.Add(new ImmediateJobContinuationEntity
×
1818
                                {
×
1819
                                        ChildJobId = waiter.Id,
×
1820
                                        ParentKind = ContinuationParentKind.Job,
×
1821
                                        ParentId = job.Id,
×
1822
                                        Trigger = ContinuationTrigger.Success,
×
1823
                                });
×
1824
                                waiter.RemainingDependencies++;
×
1825
                                waiter.ConcurrencyStamp = Guid.NewGuid();
×
1826
                        }
1827
                }
1828
        }
×
1829

1830
        private static async Task<List<ImmediateJobEntity>> GetActiveWaitersAsync(
1831
                TContext context,
1832
                string currentJobId,
1833
                CancellationToken cancellationToken
1834
        )
1835
        {
1836
                var waiterIds = await context.Set<ImmediateJobContinuationEntity>()
×
1837
                        .Where(edge => edge.ParentKind == ContinuationParentKind.Job && edge.ParentId == currentJobId)
×
1838
                        .Select(edge => edge.ChildJobId)
×
1839
                        .Distinct()
×
1840
                        .ToArrayAsync(cancellationToken)
×
1841
                        .ConfigureAwait(false);
×
1842
                return waiterIds.Length == 0
×
1843
                        ? []
×
1844
                        : await context.Set<ImmediateJobEntity>()
×
1845
                                .Where(job => waiterIds.Contains(job.Id) && job.State == JobState.AwaitingContinuation)
×
1846
                                .ToListAsync(cancellationToken)
×
1847
                                .ConfigureAwait(false);
×
1848
        }
1849

1850
        private static async Task PropagateTerminalAsync(
1851
                TContext context,
1852
                ImmediateJobEntity terminalJob,
1853
                DateTimeOffset now,
1854
                CancellationToken cancellationToken
1855
        )
1856
        {
1857
                var parents = new Queue<(ContinuationParentKind Kind, string Id, bool Failed)>();
5✔
1858
                var processed = new HashSet<(ContinuationParentKind Kind, string Id)>();
5✔
1859
                parents.Enqueue((
5✔
1860
                        ContinuationParentKind.Job,
5✔
1861
                        terminalJob.Id,
5✔
1862
                        terminalJob.State == JobState.Failed
5✔
1863
                ));
5✔
1864
                await UpdateBatchForTerminalJobAsync(context, terminalJob, now, parents, cancellationToken).ConfigureAwait(false);
5✔
1865

1866
                while (parents.TryDequeue(out var parent))
5✔
1867
                {
1868
                        if (!processed.Add((parent.Kind, parent.Id)))
5✔
1869
                                continue;
1870
                        var edges = await context.Set<ImmediateJobContinuationEntity>()
5✔
1871
                                .Where(edge => edge.ParentKind == parent.Kind && edge.ParentId == parent.Id)
5✔
1872
                                .ToListAsync(cancellationToken)
5✔
1873
                                .ConfigureAwait(false);
5✔
1874
                        foreach (var edge in edges)
5✔
1875
                        {
1876
                                var child = await context.Set<ImmediateJobEntity>()
5✔
1877
                                        .SingleOrDefaultAsync(job => job.Id == edge.ChildJobId, cancellationToken)
5✔
1878
                                        .ConfigureAwait(false);
5✔
1879
                                if (child is null || IsTerminal(child.State))
5✔
1880
                                        continue;
1881

1882
                                if (child.State != JobState.AwaitingContinuation || child.RemainingDependencies <= 0)
5✔
1883
                                        continue;
1884
                                child.RemainingDependencies--;
5✔
1885
                                if (parent.Failed)
5✔
1886
                                        child.FailedDependencies++;
5✔
1887
                                if (child.RemainingDependencies == 0)
5✔
1888
                                {
1889
                                        var cancel = await ShouldCancelSettledContinuationAsync(
5✔
1890
                                                context,
5✔
1891
                                                child.Id,
5✔
1892
                                                cancellationToken
5✔
1893
                                        ).ConfigureAwait(false);
5✔
1894
                                        if (cancel)
5✔
1895
                                        {
1896
                                                child.State = JobState.Cancelled;
5✔
1897
                                                child.CompletedAt = now;
5✔
1898
                                                parents.Enqueue((ContinuationParentKind.Job, child.Id, Failed: false));
5✔
1899
                                                await UpdateBatchForTerminalJobAsync(context, child, now, parents, cancellationToken).ConfigureAwait(false);
5✔
1900
                                        }
1901
                                        else
1902
                                        {
1903
                                                child.State = child.DueAt <= now ? JobState.Pending : JobState.Scheduled;
5✔
1904
                                        }
1905
                                }
1906

1907
                                child.ConcurrencyStamp = Guid.NewGuid();
5✔
1908
                        }
4✔
1909
                }
4✔
1910
        }
5✔
1911

1912
        private static async Task<bool> ShouldCancelSettledContinuationAsync(
1913
                TContext context,
1914
                string childJobId,
1915
                CancellationToken cancellationToken
1916
        )
1917
        {
1918
                var edges = await context.Set<ImmediateJobContinuationEntity>()
5✔
1919
                        .Where(edge => edge.ChildJobId == childJobId)
5✔
1920
                        .ToListAsync(cancellationToken)
5✔
1921
                        .ConfigureAwait(false);
5✔
1922
                var parentJobIds = edges
5✔
1923
                        .Where(static edge => edge.ParentKind == ContinuationParentKind.Job)
5✔
1924
                        .Select(static edge => edge.ParentId)
5✔
1925
                        .Distinct(StringComparer.Ordinal)
5✔
1926
                        .ToArray();
5✔
1927
                var parentBatchIds = edges
5✔
1928
                        .Where(static edge => edge.ParentKind == ContinuationParentKind.Batch)
5✔
NEW
1929
                        .Select(static edge => edge.ParentId)
×
1930
                        .Distinct(StringComparer.Ordinal)
5✔
1931
                        .ToArray();
5✔
1932
                var parentJobs = parentJobIds.Length == 0
5✔
1933
                        ? []
5✔
1934
                        : await context.Set<ImmediateJobEntity>()
5✔
1935
                                .Where(job => parentJobIds.Contains(job.Id))
5✔
1936
                                .ToDictionaryAsync(job => job.Id, StringComparer.Ordinal, cancellationToken)
5✔
1937
                                .ConfigureAwait(false);
5✔
1938
                var parentBatches = parentBatchIds.Length == 0
5✔
1939
                        ? []
5✔
1940
                        : await context.Set<ImmediateJobBatchEntity>()
5✔
1941
                                .Where(batch => parentBatchIds.Contains(batch.Id))
5✔
NEW
1942
                                .ToDictionaryAsync(batch => batch.Id, StringComparer.Ordinal, cancellationToken)
×
1943
                                .ConfigureAwait(false);
5✔
1944
                var requiresFailure = false;
5✔
1945
                var anyFailed = false;
5✔
1946
                // A missing parent has no success or failure outcome; Complete continuations can still proceed.
1947
                foreach (var edge in edges)
5✔
1948
                {
1949
                        bool succeeded;
1950
                        bool failed;
1951
                        if (edge.ParentKind == ContinuationParentKind.Job)
5✔
1952
                        {
1953
                                var state = parentJobs.TryGetValue(edge.ParentId, out var parentJob) ? parentJob?.State : null;
5✔
1954
                                succeeded = state == JobState.Succeeded;
5✔
1955
                                failed = state == JobState.Failed;
5✔
1956
                        }
1957
                        else
1958
                        {
NEW
1959
                                var state = parentBatches.TryGetValue(edge.ParentId, out var parentBatch) ? parentBatch?.State : null;
×
NEW
1960
                                succeeded = state == BatchState.Succeeded;
×
NEW
1961
                                failed = state == BatchState.Failed;
×
1962
                        }
1963

1964
                        if (edge.Trigger == ContinuationTrigger.Success && !succeeded)
5✔
NEW
1965
                                return true;
×
1966
                        requiresFailure |= edge.Trigger == ContinuationTrigger.Failure;
5✔
1967
                        anyFailed |= failed;
5✔
1968
                }
1969

1970
                return requiresFailure && !anyFailed;
5✔
1971
        }
4✔
1972

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

2008
                batch.ConcurrencyStamp = Guid.NewGuid();
5✔
2009
                if (batch.PendingCount != 0)
5✔
2010
                        return;
5✔
2011
                batch.State = GetTerminalBatchState(batch.FailedCount, batch.CancelledCount);
5✔
2012
                batch.CompletedAt = now;
5✔
2013
                parents.Enqueue((
5✔
2014
                        ContinuationParentKind.Batch,
5✔
2015
                        batch.Id,
5✔
2016
                        batch.State == BatchState.Failed
5✔
2017
                ));
5✔
2018
        }
5✔
2019

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

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

2084
                                        if (parentFailed)
1✔
2085
                                                failedDependencies++;
×
2086
                                        if (edge.Trigger == ContinuationTrigger.Success && !parentSucceeded)
1✔
2087
                                                violated = true;
×
2088
                                }
2089

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

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

2125
                var jobState = jobs.TryGetValue(edge.ParentId, out var job) ? job.State : externalJobs[edge.ParentId];
5✔
2126
                return (IsTerminal(jobState), jobState == JobState.Succeeded, jobState == JobState.Failed);
5✔
2127
        }
2128

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

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

2159
                if (visited != jobIds.Count)
5✔
2160
                        throw new ImmediateJobException("The continuation graph contains a dependency cycle.");
×
2161
        }
5✔
2162

2163
        private static bool IsTerminal(JobState state) =>
2164
                state is JobState.Succeeded or JobState.Failed or JobState.Cancelled;
5✔
2165

2166
        private static BatchState GetTerminalBatchState(int failed, int cancelled) =>
2167
                failed != 0 ? BatchState.Failed : cancelled != 0 ? BatchState.Cancelled : BatchState.Succeeded;
5✔
2168

2169
        private async ValueTask MutateRecurringAsync(string name, Action<ImmediateRecurringJobEntity> mutate, CancellationToken cancellationToken)
2170
        {
2171
                ArgumentException.ThrowIfNullOrWhiteSpace(name);
×
2172
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
2173
                var schedule = await context.Set<ImmediateRecurringJobEntity>().FindAsync([name], cancellationToken).ConfigureAwait(false);
×
2174
                if (schedule is null)
×
2175
                        return;
2176
                mutate(schedule);
×
2177
                schedule.ConcurrencyStamp = Guid.NewGuid();
×
2178
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
2179
        }
×
2180

2181
        private static Task<int> UpdateRecurringAsync(
2182
                TContext context,
2183
                RecurringJobSchedule schedule,
2184
                CancellationToken cancellationToken
2185
        )
2186
        {
2187
                var concurrencyStamp = Guid.NewGuid();
5✔
2188
                return context.Set<ImmediateRecurringJobEntity>()
5✔
2189
                        .Where(entity => entity.Name == schedule.Name && (schedule.IsCodeDefined || !entity.IsCodeDefined))
5✔
2190
                        .ExecuteUpdateAsync(setters => setters
5✔
2191
                                .SetProperty(entity => entity.JobName, schedule.JobName)
5✔
2192
                                .SetProperty(entity => entity.Cron, schedule.Cron)
5✔
2193
                                .SetProperty(entity => entity.TimeZone, schedule.TimeZone)
5✔
2194
                                .SetProperty(entity => entity.IsCodeDefined, schedule.IsCodeDefined)
5✔
2195
                                .SetProperty(entity => entity.NextRunAt, schedule.NextRunAt)
5✔
2196
                                .SetProperty(entity => entity.ConcurrencyStamp, concurrencyStamp),
5✔
2197
                                cancellationToken);
5✔
2198
        }
2199

2200
        private static async Task ThrowIfReplacingCodeDefinedScheduleAsync(
2201
                TContext context,
2202
                RecurringJobSchedule schedule,
2203
                CancellationToken cancellationToken
2204
        )
2205
        {
2206
                if (!schedule.IsCodeDefined && await context.Set<ImmediateRecurringJobEntity>()
5✔
2207
                        .AnyAsync(entity => entity.Name == schedule.Name && entity.IsCodeDefined, cancellationToken)
5✔
2208
                        .ConfigureAwait(false))
5✔
2209
                {
2210
                        throw new ImmediateJobException("Code-defined recurring schedules cannot be replaced by dynamic schedules.");
4✔
2211
                }
2212
        }
5✔
2213

2214
        private static ImmediateJobEntity ToEntity(JobRecord job) => new()
5✔
2215
        {
5✔
2216
                Id = job.Id,
5✔
2217
                QueueName = job.QueueName,
5✔
2218
                JobName = job.JobName,
5✔
2219
                GroupId = job.GroupId,
5✔
2220
                Payload = job.Payload,
5✔
2221
                Context = job.Context,
5✔
2222
                State = job.State,
5✔
2223
                DueAt = job.DueAt,
5✔
2224
                CreatedAt = job.CreatedAt,
5✔
2225
                Attempt = job.Attempt,
5✔
2226
                WorkerId = job.WorkerId,
5✔
2227
                LeaseExpiresAt = job.LeaseExpiresAt,
5✔
2228
                LastError = job.LastError,
5✔
2229
                CompletedAt = job.CompletedAt,
5✔
2230
                RecurringKey = job.RecurringKey,
5✔
2231
                TraceParent = job.TraceParent,
5✔
2232
                TraceState = job.TraceState,
5✔
2233
                ExecutionTraceId = job.ExecutionTraceId,
5✔
2234
                ExecutionSpanId = job.ExecutionSpanId,
5✔
2235
                ExecutionStartedAt = job.ExecutionStartedAt,
5✔
2236
                BatchId = job.BatchId,
5✔
2237
                RemainingDependencies = job.RemainingDependencies,
5✔
2238
                FailedDependencies = job.FailedDependencies,
5✔
2239
                ConcurrencyStamp = Guid.NewGuid(),
5✔
2240
        };
5✔
2241

2242
        private static ImmediateJobContinuationEntity ToEntity(JobContinuationEdge edge)
2243
        {
2244
                var hasJobParent = edge.ParentJobId is not null;
5✔
2245
                var hasBatchParent = edge.ParentBatchId is not null;
5✔
2246
                if (hasJobParent == hasBatchParent)
5✔
2247
                        throw new ImmediateJobException("A continuation edge must identify exactly one parent job or batch.");
×
2248
                return new()
5✔
2249
                {
5✔
2250
                        ChildJobId = edge.ChildJobId,
5✔
2251
                        ParentKind = hasJobParent ? ContinuationParentKind.Job : ContinuationParentKind.Batch,
5✔
2252
                        ParentId = edge.ParentJobId ?? edge.ParentBatchId!,
5✔
2253
                        Trigger = edge.Trigger,
5✔
2254
                };
5✔
2255
        }
2256

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

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

2312
        private static BatchStatus ToStatus(ImmediateJobBatchEntity batch) => new(
5✔
2313
                batch.Id,
5✔
2314
                batch.State,
5✔
2315
                batch.TotalJobs,
5✔
2316
                batch.SucceededCount,
5✔
2317
                batch.FailedCount,
5✔
2318
                batch.CancelledCount,
5✔
2319
                batch.PendingCount,
5✔
2320
                batch.CreatedAt,
5✔
2321
                batch.StartedAt,
5✔
2322
                batch.CompletedAt,
5✔
2323
                BatchStatus.CalculateFractionSettled(batch.TotalJobs, batch.PendingCount)
5✔
2324
        );
5✔
2325

2326
        private static JobContinuationEdge ToContinuationEdge(ImmediateJobContinuationEntity edge) => new()
5✔
2327
        {
5✔
2328
                ChildJobId = edge.ChildJobId,
5✔
2329
                ParentJobId = edge.ParentKind == ContinuationParentKind.Job ? edge.ParentId : null,
5✔
2330
                ParentBatchId = edge.ParentKind == ContinuationParentKind.Batch ? edge.ParentId : null,
5✔
2331
                Trigger = edge.Trigger,
5✔
2332
        };
5✔
2333

2334
        private static BatchGraphEdge ToGraphEdge(ImmediateJobContinuationEntity edge) => new(
5✔
2335
                edge.ChildJobId,
5✔
2336
                edge.ParentKind == ContinuationParentKind.Job ? edge.ParentId : null,
5✔
2337
                edge.ParentKind == ContinuationParentKind.Batch ? edge.ParentId : null,
5✔
2338
                edge.Trigger
5✔
2339
        );
5✔
2340

2341
        private static ImmediateRecurringJobEntity ToEntity(RecurringJobSchedule schedule) => new()
5✔
2342
        {
5✔
2343
                Name = schedule.Name,
5✔
2344
                JobName = schedule.JobName,
5✔
2345
                Cron = schedule.Cron,
5✔
2346
                TimeZone = schedule.TimeZone,
5✔
2347
                IsCodeDefined = schedule.IsCodeDefined,
5✔
2348
                IsPaused = schedule.IsPaused,
5✔
2349
                NextRunAt = schedule.NextRunAt,
5✔
2350
                LastRunAt = schedule.LastRunAt,
5✔
2351
                ConcurrencyStamp = Guid.NewGuid(),
5✔
2352
        };
5✔
2353
}
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