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

ImmediatePlatform / Immediate.Jobs / 30665688881

31 Jul 2026 09:11PM UTC coverage: 83.969% (+0.1%) from 83.82%
30665688881

Pull #86

github

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

103 of 116 new or added lines in 9 files covered. (88.79%)

2 existing lines in 1 file now uncovered.

8213 of 9781 relevant lines covered (83.97%)

2.7 hits per line

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

76.85
/src/Immediate.Jobs.LinqToDB/LinqToDBJobStorage.cs
1
using System.Data.Common;
2
using LinqToDB;
3
using LinqToDB.Async;
4
using LinqToDB.Data;
5

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

9
namespace Immediate.Jobs.LinqToDB;
10

11
/// <summary>An optimistic-concurrency LinqToDB implementation of <see cref="IJobStorage"/>.</summary>
12
public sealed class LinqToDBJobStorage : IRecurringJobStorage, IJobGraphStorage, IJobStorageReplica
13
{
14
        private const int MaxContendedCompletionAttempts = 50;
15
        private const int MaxConcurrencyAttempts = 5;
16
        private const int MaxConsecutiveFailedFairClaims = 5;
17
        private readonly DataOptions _dataOptions;
18
        private readonly string? _schema;
19
        private readonly TimeProvider _timeProvider;
20

21
        /// <summary>Creates storage using immutable LinqToDB connection options.</summary>
22
        /// <param name="dataOptions">The immutable LinqToDB connection options.</param>
23
        /// <param name="schema">The database schema containing the Immediate.Jobs tables, or <see langword="null"/> for the provider default.</param>
24
        /// <param name="timeProvider">The clock used for storage timestamps, or <see langword="null"/> to use the system clock.</param>
25
        public LinqToDBJobStorage(DataOptions dataOptions, string? schema = null, TimeProvider? timeProvider = null)
1✔
26
        {
27
                ArgumentNullException.ThrowIfNull(dataOptions);
1✔
28
                LinqToDBSchemaExtensions.ValidateSchema(schema);
1✔
29
                _dataOptions = dataOptions;
1✔
30
                _schema = schema;
1✔
31
                _timeProvider = timeProvider ?? TimeProvider.System;
1✔
32
        }
1✔
33

34
        /// <inheritdoc />
35
        public ValueTask DisposeAsync() => ValueTask.CompletedTask;
1✔
36

37
        /// <inheritdoc />
38
        public ValueTask InitializeAsync(CancellationToken cancellationToken = default) => ValueTask.CompletedTask;
×
39

40
        /// <inheritdoc />
41
        public async ValueTask EnqueueAsync(JobRecord job, CancellationToken cancellationToken = default)
42
        {
43
                ArgumentNullException.ThrowIfNull(job);
1✔
44
                await using var connection = CreateConnection();
1✔
45
                _ = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
1✔
46
                try
47
                {
48
                        await ResetReturningGroupCursorsAsync(connection, [job], cancellationToken).ConfigureAwait(false);
1✔
49
                        _ = await InsertAsync(connection, ToEntity(job), cancellationToken).ConfigureAwait(false);
1✔
50
                        await connection.CommitTransactionAsync(cancellationToken).ConfigureAwait(false);
1✔
51
                }
1✔
52
                catch
1✔
53
                {
54
                        await connection.RollbackTransactionAsync(cancellationToken).ConfigureAwait(false);
1✔
55
                        throw;
1✔
56
                }
57
        }
1✔
58

59
        /// <inheritdoc />
60
        public async ValueTask<IReadOnlyList<JobContinuationEdge>> GetIncomingEdgesAsync(
61
                IReadOnlyCollection<string> childJobIds,
62
                CancellationToken cancellationToken = default
63
        )
64
        {
65
                ArgumentNullException.ThrowIfNull(childJobIds);
1✔
66
                if (childJobIds.Any(string.IsNullOrWhiteSpace))
1✔
67
                        throw new ArgumentException("Child job identifiers cannot be null or blank.", nameof(childJobIds));
1✔
68
                if (childJobIds.Count == 0)
1✔
69
                        return [];
1✔
70

71
                var ids = childJobIds.Distinct(StringComparer.Ordinal).ToArray();
1✔
72
                await using var connection = CreateConnection();
1✔
73
                var edges = await Continuations(connection)
1✔
74
                        .Where(edge => ids.Contains(edge.ChildJobId))
1✔
75
                        .OrderBy(edge => edge.ChildJobId)
1✔
76
                        .ThenBy(edge => edge.ParentKind)
1✔
77
                        .ThenBy(edge => edge.ParentId)
1✔
78
                        .ToListAsync(cancellationToken)
1✔
79
                        .ConfigureAwait(false);
1✔
80
                return [.. edges.Select(ToContinuationEdge)];
1✔
81
        }
1✔
82

83
        /// <inheritdoc />
84
        public ValueTask EnqueueContinuationAsync(
85
                JobRecord job,
86
                IReadOnlyList<JobContinuationEdge> edges,
87
                CancellationToken cancellationToken = default
88
        )
89
        {
90
                ArgumentNullException.ThrowIfNull(job);
1✔
91
                ArgumentNullException.ThrowIfNull(edges);
1✔
92
                return ExecuteGraphInsertAsync(batch: null, [job], edges, cancellationToken);
1✔
93
        }
94

95
        /// <inheritdoc />
96
        public ValueTask EnqueueBatchAsync(
97
                JobBatchRecord batch,
98
                IReadOnlyList<JobRecord> jobs,
99
                IReadOnlyList<JobContinuationEdge> edges,
100
                CancellationToken cancellationToken = default
101
        )
102
        {
103
                ArgumentNullException.ThrowIfNull(batch);
1✔
104
                ArgumentNullException.ThrowIfNull(jobs);
1✔
105
                ArgumentNullException.ThrowIfNull(edges);
1✔
106
                if (jobs.Count == 0)
1✔
107
                        throw new ImmediateJobException("An atomic batch cannot be committed without jobs.");
×
108
                return ExecuteGraphInsertAsync(batch, jobs, edges, cancellationToken);
1✔
109
        }
110

111
        private ValueTask ExecuteGraphInsertAsync(
112
                JobBatchRecord? batch,
113
                IReadOnlyList<JobRecord> jobs,
114
                IReadOnlyList<JobContinuationEdge> edges,
115
                CancellationToken cancellationToken
116
        ) => RetryConcurrencyAsync(
1✔
117
                connection => InsertGraphCoreAsync(connection, batch, jobs, edges, cancellationToken),
1✔
118
                cancellationToken
1✔
119
        );
1✔
120

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

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

142
                var jobEntities = jobs.Select(ToEntity).ToDictionary(static job => job.Id, StringComparer.Ordinal);
1✔
143
                await ResetReturningGroupCursorsAsync(connection, jobs, cancellationToken).ConfigureAwait(false);
1✔
144
                await EvaluateInitialDependenciesAsync(
1✔
145
                        connection,
1✔
146
                        jobEntities,
1✔
147
                        edgeEntities,
1✔
148
                        _timeProvider.GetUtcNow().UtcTicks,
1✔
149
                        cancellationToken
1✔
150
                ).ConfigureAwait(false);
1✔
151

152
                if (batch is not null)
1✔
153
                {
154
                        var terminal = jobEntities.Values.Where(static job => IsTerminal(job.State)).ToArray();
1✔
155
                        var pending = jobEntities.Count - terminal.Length;
1✔
156
                        var failed = terminal.Count(static job => job.State == JobState.Failed);
1✔
157
                        var cancelled = terminal.Count(static job => job.State == JobState.Cancelled);
1✔
158
                        var skipped = terminal.Count(static job => job.State == JobState.Skipped);
1✔
159
                        _ = await InsertAsync(connection, new ImmediateJobBatchEntity
1✔
160
                        {
1✔
161
                                Id = batch.Id,
1✔
162
                                CreatedAt = batch.CreatedAt.UtcTicks,
1✔
163
                                TotalJobs = jobEntities.Count,
1✔
164
                                PendingCount = pending,
1✔
165
                                SucceededCount = terminal.Count(static job => job.State == JobState.Succeeded),
1✔
166
                                FailedCount = failed,
1✔
167
                                CancelledCount = cancelled,
1✔
168
                                SkippedCount = skipped,
1✔
169
                                StartedAt = Ticks(batch.StartedAt),
1✔
170
                                CompletedAt = pending == 0 ? Ticks(batch.CompletedAt ?? _timeProvider.GetUtcNow()) : null,
1✔
171
                                State = pending == 0 ? GetTerminalBatchState(failed, cancelled) : BatchState.Executing,
1✔
172
                                ConcurrencyStamp = Guid.NewGuid(),
1✔
173
                        }, cancellationToken).ConfigureAwait(false);
1✔
174
                }
175

176
                foreach (var entity in jobEntities.Values)
1✔
177
                        _ = await InsertAsync(connection, entity, cancellationToken).ConfigureAwait(false);
1✔
178
                foreach (var edge in edgeEntities)
1✔
179
                        _ = await InsertAsync(connection, edge, cancellationToken).ConfigureAwait(false);
1✔
180
        }
1✔
181

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

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

203
                        var jobCapacities = queue.JobCapacities.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal);
1✔
204
                        while (queueCapacity > 0)
1✔
205
                        {
206
                                var eligibleNames = jobCapacities.Where(static pair => pair.Value > 0).Select(static pair => pair.Key).ToArray();
1✔
207
                                if (eligibleNames.Length == 0)
1✔
208
                                        break;
209
                                await using var readConnection = CreateConnection();
1✔
210
                                var candidates = await Jobs(readConnection)
1✔
211
                                        .Where(job => job.QueueName == queue.QueueName && eligibleNames.Contains(job.JobName) &&
1✔
212
                                                (((job.State == JobState.Scheduled || job.State == JobState.Pending) && job.DueAt <= now)
1✔
213
                                                        || (job.State == JobState.Active && job.LeaseExpiresAt <= now)))
1✔
214
                                        .OrderBy(job => job.DueAt)
1✔
215
                                        .ThenBy(job => job.CreatedAt)
1✔
216
                                        .ThenBy(job => job.Id)
1✔
217
                                        .Take(queueCapacity)
1✔
218
                                        .ToListAsync(cancellationToken)
1✔
219
                                        .ConfigureAwait(false);
1✔
220
                                if (candidates.Count == 0)
1✔
221
                                        break;
222

223
                                var selectionCapacities = new Dictionary<string, int>(jobCapacities, StringComparer.Ordinal);
1✔
224
                                var selected = candidates.Where(candidate => selectionCapacities[candidate.JobName]-- > 0).ToList();
1✔
225
                                var claimed = await AcquireCandidatesAsync(selected, request.WorkerId, request.Lease, now, cancellationToken)
1✔
226
                                        .ConfigureAwait(false);
1✔
227
                                foreach (var job in claimed)
1✔
228
                                {
229
                                        jobCapacities[job.JobName]--;
1✔
230
                                        queueCapacity--;
1✔
231
                                        acquired.Add(job);
1✔
232
                                }
233

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

239
                return acquired;
1✔
240
        }
1✔
241

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

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

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

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

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

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

413
                                consecutiveFailedClaims = 0;
1✔
414
                                jobCapacities[claimedJob.JobName]--;
1✔
415
                                queueCapacity--;
1✔
416
                                acquired.Add(claimedJob);
1✔
417
                        }
1✔
418
                }
1✔
419

420
                return acquired;
1✔
421
        }
1✔
422

423
        private async ValueTask<IReadOnlyList<JobRecord>> AcquireFairFastPathAsync(
424
                string queueName,
425
                Dictionary<string, int> jobCapacities,
426
                int queueCapacity,
427
                string workerId,
428
                TimeSpan lease,
429
                long now,
430
                CancellationToken cancellationToken
431
        )
432
        {
433
                var acquired = new List<JobRecord>(queueCapacity);
1✔
434
                while (queueCapacity > 0)
1✔
435
                {
436
                        var eligibleNames = jobCapacities
1✔
437
                                .Where(static pair => pair.Value > 0)
1✔
438
                                .Select(static pair => pair.Key)
1✔
439
                                .ToArray();
1✔
440
                        if (eligibleNames.Length == 0)
1✔
441
                                break;
442

443
                        await using var readConnection = CreateConnection();
1✔
444
                        var candidates = await Jobs(readConnection)
1✔
445
                                .Where(job => job.QueueName == queueName && eligibleNames.Contains(job.JobName) &&
1✔
446
                                        (((job.State == JobState.Scheduled || job.State == JobState.Pending) && job.DueAt <= now)
1✔
447
                                                || (job.State == JobState.Active && job.LeaseExpiresAt <= now)))
1✔
448
                                .OrderBy(job => job.DueAt)
1✔
449
                                .ThenBy(job => job.CreatedAt)
1✔
450
                                .ThenBy(job => job.Id)
1✔
451
                                .Take(queueCapacity)
1✔
452
                                .ToListAsync(cancellationToken)
1✔
453
                                .ConfigureAwait(false);
1✔
454
                        if (candidates.Count == 0)
1✔
455
                                break;
456

457
                        var selected = new List<ImmediateJobEntity>(candidates.Count);
1✔
458
                        var selectionCapacities = new Dictionary<string, int>(jobCapacities, StringComparer.Ordinal);
1✔
459
                        foreach (var candidate in candidates)
1✔
460
                        {
461
                                if (selectionCapacities[candidate.JobName] <= 0)
1✔
462
                                        continue;
463
                                selectionCapacities[candidate.JobName]--;
1✔
464
                                selected.Add(candidate);
1✔
465
                        }
466

467
                        var claimed = await AcquireCandidatesAsync(
1✔
468
                                selected,
1✔
469
                                workerId,
1✔
470
                                lease,
1✔
471
                                now,
1✔
472
                                cancellationToken
1✔
473
                        ).ConfigureAwait(false);
1✔
474
                        foreach (var job in claimed)
1✔
475
                        {
476
                                jobCapacities[job.JobName]--;
1✔
477
                                queueCapacity--;
1✔
478
                                acquired.Add(job);
1✔
479
                        }
480

481
                        if (claimed.Count == 0)
1✔
482
                                break;
483
                }
1✔
484

485
                return acquired;
1✔
486
        }
1✔
487

488
        private async ValueTask<JobRecord?> AcquireFairCandidateAsync(
489
                ImmediateJobEntity candidate,
490
                string workerId,
491
                TimeSpan lease,
492
                long now,
493
                long nextSequence,
494
                CancellationToken cancellationToken
495
        )
496
        {
497
                await using var connection = CreateConnection();
1✔
498
                _ = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
1✔
499
                Guid? observedCursorStamp = null;
1✔
500
                var cursorWasMissing = false;
1✔
501
                try
502
                {
503
                        var previous = ToRecord(candidate);
1✔
504
                        var oldStamp = candidate.ConcurrencyStamp;
1✔
505
                        candidate.State = JobState.Active;
1✔
506
                        candidate.WorkerId = workerId;
1✔
507
                        candidate.LeaseExpiresAt = now + lease.Ticks;
1✔
508
                        candidate.Attempt++;
1✔
509
                        candidate.CompletedAt = null;
1✔
510
                        candidate.ExecutionTraceId = null;
1✔
511
                        candidate.ExecutionSpanId = null;
1✔
512
                        candidate.ExecutionStartedAt = null;
1✔
513
                        candidate.ConcurrencyStamp = Guid.NewGuid();
1✔
514
                        if (!await UpdateJobAsync(connection, candidate, oldStamp, cancellationToken).ConfigureAwait(false))
1✔
515
                                throw new LostRaceException();
×
516
                        await PrepareAcquisitionExecutionsAsync(connection, previous, workerId, now, cancellationToken).ConfigureAwait(false);
1✔
517

518
                        if (candidate.BatchId is { } batchId)
1✔
519
                        {
520
                                var batch = await Batches(connection).SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
×
521
                                        .ConfigureAwait(false);
×
522
                                if (batch is not null && batch.StartedAt is null)
×
523
                                {
524
                                        var batchStamp = batch.ConcurrencyStamp;
×
525
                                        batch.StartedAt = now;
×
526
                                        batch.ConcurrencyStamp = Guid.NewGuid();
×
527
                                        if (!await UpdateBatchAsync(connection, batch, batchStamp, cancellationToken).ConfigureAwait(false))
×
528
                                                throw new LostRaceException();
×
529
                                }
530
                        }
531

532
                        if (candidate.GroupId is { } groupId)
1✔
533
                        {
534
                                var cursor = await FairQueueGroups(connection)
1✔
535
                                        .SingleOrDefaultAsync(
1✔
536
                                                group => group.QueueName == candidate.QueueName && group.GroupId == groupId,
1✔
537
                                                cancellationToken
1✔
538
                                        )
1✔
539
                                        .ConfigureAwait(false);
1✔
540
                                if (cursor is null)
1✔
541
                                {
542
                                        cursorWasMissing = true;
1✔
543
                                        _ = await InsertAsync(connection, new ImmediateFairQueueGroupEntity
1✔
544
                                        {
1✔
545
                                                QueueName = candidate.QueueName,
1✔
546
                                                GroupId = groupId,
1✔
547
                                                LastServedSequence = nextSequence,
1✔
548
                                                ConcurrencyStamp = Guid.NewGuid(),
1✔
549
                                        }, cancellationToken).ConfigureAwait(false);
1✔
550
                                }
551
                                else if (cursor.LastServedSequence >= nextSequence)
1✔
552
                                {
553
                                        throw new LostRaceException();
×
554
                                }
555
                                else
556
                                {
557
                                        var cursorStamp = cursor.ConcurrencyStamp;
1✔
558
                                        observedCursorStamp = cursorStamp;
1✔
559
                                        cursor.LastServedSequence = nextSequence;
1✔
560
                                        cursor.ConcurrencyStamp = Guid.NewGuid();
1✔
561
                                        if (!await UpdateFairQueueGroupAsync(connection, cursor, cursorStamp, cancellationToken)
1✔
562
                                                .ConfigureAwait(false))
1✔
563
                                        {
564
                                                throw new LostRaceException();
×
565
                                        }
566
                                }
567
                        }
568

569
                        await connection.CommitTransactionAsync(cancellationToken).ConfigureAwait(false);
1✔
570
                        return ToRecord(candidate);
1✔
571
                }
572
                catch (SyntheticExecutionInsertFailedException exception)
×
573
                {
574
                        await connection.RollbackTransactionAsync(cancellationToken).ConfigureAwait(false);
×
575
                        if (await SyntheticExecutionExistsAsync(exception.JobId, exception.Attempt, cancellationToken).ConfigureAwait(false))
×
576
                                return null;
×
577
                        throw exception.DatabaseException;
×
578
                }
579
                catch (LostRaceException)
×
580
                {
581
                        await connection.RollbackTransactionAsync(cancellationToken).ConfigureAwait(false);
×
582
                        return null;
×
583
                }
584
                catch (DbException)
×
585
                {
586
                        try
587
                        {
588
                                await connection.RollbackTransactionAsync(cancellationToken).ConfigureAwait(false);
×
589
                        }
×
590
                        catch (DbException)
×
591
                        {
592
                                // The original database error remains authoritative.
593
                        }
×
594

595
                        if (await FairQueueCursorChangedAsync(
×
596
                                candidate.QueueName,
×
597
                                candidate.GroupId,
×
598
                                observedCursorStamp,
×
599
                                cursorWasMissing,
×
600
                                cancellationToken
×
601
                        ).ConfigureAwait(false))
×
602
                        {
603
                                return null;
×
604
                        }
605

606
                        throw;
×
607
                }
608
        }
1✔
609

610
        private async ValueTask<bool> FairQueueCursorChangedAsync(
611
                string queueName,
612
                string? groupId,
613
                Guid? observedCursorStamp,
614
                bool cursorWasMissing,
615
                CancellationToken cancellationToken
616
        )
617
        {
618
                if (groupId is null || (!cursorWasMissing && observedCursorStamp is null))
×
619
                        return false;
×
620

621
                await using var connection = CreateConnection();
×
622
                var currentStamp = await FairQueueGroups(connection)
×
623
                        .Where(group => group.QueueName == queueName && group.GroupId == groupId)
×
624
                        .Select(static group => (Guid?)group.ConcurrencyStamp)
×
625
                        .SingleOrDefaultAsync(cancellationToken)
×
626
                        .ConfigureAwait(false);
×
627
                return cursorWasMissing
×
628
                        ? currentStamp is not null
×
629
                        : currentStamp != observedCursorStamp;
×
630
        }
×
631

632
        private static JobRecord? GetFirstOrDefault(IReadOnlyList<JobRecord> jobs) =>
633
                jobs.Count == 0 ? null : jobs[0];
1✔
634

635
        private static bool IsNoisy(
636
                string? groupId,
637
                int inflight,
638
                int totalInflight,
639
                FairQueuePolicy policy
640
        )
641
        {
642
                return groupId is not null
1✔
643
                        && totalInflight > 0
1✔
644
                        && inflight >= policy.MinInflightForNoisy
1✔
645
                        && (double)inflight / totalInflight > policy.ConcurrencyShareThreshold;
1✔
646
        }
647

648
        private async Task ResetReturningGroupCursorsAsync(
649
                DataConnection connection,
650
                IReadOnlyCollection<JobRecord> jobs,
651
                CancellationToken cancellationToken
652
        )
653
        {
654
                var groups = jobs
1✔
655
                        .Where(static job => job.GroupId is not null)
1✔
656
                        .Select(static job => (job.QueueName, job.GroupId))
1✔
657
                        .Distinct()
1✔
658
                        .ToArray();
1✔
659
                foreach (var (queueName, groupId) in groups)
1✔
660
                {
661
                        var hasLiveJobs = await Jobs(connection)
1✔
662
                                .AnyAsync(
1✔
663
                                        job => job.QueueName == queueName
1✔
664
                                                && job.GroupId == groupId
1✔
665
                                                && (job.State == JobState.Pending
1✔
666
                                                        || job.State == JobState.Scheduled
1✔
667
                                                        || job.State == JobState.Active),
1✔
668
                                        cancellationToken
1✔
669
                                )
1✔
670
                                .ConfigureAwait(false);
1✔
671
                        if (hasLiveJobs)
1✔
672
                                continue;
673

674
                        _ = await FairQueueGroups(connection)
1✔
675
                                .Where(group => group.QueueName == queueName && group.GroupId == groupId)
1✔
676
                                .DeleteAsync(cancellationToken)
1✔
677
                                .ConfigureAwait(false);
1✔
678
                }
1✔
679
        }
1✔
680

681
        private static void ValidateDynamicJob(JobRecord job, string description)
682
        {
683
                ArgumentException.ThrowIfNullOrWhiteSpace(job.Id);
1✔
684
                ArgumentException.ThrowIfNullOrWhiteSpace(job.JobName);
1✔
685
                if (job.State is not (JobState.Pending or JobState.Scheduled))
1✔
686
                        throw new ImmediateJobException($"{description} '{job.Id}' has invalid state '{job.State}'.");
×
687
        }
1✔
688

689
        private sealed record FairQueueCandidateState(
1✔
690
                string JobId,
1✔
691
                int Inflight,
1✔
692
                long LastServedSequence
1✔
693
        );
1✔
694

695
        /// <inheritdoc />
696
        public async ValueTask<IReadOnlyList<JobRecord>> AcquireJobsAsync(
697
                IReadOnlyCollection<string> jobIds,
698
                string workerId,
699
                TimeSpan lease,
700
                CancellationToken cancellationToken = default
701
        )
702
        {
703
                ArgumentNullException.ThrowIfNull(jobIds);
1✔
704
                ArgumentException.ThrowIfNullOrWhiteSpace(workerId);
1✔
705
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lease, TimeSpan.Zero);
1✔
706
                if (jobIds.Count == 0)
1✔
707
                        return [];
×
708
                var now = _timeProvider.GetUtcNow().UtcTicks;
1✔
709
                var ids = jobIds.ToArray();
1✔
710
                await using var connection = CreateConnection();
1✔
711
                var candidates = await Jobs(connection)
1✔
712
                        .Where(job => ids.Contains(job.Id) &&
1✔
713
                                (((job.State == JobState.Scheduled || job.State == JobState.Pending) && job.DueAt <= now)
1✔
714
                                        || (job.State == JobState.Active && job.LeaseExpiresAt <= now)))
1✔
715
                        .ToListAsync(cancellationToken)
1✔
716
                        .ConfigureAwait(false);
1✔
717
                return await AcquireCandidatesAsync(candidates, workerId, lease, now, cancellationToken).ConfigureAwait(false);
1✔
718
        }
1✔
719

720
        private async ValueTask<IReadOnlyList<JobRecord>> AcquireCandidatesAsync(
721
                List<ImmediateJobEntity> candidates,
722
                string workerId,
723
                TimeSpan lease,
724
                long now,
725
                CancellationToken cancellationToken
726
        )
727
        {
728
                var acquired = new List<JobRecord>(candidates.Count);
1✔
729
                foreach (var candidate in candidates)
1✔
730
                {
731
                        await using var connection = CreateConnection();
1✔
732
                        _ = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
1✔
733
                        try
734
                        {
735
                                var previous = ToRecord(candidate);
1✔
736
                                var oldStamp = candidate.ConcurrencyStamp;
1✔
737
                                candidate.State = JobState.Active;
1✔
738
                                candidate.WorkerId = workerId;
1✔
739
                                candidate.LeaseExpiresAt = now + lease.Ticks;
1✔
740
                                candidate.Attempt++;
1✔
741
                                candidate.CompletedAt = null;
1✔
742
                                candidate.ExecutionTraceId = null;
1✔
743
                                candidate.ExecutionSpanId = null;
1✔
744
                                candidate.ExecutionStartedAt = null;
1✔
745
                                candidate.ConcurrencyStamp = Guid.NewGuid();
1✔
746
                                if (!await UpdateJobAsync(connection, candidate, oldStamp, cancellationToken).ConfigureAwait(false))
1✔
747
                                {
748
                                        await connection.RollbackTransactionAsync(cancellationToken).ConfigureAwait(false);
1✔
749
                                        continue;
1✔
750
                                }
751

752
                                await PrepareAcquisitionExecutionsAsync(connection, previous, workerId, now, cancellationToken).ConfigureAwait(false);
1✔
753

754
                                if (candidate.BatchId is { } batchId)
1✔
755
                                {
756
                                        var batch = await Batches(connection).SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
1✔
757
                                                .ConfigureAwait(false);
1✔
758
                                        if (batch is not null && batch.StartedAt is null)
1✔
759
                                        {
760
                                                var batchStamp = batch.ConcurrencyStamp;
1✔
761
                                                batch.StartedAt = now;
1✔
762
                                                batch.ConcurrencyStamp = Guid.NewGuid();
1✔
763
                                                if (!await UpdateBatchAsync(connection, batch, batchStamp, cancellationToken).ConfigureAwait(false))
1✔
764
                                                        throw new LostRaceException();
×
765
                                        }
766
                                }
767

768
                                await connection.CommitTransactionAsync(cancellationToken).ConfigureAwait(false);
1✔
769
                                acquired.Add(ToRecord(candidate));
1✔
770
                        }
1✔
771
                        catch (SyntheticExecutionInsertFailedException exception)
×
772
                        {
773
                                await connection.RollbackTransactionAsync(cancellationToken).ConfigureAwait(false);
×
774
                                if (!await SyntheticExecutionExistsAsync(exception.JobId, exception.Attempt, cancellationToken)
×
775
                                        .ConfigureAwait(false))
×
776
                                {
777
                                        throw exception.DatabaseException;
×
778
                                }
779
                        }
×
780
                        catch (LostRaceException)
×
781
                        {
782
                                await connection.RollbackTransactionAsync(cancellationToken).ConfigureAwait(false);
×
783
                        }
784
                }
1✔
785

786
                return acquired;
1✔
787
        }
1✔
788

789
        /// <inheritdoc />
790
        public async ValueTask SetExecutionTelemetryAsync(
791
                string jobId,
792
                int executionNumber,
793
                string workerId,
794
                string? traceId,
795
                string? spanId,
796
                DateTimeOffset startedAt,
797
                CancellationToken cancellationToken = default
798
        )
799
        {
800
                await RetryConcurrencyAsync(async connection =>
1✔
801
                {
1✔
802
                        var job = await Jobs(connection).SingleOrDefaultAsync(
1✔
803
                                item => item.Id == jobId && item.Attempt == executionNumber && item.State == JobState.Active && item.WorkerId == workerId,
1✔
804
                                cancellationToken
1✔
805
                        ).ConfigureAwait(false) ?? throw new ImmediateJobException($"Worker '{workerId}' does not own active job '{jobId}'.");
1✔
806
                        _ = await GetOrMaterializeExecutionAsync(connection, job, cancellationToken).ConfigureAwait(false)
1✔
807
                                ?? throw new ImmediateJobException($"Active job '{job.Id}' has no execution ordinal.");
1✔
808
                        var oldStamp = job.ConcurrencyStamp;
1✔
809
                        job.ExecutionTraceId = traceId;
1✔
810
                        job.ExecutionSpanId = spanId;
1✔
811
                        job.ExecutionStartedAt = startedAt.UtcTicks;
1✔
812
                        job.ConcurrencyStamp = Guid.NewGuid();
1✔
813
                        if (!await UpdateJobAsync(connection, job, oldStamp, cancellationToken).ConfigureAwait(false))
1✔
814
                                throw new LostRaceException();
×
815
                        var executionUpdated = await Executions(connection)
1✔
816
                                .Where(execution => execution.JobId == jobId && execution.Attempt == executionNumber && execution.State == JobExecutionState.Active)
1✔
817
                                .Set(execution => execution.ExecutionTraceId, traceId)
1✔
818
                                .Set(execution => execution.ExecutionSpanId, spanId)
1✔
819
                                .Set(execution => execution.ExecutionStartedAt, startedAt.UtcTicks)
1✔
820
                                .UpdateAsync(cancellationToken)
1✔
821
                                .ConfigureAwait(false);
1✔
822
                        if (executionUpdated == 0)
1✔
823
                                throw new LostRaceException();
×
824
                }, cancellationToken).ConfigureAwait(false);
1✔
825
        }
1✔
826

827
        /// <inheritdoc />
828
        public async ValueTask RenewLeaseAsync(
829
                string jobId,
830
                int executionNumber,
831
                string workerId,
832
                TimeSpan lease,
833
                CancellationToken cancellationToken = default
834
        )
835
        {
836
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lease, TimeSpan.Zero);
1✔
837
                await using var connection = CreateConnection();
1✔
838
                var updated = await Jobs(connection)
1✔
839
                        .Where(job => job.Id == jobId && job.Attempt == executionNumber && job.State == JobState.Active && job.WorkerId == workerId)
1✔
840
                        .Set(job => job.LeaseExpiresAt, _timeProvider.GetUtcNow().UtcTicks + lease.Ticks)
1✔
841
                        .Set(job => job.ConcurrencyStamp, Guid.NewGuid())
1✔
842
                        .UpdateAsync(cancellationToken)
1✔
843
                        .ConfigureAwait(false);
1✔
844
                if (updated == 0)
1✔
845
                        throw new ImmediateJobException($"Worker '{workerId}' does not own active job '{jobId}'.");
1✔
846
        }
×
847

848
        /// <inheritdoc />
849
        public ValueTask CompleteAsync(
850
                string jobId,
851
                int executionNumber,
852
                string workerId,
853
                CancellationToken cancellationToken = default
854
        ) => CompleteWithContinuationsAsync(jobId, executionNumber, workerId, [], cancellationToken);
1✔
855

856
        /// <inheritdoc />
857
        public ValueTask CompleteWithContinuationsAsync(
858
                string jobId,
859
                int executionNumber,
860
                string workerId,
861
                IReadOnlyList<JobContinuationAddition> additions,
862
                CancellationToken cancellationToken = default
863
        )
864
        {
865
                ArgumentNullException.ThrowIfNull(additions);
1✔
866
                return MutateOwnedWithDependenciesAsync(
1✔
867
                        jobId,
1✔
868
                        executionNumber,
1✔
869
                        workerId,
1✔
870
                        error: null,
1✔
871
                        nextRetryAt: null,
1✔
872
                        succeeded: true,
1✔
873
                        additions,
1✔
874
                        cancellationToken
1✔
875
                );
1✔
876
        }
877

878
        /// <inheritdoc />
879
        public ValueTask AddBatchJobAsync(
880
                string currentJobId,
881
                int executionNumber,
882
                JobRecord job,
883
                ContinuationOptions options,
884
                CancellationToken cancellationToken = default
885
        )
886
        {
887
                ArgumentNullException.ThrowIfNull(job);
1✔
888
                if (options == ContinuationOptions.Detached)
1✔
889
                        throw new ImmediateJobException("AddToBatchAsync cannot create a detached job.");
×
890
                if (options is not (ContinuationOptions.BesideContinuations or ContinuationOptions.BeforeContinuations))
1✔
891
                        throw new ArgumentOutOfRangeException(nameof(options));
1✔
892
                return RetryConcurrencyAsync(
1✔
893
                        connection => AddBatchJobCoreAsync(connection, currentJobId, executionNumber, job, options, cancellationToken),
1✔
894
                        cancellationToken
1✔
895
                );
1✔
896
        }
897

898
        /// <inheritdoc />
899
        public ValueTask FailAsync(
900
                string jobId,
901
                int executionNumber,
902
                string workerId,
903
                string error,
904
                DateTimeOffset? nextRetryAt,
905
                CancellationToken cancellationToken = default
906
        ) => MutateOwnedWithDependenciesAsync(
1✔
907
                jobId,
1✔
908
                executionNumber,
1✔
909
                workerId,
1✔
910
                error,
1✔
911
                nextRetryAt,
1✔
912
                succeeded: false,
1✔
913
                [],
1✔
914
                cancellationToken
1✔
915
        );
1✔
916

917
        /// <inheritdoc />
918
        public async ValueTask UpsertRecurringAsync(
919
                RecurringJobSchedule schedule,
920
                CancellationToken cancellationToken = default
921
        )
922
        {
923
                ArgumentNullException.ThrowIfNull(schedule);
1✔
924
                for (var attempt = 0; attempt < MaxConcurrencyAttempts; attempt++)
1✔
925
                {
926
                        await using var connection = CreateConnection();
1✔
927
                        var existing = await Recurring(connection).SingleOrDefaultAsync(item => item.Name == schedule.Name, cancellationToken)
1✔
928
                                .ConfigureAwait(false);
1✔
929
                        if (existing is null)
1✔
930
                        {
931
                                try
932
                                {
933
                                        _ = await InsertAsync(connection, ToEntity(schedule), cancellationToken).ConfigureAwait(false);
1✔
934
                                        return;
1✔
935
                                }
936
                                catch (DbException)
×
937
                                {
938
                                        existing = await Recurring(connection).SingleOrDefaultAsync(item => item.Name == schedule.Name, cancellationToken)
×
939
                                                .ConfigureAwait(false);
×
940
                                        if (existing is null)
×
941
                                                throw;
×
942
                                }
943
                        }
944

945
                        if (!schedule.IsCodeDefined && existing.IsCodeDefined)
1✔
946
                                throw new ImmediateJobException("Code-defined recurring schedules cannot be replaced by dynamic schedules.");
×
947
                        var oldStamp = existing.ConcurrencyStamp;
1✔
948
                        existing.JobName = schedule.JobName;
1✔
949
                        existing.Cron = schedule.Cron;
1✔
950
                        existing.TimeZone = schedule.TimeZone;
1✔
951
                        existing.IsCodeDefined = schedule.IsCodeDefined;
1✔
952
                        existing.NextRunAt = schedule.NextRunAt.UtcTicks;
1✔
953
                        existing.ConcurrencyStamp = Guid.NewGuid();
1✔
954
                        if (await UpdateRecurringAsync(connection, existing, oldStamp, cancellationToken).ConfigureAwait(false))
1✔
955
                                return;
956
                        if (attempt + 1 < MaxConcurrencyAttempts)
1✔
957
                                await DelayConcurrencyRetryAsync(cancellationToken).ConfigureAwait(false);
1✔
958
                }
1✔
959

960
                throw new ImmediateJobException($"Recurring schedule '{schedule.Name}' could not be upserted under contention.");
×
961
        }
1✔
962

963
        /// <inheritdoc />
964
        public async ValueTask RemoveObsoleteCodeDefinedRecurringAsync(
965
                IReadOnlyCollection<string> activeScheduleNames,
966
                CancellationToken cancellationToken = default
967
        )
968
        {
969
                ArgumentNullException.ThrowIfNull(activeScheduleNames);
×
970
                await using var connection = CreateConnection();
×
971
                var schedules = Recurring(connection).Where(schedule => schedule.IsCodeDefined);
×
972
                if (activeScheduleNames.Count != 0)
×
973
                        schedules = schedules.Where(schedule => !activeScheduleNames.Contains(schedule.Name));
×
974
                _ = await schedules.DeleteAsync(cancellationToken).ConfigureAwait(false);
×
975
        }
×
976

977
        /// <inheritdoc />
978
        public async ValueTask RemoveRecurringAsync(string name, CancellationToken cancellationToken = default)
979
        {
980
                ArgumentException.ThrowIfNullOrWhiteSpace(name);
1✔
981
                await using var connection = CreateConnection();
1✔
982
                var removed = await Recurring(connection)
1✔
983
                        .Where(schedule => schedule.Name == name && !schedule.IsCodeDefined)
1✔
984
                        .DeleteAsync(cancellationToken)
1✔
985
                        .ConfigureAwait(false);
1✔
986
                if (removed != 0)
1✔
987
                        return;
988
                if (await Recurring(connection).AnyAsync(schedule => schedule.Name == name, cancellationToken).ConfigureAwait(false))
1✔
989
                        throw new ImmediateJobException("Code-defined recurring schedules cannot be deleted.");
1✔
990
                throw new KeyNotFoundException($"Recurring schedule '{name}' was not found.");
1✔
991
        }
×
992

993
        /// <inheritdoc />
994
        public ValueTask PauseRecurringAsync(string name, CancellationToken cancellationToken = default) =>
995
                SetRecurringPausedAsync(name, paused: true, cancellationToken);
1✔
996

997
        /// <inheritdoc />
998
        public ValueTask ResumeRecurringAsync(string name, CancellationToken cancellationToken = default) =>
999
                SetRecurringPausedAsync(name, paused: false, cancellationToken);
1✔
1000

1001
        private async ValueTask SetRecurringPausedAsync(string name, bool paused, CancellationToken cancellationToken)
1002
        {
1003
                ArgumentException.ThrowIfNullOrWhiteSpace(name);
1✔
1004
                await using var connection = CreateConnection();
1✔
1005
                var updated = await Recurring(connection)
1✔
1006
                        .Where(schedule => schedule.Name == name)
1✔
1007
                        .Set(schedule => schedule.IsPaused, paused)
1✔
1008
                        .Set(schedule => schedule.ConcurrencyStamp, Guid.NewGuid())
1✔
1009
                        .UpdateAsync(cancellationToken)
1✔
1010
                        .ConfigureAwait(false);
1✔
1011
                if (updated == 0)
1✔
1012
                        throw new KeyNotFoundException($"Recurring schedule '{name}' was not found.");
1✔
1013
        }
×
1014

1015
        /// <inheritdoc />
1016
        public async ValueTask<IReadOnlyList<RecurringJobSchedule>> GetDueRecurringAsync(
1017
                DateTimeOffset now,
1018
                int batchSize,
1019
                CancellationToken cancellationToken = default
1020
        )
1021
        {
1022
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(batchSize, 0);
×
1023
                await using var connection = CreateConnection();
×
1024
                var schedules = await Recurring(connection)
×
1025
                        .Where(schedule => !schedule.IsPaused && schedule.NextRunAt <= now.UtcTicks)
×
1026
                        .OrderBy(schedule => schedule.NextRunAt)
×
1027
                        .Take(batchSize)
×
1028
                        .ToListAsync(cancellationToken)
×
1029
                        .ConfigureAwait(false);
×
1030
                return [.. schedules.Select(ToRecord)];
×
1031
        }
×
1032

1033
        /// <inheritdoc />
1034
        public async ValueTask<bool> MaterializeRecurringAsync(
1035
                RecurringJobSchedule schedule,
1036
                JobRecord job,
1037
                DateTimeOffset nextRunAt,
1038
                CancellationToken cancellationToken = default
1039
        )
1040
        {
1041
                ArgumentNullException.ThrowIfNull(schedule);
1✔
1042
                ArgumentNullException.ThrowIfNull(job);
1✔
1043
                await using var connection = CreateConnection();
1✔
1044
                _ = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
1✔
1045
                try
1046
                {
1047
                        var entity = await Recurring(connection).SingleOrDefaultAsync(item => item.Name == schedule.Name, cancellationToken)
1✔
1048
                                .ConfigureAwait(false);
1✔
1049
                        if (entity is null || entity.IsPaused || entity.NextRunAt != schedule.NextRunAt.UtcTicks)
1✔
1050
                        {
1051
                                await connection.RollbackTransactionAsync(cancellationToken).ConfigureAwait(false);
1✔
1052
                                return false;
1✔
1053
                        }
1054

1055
                        var oldStamp = entity.ConcurrencyStamp;
1✔
1056
                        entity.LastRunAt = schedule.NextRunAt.UtcTicks;
1✔
1057
                        entity.NextRunAt = nextRunAt.UtcTicks;
1✔
1058
                        entity.ConcurrencyStamp = Guid.NewGuid();
1✔
1059
                        if (!await UpdateRecurringAsync(connection, entity, oldStamp, cancellationToken).ConfigureAwait(false))
1✔
1060
                                throw new LostRaceException();
1✔
1061
                        _ = await InsertAsync(connection, ToEntity(job), cancellationToken).ConfigureAwait(false);
1✔
1062
                        await connection.CommitTransactionAsync(cancellationToken).ConfigureAwait(false);
1✔
1063
                        return true;
1✔
1064
                }
1065
                catch (Exception exception) when (exception is LostRaceException or DbException)
1✔
1066
                {
1067
                        await connection.RollbackTransactionAsync(cancellationToken).ConfigureAwait(false);
1✔
1068
                        return false;
1✔
1069
                }
1070
        }
1✔
1071

1072
        /// <inheritdoc />
1073
        public async ValueTask<JobMonitoringSnapshot> GetMonitoringSnapshotAsync(
1074
                CancellationToken cancellationToken = default
1075
        )
1076
        {
1077
                await using var connection = CreateConnection();
1✔
1078
                var rawCounts = await Jobs(connection)
1✔
1079
                        .GroupBy(job => job.State)
1✔
1080
                        .Select(group => new { State = group.Key, Count = group.LongCount() })
1✔
1081
                        .ToListAsync(cancellationToken)
1✔
1082
                        .ConfigureAwait(false);
1✔
1083
                var counts = Enum.GetValues<JobState>().ToDictionary(static state => state, static _ => 0L);
1✔
1084
                foreach (var item in rawCounts)
1✔
1085
                        counts[item.State] = item.Count;
1✔
1086

1087
                var recurringEntities = await Recurring(connection)
1✔
1088
                        .OrderBy(schedule => schedule.Name)
1✔
1089
                        .ToListAsync(cancellationToken)
1✔
1090
                        .ConfigureAwait(false);
1✔
1091
                var cutoff = (_timeProvider.GetUtcNow() - TimeSpan.FromMinutes(2)).UtcTicks;
1✔
1092
                var serverEntities = await Servers(connection)
1✔
1093
                        .Where(server => server.LastHeartbeat >= cutoff)
1✔
1094
                        .OrderBy(server => server.WorkerId)
1✔
1095
                        .ToListAsync(cancellationToken)
1✔
1096
                        .ConfigureAwait(false);
1✔
1097
                return new(
1✔
1098
                        _timeProvider.GetUtcNow(),
1✔
1099
                        counts,
1✔
1100
                        [.. recurringEntities.Select(ToRecord)],
1✔
1101
                        [.. serverEntities.Select(server => new JobServerSnapshot(
1✔
1102
                                server.WorkerId,
1✔
1103
                                FromTicks(server.LastHeartbeat),
1✔
1104
                                server.ActiveWorkers,
1✔
1105
                                server.MaxWorkers
1✔
1106
                        ))]
1✔
1107
                )
1✔
1108
                {
1✔
1109
                        Capabilities = this.GetCapabilities(),
1✔
1110
                };
1✔
1111
        }
1✔
1112

1113
        /// <inheritdoc />
1114
        public async ValueTask<IReadOnlyList<JobRecord>> QueryJobsAsync(
1115
                JobQuery query,
1116
                CancellationToken cancellationToken = default
1117
        )
1118
        {
1119
                ArgumentNullException.ThrowIfNull(query);
1✔
1120
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
1✔
1121
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
1✔
1122
                await using var connection = CreateConnection();
1✔
1123
                IQueryable<ImmediateJobEntity> jobs = Jobs(connection);
1✔
1124
                if (query.Id is { } id)
1✔
1125
                        jobs = jobs.Where(job => job.Id == id);
1✔
1126
                if (query.State is { } state)
1✔
1127
                        jobs = jobs.Where(job => job.State == state);
×
1128
                if (!string.IsNullOrWhiteSpace(query.QueueName))
1✔
1129
                        jobs = jobs.Where(job => job.QueueName == query.QueueName);
×
1130
                if (!string.IsNullOrWhiteSpace(query.JobName))
1✔
1131
                        jobs = jobs.Where(job => job.JobName == query.JobName);
×
1132
                if (!string.IsNullOrWhiteSpace(query.Search))
1✔
1133
                {
1134
                        var search = query.Search.ToUpperInvariant();
×
1135
#pragma warning disable CA1304, CA1311, CA1862, MA0011
1136
                        jobs = jobs.Where(job => job.JobName.ToUpper().Contains(search));
×
1137
#pragma warning restore CA1304, CA1311, CA1862, MA0011
1138
                }
1139

1140
                var entities = await jobs.OrderByDescending(job => job.CreatedAt)
1✔
1141
                        .ThenBy(job => job.Id)
1✔
1142
                        .Skip(query.Skip)
1✔
1143
                        .Take(query.Take)
1✔
1144
                        .ToListAsync(cancellationToken)
1✔
1145
                        .ConfigureAwait(false);
1✔
1146
                return [.. entities.Select(ToRecord)];
1✔
1147
        }
1✔
1148

1149
        /// <inheritdoc />
1150
        public async ValueTask<IReadOnlyList<JobExecutionRecord>> QueryJobExecutionsAsync(
1151
                JobExecutionQuery query,
1152
                CancellationToken cancellationToken = default
1153
        )
1154
        {
1155
                ArgumentNullException.ThrowIfNull(query);
1✔
1156
                query.Validate();
1✔
1157
                await using var connection = CreateConnection();
1✔
1158
                var job = await Jobs(connection)
1✔
1159
                        .SingleOrDefaultAsync(item => item.Id == query.JobId, cancellationToken)
1✔
1160
                        .ConfigureAwait(false);
1✔
1161
                if (job is null)
1✔
1162
                        return [];
1✔
1163

1164
                var executions = Executions(connection)
1✔
1165
                        .Where(execution => execution.JobId == query.JobId);
1✔
1166
                if (query.Attempt is { } attempt)
1✔
1167
                        executions = executions.Where(execution => execution.Attempt == attempt);
×
1168

1169
                var synthetic = JobExecutionRecord.CreateSynthetic(ToRecord(job));
1✔
1170
                var syntheticMissing = synthetic is not null
1✔
1171
                        && (query.Attempt is null || query.Attempt == synthetic.Attempt)
1✔
1172
                        && !await executions.AnyAsync(
1✔
1173
                                execution => execution.Attempt == synthetic.Attempt,
1✔
1174
                                cancellationToken
1✔
1175
                        ).ConfigureAwait(false);
1✔
1176
                var skip = query.Skip;
1✔
1177
                var take = Math.Min(query.Take, JobExecutionQuery.MaximumTake);
1✔
1178
                var result = new List<JobExecutionRecord>(take);
1✔
1179
                if (syntheticMissing && skip == 0)
1✔
1180
                {
1181
                        result.Add(synthetic!);
×
1182
                        take--;
×
1183
                }
1184
                else if (syntheticMissing)
1✔
1185
                {
1186
                        skip--;
×
1187
                }
1188

1189
                if (take != 0 && skip >= 0)
1✔
1190
                {
1191
                        var persisted = await executions
1✔
1192
                                .OrderByDescending(execution => execution.Attempt)
1✔
1193
                                .Skip(skip)
1✔
1194
                                .Take(take)
1✔
1195
                                .ToListAsync(cancellationToken)
1✔
1196
                                .ConfigureAwait(false);
1✔
1197
                        result.AddRange(persisted.Select(ToRecord));
1✔
1198
                }
1199

1200
                return result;
1✔
1201
        }
1✔
1202

1203
        /// <inheritdoc />
1204
        public async ValueTask<BatchStatus?> GetBatchStatusAsync(
1205
                string batchId,
1206
                CancellationToken cancellationToken = default
1207
        )
1208
        {
1209
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
1✔
1210
                await using var connection = CreateConnection();
1✔
1211
                var batch = await Batches(connection).SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
1✔
1212
                        .ConfigureAwait(false);
1✔
1213
                return batch is null ? null : ToStatus(batch);
1✔
1214
        }
1✔
1215

1216
        /// <inheritdoc />
1217
        public async ValueTask<IReadOnlyList<BatchStatus>> QueryBatchesAsync(
1218
                JobBatchQuery query,
1219
                CancellationToken cancellationToken = default
1220
        )
1221
        {
1222
                ArgumentNullException.ThrowIfNull(query);
×
1223
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
×
1224
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
×
1225
                await using var connection = CreateConnection();
×
1226
                IQueryable<ImmediateJobBatchEntity> batches = Batches(connection);
×
1227
                if (query.State is { } state)
×
1228
                        batches = batches.Where(batch => batch.State == state);
×
1229
                var entities = await batches.OrderByDescending(batch => batch.CreatedAt)
×
1230
                        .ThenBy(batch => batch.Id)
×
1231
                        .Skip(query.Skip)
×
1232
                        .Take(query.Take)
×
1233
                        .ToListAsync(cancellationToken)
×
1234
                        .ConfigureAwait(false);
×
1235
                return [.. entities.Select(ToStatus)];
×
1236
        }
×
1237

1238
        /// <inheritdoc />
1239
        public async ValueTask<IReadOnlyList<BatchMemberStatus>> QueryBatchMembersAsync(
1240
                string batchId,
1241
                BatchMemberQuery query,
1242
                CancellationToken cancellationToken = default
1243
        )
1244
        {
1245
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
×
1246
                ArgumentNullException.ThrowIfNull(query);
×
1247
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
×
1248
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
×
1249
                await using var connection = CreateConnection();
×
1250
                var jobs = Jobs(connection).Where(job => job.BatchId == batchId);
×
1251
                if (query.State is { } state)
×
1252
                        jobs = jobs.Where(job => job.State == state);
×
1253
                var entities = await jobs.OrderBy(job => job.CreatedAt)
×
1254
                        .ThenBy(job => job.Id)
×
1255
                        .Skip(query.Skip)
×
1256
                        .Take(query.Take)
×
1257
                        .ToListAsync(cancellationToken)
×
1258
                        .ConfigureAwait(false);
×
1259
                return [.. entities.Select(job => new BatchMemberStatus(
×
1260
                        job.Id,
×
1261
                        job.JobName,
×
1262
                        job.QueueName,
×
1263
                        job.State,
×
1264
                        job.Attempt,
×
1265
                        FromTicks(job.CreatedAt),
×
1266
                        FromTicks(job.CompletedAt),
×
1267
                        job.LastError
×
1268
                ))];
×
1269
        }
×
1270

1271
        /// <inheritdoc />
1272
        public async ValueTask<BatchGraph?> GetBatchGraphAsync(
1273
                string batchId,
1274
                CancellationToken cancellationToken = default
1275
        )
1276
        {
1277
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
×
1278
                await using var connection = CreateConnection();
×
1279
                if (!await Batches(connection).AnyAsync(batch => batch.Id == batchId, cancellationToken).ConfigureAwait(false))
×
1280
                        return null;
×
1281
                var entities = await Jobs(connection)
×
1282
                        .Where(job => job.BatchId == batchId)
×
1283
                        .OrderBy(job => job.CreatedAt)
×
1284
                        .ThenBy(job => job.Id)
×
1285
                        .ToListAsync(cancellationToken)
×
1286
                        .ConfigureAwait(false);
×
1287
                var jobs = entities.Select(job => new BatchGraphNode(job.Id, job.JobName, job.State)).ToArray();
×
1288
                var ids = jobs.Select(static job => job.JobId).ToArray();
×
1289
                var edges = ids.Length == 0
×
1290
                        ? []
×
1291
                        : await Continuations(connection)
×
1292
                                .Where(edge => ids.Contains(edge.ChildJobId))
×
1293
                                .OrderBy(edge => edge.ChildJobId)
×
1294
                                .ThenBy(edge => edge.ParentKind)
×
1295
                                .ThenBy(edge => edge.ParentId)
×
1296
                                .ToListAsync(cancellationToken)
×
1297
                                .ConfigureAwait(false);
×
1298
                return new(batchId, jobs, [.. edges.Select(ToGraphEdge)]);
×
1299
        }
×
1300

1301
        /// <inheritdoc />
1302
        public async ValueTask<JobStatus?> GetJobStatusAsync(
1303
                string jobId,
1304
                CancellationToken cancellationToken = default
1305
        )
1306
        {
1307
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
1✔
1308
                await using var connection = CreateConnection();
1✔
1309
                var job = await Jobs(connection).SingleOrDefaultAsync(item => item.Id == jobId, cancellationToken)
1✔
1310
                        .ConfigureAwait(false);
1✔
1311
                if (job is null)
1✔
1312
                        return null;
1✔
1313
                var edges = await Continuations(connection)
1✔
1314
                        .Where(edge => edge.ChildJobId == jobId)
1✔
1315
                        .OrderBy(edge => edge.ParentKind)
1✔
1316
                        .ThenBy(edge => edge.ParentId)
1✔
1317
                        .ToListAsync(cancellationToken)
1✔
1318
                        .ConfigureAwait(false);
1✔
1319
                return new(
1✔
1320
                        job.Id,
1✔
1321
                        job.JobName,
1✔
1322
                        job.QueueName,
1✔
1323
                        job.State,
1✔
1324
                        job.Attempt,
1✔
1325
                        MaxAttempts: null,
1✔
1326
                        FromTicks(job.CreatedAt),
1✔
1327
                        FromTicks(job.DueAt),
1✔
1328
                        FromTicks(job.CompletedAt),
1✔
1329
                        job.LastError,
1✔
1330
                        job.BatchId,
1✔
1331
                        [.. edges.Select(ToGraphEdge)]
1✔
1332
                );
1✔
1333
        }
1✔
1334

1335
        /// <inheritdoc />
1336
        public async ValueTask CancelBatchAsync(string batchId, CancellationToken cancellationToken = default)
1337
        {
1338
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
1✔
1339
                var terminalGroups = new HashSet<(string QueueName, string GroupId)>();
1✔
1340
                await RetryConcurrencyAsync(
1✔
1341
                        connection => CancelBatchCoreAsync(
1✔
1342
                                connection,
1✔
1343
                                batchId,
1✔
1344
                                terminalGroups,
1✔
1345
                                cancellationToken
1✔
1346
                        ),
1✔
1347
                        cancellationToken
1✔
1348
                ).ConfigureAwait(false);
1✔
1349
                await CleanupFairQueueGroupsAsync(terminalGroups).ConfigureAwait(false);
1✔
1350
        }
1✔
1351

1352
        private async Task CancelBatchCoreAsync(
1353
                DataConnection connection,
1354
                string batchId,
1355
                ISet<(string QueueName, string GroupId)> terminalGroups,
1356
                CancellationToken cancellationToken
1357
        )
1358
        {
1359
                var now = _timeProvider.GetUtcNow().UtcTicks;
1✔
1360
                var batch = await Batches(connection).SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
1✔
1361
                        .ConfigureAwait(false)
1✔
1362
                        ?? throw new KeyNotFoundException($"Batch '{batchId}' was not found.");
1✔
1363
                if (batch.State != BatchState.Executing)
1✔
1364
                        throw new ImmediateJobException("Only an executing batch can be cancelled.");
×
1365
                var jobIds = await Jobs(connection).Where(job => job.BatchId == batchId).Select(job => job.Id)
1✔
1366
                        .ToArrayAsync(cancellationToken).ConfigureAwait(false);
1✔
1367
                var jobsToCancel = new List<ImmediateJobEntity>(jobIds.Length);
1✔
1368
                foreach (var jobId in jobIds)
1✔
1369
                {
1370
                        var job = await Jobs(connection).SingleOrDefaultAsync(item => item.Id == jobId, cancellationToken)
1✔
1371
                                .ConfigureAwait(false);
1✔
1372
                        if (job is null || IsTerminal(job.State))
1✔
1373
                                continue;
1374
                        if (job.State == JobState.Active)
1✔
1375
                        {
1376
                                _ = await GetOrMaterializeExecutionAsync(connection, job, cancellationToken).ConfigureAwait(false)
×
1377
                                        ?? throw new ImmediateJobException($"Active job '{job.Id}' has no execution ordinal.");
×
1378
                                _ = await Executions(connection)
×
1379
                                        .Where(execution => execution.JobId == job.Id && execution.Attempt == job.Attempt)
×
1380
                                        .Set(execution => execution.State, JobExecutionState.Cancelled)
×
1381
                                        .Set(execution => execution.CompletedAt, now)
×
1382
                                        .Set(execution => execution.Error, (string?)null)
×
1383
                                                .UpdateAsync(cancellationToken)
×
1384
                                                .ConfigureAwait(false);
×
1385
                        }
1386

1387
                        var oldStamp = job.ConcurrencyStamp;
1✔
1388
                        job.State = JobState.Cancelled;
1✔
1389
                        job.CompletedAt = now;
1✔
1390
                        job.WorkerId = null;
1✔
1391
                        job.LeaseExpiresAt = null;
1✔
1392
                        job.ConcurrencyStamp = Guid.NewGuid();
1✔
1393
                        if (!await UpdateJobAsync(connection, job, oldStamp, cancellationToken).ConfigureAwait(false))
1✔
1394
                                throw new LostRaceException();
×
1395
                        jobsToCancel.Add(job);
1✔
1396
                }
1✔
1397

1398
                foreach (var job in jobsToCancel)
1✔
1399
                {
1400
                        await PropagateTerminalAsync(
1✔
1401
                                connection,
1✔
1402
                                job,
1✔
1403
                                now,
1✔
1404
                                terminalGroups,
1✔
1405
                                cancellationToken
1✔
1406
                        ).ConfigureAwait(false);
1✔
1407
                }
1408
        }
1✔
1409

1410
        /// <inheritdoc />
1411
        public async ValueTask DeleteBatchAsync(string batchId, CancellationToken cancellationToken = default)
1412
        {
1413
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
1✔
1414
                await using var connection = CreateConnection();
1✔
1415
                _ = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
1✔
1416
                try
1417
                {
1418
                        var batch = await Batches(connection).SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
1✔
1419
                                .ConfigureAwait(false)
1✔
1420
                                ?? throw new KeyNotFoundException($"Batch '{batchId}' was not found.");
1✔
1421
                        if (batch.State == BatchState.Executing)
1✔
1422
                                throw new ImmediateJobException("Only a terminal batch can be deleted.");
×
1423
                        var jobIds = await Jobs(connection).Where(job => job.BatchId == batchId).Select(job => job.Id)
1✔
1424
                                .ToArrayAsync(cancellationToken).ConfigureAwait(false);
1✔
1425
                        _ = await Continuations(connection)
1✔
1426
                                .Where(edge =>
1✔
1427
                                        jobIds.Contains(edge.ChildJobId)
1✔
1428
                                        || (edge.ParentKind == ContinuationParentKind.Job && jobIds.Contains(edge.ParentId))
1✔
1429
                                        || (edge.ParentKind == ContinuationParentKind.Batch && edge.ParentId == batchId)
1✔
1430
                                )
1✔
1431
                                .DeleteAsync(cancellationToken)
1✔
1432
                                .ConfigureAwait(false);
1✔
1433
                        _ = await Executions(connection).Where(execution => jobIds.Contains(execution.JobId))
1✔
1434
                                .DeleteAsync(cancellationToken).ConfigureAwait(false);
1✔
1435
                        _ = await Jobs(connection).Where(job => job.BatchId == batchId).DeleteAsync(cancellationToken).ConfigureAwait(false);
1✔
1436
                        _ = await Batches(connection).Where(item => item.Id == batchId).DeleteAsync(cancellationToken).ConfigureAwait(false);
1✔
1437
                        await connection.CommitTransactionAsync(cancellationToken).ConfigureAwait(false);
1✔
1438
                }
1✔
1439
                catch
1✔
1440
                {
1441
                        await connection.RollbackTransactionAsync(cancellationToken).ConfigureAwait(false);
1✔
1442
                        throw;
1✔
1443
                }
1444
        }
1✔
1445

1446
        /// <inheritdoc />
1447
        public async ValueTask CancelAsync(string jobId, CancellationToken cancellationToken = default)
1448
        {
1449
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
1✔
1450
                var terminalGroups = new HashSet<(string QueueName, string GroupId)>();
1✔
1451
                await RetryConcurrencyAsync(
1✔
1452
                        connection => CancelCoreAsync(connection, jobId, terminalGroups, cancellationToken),
1✔
1453
                        cancellationToken
1✔
1454
                ).ConfigureAwait(false);
1✔
1455
                await CleanupFairQueueGroupsAsync(terminalGroups).ConfigureAwait(false);
1✔
1456
        }
1✔
1457

1458
        private async Task CancelCoreAsync(
1459
                DataConnection connection,
1460
                string jobId,
1461
                ISet<(string QueueName, string GroupId)> terminalGroups,
1462
                CancellationToken cancellationToken
1463
        )
1464
        {
1465
                var job = await Jobs(connection).SingleOrDefaultAsync(item => item.Id == jobId, cancellationToken)
1✔
1466
                        .ConfigureAwait(false)
1✔
1467
                        ?? throw new KeyNotFoundException($"Job '{jobId}' was not found.");
1✔
1468
                if (IsTerminal(job.State))
1✔
1469
                        throw new ImmediateJobException("Only a non-terminal job can be cancelled.");
1✔
1470

1471
                var now = _timeProvider.GetUtcNow().UtcTicks;
1✔
1472
                if (job.State == JobState.Active)
1✔
1473
                {
1474
                        _ = await GetOrMaterializeExecutionAsync(connection, job, cancellationToken).ConfigureAwait(false)
1✔
1475
                                ?? throw new ImmediateJobException($"Active job '{job.Id}' has no execution ordinal.");
1✔
1476
                        _ = await Executions(connection)
1✔
1477
                                .Where(execution => execution.JobId == job.Id && execution.Attempt == job.Attempt)
1✔
1478
                                .Set(execution => execution.State, JobExecutionState.Cancelled)
1✔
1479
                                .Set(execution => execution.CompletedAt, now)
1✔
1480
                                .Set(execution => execution.Error, (string?)null)
1✔
1481
                                .UpdateAsync(cancellationToken)
1✔
1482
                                .ConfigureAwait(false);
1✔
1483
                }
1484

1485
                var oldStamp = job.ConcurrencyStamp;
1✔
1486
                job.State = JobState.Cancelled;
1✔
1487
                job.CompletedAt = now;
1✔
1488
                job.WorkerId = null;
1✔
1489
                job.LeaseExpiresAt = null;
1✔
1490
                job.ConcurrencyStamp = Guid.NewGuid();
1✔
1491
                if (!await UpdateJobAsync(connection, job, oldStamp, cancellationToken).ConfigureAwait(false))
1✔
NEW
1492
                        throw new LostRaceException();
×
1493
                await PropagateTerminalAsync(connection, job, now, terminalGroups, cancellationToken).ConfigureAwait(false);
1✔
1494
        }
1✔
1495

1496
        /// <inheritdoc />
1497
        public ValueTask RetryAsync(string jobId, CancellationToken cancellationToken = default)
1498
        {
1499
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
1✔
1500
                return RetryConcurrencyAsync(
1✔
1501
                        connection => RetryCoreAsync(connection, jobId, cancellationToken),
1✔
1502
                        cancellationToken
1✔
1503
                );
1✔
1504
        }
1505

1506
        private async Task RetryCoreAsync(DataConnection connection, string jobId, CancellationToken cancellationToken)
1507
        {
1508
                var job = await Jobs(connection)
1✔
1509
                        .SingleOrDefaultAsync(item => item.Id == jobId &&
1✔
1510
                                (item.State == JobState.Failed || item.State == JobState.Scheduled), cancellationToken)
1✔
1511
                        .ConfigureAwait(false);
1✔
1512
                if (job is null)
1✔
1513
                {
1514
                        if (await Jobs(connection).AnyAsync(item => item.Id == jobId, cancellationToken).ConfigureAwait(false))
1✔
1515
                                throw new ImmediateJobException("Only failed or scheduled jobs can be retried.");
1✔
1516
                        throw new KeyNotFoundException($"Job '{jobId}' was not found.");
1✔
1517
                }
1518

1519
                var wasFailed = job.State == JobState.Failed;
1✔
1520
                _ = await GetOrMaterializeExecutionAsync(connection, job, cancellationToken).ConfigureAwait(false);
1✔
1521
                if (wasFailed && job.BatchId is { } batchId)
1✔
1522
                {
1523
                        var batch = await Batches(connection).SingleAsync(item => item.Id == batchId, cancellationToken).ConfigureAwait(false);
×
1524
                        var batchStamp = batch.ConcurrencyStamp;
×
1525
                        batch.PendingCount++;
×
1526
                        batch.FailedCount = Math.Max(0, batch.FailedCount - 1);
×
1527
                        batch.State = BatchState.Executing;
×
1528
                        batch.CompletedAt = null;
×
1529
                        batch.ConcurrencyStamp = Guid.NewGuid();
×
1530
                        if (!await UpdateBatchAsync(connection, batch, batchStamp, cancellationToken).ConfigureAwait(false))
×
1531
                                throw new LostRaceException();
×
1532
                }
1533

1534
                var oldStamp = job.ConcurrencyStamp;
1✔
1535
                job.State = JobState.Pending;
1✔
1536
                job.DueAt = _timeProvider.GetUtcNow().UtcTicks;
1✔
1537
                job.WorkerId = null;
1✔
1538
                job.LeaseExpiresAt = null;
1✔
1539
                if (wasFailed)
1✔
1540
                {
1541
                        job.CompletedAt = null;
1✔
1542
                        job.LastError = null;
1✔
1543
                }
1544

1545
                job.ConcurrencyStamp = Guid.NewGuid();
1✔
1546
                if (!await UpdateJobAsync(connection, job, oldStamp, cancellationToken).ConfigureAwait(false))
1✔
1547
                        throw new LostRaceException();
×
1548
        }
1✔
1549

1550
        /// <inheritdoc />
1551
        public async ValueTask DeleteAsync(string jobId, CancellationToken cancellationToken = default)
1552
        {
1553
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
1✔
1554
                await using var connection = CreateConnection();
1✔
1555
                _ = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
1✔
1556
                try
1557
                {
1558
                        var job = await Jobs(connection).SingleOrDefaultAsync(item => item.Id == jobId &&
1✔
1559
                                (item.State == JobState.Succeeded || item.State == JobState.Failed || item.State == JobState.Cancelled || item.State == JobState.Skipped), cancellationToken)
1✔
1560
                                .ConfigureAwait(false);
1✔
1561
                        if (job is null)
1✔
1562
                        {
1563
                                if (await Jobs(connection).AnyAsync(item => item.Id == jobId, cancellationToken).ConfigureAwait(false))
1✔
1564
                                        throw new ImmediateJobException("Only terminal jobs can be deleted.");
1✔
1565
                                throw new KeyNotFoundException($"Job '{jobId}' was not found.");
1✔
1566
                        }
1567

1568
                        if (job.BatchId is not null)
1✔
1569
                                throw new ImmediateJobException("Batch members are deleted with their batch so the workflow remains coherent.");
×
1570
                        _ = await Continuations(connection)
1✔
1571
                                .Where(edge => edge.ChildJobId == jobId ||
1✔
1572
                                        (edge.ParentKind == ContinuationParentKind.Job && edge.ParentId == jobId))
1✔
1573
                                .DeleteAsync(cancellationToken)
1✔
1574
                                .ConfigureAwait(false);
1✔
1575
                        _ = await Executions(connection).Where(execution => execution.JobId == jobId)
1✔
1576
                                .DeleteAsync(cancellationToken).ConfigureAwait(false);
1✔
1577
                        var removed = await Jobs(connection)
1✔
1578
                                .Where(item => item.Id == jobId &&
1✔
1579
                                        (item.State == JobState.Succeeded || item.State == JobState.Failed || item.State == JobState.Cancelled || item.State == JobState.Skipped))
1✔
1580
                                .DeleteAsync(cancellationToken)
1✔
1581
                                .ConfigureAwait(false);
1✔
1582
                        if (removed == 0)
1✔
1583
                        {
1584
                                if (await Jobs(connection).AnyAsync(item => item.Id == jobId, cancellationToken).ConfigureAwait(false))
×
1585
                                        throw new ImmediateJobException("Only terminal jobs can be deleted.");
×
1586
                                throw new KeyNotFoundException($"Job '{jobId}' was not found.");
×
1587
                        }
1588

1589
                        await connection.CommitTransactionAsync(cancellationToken).ConfigureAwait(false);
1✔
1590
                }
1✔
1591
                catch
1✔
1592
                {
1593
                        await connection.RollbackTransactionAsync(cancellationToken).ConfigureAwait(false);
1✔
1594
                        throw;
1✔
1595
                }
1596
        }
1✔
1597

1598
        /// <inheritdoc />
1599
        public async ValueTask PurgeJobsAsync(
1600
                TimeSpan succeededRetention,
1601
                TimeSpan failedRetention,
1602
                CancellationToken cancellationToken = default
1603
        )
1604
        {
1605
                var now = _timeProvider.GetUtcNow();
×
1606
                await using var connection = CreateConnection();
×
1607
                _ = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
×
1608
                try
1609
                {
1610
                        var jobIds = await Jobs(connection)
×
1611
                                .Where(job =>
×
1612
                                        job.BatchId == null
×
1613
                                        && (
×
1614
                                                (
×
1615
                                                        job.State == JobState.Succeeded
×
1616
                                                        && job.CompletedAt < (now - succeededRetention).UtcTicks
×
1617
                                                ) || (
×
1618
                                                        (job.State == JobState.Failed || job.State == JobState.Cancelled || job.State == JobState.Skipped)
×
1619
                                                        && job.CompletedAt < (now - failedRetention).UtcTicks
×
1620
                                                )
×
1621
                                        )
×
1622
                                )
×
1623
                                .Select(job => job.Id)
×
1624
                                .ToArrayAsync(cancellationToken)
×
1625
                                .ConfigureAwait(false);
×
1626

1627
                        if (jobIds.Length != 0)
×
1628
                        {
1629
                                _ = await Continuations(connection)
×
1630
                                        .Where(edge =>
×
1631
                                                jobIds.Contains(edge.ChildJobId)
×
1632
                                                || (jobIds.Contains(edge.ParentId) && edge.ParentKind == ContinuationParentKind.Job)
×
1633
                                        )
×
1634
                                        .DeleteAsync(cancellationToken).ConfigureAwait(false);
×
1635
                                _ = await Executions(connection).Where(execution => jobIds.Contains(execution.JobId))
×
1636
                                        .DeleteAsync(cancellationToken).ConfigureAwait(false);
×
1637
                                _ = await Jobs(connection).Where(job => jobIds.Contains(job.Id)).DeleteAsync(cancellationToken)
×
1638
                                        .ConfigureAwait(false);
×
1639
                        }
1640

1641
                        await connection.CommitTransactionAsync(cancellationToken).ConfigureAwait(false);
×
1642
                }
×
1643
                catch
×
1644
                {
1645
                        await connection.RollbackTransactionAsync(cancellationToken).ConfigureAwait(false);
×
1646
                        throw;
×
1647
                }
1648
        }
×
1649

1650
        /// <inheritdoc />
1651
        public async ValueTask PurgeBatchesAsync(
1652
                TimeSpan batchSucceededRetention,
1653
                TimeSpan batchFailedRetention,
1654
                CancellationToken cancellationToken = default
1655
        )
1656
        {
1657
                var now = _timeProvider.GetUtcNow();
×
1658
                await using var connection = CreateConnection();
×
1659
                _ = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
×
1660
                try
1661
                {
1662
                        var batchIds = await Batches(connection)
×
1663
                                .Where(batch =>
×
1664
                                        (
×
1665
                                                batch.State == BatchState.Succeeded
×
1666
                                                && batch.CompletedAt < (now - batchSucceededRetention).UtcTicks
×
1667
                                        ) || (
×
1668
                                                (batch.State == BatchState.Failed || batch.State == BatchState.Cancelled)
×
1669
                                                && batch.CompletedAt < (now - batchFailedRetention).UtcTicks
×
1670
                                        )
×
1671
                                )
×
1672
                                .Select(batch => batch.Id)
×
1673
                                .ToArrayAsync(cancellationToken)
×
1674
                                .ConfigureAwait(false);
×
1675
                        if (batchIds.Length != 0)
×
1676
                        {
1677
                                var memberIds = await Jobs(connection).Where(job => job.BatchId != null && batchIds.Contains(job.BatchId))
×
1678
                                        .Select(job => job.Id).ToArrayAsync(cancellationToken).ConfigureAwait(false);
×
1679
                                _ = await Continuations(connection)
×
1680
                                        .Where(edge =>
×
1681
                                                (batchIds.Contains(edge.ParentId) && edge.ParentKind == ContinuationParentKind.Batch)
×
1682
                                                || memberIds.Contains(edge.ChildJobId)
×
1683
                                                || (memberIds.Contains(edge.ParentId) && edge.ParentKind == ContinuationParentKind.Job)
×
1684
                                        )
×
1685
                                        .DeleteAsync(cancellationToken).ConfigureAwait(false);
×
1686
                                _ = await Executions(connection).Where(execution => memberIds.Contains(execution.JobId))
×
1687
                                        .DeleteAsync(cancellationToken).ConfigureAwait(false);
×
1688
                                _ = await Jobs(connection).Where(job => job.BatchId != null && batchIds.Contains(job.BatchId))
×
1689
                                        .DeleteAsync(cancellationToken).ConfigureAwait(false);
×
1690
                                _ = await Batches(connection).Where(batch => batchIds.Contains(batch.Id)).DeleteAsync(cancellationToken)
×
1691
                                        .ConfigureAwait(false);
×
1692
                        }
×
1693

1694
                        await connection.CommitTransactionAsync(cancellationToken).ConfigureAwait(false);
×
1695
                }
×
1696
                catch
×
1697
                {
1698
                        await connection.RollbackTransactionAsync(cancellationToken).ConfigureAwait(false);
×
1699
                        throw;
×
1700
                }
1701
        }
×
1702

1703
        /// <inheritdoc />
1704
        public async ValueTask HeartbeatAsync(JobServerSnapshot server, CancellationToken cancellationToken = default)
1705
        {
1706
                ArgumentNullException.ThrowIfNull(server);
1✔
1707
                await using var connection = CreateConnection();
1✔
1708
                var cutoff = (_timeProvider.GetUtcNow() - TimeSpan.FromMinutes(2)).UtcTicks;
1✔
1709
                _ = await Servers(connection)
1✔
1710
                        .Where(entity => entity.LastHeartbeat < cutoff)
1✔
1711
                        .DeleteAsync(cancellationToken)
1✔
1712
                        .ConfigureAwait(false);
1✔
1713
                var updated = await Servers(connection)
1✔
1714
                        .Where(entity => entity.WorkerId == server.WorkerId)
1✔
1715
                        .Set(entity => entity.LastHeartbeat, server.LastHeartbeat.UtcTicks)
1✔
1716
                        .Set(entity => entity.ActiveWorkers, server.ActiveWorkers)
1✔
1717
                        .Set(entity => entity.MaxWorkers, server.MaxWorkers)
1✔
1718
                        .UpdateAsync(cancellationToken)
1✔
1719
                        .ConfigureAwait(false);
1✔
1720
                if (updated != 0)
1✔
1721
                        return;
1722
                try
1723
                {
1724
                        _ = await InsertAsync(connection, new ImmediateJobServerEntity
1✔
1725
                        {
1✔
1726
                                WorkerId = server.WorkerId,
1✔
1727
                                LastHeartbeat = server.LastHeartbeat.UtcTicks,
1✔
1728
                                ActiveWorkers = server.ActiveWorkers,
1✔
1729
                                MaxWorkers = server.MaxWorkers,
1✔
1730
                        }, cancellationToken).ConfigureAwait(false);
1✔
1731
                }
1✔
1732
                catch (DbException)
1733
                {
1734
                        _ = await Servers(connection)
×
1735
                                .Where(entity => entity.WorkerId == server.WorkerId)
×
1736
                                .Set(entity => entity.LastHeartbeat, server.LastHeartbeat.UtcTicks)
×
1737
                                .Set(entity => entity.ActiveWorkers, server.ActiveWorkers)
×
1738
                                .Set(entity => entity.MaxWorkers, server.MaxWorkers)
×
1739
                                .UpdateAsync(cancellationToken)
×
1740
                                .ConfigureAwait(false);
×
1741
                }
1✔
1742
        }
1✔
1743

1744
        /// <inheritdoc />
1745
        public async ValueTask<bool> IsHealthyAsync(CancellationToken cancellationToken = default)
1746
        {
1747
                try
1748
                {
1749
                        await using var connection = CreateConnection();
×
1750
                        _ = await connection.ExecuteAsync("SELECT 1", cancellationToken).ConfigureAwait(false);
×
1751
                        return true;
×
1752
                }
×
1753
                catch (Exception exception) when (exception is DbException or InvalidOperationException)
×
1754
                {
1755
                        return false;
×
1756
                }
1757
        }
×
1758

1759
        private async ValueTask MutateOwnedWithDependenciesAsync(
1760
                string jobId,
1761
                int executionNumber,
1762
                string workerId,
1763
                string? error,
1764
                DateTimeOffset? nextRetryAt,
1765
                bool succeeded,
1766
                IReadOnlyList<JobContinuationAddition> additions,
1767
                CancellationToken cancellationToken
1768
        )
1769
        {
1770
                var terminalGroups = new HashSet<(string QueueName, string GroupId)>();
1✔
1771
                await RetryConcurrencyAsync(
1✔
1772
                        connection => MutateOwnedCoreAsync(
1✔
1773
                                connection,
1✔
1774
                                jobId,
1✔
1775
                                executionNumber,
1✔
1776
                                workerId,
1✔
1777
                                error,
1✔
1778
                                nextRetryAt,
1✔
1779
                                succeeded,
1✔
1780
                                additions,
1✔
1781
                                terminalGroups,
1✔
1782
                                cancellationToken
1✔
1783
                        ),
1✔
1784
                        cancellationToken,
1✔
1785
                        maxAttempts: MaxContendedCompletionAttempts
1✔
1786
                ).ConfigureAwait(false);
1✔
1787
                await CleanupFairQueueGroupsAsync(terminalGroups).ConfigureAwait(false);
1✔
1788
        }
1✔
1789

1790
        private async Task CleanupFairQueueGroupsAsync(
1791
                IEnumerable<(string QueueName, string GroupId)> groups
1792
        )
1793
        {
1794
                foreach (var (queueName, groupId) in groups.Distinct())
1✔
1795
                {
1796
                        try
1797
                        {
1798
                                await using var connection = CreateConnection();
1✔
1799
                                if (await Jobs(connection)
1✔
1800
                                        .AnyAsync(
1✔
1801
                                                item => item.QueueName == queueName
1✔
1802
                                                        && item.GroupId == groupId
1✔
1803
                                                        && (item.State == JobState.Pending
1✔
1804
                                                                || item.State == JobState.Scheduled
1✔
1805
                                                                || item.State == JobState.Active),
1✔
1806
                                                CancellationToken.None
1✔
1807
                                        )
1✔
1808
                                        .ConfigureAwait(false))
1✔
1809
                                {
1810
                                        continue;
1811
                                }
1812

1813
                                _ = await FairQueueGroups(connection)
1✔
1814
                                        .Where(group => group.QueueName == queueName && group.GroupId == groupId)
1✔
1815
                                        .DeleteAsync(CancellationToken.None)
1✔
1816
                                        .ConfigureAwait(false);
1✔
1817
                        }
1✔
1818
#pragma warning disable CA1031 // Cleanup cannot make an already committed job transition appear to fail.
1819
                        catch (Exception)
1✔
1820
#pragma warning restore CA1031
1821
                        {
1822
                                // Cleanup is best-effort metadata maintenance and must not invalidate a committed transition.
1823
                        }
1✔
1824
                }
1✔
1825
        }
1✔
1826

1827
        private async Task MutateOwnedCoreAsync(
1828
                DataConnection connection,
1829
                string jobId,
1830
                int executionNumber,
1831
                string workerId,
1832
                string? error,
1833
                DateTimeOffset? nextRetryAt,
1834
                bool succeeded,
1835
                IReadOnlyList<JobContinuationAddition> additions,
1836
                ISet<(string QueueName, string GroupId)> terminalGroups,
1837
                CancellationToken cancellationToken
1838
        )
1839
        {
1840
                var job = await Jobs(connection)
1✔
1841
                        .SingleOrDefaultAsync(
1✔
1842
                                item => item.Id == jobId && item.Attempt == executionNumber && item.State == JobState.Active && item.WorkerId == workerId,
1✔
1843
                                cancellationToken
1✔
1844
                        )
1✔
1845
                        .ConfigureAwait(false) ?? throw new ImmediateJobException($"Worker '{workerId}' does not own active job '{jobId}'.");
1✔
1846
                var oldStamp = job.ConcurrencyStamp;
1✔
1847
                var now = _timeProvider.GetUtcNow().UtcTicks;
1✔
1848
                _ = await GetOrMaterializeExecutionAsync(connection, job, cancellationToken).ConfigureAwait(false)
1✔
1849
                        ?? throw new ImmediateJobException($"Active job '{job.Id}' has no execution ordinal.");
1✔
1850
                var executionUpdated = await Executions(connection)
1✔
1851
                        .Where(execution => execution.JobId == jobId && execution.Attempt == executionNumber && execution.State == JobExecutionState.Active)
1✔
1852
                        .Set(execution => execution.State, succeeded ? JobExecutionState.Succeeded : JobExecutionState.Failed)
1✔
1853
                        .Set(execution => execution.CompletedAt, now)
1✔
1854
                        .Set(execution => execution.Error, error)
1✔
1855
                        .UpdateAsync(cancellationToken)
1✔
1856
                        .ConfigureAwait(false);
1✔
1857
                if (executionUpdated == 0)
1✔
1858
                        throw new LostRaceException();
×
1859
                job.WorkerId = null;
1✔
1860
                job.LeaseExpiresAt = null;
1✔
1861
                job.LastError = error;
1✔
1862
                job.ConcurrencyStamp = Guid.NewGuid();
1✔
1863
                if (!succeeded && nextRetryAt is { } retryAt)
1✔
1864
                {
1865
                        job.State = retryAt.UtcTicks <= now ? JobState.Pending : JobState.Scheduled;
×
1866
                        job.DueAt = retryAt.UtcTicks;
×
1867
                        job.CompletedAt = null;
×
1868
                        if (!await UpdateJobAsync(connection, job, oldStamp, cancellationToken).ConfigureAwait(false))
×
1869
                                throw new LostRaceException();
×
1870
                        return;
×
1871
                }
1872

1873
                if (succeeded && additions.Count != 0)
1✔
1874
                        await FlushContinuationAdditionsAsync(connection, job, additions, cancellationToken).ConfigureAwait(false);
1✔
1875
                job.State = succeeded ? JobState.Succeeded : JobState.Failed;
1✔
1876
                job.CompletedAt = now;
1✔
1877
                if (!await UpdateJobAsync(connection, job, oldStamp, cancellationToken).ConfigureAwait(false))
1✔
1878
                        throw new LostRaceException();
1✔
1879
                await PropagateTerminalAsync(
1✔
1880
                        connection,
1✔
1881
                        job,
1✔
1882
                        now,
1✔
1883
                        terminalGroups,
1✔
1884
                        cancellationToken
1✔
1885
                ).ConfigureAwait(false);
1✔
1886
        }
1✔
1887

1888
        private async Task AddBatchJobCoreAsync(
1889
                DataConnection connection,
1890
                string currentJobId,
1891
                int executionNumber,
1892
                JobRecord record,
1893
                ContinuationOptions options,
1894
                CancellationToken cancellationToken
1895
        )
1896
        {
1897
                var current = await Jobs(connection)
1✔
1898
                        .SingleOrDefaultAsync(job => job.Id == currentJobId && job.Attempt == executionNumber && job.State == JobState.Active, cancellationToken)
1✔
1899
                        .ConfigureAwait(false)
1✔
1900
                        ?? throw new ImmediateJobException($"The current active job '{currentJobId}' was not found.");
1✔
1901
                if (current.BatchId is not { } batchId)
1✔
1902
                        throw new ImmediateJobException("The current job does not belong to a batch.");
×
1903
                ValidateDynamicJob(record, "Concurrent batch member");
1✔
1904
                if (!string.Equals(record.BatchId, batchId, StringComparison.Ordinal))
1✔
1905
                        throw new ImmediateJobException("The new job must belong to the current job's batch.");
1✔
1906
                var batch = await Batches(connection)
×
1907
                        .SingleAsync(item => item.Id == batchId && item.State == BatchState.Executing, cancellationToken)
×
1908
                        .ConfigureAwait(false);
×
1909
                await ResetReturningGroupCursorsAsync(connection, [record], cancellationToken).ConfigureAwait(false);
×
1910
                var job = ToEntity(record);
×
1911
                _ = await InsertAsync(connection, job, cancellationToken).ConfigureAwait(false);
×
1912
                var batchStamp = batch.ConcurrencyStamp;
×
1913
                batch.TotalJobs++;
×
1914
                batch.PendingCount++;
×
1915
                batch.ConcurrencyStamp = Guid.NewGuid();
×
1916
                if (!await UpdateBatchAsync(connection, batch, batchStamp, cancellationToken).ConfigureAwait(false))
×
1917
                        throw new LostRaceException();
×
1918

1919
                if (options != ContinuationOptions.BeforeContinuations)
×
1920
                        return;
×
1921
                var waiters = await GetActiveWaitersAsync(connection, currentJobId, cancellationToken).ConfigureAwait(false);
×
1922
                foreach (var waiter in waiters)
×
1923
                {
1924
                        _ = await InsertAsync(connection, new ImmediateJobContinuationEntity
×
1925
                        {
×
1926
                                ChildJobId = waiter.Id,
×
1927
                                ParentKind = ContinuationParentKind.Job,
×
1928
                                ParentId = job.Id,
×
1929
                                Trigger = ContinuationTrigger.Success,
×
1930
                        }, cancellationToken).ConfigureAwait(false);
×
1931
                        var waiterStamp = waiter.ConcurrencyStamp;
×
1932
                        waiter.RemainingDependencies++;
×
1933
                        waiter.ConcurrencyStamp = Guid.NewGuid();
×
1934
                        if (!await UpdateJobAsync(connection, waiter, waiterStamp, cancellationToken).ConfigureAwait(false))
×
1935
                                throw new LostRaceException();
×
1936
                }
×
1937
        }
×
1938

1939
        private async Task FlushContinuationAdditionsAsync(
1940
                DataConnection connection,
1941
                ImmediateJobEntity current,
1942
                IReadOnlyList<JobContinuationAddition> additions,
1943
                CancellationToken cancellationToken
1944
        )
1945
        {
1946
                var ids = new HashSet<string>(StringComparer.Ordinal);
1✔
1947
                var trackedAdditions = 0;
1✔
1948
                foreach (var addition in additions)
1✔
1949
                {
1950
                        ArgumentNullException.ThrowIfNull(addition);
1✔
1951
                        ArgumentNullException.ThrowIfNull(addition.Job);
1✔
1952
                        ValidateDynamicJob(addition.Job, "Dynamic continuation");
1✔
1953
                        if (!ids.Add(addition.Job.Id))
1✔
1954
                                throw new ImmediateJobException($"Job '{addition.Job.Id}' occurs more than once in the completion buffer.");
×
1955
                        if (!Enum.IsDefined(addition.Trigger))
1✔
1956
                                throw new ArgumentOutOfRangeException(nameof(additions), "Unknown continuation trigger.");
1✔
1957

1958
                        if (addition.Options == ContinuationOptions.Detached)
1✔
1959
                        {
1960
                                if (addition.Job.BatchId is not null)
1✔
1961
                                        throw new ImmediateJobException("A detached continuation cannot belong to a batch.");
1✔
1962
                        }
1963
                        else if (addition.Options is ContinuationOptions.BesideContinuations or ContinuationOptions.BeforeContinuations)
1✔
1964
                        {
1965
                                if (current.BatchId is null || !string.Equals(addition.Job.BatchId, current.BatchId, StringComparison.Ordinal))
1✔
1966
                                        throw new ImmediateJobException("A batch-tracked continuation must belong to the current job's batch.");
1✔
1967
                                trackedAdditions++;
×
1968
                        }
1969
                        else
1970
                        {
1971
                                throw new ArgumentOutOfRangeException(nameof(additions), "Unknown continuation option.");
1✔
1972
                        }
1973
                }
1974

1975
                var waiters = additions.Any(static addition => addition.Options == ContinuationOptions.BeforeContinuations)
×
1976
                        ? await GetActiveWaitersAsync(connection, current.Id, cancellationToken).ConfigureAwait(false)
×
1977
                        : [];
×
1978
                if (trackedAdditions != 0)
×
1979
                {
1980
                        if (current.BatchId is not { } batchId)
×
1981
                                throw new ImmediateJobException("The current job does not belong to a batch.");
×
1982
                        var batch = await Batches(connection)
×
1983
                                .SingleAsync(item => item.Id == batchId && item.State == BatchState.Executing, cancellationToken)
×
1984
                                .ConfigureAwait(false);
×
1985
                        var batchStamp = batch.ConcurrencyStamp;
×
1986
                        batch.TotalJobs += trackedAdditions;
×
1987
                        batch.PendingCount += trackedAdditions;
×
1988
                        batch.ConcurrencyStamp = Guid.NewGuid();
×
1989
                        if (!await UpdateBatchAsync(connection, batch, batchStamp, cancellationToken).ConfigureAwait(false))
×
1990
                                throw new LostRaceException();
×
1991
                }
1992

1993
                await ResetReturningGroupCursorsAsync(
×
1994
                        connection,
×
1995
                        [.. additions.Select(static addition => addition.Job)],
×
1996
                        cancellationToken
×
1997
                ).ConfigureAwait(false);
×
1998

1999
                foreach (var addition in additions)
×
2000
                {
2001
                        var job = ToEntity(addition.Job with
×
2002
                        {
×
2003
                                State = JobState.AwaitingContinuation,
×
2004
                                RemainingDependencies = 1,
×
2005
                        });
×
2006
                        _ = await InsertAsync(connection, job, cancellationToken).ConfigureAwait(false);
×
2007
                        _ = await InsertAsync(connection, new ImmediateJobContinuationEntity
×
2008
                        {
×
2009
                                ChildJobId = job.Id,
×
2010
                                ParentKind = ContinuationParentKind.Job,
×
2011
                                ParentId = current.Id,
×
2012
                                Trigger = addition.Trigger,
×
2013
                        }, cancellationToken).ConfigureAwait(false);
×
2014

2015
                        if (addition.Options != ContinuationOptions.BeforeContinuations)
×
2016
                                continue;
2017
                        foreach (var waiter in waiters)
×
2018
                        {
2019
                                _ = await InsertAsync(connection, new ImmediateJobContinuationEntity
×
2020
                                {
×
2021
                                        ChildJobId = waiter.Id,
×
2022
                                        ParentKind = ContinuationParentKind.Job,
×
2023
                                        ParentId = job.Id,
×
2024
                                        Trigger = ContinuationTrigger.Success,
×
2025
                                }, cancellationToken).ConfigureAwait(false);
×
2026
                                var waiterStamp = waiter.ConcurrencyStamp;
×
2027
                                waiter.RemainingDependencies++;
×
2028
                                waiter.ConcurrencyStamp = Guid.NewGuid();
×
2029
                                if (!await UpdateJobAsync(connection, waiter, waiterStamp, cancellationToken).ConfigureAwait(false))
×
2030
                                        throw new LostRaceException();
×
2031
                        }
×
2032
                }
×
2033
        }
×
2034

2035
        private async Task<List<ImmediateJobEntity>> GetActiveWaitersAsync(
2036
                DataConnection connection,
2037
                string currentJobId,
2038
                CancellationToken cancellationToken
2039
        )
2040
        {
2041
                var waiterIds = await Continuations(connection)
×
2042
                        .Where(edge => edge.ParentKind == ContinuationParentKind.Job && edge.ParentId == currentJobId)
×
2043
                        .Select(edge => edge.ChildJobId)
×
2044
                        .Distinct()
×
2045
                        .ToArrayAsync(cancellationToken)
×
2046
                        .ConfigureAwait(false);
×
2047
                return waiterIds.Length == 0
×
2048
                        ? []
×
2049
                        : await Jobs(connection)
×
2050
                                .Where(job => waiterIds.Contains(job.Id) && job.State == JobState.AwaitingContinuation)
×
2051
                                .ToListAsync(cancellationToken)
×
2052
                                .ConfigureAwait(false);
×
2053
        }
×
2054

2055
        private async Task PropagateTerminalAsync(
2056
                DataConnection connection,
2057
                ImmediateJobEntity terminalJob,
2058
                long now,
2059
                ISet<(string QueueName, string GroupId)> terminalGroups,
2060
                CancellationToken cancellationToken
2061
        )
2062
        {
2063
                AddFairQueueGroup(terminalGroups, terminalJob);
1✔
2064
                var parents = new Queue<(ContinuationParentKind Kind, string Id, ContinuationParentOutcome Outcome)>();
1✔
2065
                var processed = new HashSet<(ContinuationParentKind Kind, string Id)>();
1✔
2066
                parents.Enqueue((
1✔
2067
                        ContinuationParentKind.Job,
1✔
2068
                        terminalJob.Id,
1✔
2069
                        GetParentOutcome(terminalJob.State)
1✔
2070
                ));
1✔
2071
                await UpdateBatchForTerminalJobAsync(connection, terminalJob, now, parents, cancellationToken)
1✔
2072
                        .ConfigureAwait(false);
1✔
2073

2074
                while (parents.TryDequeue(out var parent))
1✔
2075
                {
2076
                        if (!processed.Add((parent.Kind, parent.Id)))
1✔
2077
                                continue;
2078
                        var edges = await Continuations(connection)
1✔
2079
                                .Where(edge => edge.ParentKind == parent.Kind
1✔
2080
                                        && edge.ParentId == parent.Id
1✔
2081
                                        && edge.ParentOutcome == ContinuationParentOutcome.Unsettled)
1✔
2082
                                .ToListAsync(cancellationToken)
1✔
2083
                                .ConfigureAwait(false);
1✔
2084
                        foreach (var edge in edges)
1✔
2085
                        {
2086
                                var settled = await Continuations(connection)
1✔
2087
                                        .Where(entity => entity.ChildJobId == edge.ChildJobId
1✔
2088
                                                && entity.ParentKind == edge.ParentKind
1✔
2089
                                                && entity.ParentId == edge.ParentId
1✔
2090
                                                && entity.ParentOutcome == ContinuationParentOutcome.Unsettled)
1✔
2091
                                        .Set(entity => entity.ParentOutcome, parent.Outcome)
1✔
2092
                                        .UpdateAsync(cancellationToken)
1✔
2093
                                        .ConfigureAwait(false);
1✔
2094
                                if (settled == 0)
1✔
2095
                                        continue;
2096
                                var child = await Jobs(connection).SingleOrDefaultAsync(job => job.Id == edge.ChildJobId, cancellationToken)
1✔
2097
                                        .ConfigureAwait(false);
1✔
2098
                                if (child is null || IsTerminal(child.State))
1✔
2099
                                        continue;
2100
                                var childStamp = child.ConcurrencyStamp;
1✔
2101
                                if (child.State != JobState.AwaitingContinuation || child.RemainingDependencies <= 0)
1✔
2102
                                        continue;
2103
                                child.RemainingDependencies--;
1✔
2104
                                if (parent.Outcome == ContinuationParentOutcome.Failed)
1✔
2105
                                        child.FailedDependencies++;
1✔
2106
                                if (child.RemainingDependencies == 0)
1✔
2107
                                {
2108
                                        if (await ShouldSkipSettledContinuationAsync(connection, child.Id, cancellationToken).ConfigureAwait(false))
1✔
2109
                                        {
2110
                                                child.State = JobState.Skipped;
1✔
2111
                                                child.CompletedAt = now;
1✔
2112
                                                child.WorkerId = null;
1✔
2113
                                                child.LeaseExpiresAt = null;
1✔
2114
                                                AddFairQueueGroup(terminalGroups, child);
1✔
2115
                                                parents.Enqueue((ContinuationParentKind.Job, child.Id, ContinuationParentOutcome.Other));
1✔
2116
                                                await UpdateBatchForTerminalJobAsync(connection, child, now, parents, cancellationToken)
1✔
2117
                                                        .ConfigureAwait(false);
1✔
2118
                                        }
2119
                                        else
2120
                                        {
2121
                                                child.State = child.DueAt <= now ? JobState.Pending : JobState.Scheduled;
1✔
2122
                                        }
2123
                                }
2124

2125
                                child.ConcurrencyStamp = Guid.NewGuid();
1✔
2126
                                if (!await UpdateJobAsync(connection, child, childStamp, cancellationToken).ConfigureAwait(false))
1✔
2127
                                        throw new LostRaceException();
×
2128
                        }
1✔
2129
                }
1✔
2130
        }
1✔
2131

2132
        private async Task<bool> ShouldSkipSettledContinuationAsync(
2133
                DataConnection connection,
2134
                string childJobId,
2135
                CancellationToken cancellationToken
2136
        )
2137
        {
2138
                var edges = await Continuations(connection)
1✔
2139
                        .Where(edge => edge.ChildJobId == childJobId)
1✔
2140
                        .ToListAsync(cancellationToken)
1✔
2141
                        .ConfigureAwait(false);
1✔
2142
                var requiresFailure = false;
1✔
2143
                var anyParentFailed = false;
1✔
2144
                foreach (var edge in edges)
1✔
2145
                {
2146
                        if (edge.Trigger == ContinuationTrigger.Success
1✔
2147
                                && edge.ParentOutcome != ContinuationParentOutcome.Succeeded)
1✔
2148
                        {
2149
                                return true;
1✔
2150
                        }
2151

2152
                        requiresFailure |= edge.Trigger == ContinuationTrigger.Failure;
1✔
2153
                        anyParentFailed |= edge.ParentOutcome == ContinuationParentOutcome.Failed;
1✔
2154
                }
2155

2156
                return requiresFailure && !anyParentFailed;
1✔
2157
        }
1✔
2158

2159
        private static void AddFairQueueGroup(
2160
                ISet<(string QueueName, string GroupId)> groups,
2161
                ImmediateJobEntity job
2162
        )
2163
        {
2164
                if (job.GroupId is { } groupId)
1✔
2165
                        _ = groups.Add((job.QueueName, groupId));
1✔
2166
        }
1✔
2167

2168
        private async Task UpdateBatchForTerminalJobAsync(
2169
                DataConnection connection,
2170
                ImmediateJobEntity job,
2171
                long now,
2172
                Queue<(ContinuationParentKind Kind, string Id, ContinuationParentOutcome Outcome)> parents,
2173
                CancellationToken cancellationToken
2174
        )
2175
        {
2176
                if (job.BatchId is not { } batchId)
1✔
2177
                        return;
1✔
2178
                var batch = await Batches(connection).SingleAsync(item => item.Id == batchId, cancellationToken)
1✔
2179
                        .ConfigureAwait(false);
1✔
2180
                var oldStamp = batch.ConcurrencyStamp;
1✔
2181
                batch.PendingCount = Math.Max(0, batch.PendingCount - 1);
1✔
2182
                switch (job.State)
1✔
2183
                {
2184
                        case JobState.Succeeded:
2185
                                batch.SucceededCount++;
1✔
2186
                                break;
1✔
2187
                        case JobState.Failed:
2188
                                batch.FailedCount++;
×
2189
                                break;
×
2190
                        case JobState.Cancelled:
2191
                                batch.CancelledCount++;
1✔
2192
                                break;
1✔
2193
                        case JobState.Skipped:
2194
                                batch.SkippedCount++;
1✔
2195
                                break;
1✔
2196
                        case JobState.AwaitingContinuation:
2197
                        case JobState.AwaitingParameters:
2198
                        case JobState.Scheduled:
2199
                        case JobState.Pending:
2200
                        case JobState.Active:
2201
                                throw new ImmediateJobException($"Job '{job.Id}' is not terminal.");
×
2202
                        default:
2203
                                throw new ArgumentOutOfRangeException(nameof(job), job.State, "Unknown job state.");
×
2204
                }
2205

2206
                batch.ConcurrencyStamp = Guid.NewGuid();
1✔
2207
                if (batch.PendingCount == 0)
1✔
2208
                {
2209
                        batch.State = GetTerminalBatchState(batch.FailedCount, batch.CancelledCount);
1✔
2210
                        batch.CompletedAt = now;
1✔
2211
                        parents.Enqueue((
1✔
2212
                                ContinuationParentKind.Batch,
1✔
2213
                                batch.Id,
1✔
2214
                                GetParentOutcome(batch.State)
1✔
2215
                        ));
1✔
2216
                }
2217

2218
                if (!await UpdateBatchAsync(connection, batch, oldStamp, cancellationToken).ConfigureAwait(false))
1✔
2219
                        throw new LostRaceException();
1✔
2220
        }
1✔
2221

2222
        private async Task EvaluateInitialDependenciesAsync(
2223
                DataConnection connection,
2224
                Dictionary<string, ImmediateJobEntity> jobs,
2225
                ImmediateJobContinuationEntity[] edges,
2226
                long now,
2227
                CancellationToken cancellationToken
2228
        )
2229
        {
2230
                var externalJobIds = edges
1✔
2231
                        .Where(edge => edge.ParentKind == ContinuationParentKind.Job && !jobs.ContainsKey(edge.ParentId))
1✔
2232
                        .Select(static edge => edge.ParentId)
1✔
2233
                        .Distinct(StringComparer.Ordinal)
1✔
2234
                        .Order(StringComparer.Ordinal)
1✔
2235
                        .ToArray();
1✔
2236
                var externalBatchIds = edges
1✔
2237
                        .Where(static edge => edge.ParentKind == ContinuationParentKind.Batch)
1✔
2238
                        .Select(static edge => edge.ParentId)
1✔
2239
                        .Distinct(StringComparer.Ordinal)
1✔
2240
                        .Order(StringComparer.Ordinal)
1✔
2241
                        .ToArray();
1✔
2242
                var externalJobEntities = externalJobIds.Length == 0
1✔
2243
                        ? []
1✔
2244
                        : (await Jobs(connection).Where(job => externalJobIds.Contains(job.Id)).ToListAsync(cancellationToken)
1✔
2245
                                .ConfigureAwait(false)).ToDictionary(job => job.Id, StringComparer.Ordinal);
1✔
2246
                var externalBatchEntities = externalBatchIds.Length == 0
1✔
2247
                        ? []
1✔
2248
                        : (await Batches(connection).Where(batch => externalBatchIds.Contains(batch.Id)).ToListAsync(cancellationToken)
1✔
2249
                                .ConfigureAwait(false)).ToDictionary(batch => batch.Id, StringComparer.Ordinal);
1✔
2250
                if (externalJobEntities.Count != externalJobIds.Length || externalBatchEntities.Count != externalBatchIds.Length)
1✔
2251
                        throw new ImmediateJobException("A continuation parent does not exist.");
1✔
2252
                foreach (var parentId in externalJobIds)
1✔
2253
                {
2254
                        var parent = externalJobEntities[parentId];
1✔
2255
                        if (IsTerminal(parent.State))
1✔
2256
                                continue;
2257
                        var oldStamp = parent.ConcurrencyStamp;
1✔
2258
                        parent.ConcurrencyStamp = Guid.NewGuid();
1✔
2259
                        if (!await UpdateJobAsync(connection, parent, oldStamp, cancellationToken).ConfigureAwait(false))
1✔
2260
                                throw new LostRaceException();
×
2261
                }
2262

2263
                foreach (var parentId in externalBatchIds)
1✔
2264
                {
2265
                        var parent = externalBatchEntities[parentId];
1✔
2266
                        if (parent.State != BatchState.Executing)
1✔
2267
                                continue;
2268
                        var oldStamp = parent.ConcurrencyStamp;
×
2269
                        parent.ConcurrencyStamp = Guid.NewGuid();
×
2270
                        if (!await UpdateBatchAsync(connection, parent, oldStamp, cancellationToken).ConfigureAwait(false))
×
2271
                                throw new LostRaceException();
×
2272
                }
2273

2274
                var incoming = edges.ToLookup(static edge => edge.ChildJobId, StringComparer.Ordinal);
1✔
2275
                var changed = true;
1✔
2276
                while (changed)
1✔
2277
                {
2278
                        changed = false;
1✔
2279
                        foreach (var job in jobs.Values)
1✔
2280
                        {
2281
                                var dependencies = incoming[job.Id];
1✔
2282
                                if (!dependencies.Any() || IsTerminal(job.State))
1✔
2283
                                        continue;
2284
                                var remaining = 0;
1✔
2285
                                var failedDependencies = 0;
1✔
2286
                                var requiresFailure = false;
1✔
2287
                                var violated = false;
1✔
2288
                                foreach (var edge in dependencies)
1✔
2289
                                {
2290
                                        var (terminal, parentSucceeded, parentFailed) = GetParentState(
1✔
2291
                                                edge,
1✔
2292
                                                jobs,
1✔
2293
                                                externalJobEntities,
1✔
2294
                                                externalBatchEntities
1✔
2295
                                        );
1✔
2296
                                        requiresFailure |= edge.Trigger == ContinuationTrigger.Failure;
1✔
2297
                                        if (!terminal)
1✔
2298
                                        {
2299
                                                remaining++;
1✔
2300
                                                continue;
1✔
2301
                                        }
2302

2303
                                        edge.ParentOutcome = GetParentOutcome(parentSucceeded, parentFailed);
1✔
2304

2305
                                        if (parentFailed)
1✔
2306
                                                failedDependencies++;
×
2307
                                        if (edge.Trigger == ContinuationTrigger.Success && !parentSucceeded)
1✔
2308
                                                violated = true;
×
2309
                                }
2310

2311
                                job.FailedDependencies = failedDependencies;
1✔
2312
                                if (violated || (remaining == 0 && requiresFailure && failedDependencies == 0))
1✔
2313
                                {
2314
                                        job.State = JobState.Skipped;
×
2315
                                        job.RemainingDependencies = 0;
×
2316
                                        job.CompletedAt = now;
×
2317
                                        changed = true;
×
2318
                                }
2319
                                else if (remaining == 0)
1✔
2320
                                {
UNCOV
2321
                                        job.State = job.DueAt <= now ? JobState.Pending : JobState.Scheduled;
×
UNCOV
2322
                                        job.RemainingDependencies = 0;
×
2323
                                }
2324
                                else
2325
                                {
2326
                                        job.State = JobState.AwaitingContinuation;
1✔
2327
                                        job.RemainingDependencies = remaining;
1✔
2328
                                }
2329
                        }
2330
                }
2331
        }
1✔
2332

2333
        private static (bool Terminal, bool Succeeded, bool Failed) GetParentState(
2334
                ImmediateJobContinuationEntity edge,
2335
                Dictionary<string, ImmediateJobEntity> jobs,
2336
                Dictionary<string, ImmediateJobEntity> externalJobs,
2337
                Dictionary<string, ImmediateJobBatchEntity> externalBatches
2338
        )
2339
        {
2340
                if (edge.ParentKind == ContinuationParentKind.Batch)
1✔
2341
                {
2342
                        var state = externalBatches[edge.ParentId].State;
1✔
2343
                        return (state != BatchState.Executing, state == BatchState.Succeeded, state == BatchState.Failed);
1✔
2344
                }
2345

2346
                var jobState = jobs.TryGetValue(edge.ParentId, out var job) ? job.State : externalJobs[edge.ParentId].State;
1✔
2347
                return (IsTerminal(jobState), jobState == JobState.Succeeded, jobState == JobState.Failed);
1✔
2348
        }
2349

2350
        private static void ThrowIfCyclic(
2351
                HashSet<string> jobIds,
2352
                IReadOnlyList<ImmediateJobContinuationEntity> edges
2353
        )
2354
        {
2355
                var indegree = jobIds.ToDictionary(static id => id, static _ => 0, StringComparer.Ordinal);
1✔
2356
                var children = new Dictionary<string, List<string>>(StringComparer.Ordinal);
1✔
2357
                foreach (var edge in edges.Where(edge =>
1✔
2358
                        edge.ParentKind == ContinuationParentKind.Job && jobIds.Contains(edge.ParentId)))
1✔
2359
                {
2360
                        indegree[edge.ChildJobId]++;
1✔
2361
                        if (!children.TryGetValue(edge.ParentId, out var values))
1✔
2362
                                children[edge.ParentId] = values = [];
1✔
2363
                        values.Add(edge.ChildJobId);
1✔
2364
                }
2365

2366
                var ready = new Queue<string>(indegree.Where(static pair => pair.Value == 0).Select(static pair => pair.Key));
1✔
2367
                var visited = 0;
1✔
2368
                while (ready.TryDequeue(out var parent))
1✔
2369
                {
2370
                        visited++;
1✔
2371
                        if (!children.TryGetValue(parent, out var values))
1✔
2372
                                continue;
2373
                        foreach (var child in values)
1✔
2374
                        {
2375
                                if (--indegree[child] == 0)
1✔
2376
                                        ready.Enqueue(child);
1✔
2377
                        }
2378
                }
2379

2380
                if (visited != jobIds.Count)
1✔
2381
                        throw new ImmediateJobException("The continuation graph contains a dependency cycle.");
×
2382
        }
1✔
2383

2384
        private async ValueTask RetryConcurrencyAsync(
2385
                Func<DataConnection, Task> operation,
2386
                CancellationToken cancellationToken,
2387
                int maxAttempts = MaxConcurrencyAttempts
2388
        )
2389
        {
2390
                var concurrencyAttempt = 0;
1✔
2391
                while (true)
1✔
2392
                {
2393
                        await using var connection = CreateConnection();
1✔
2394
                        _ = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
1✔
2395
                        try
2396
                        {
2397
                                await operation(connection).ConfigureAwait(false);
1✔
2398
                                await connection.CommitTransactionAsync(cancellationToken).ConfigureAwait(false);
1✔
2399
                                return;
1✔
2400
                        }
2401
                        catch (SyntheticExecutionInsertFailedException exception) when (++concurrencyAttempt < maxAttempts)
1✔
2402
                        {
2403
                                await connection.RollbackTransactionAsync(cancellationToken).ConfigureAwait(false);
1✔
2404
                                if (!await SyntheticExecutionExistsAsync(exception.JobId, exception.Attempt, cancellationToken)
1✔
2405
                                        .ConfigureAwait(false))
1✔
2406
                                {
2407
                                        throw exception.DatabaseException;
×
2408
                                }
2409
                                // Retry in a new transaction and re-read the execution inserted by the winner.
2410
                                await DelayConcurrencyRetryAsync(cancellationToken).ConfigureAwait(false);
1✔
2411
                        }
1✔
2412
                        catch (LostRaceException) when (++concurrencyAttempt < maxAttempts)
1✔
2413
                        {
2414
                                await connection.RollbackTransactionAsync(cancellationToken).ConfigureAwait(false);
1✔
2415
                                await DelayConcurrencyRetryAsync(cancellationToken).ConfigureAwait(false);
1✔
2416
                        }
1✔
2417
                        catch (SyntheticExecutionInsertFailedException exception)
×
2418
                        {
2419
                                await connection.RollbackTransactionAsync(cancellationToken).ConfigureAwait(false);
×
2420
                                if (!await SyntheticExecutionExistsAsync(exception.JobId, exception.Attempt, cancellationToken)
×
2421
                                        .ConfigureAwait(false))
×
2422
                                {
2423
                                        throw exception.DatabaseException;
×
2424
                                }
2425

2426
                                throw new ImmediateJobException(
×
2427
                                        "The job operation could not be completed after repeated concurrency conflicts.",
×
2428
                                        exception.DatabaseException
×
2429
                                );
×
2430
                        }
2431
                        catch (LostRaceException exception)
×
2432
                        {
2433
                                await connection.RollbackTransactionAsync(cancellationToken).ConfigureAwait(false);
×
2434
                                throw new ImmediateJobException(
×
2435
                                        "The job operation could not be completed after repeated concurrency conflicts.",
×
2436
                                        exception
×
2437
                                );
×
2438
                        }
2439
                        catch
1✔
2440
                        {
2441
                                await connection.RollbackTransactionAsync(cancellationToken).ConfigureAwait(false);
1✔
2442
                                throw;
1✔
2443
                        }
2444
                }
1✔
2445
        }
1✔
2446

2447
        private async ValueTask<bool> SyntheticExecutionExistsAsync(
2448
                string jobId,
2449
                int attempt,
2450
                CancellationToken cancellationToken
2451
        )
2452
        {
2453
                try
2454
                {
2455
                        await using var connection = CreateConnection();
1✔
2456
                        return await Executions(connection)
1✔
2457
                                .AnyAsync(
1✔
2458
                                        execution => execution.JobId == jobId && execution.Attempt == attempt,
1✔
2459
                                        cancellationToken
1✔
2460
                                )
1✔
2461
                                .ConfigureAwait(false);
1✔
2462
                }
×
2463
                catch (DbException)
×
2464
                {
2465
                        return false;
×
2466
                }
2467
        }
1✔
2468

2469
        private static Task DelayConcurrencyRetryAsync(CancellationToken cancellationToken) =>
2470
                Task.Delay(Random.Shared.Next(1, 6), cancellationToken);
1✔
2471

2472
        private async Task<bool> UpdateJobAsync(
2473
                DataConnection connection,
2474
                ImmediateJobEntity job,
2475
                Guid oldStamp,
2476
                CancellationToken cancellationToken
2477
        )
2478
        {
2479
                var updated = await Jobs(connection)
1✔
2480
                        .Where(entity => entity.Id == job.Id && entity.ConcurrencyStamp == oldStamp)
1✔
2481
                        .Set(entity => entity.QueueName, job.QueueName)
1✔
2482
                        .Set(entity => entity.JobName, job.JobName)
1✔
2483
                        .Set(entity => entity.GroupId, job.GroupId)
1✔
2484
                        .Set(entity => entity.Payload, job.Payload)
1✔
2485
                        .Set(entity => entity.Context, job.Context)
1✔
2486
                        .Set(entity => entity.State, job.State)
1✔
2487
                        .Set(entity => entity.DueAt, job.DueAt)
1✔
2488
                        .Set(entity => entity.CreatedAt, job.CreatedAt)
1✔
2489
                        .Set(entity => entity.Attempt, job.Attempt)
1✔
2490
                        .Set(entity => entity.WorkerId, job.WorkerId)
1✔
2491
                        .Set(entity => entity.LeaseExpiresAt, job.LeaseExpiresAt)
1✔
2492
                        .Set(entity => entity.LastError, job.LastError)
1✔
2493
                        .Set(entity => entity.CompletedAt, job.CompletedAt)
1✔
2494
                        .Set(entity => entity.RecurringKey, job.RecurringKey)
1✔
2495
                        .Set(entity => entity.TraceParent, job.TraceParent)
1✔
2496
                        .Set(entity => entity.TraceState, job.TraceState)
1✔
2497
                        .Set(entity => entity.ExecutionTraceId, job.ExecutionTraceId)
1✔
2498
                        .Set(entity => entity.ExecutionSpanId, job.ExecutionSpanId)
1✔
2499
                        .Set(entity => entity.ExecutionStartedAt, job.ExecutionStartedAt)
1✔
2500
                        .Set(entity => entity.BatchId, job.BatchId)
1✔
2501
                        .Set(entity => entity.RemainingDependencies, job.RemainingDependencies)
1✔
2502
                        .Set(entity => entity.FailedDependencies, job.FailedDependencies)
1✔
2503
                        .Set(entity => entity.ConcurrencyStamp, job.ConcurrencyStamp)
1✔
2504
                        .UpdateAsync(cancellationToken)
1✔
2505
                        .ConfigureAwait(false);
1✔
2506
                return updated != 0;
1✔
2507
        }
1✔
2508

2509
        private async Task<bool> UpdateFairQueueGroupAsync(
2510
                DataConnection connection,
2511
                ImmediateFairQueueGroupEntity group,
2512
                Guid oldStamp,
2513
                CancellationToken cancellationToken
2514
        )
2515
        {
2516
                var updated = await FairQueueGroups(connection)
1✔
2517
                        .Where(entity => entity.QueueName == group.QueueName
1✔
2518
                                && entity.GroupId == group.GroupId
1✔
2519
                                && entity.ConcurrencyStamp == oldStamp)
1✔
2520
                        .Set(entity => entity.LastServedSequence, group.LastServedSequence)
1✔
2521
                        .Set(entity => entity.ConcurrencyStamp, group.ConcurrencyStamp)
1✔
2522
                        .UpdateAsync(cancellationToken)
1✔
2523
                        .ConfigureAwait(false);
1✔
2524
                return updated != 0;
1✔
2525
        }
1✔
2526

2527
        private async Task<bool> UpdateBatchAsync(
2528
                DataConnection connection,
2529
                ImmediateJobBatchEntity batch,
2530
                Guid oldStamp,
2531
                CancellationToken cancellationToken
2532
        )
2533
        {
2534
                var updated = await Batches(connection)
1✔
2535
                        .Where(entity => entity.Id == batch.Id && entity.ConcurrencyStamp == oldStamp)
1✔
2536
                        .Set(entity => entity.CreatedAt, batch.CreatedAt)
1✔
2537
                        .Set(entity => entity.TotalJobs, batch.TotalJobs)
1✔
2538
                        .Set(entity => entity.PendingCount, batch.PendingCount)
1✔
2539
                        .Set(entity => entity.SucceededCount, batch.SucceededCount)
1✔
2540
                        .Set(entity => entity.FailedCount, batch.FailedCount)
1✔
2541
                        .Set(entity => entity.CancelledCount, batch.CancelledCount)
1✔
2542
                        .Set(entity => entity.SkippedCount, batch.SkippedCount)
1✔
2543
                        .Set(entity => entity.StartedAt, batch.StartedAt)
1✔
2544
                        .Set(entity => entity.CompletedAt, batch.CompletedAt)
1✔
2545
                        .Set(entity => entity.State, batch.State)
1✔
2546
                        .Set(entity => entity.ConcurrencyStamp, batch.ConcurrencyStamp)
1✔
2547
                        .UpdateAsync(cancellationToken)
1✔
2548
                        .ConfigureAwait(false);
1✔
2549
                return updated != 0;
1✔
2550
        }
1✔
2551

2552
        private async Task<bool> UpdateRecurringAsync(
2553
                DataConnection connection,
2554
                ImmediateRecurringJobEntity schedule,
2555
                Guid oldStamp,
2556
                CancellationToken cancellationToken
2557
        )
2558
        {
2559
                var updated = await Recurring(connection)
1✔
2560
                        .Where(entity => entity.Name == schedule.Name && entity.ConcurrencyStamp == oldStamp)
1✔
2561
                        .Set(entity => entity.JobName, schedule.JobName)
1✔
2562
                        .Set(entity => entity.Cron, schedule.Cron)
1✔
2563
                        .Set(entity => entity.TimeZone, schedule.TimeZone)
1✔
2564
                        .Set(entity => entity.IsCodeDefined, schedule.IsCodeDefined)
1✔
2565
                        .Set(entity => entity.IsPaused, schedule.IsPaused)
1✔
2566
                        .Set(entity => entity.NextRunAt, schedule.NextRunAt)
1✔
2567
                        .Set(entity => entity.LastRunAt, schedule.LastRunAt)
1✔
2568
                        .Set(entity => entity.ConcurrencyStamp, schedule.ConcurrencyStamp)
1✔
2569
                        .UpdateAsync(cancellationToken)
1✔
2570
                        .ConfigureAwait(false);
1✔
2571
                return updated != 0;
1✔
2572
        }
1✔
2573

2574
        private DataConnection CreateConnection() => new(_dataOptions);
1✔
2575

2576
        private ITable<ImmediateJobEntity> Jobs(DataConnection connection) =>
2577
                WithSchema(connection.GetTable<ImmediateJobEntity>());
1✔
2578

2579
        private ITable<ImmediateJobExecutionEntity> Executions(DataConnection connection) =>
2580
                WithSchema(connection.GetTable<ImmediateJobExecutionEntity>());
1✔
2581

2582
        private ITable<ImmediateJobBatchEntity> Batches(DataConnection connection) =>
2583
                WithSchema(connection.GetTable<ImmediateJobBatchEntity>());
1✔
2584

2585
        private ITable<ImmediateFairQueueGroupEntity> FairQueueGroups(DataConnection connection) =>
2586
                WithSchema(connection.GetTable<ImmediateFairQueueGroupEntity>());
1✔
2587

2588
        private ITable<ImmediateJobContinuationEntity> Continuations(DataConnection connection) =>
2589
                WithSchema(connection.GetTable<ImmediateJobContinuationEntity>());
1✔
2590

2591
        private ITable<ImmediateRecurringJobEntity> Recurring(DataConnection connection) =>
2592
                WithSchema(connection.GetTable<ImmediateRecurringJobEntity>());
1✔
2593

2594
        private ITable<ImmediateJobServerEntity> Servers(DataConnection connection) =>
2595
                WithSchema(connection.GetTable<ImmediateJobServerEntity>());
1✔
2596

2597
        private ITable<T> WithSchema<T>(ITable<T> table)
2598
                where T : notnull => _schema is null ? table : table.SchemaName(_schema);
1✔
2599

2600
        private Task<int> InsertAsync<T>(DataConnection connection, T entity, CancellationToken cancellationToken)
2601
                where T : notnull => connection.InsertAsync(entity, schemaName: _schema, token: cancellationToken);
1✔
2602

2603
        private static bool IsTerminal(JobState state) =>
2604
                state is JobState.Succeeded or JobState.Failed or JobState.Cancelled or JobState.Skipped;
1✔
2605

2606
        private static ContinuationParentOutcome GetParentOutcome(JobState state) => state switch
1✔
2607
        {
1✔
2608
                JobState.Succeeded => ContinuationParentOutcome.Succeeded,
1✔
2609
                JobState.Failed => ContinuationParentOutcome.Failed,
1✔
2610
                JobState.AwaitingContinuation or
1✔
2611
                JobState.AwaitingParameters or
1✔
2612
                JobState.Scheduled or
1✔
2613
                JobState.Pending or
1✔
2614
                JobState.Active or
1✔
2615
                JobState.Cancelled or
1✔
2616
                JobState.Skipped => ContinuationParentOutcome.Other,
1✔
2617
                _ => throw new ArgumentOutOfRangeException(nameof(state), state, "Unknown job state."),
×
2618
        };
1✔
2619

2620
        private static ContinuationParentOutcome GetParentOutcome(BatchState state) => state switch
1✔
2621
        {
1✔
2622
                BatchState.Succeeded => ContinuationParentOutcome.Succeeded,
1✔
2623
                BatchState.Failed => ContinuationParentOutcome.Failed,
×
2624
                BatchState.Executing or BatchState.Cancelled => ContinuationParentOutcome.Other,
1✔
2625
                _ => throw new ArgumentOutOfRangeException(nameof(state), state, "Unknown batch state."),
×
2626
        };
1✔
2627

2628
        private static ContinuationParentOutcome GetParentOutcome(bool succeeded, bool failed) =>
2629
                (succeeded, failed) switch
1✔
2630
                {
1✔
2631
                        (true, _) => ContinuationParentOutcome.Succeeded,
1✔
2632
                        (_, true) => ContinuationParentOutcome.Failed,
×
2633
                        _ => ContinuationParentOutcome.Other,
×
2634
                };
1✔
2635

2636
        private static BatchState GetTerminalBatchState(int failed, int cancelled)
2637
        {
2638
                if (failed != 0)
1✔
2639
                        return BatchState.Failed;
×
2640
                return cancelled != 0 ? BatchState.Cancelled : BatchState.Succeeded;
1✔
2641
        }
2642

2643
        private static long? Ticks(DateTimeOffset? value) => value?.UtcTicks;
1✔
2644

2645
        private static DateTimeOffset FromTicks(long value) => new(value, TimeSpan.Zero);
1✔
2646

2647
        private static DateTimeOffset? FromTicks(long? value) =>
2648
                value is { } ticks ? new DateTimeOffset(ticks, TimeSpan.Zero) : null;
1✔
2649

2650
        private static ImmediateJobEntity ToEntity(JobRecord job) => new()
1✔
2651
        {
1✔
2652
                Id = job.Id,
1✔
2653
                QueueName = job.QueueName,
1✔
2654
                JobName = job.JobName,
1✔
2655
                Payload = job.Payload,
1✔
2656
                Context = job.Context,
1✔
2657
                GroupId = job.GroupId,
1✔
2658
                State = job.State,
1✔
2659
                DueAt = job.DueAt.UtcTicks,
1✔
2660
                CreatedAt = job.CreatedAt.UtcTicks,
1✔
2661
                Attempt = job.Attempt,
1✔
2662
                WorkerId = job.WorkerId,
1✔
2663
                LeaseExpiresAt = Ticks(job.LeaseExpiresAt),
1✔
2664
                LastError = job.LastError,
1✔
2665
                CompletedAt = Ticks(job.CompletedAt),
1✔
2666
                RecurringKey = job.RecurringKey,
1✔
2667
                TraceParent = job.TraceParent,
1✔
2668
                TraceState = job.TraceState,
1✔
2669
                ExecutionTraceId = job.ExecutionTraceId,
1✔
2670
                ExecutionSpanId = job.ExecutionSpanId,
1✔
2671
                ExecutionStartedAt = Ticks(job.ExecutionStartedAt),
1✔
2672
                BatchId = job.BatchId,
1✔
2673
                RemainingDependencies = job.RemainingDependencies,
1✔
2674
                FailedDependencies = job.FailedDependencies,
1✔
2675
                ConcurrencyStamp = Guid.NewGuid(),
1✔
2676
        };
1✔
2677

2678
        private async Task PrepareAcquisitionExecutionsAsync(
2679
                DataConnection connection,
2680
                JobRecord previous,
2681
                string workerId,
2682
                long acquiredAt,
2683
                CancellationToken cancellationToken
2684
        )
2685
        {
2686
                var priorExecution = await GetOrMaterializeExecutionAsync(connection, ToEntity(previous), cancellationToken)
1✔
2687
                        .ConfigureAwait(false);
1✔
2688
                if (previous.State == JobState.Active && priorExecution is not null)
1✔
2689
                {
2690
                        _ = await Executions(connection)
1✔
2691
                                .Where(execution => execution.JobId == previous.Id && execution.Attempt == previous.Attempt)
1✔
2692
                                .Set(execution => execution.State, JobExecutionState.Interrupted)
1✔
2693
                                .Set(execution => execution.CompletedAt, Ticks(previous.LeaseExpiresAt))
1✔
2694
                                .Set(execution => execution.Error, (string?)null)
1✔
2695
                                .UpdateAsync(cancellationToken)
1✔
2696
                                .ConfigureAwait(false);
1✔
2697
                }
2698

2699
                _ = await InsertAsync(connection, new ImmediateJobExecutionEntity
1✔
2700
                {
1✔
2701
                        JobId = previous.Id,
1✔
2702
                        Attempt = previous.Attempt + 1,
1✔
2703
                        State = JobExecutionState.Active,
1✔
2704
                        WorkerId = workerId,
1✔
2705
                        AcquiredAt = acquiredAt,
1✔
2706
                }, cancellationToken).ConfigureAwait(false);
1✔
2707
        }
1✔
2708

2709
        private async Task<ImmediateJobExecutionEntity?> GetOrMaterializeExecutionAsync(
2710
                DataConnection connection,
2711
                ImmediateJobEntity job,
2712
                CancellationToken cancellationToken
2713
        )
2714
        {
2715
                if (job.Attempt <= 0)
1✔
2716
                        return null;
1✔
2717
                var execution = await Executions(connection)
1✔
2718
                        .SingleOrDefaultAsync(
1✔
2719
                                item => item.JobId == job.Id && item.Attempt == job.Attempt,
1✔
2720
                                cancellationToken
1✔
2721
                        )
1✔
2722
                        .ConfigureAwait(false);
1✔
2723
                if (execution is not null)
1✔
2724
                        return execution;
1✔
2725

2726
                var synthetic = JobExecutionRecord.CreateSynthetic(ToRecord(job));
1✔
2727
                if (synthetic is null)
1✔
2728
                        return null;
×
2729
                execution = ToEntity(synthetic);
1✔
2730
                try
2731
                {
2732
                        _ = await InsertAsync(connection, execution, cancellationToken).ConfigureAwait(false);
1✔
2733
                }
1✔
2734
                catch (DbException exception)
1✔
2735
                {
2736
                        throw new SyntheticExecutionInsertFailedException(job.Id, job.Attempt, exception);
1✔
2737
                }
2738

2739
                return execution;
1✔
2740
        }
1✔
2741

2742
        private static ImmediateJobExecutionEntity ToEntity(JobExecutionRecord execution) => new()
1✔
2743
        {
1✔
2744
                JobId = execution.JobId,
1✔
2745
                Attempt = execution.Attempt,
1✔
2746
                State = execution.State,
1✔
2747
                WorkerId = execution.WorkerId,
1✔
2748
                AcquiredAt = Ticks(execution.AcquiredAt),
1✔
2749
                ExecutionStartedAt = Ticks(execution.ExecutionStartedAt),
1✔
2750
                CompletedAt = Ticks(execution.CompletedAt),
1✔
2751
                ExecutionTraceId = execution.ExecutionTraceId,
1✔
2752
                ExecutionSpanId = execution.ExecutionSpanId,
1✔
2753
                Error = execution.Error,
1✔
2754
                IsSynthetic = execution.IsSynthetic,
1✔
2755
        };
1✔
2756

2757
        private static JobExecutionRecord ToRecord(ImmediateJobExecutionEntity execution) => new()
1✔
2758
        {
1✔
2759
                JobId = execution.JobId,
1✔
2760
                Attempt = execution.Attempt,
1✔
2761
                State = execution.State,
1✔
2762
                WorkerId = execution.WorkerId,
1✔
2763
                AcquiredAt = FromTicks(execution.AcquiredAt),
1✔
2764
                ExecutionStartedAt = FromTicks(execution.ExecutionStartedAt),
1✔
2765
                CompletedAt = FromTicks(execution.CompletedAt),
1✔
2766
                ExecutionTraceId = execution.ExecutionTraceId,
1✔
2767
                ExecutionSpanId = execution.ExecutionSpanId,
1✔
2768
                Error = execution.Error,
1✔
2769
                IsSynthetic = execution.IsSynthetic,
1✔
2770
        };
1✔
2771

2772
        private static ImmediateJobContinuationEntity ToEntity(JobContinuationEdge edge)
2773
        {
2774
                var hasJobParent = edge.ParentJobId is not null;
1✔
2775
                var hasBatchParent = edge.ParentBatchId is not null;
1✔
2776
                if (hasJobParent == hasBatchParent)
1✔
2777
                        throw new ImmediateJobException("A continuation edge must identify exactly one parent job or batch.");
×
2778
                return new()
1✔
2779
                {
1✔
2780
                        ChildJobId = edge.ChildJobId,
1✔
2781
                        ParentKind = hasJobParent ? ContinuationParentKind.Job : ContinuationParentKind.Batch,
1✔
2782
                        ParentId = edge.ParentJobId ?? edge.ParentBatchId!,
1✔
2783
                        Trigger = edge.Trigger,
1✔
2784
                };
1✔
2785
        }
2786

2787
        private static JobRecord ToRecord(ImmediateJobEntity job) => new()
1✔
2788
        {
1✔
2789
                Id = job.Id,
1✔
2790
                QueueName = job.QueueName,
1✔
2791
                JobName = job.JobName,
1✔
2792
                Payload = job.Payload,
1✔
2793
                Context = job.Context,
1✔
2794
                GroupId = job.GroupId,
1✔
2795
                State = job.State,
1✔
2796
                DueAt = FromTicks(job.DueAt),
1✔
2797
                CreatedAt = FromTicks(job.CreatedAt),
1✔
2798
                Attempt = job.Attempt,
1✔
2799
                WorkerId = job.WorkerId,
1✔
2800
                LeaseExpiresAt = FromTicks(job.LeaseExpiresAt),
1✔
2801
                LastError = job.LastError,
1✔
2802
                CompletedAt = FromTicks(job.CompletedAt),
1✔
2803
                RecurringKey = job.RecurringKey,
1✔
2804
                TraceParent = job.TraceParent,
1✔
2805
                TraceState = job.TraceState,
1✔
2806
                ExecutionTraceId = job.ExecutionTraceId,
1✔
2807
                ExecutionSpanId = job.ExecutionSpanId,
1✔
2808
                ExecutionStartedAt = FromTicks(job.ExecutionStartedAt),
1✔
2809
                BatchId = job.BatchId,
1✔
2810
                RemainingDependencies = job.RemainingDependencies,
1✔
2811
                FailedDependencies = job.FailedDependencies,
1✔
2812
        };
1✔
2813

2814
        private static ImmediateRecurringJobEntity ToEntity(RecurringJobSchedule schedule) => new()
1✔
2815
        {
1✔
2816
                Name = schedule.Name,
1✔
2817
                JobName = schedule.JobName,
1✔
2818
                Cron = schedule.Cron,
1✔
2819
                TimeZone = schedule.TimeZone,
1✔
2820
                IsCodeDefined = schedule.IsCodeDefined,
1✔
2821
                IsPaused = schedule.IsPaused,
1✔
2822
                NextRunAt = schedule.NextRunAt.UtcTicks,
1✔
2823
                LastRunAt = Ticks(schedule.LastRunAt),
1✔
2824
                ConcurrencyStamp = Guid.NewGuid(),
1✔
2825
        };
1✔
2826

2827
        private static RecurringJobSchedule ToRecord(ImmediateRecurringJobEntity schedule) => new()
1✔
2828
        {
1✔
2829
                Name = schedule.Name,
1✔
2830
                JobName = schedule.JobName,
1✔
2831
                Cron = schedule.Cron,
1✔
2832
                TimeZone = schedule.TimeZone,
1✔
2833
                IsCodeDefined = schedule.IsCodeDefined,
1✔
2834
                IsPaused = schedule.IsPaused,
1✔
2835
                NextRunAt = FromTicks(schedule.NextRunAt),
1✔
2836
                LastRunAt = FromTicks(schedule.LastRunAt),
1✔
2837
        };
1✔
2838

2839
        private static BatchStatus ToStatus(ImmediateJobBatchEntity batch) => new(
1✔
2840
                batch.Id,
1✔
2841
                batch.State,
1✔
2842
                batch.TotalJobs,
1✔
2843
                batch.SucceededCount,
1✔
2844
                batch.FailedCount,
1✔
2845
                batch.CancelledCount,
1✔
2846
                batch.SkippedCount,
1✔
2847
                batch.PendingCount,
1✔
2848
                FromTicks(batch.CreatedAt),
1✔
2849
                FromTicks(batch.StartedAt),
1✔
2850
                FromTicks(batch.CompletedAt),
1✔
2851
                BatchStatus.CalculateFractionSettled(batch.TotalJobs, batch.PendingCount)
1✔
2852
        );
1✔
2853

2854
        private static JobContinuationEdge ToContinuationEdge(ImmediateJobContinuationEntity edge) => new()
1✔
2855
        {
1✔
2856
                ChildJobId = edge.ChildJobId,
1✔
2857
                ParentJobId = edge.ParentKind == ContinuationParentKind.Job ? edge.ParentId : null,
1✔
2858
                ParentBatchId = edge.ParentKind == ContinuationParentKind.Batch ? edge.ParentId : null,
1✔
2859
                Trigger = edge.Trigger,
1✔
2860
        };
1✔
2861

2862
        private static BatchGraphEdge ToGraphEdge(ImmediateJobContinuationEntity edge) => new(
1✔
2863
                edge.ChildJobId,
1✔
2864
                edge.ParentKind == ContinuationParentKind.Job ? edge.ParentId : null,
1✔
2865
                edge.ParentKind == ContinuationParentKind.Batch ? edge.ParentId : null,
1✔
2866
                edge.Trigger
1✔
2867
        );
1✔
2868

2869
#pragma warning disable CA1032, CA1064
2870
        private sealed class LostRaceException : Exception;
2871

2872
        private sealed class SyntheticExecutionInsertFailedException(
2873
                string jobId,
2874
                int attempt,
2875
                DbException databaseException
2876
        ) : Exception("A synthetic execution insert failed.", databaseException)
1✔
2877
        {
2878
                public string JobId { get; } = jobId;
1✔
2879
                public int Attempt { get; } = attempt;
1✔
2880
                public DbException DatabaseException { get; } = databaseException;
1✔
2881
        }
2882
#pragma warning restore CA1032, CA1064
2883
}
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