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

ImmediatePlatform / Immediate.Jobs / 30302568744

27 Jul 2026 08:26PM UTC coverage: 66.69%. First build
30302568744

Pull #26

github

web-flow
Merge 4a231468a into eac5c1044
Pull Request #26: Add batches and continuation workflows

1239 of 1810 new or added lines in 17 files covered. (68.45%)

2855 of 4281 relevant lines covered (66.69%)

2.66 hits per line

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

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

4
namespace Immediate.Jobs.EntityFrameworkCore;
5

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

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

24
        /// <inheritdoc />
25
        public async ValueTask EnqueueAsync(JobRecord job, CancellationToken cancellationToken = default)
26
        {
27
                ArgumentNullException.ThrowIfNull(job);
4✔
28
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
29
                _ = context.Set<ImmediateJobEntity>().Add(ToEntity(job));
4✔
30
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
31
        }
4✔
32

33
        /// <inheritdoc />
34
        public async ValueTask EnqueueContinuationAsync(
35
                JobRecord job,
36
                IReadOnlyList<JobContinuationEdge> edges,
37
                CancellationToken cancellationToken = default
38
        )
39
        {
NEW
40
                ArgumentNullException.ThrowIfNull(job);
×
NEW
41
                ArgumentNullException.ThrowIfNull(edges);
×
NEW
42
                await ExecuteGraphInsertAsync(batch: null, [job], edges, cancellationToken).ConfigureAwait(false);
×
NEW
43
        }
×
44

45
        /// <inheritdoc />
46
        public async ValueTask EnqueueBatchAsync(
47
                JobBatchRecord batch,
48
                IReadOnlyList<JobRecord> jobs,
49
                IReadOnlyList<JobContinuationEdge> edges,
50
                CancellationToken cancellationToken = default
51
        )
52
        {
53
                ArgumentNullException.ThrowIfNull(batch);
4✔
54
                ArgumentNullException.ThrowIfNull(jobs);
4✔
55
                ArgumentNullException.ThrowIfNull(edges);
4✔
56
                if (jobs.Count == 0)
4✔
NEW
57
                        throw new InvalidOperationException("IJOB016: An atomic batch cannot be committed without jobs.");
×
58
                await ExecuteGraphInsertAsync(batch, jobs, edges, cancellationToken).ConfigureAwait(false);
4✔
59
        }
4✔
60

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

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

89
                var edgeEntities = edges.Select(ToEntity).ToArray();
4✔
90
                if (edgeEntities.Any(edge => !jobIds.Contains(edge.ChildJobId)))
4✔
NEW
91
                        throw new InvalidOperationException("Every continuation edge must target a job inserted by the same operation.");
×
92
                if (edgeEntities.DistinctBy(static edge => (edge.ChildJobId, edge.ParentKind, edge.ParentId)).Count() != edgeEntities.Length)
4✔
NEW
93
                        throw new InvalidOperationException("Duplicate continuation edges are not allowed.");
×
94
                ThrowIfCyclic(jobIds, edgeEntities);
4✔
95

96
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
97
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
4✔
98
                var jobEntities = jobs.Select(ToEntity).ToDictionary(static job => job.Id, StringComparer.Ordinal);
4✔
99
                await EvaluateInitialDependenciesAsync(
4✔
100
                        context,
4✔
101
                        jobEntities,
4✔
102
                        edgeEntities,
4✔
103
                        _timeProvider.GetUtcNow(),
4✔
104
                        cancellationToken
4✔
105
                ).ConfigureAwait(false);
4✔
106

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

130
                context.AddRange(jobEntities.Values);
4✔
131
                context.AddRange(edgeEntities);
4✔
132
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
133
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
4✔
134
        }
4✔
135

136
        /// <inheritdoc />
137
        public async ValueTask<IReadOnlyList<JobRecord>> AcquireDueJobsAsync(
138
                JobAcquisitionRequest request,
139
                CancellationToken cancellationToken = default
140
        )
141
        {
142
                ArgumentNullException.ThrowIfNull(request);
4✔
143
                ArgumentException.ThrowIfNullOrWhiteSpace(request.WorkerId);
4✔
144
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(request.Lease, TimeSpan.Zero);
4✔
145
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(request.BatchSize, 0);
4✔
146
                var now = _timeProvider.GetUtcNow();
4✔
147
                var acquired = new List<JobRecord>(request.BatchSize);
4✔
148
                foreach (var queue in request.Queues)
4✔
149
                {
150
                        var queueCapacity = Math.Min(queue.Capacity, request.BatchSize - acquired.Count);
4✔
151
                        if (queueCapacity <= 0)
4✔
152
                                continue;
153

154
                        var jobCapacities = queue.JobCapacities.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal);
4✔
155
                        while (queueCapacity > 0)
4✔
156
                        {
157
                                var eligibleNames = jobCapacities.Where(static pair => pair.Value > 0).Select(static pair => pair.Key).ToArray();
4✔
158
                                if (eligibleNames.Length == 0)
4✔
159
                                        break;
160

161
                                await using var readContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
162
                                var candidates = await readContext.Set<ImmediateJobEntity>()
4✔
163
                                        .AsNoTracking()
4✔
164
                                        .Where(job => job.QueueName == queue.QueueName && eligibleNames.Contains(job.JobName) &&
4✔
165
                                                (((job.State == JobState.Scheduled || job.State == JobState.Pending) && job.DueAt <= now)
4✔
166
                                                        || (job.State == JobState.Active && job.LeaseExpiresAt <= now)))
4✔
167
                                        .OrderBy(job => job.DueAt)
4✔
168
                                        .ThenBy(job => job.CreatedAt)
4✔
169
                                        .ThenBy(job => job.Id)
4✔
170
                                        .Take(queueCapacity)
4✔
171
                                        .ToListAsync(cancellationToken)
4✔
172
                                        .ConfigureAwait(false);
4✔
173
                                if (candidates.Count == 0)
4✔
174
                                        break;
175

176
                                var selected = new List<ImmediateJobEntity>(candidates.Count);
4✔
177
                                var selectionCapacities = new Dictionary<string, int>(jobCapacities, StringComparer.Ordinal);
4✔
178
                                foreach (var candidate in candidates)
4✔
179
                                {
180
                                        if (selectionCapacities[candidate.JobName] <= 0)
4✔
181
                                                continue;
182
                                        selectionCapacities[candidate.JobName]--;
4✔
183
                                        selected.Add(candidate);
4✔
184
                                }
185

186
                                var claimed = await AcquireCandidatesAsync(
4✔
187
                                        selected,
4✔
188
                                        request.WorkerId,
4✔
189
                                        request.Lease,
4✔
190
                                        now,
4✔
191
                                        cancellationToken
4✔
192
                                ).ConfigureAwait(false);
4✔
193
                                foreach (var job in claimed)
4✔
194
                                {
195
                                        jobCapacities[job.JobName]--;
4✔
196
                                        queueCapacity--;
4✔
197
                                        acquired.Add(job);
4✔
198
                                }
199

200
                                if (claimed.Count == 0)
4✔
201
                                        break;
202
                        }
4✔
203
                }
4✔
204

205
                return acquired;
4✔
206
        }
4✔
207

208
        /// <inheritdoc />
209
        public async ValueTask<IReadOnlyList<JobRecord>> AcquireJobsAsync(
210
                IReadOnlyCollection<string> jobIds,
211
                string workerId,
212
                TimeSpan lease,
213
                CancellationToken cancellationToken = default
214
        )
215
        {
216
                ArgumentNullException.ThrowIfNull(jobIds);
4✔
217
                ArgumentException.ThrowIfNullOrWhiteSpace(workerId);
4✔
218
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lease, TimeSpan.Zero);
4✔
219
                if (jobIds.Count == 0)
4✔
220
                        return [];
×
221

222
                var now = _timeProvider.GetUtcNow();
4✔
223
                var ids = jobIds.ToArray();
4✔
224
                await using var readContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
225
                var candidates = await readContext.Set<ImmediateJobEntity>()
4✔
226
                        .AsNoTracking()
4✔
227
                        .Where(job => ids.Contains(job.Id) &&
4✔
228
                                (((job.State == JobState.Scheduled || job.State == JobState.Pending) && job.DueAt <= now)
4✔
229
                                || (job.State == JobState.Active && job.LeaseExpiresAt <= now)))
4✔
230
                        .ToListAsync(cancellationToken)
4✔
231
                        .ConfigureAwait(false);
4✔
232

233
                return await AcquireCandidatesAsync(candidates, workerId, lease, now, cancellationToken).ConfigureAwait(false);
4✔
234
        }
4✔
235

236
        private async ValueTask<IReadOnlyList<JobRecord>> AcquireCandidatesAsync(
237
                List<ImmediateJobEntity> candidates,
238
                string workerId,
239
                TimeSpan lease,
240
                DateTimeOffset now,
241
                CancellationToken cancellationToken
242
        )
243
        {
244
                var acquired = new List<JobRecord>(candidates.Count);
4✔
245
                foreach (var candidate in candidates)
4✔
246
                {
247
                        await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
248
                        var entity = Copy(candidate);
4✔
249
                        _ = context.Attach(entity);
4✔
250
                        entity.State = JobState.Active;
4✔
251
                        entity.WorkerId = workerId;
4✔
252
                        entity.LeaseExpiresAt = now + lease;
4✔
253
                        entity.Attempt++;
4✔
254
                        entity.CompletedAt = null;
4✔
255
                        entity.ConcurrencyStamp = Guid.NewGuid();
4✔
256
                        if (entity.BatchId is { } batchId)
4✔
257
                        {
258
                                var batch = await context.Set<ImmediateJobBatchEntity>()
4✔
259
                                        .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
4✔
260
                                        .ConfigureAwait(false);
4✔
261
                                if (batch is not null && batch.StartedAt is null)
4✔
262
                                {
263
                                        batch.StartedAt = now;
4✔
264
                                        batch.ConcurrencyStamp = Guid.NewGuid();
4✔
265
                                }
266
                        }
267

268
                        try
269
                        {
270
                                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
271
                                acquired.Add(ToRecord(entity));
4✔
272
                        }
4✔
273
                        catch (DbUpdateConcurrencyException)
×
274
                        {
275
                                // Another scheduler claimed or changed this candidate first.
276
                        }
×
277
                }
4✔
278

279
                return acquired;
4✔
280
        }
4✔
281

282
        /// <inheritdoc />
283
        public ValueTask RenewLeaseAsync(string jobId, string workerId, TimeSpan lease, CancellationToken cancellationToken = default)
284
        {
285
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lease, TimeSpan.Zero);
×
286
                return MutateOwnedAsync(jobId, workerId, job => job.LeaseExpiresAt = _timeProvider.GetUtcNow() + lease, cancellationToken);
×
287
        }
288

289
        /// <inheritdoc />
290
        public ValueTask CompleteAsync(string jobId, string workerId, CancellationToken cancellationToken = default) =>
291
                CompleteWithContinuationsAsync(jobId, workerId, [], cancellationToken);
4✔
292

293
        /// <inheritdoc />
294
        public ValueTask CompleteWithContinuationsAsync(
295
                string jobId,
296
                string workerId,
297
                IReadOnlyList<JobContinuationAddition> additions,
298
                CancellationToken cancellationToken = default
299
        )
300
        {
301
                ArgumentNullException.ThrowIfNull(additions);
4✔
302
                return MutateOwnedWithDependenciesAsync(
4✔
303
                        jobId,
4✔
304
                        workerId,
4✔
305
                        error: null,
4✔
306
                        nextRetryAt: null,
4✔
307
                        succeeded: true,
4✔
308
                        additions,
4✔
309
                        cancellationToken
4✔
310
                );
4✔
311
        }
312

313
        /// <inheritdoc />
314
        public async ValueTask AddBatchJobAsync(
315
                string currentJobId,
316
                JobRecord job,
317
                ContinuationOptions options,
318
                CancellationToken cancellationToken = default
319
        )
320
        {
NEW
321
                ArgumentNullException.ThrowIfNull(job);
×
NEW
322
                if (options == ContinuationOptions.Detached)
×
NEW
323
                        throw new InvalidOperationException("IJOB020: AddToBatch cannot create a detached job.");
×
324
                const int MaxConcurrencyAttempts = 5;
NEW
325
                for (var attempt = 0; attempt < MaxConcurrencyAttempts; attempt++)
×
326
                {
327
                        try
328
                        {
NEW
329
                                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
NEW
330
                                var strategy = strategyContext.Database.CreateExecutionStrategy();
×
NEW
331
                                await strategy.ExecuteAsync(
×
NEW
332
                                        operationCancellationToken => AddBatchJobCoreAsync(
×
NEW
333
                                                currentJobId,
×
NEW
334
                                                job,
×
NEW
335
                                                options,
×
NEW
336
                                                operationCancellationToken
×
NEW
337
                                        ),
×
NEW
338
                                        cancellationToken
×
NEW
339
                                ).ConfigureAwait(false);
×
NEW
340
                                return;
×
341
                        }
×
NEW
342
                        catch (DbUpdateConcurrencyException) when (attempt + 1 < MaxConcurrencyAttempts)
×
343
                        {
344
                        }
×
345
                }
NEW
346
        }
×
347

348
        /// <inheritdoc />
349
        public ValueTask FailAsync(
350
                string jobId,
351
                string workerId,
352
                string error,
353
                DateTimeOffset? nextRetryAt,
354
                CancellationToken cancellationToken = default
NEW
355
        ) => MutateOwnedWithDependenciesAsync(jobId, workerId, error, nextRetryAt, succeeded: false, [], cancellationToken);
×
356

357
        /// <inheritdoc />
358
        public async ValueTask UpsertRecurringAsync(RecurringJobSchedule schedule, CancellationToken cancellationToken = default)
359
        {
360
                ArgumentNullException.ThrowIfNull(schedule);
4✔
361
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
362
                if (await UpdateRecurringAsync(context, schedule, cancellationToken).ConfigureAwait(false) != 0)
4✔
363
                        return;
364
                await ThrowIfReplacingCodeDefinedScheduleAsync(context, schedule, cancellationToken).ConfigureAwait(false);
4✔
365

366
                _ = context.Add(ToEntity(schedule));
4✔
367
                try
368
                {
369
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
370
                }
4✔
371
                catch (DbUpdateException)
×
372
                {
373
                        // A competing node inserted the same schedule after our update attempt.
374
                        await using var retryContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
375
                        if (await UpdateRecurringAsync(retryContext, schedule, cancellationToken).ConfigureAwait(false) != 0)
×
376
                                return;
377
                        await ThrowIfReplacingCodeDefinedScheduleAsync(retryContext, schedule, cancellationToken).ConfigureAwait(false);
×
378
                        throw;
×
379
                }
×
380
        }
4✔
381

382
        /// <inheritdoc />
383
        public async ValueTask RemoveObsoleteCodeDefinedRecurringAsync(
384
                IReadOnlyCollection<string> activeScheduleNames,
385
                CancellationToken cancellationToken = default
386
        )
387
        {
388
                ArgumentNullException.ThrowIfNull(activeScheduleNames);
4✔
389
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
390
                var schedules = context.Set<ImmediateRecurringJobEntity>()
4✔
391
                        .Where(schedule => schedule.IsCodeDefined);
4✔
392
                if (activeScheduleNames.Count != 0)
4✔
393
                        schedules = schedules.Where(schedule => !activeScheduleNames.Contains(schedule.Name));
4✔
394
                _ = await schedules.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
4✔
395
        }
4✔
396

397
        /// <inheritdoc />
398
        public async ValueTask RemoveRecurringAsync(string name, CancellationToken cancellationToken = default)
399
        {
400
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
401
                var entity = await context.Set<ImmediateRecurringJobEntity>()
×
402
                        .SingleOrDefaultAsync(schedule => schedule.Name == name && !schedule.IsCodeDefined, cancellationToken)
×
403
                        .ConfigureAwait(false);
×
404
                if (entity is not null)
×
405
                {
406
                        _ = context.Remove(entity);
×
407
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
408
                }
409
        }
×
410

411
        /// <inheritdoc />
412
        public ValueTask PauseRecurringAsync(string name, CancellationToken cancellationToken = default)
413
                => MutateRecurringAsync(name, schedule => schedule.IsPaused = true, cancellationToken);
×
414

415
        /// <inheritdoc />
416
        public ValueTask ResumeRecurringAsync(string name, CancellationToken cancellationToken = default)
417
                => MutateRecurringAsync(name, schedule => schedule.IsPaused = false, cancellationToken);
×
418

419
        /// <inheritdoc />
420
        public async ValueTask<IReadOnlyList<RecurringJobSchedule>> GetDueRecurringAsync(DateTimeOffset now, int batchSize, CancellationToken cancellationToken = default)
421
        {
422
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(batchSize, 0);
×
423
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
424
                return await context.Set<ImmediateRecurringJobEntity>()
×
425
                        .AsNoTracking()
×
426
                        .Where(schedule => !schedule.IsPaused && schedule.NextRunAt <= now)
×
427
                        .OrderBy(schedule => schedule.NextRunAt)
×
428
                        .Take(batchSize)
×
429
                        .Select(schedule => new RecurringJobSchedule
×
430
                        {
×
431
                                Name = schedule.Name,
×
432
                                JobName = schedule.JobName,
×
433
                                Cron = schedule.Cron,
×
434
                                TimeZone = schedule.TimeZone,
×
435
                                IsCodeDefined = schedule.IsCodeDefined,
×
436
                                IsPaused = schedule.IsPaused,
×
437
                                NextRunAt = schedule.NextRunAt,
×
438
                                LastRunAt = schedule.LastRunAt,
×
439
                        })
×
440
                        .ToListAsync(cancellationToken)
×
441
                        .ConfigureAwait(false);
×
442
        }
×
443

444
        /// <inheritdoc />
445
        public async ValueTask<bool> MaterializeRecurringAsync(
446
                RecurringJobSchedule schedule,
447
                JobRecord job,
448
                DateTimeOffset nextRunAt,
449
                CancellationToken cancellationToken = default
450
        )
451
        {
452
                ArgumentNullException.ThrowIfNull(schedule);
4✔
453
                ArgumentNullException.ThrowIfNull(job);
4✔
454
                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
455
                var strategy = strategyContext.Database.CreateExecutionStrategy();
4✔
456
                return await strategy.ExecuteAsync(
4✔
457
                        operationCancellationToken => MaterializeRecurringCoreAsync(
4✔
458
                                schedule,
4✔
459
                                job,
4✔
460
                                nextRunAt,
4✔
461
                                operationCancellationToken
4✔
462
                        ),
4✔
463
                        cancellationToken
4✔
464
                ).ConfigureAwait(false);
4✔
465
        }
4✔
466

467
        private async Task<bool> MaterializeRecurringCoreAsync(
468
                RecurringJobSchedule schedule,
469
                JobRecord job,
470
                DateTimeOffset nextRunAt,
471
                CancellationToken cancellationToken
472
        )
473
        {
474
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
475
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
4✔
476
                var entity = await context.Set<ImmediateRecurringJobEntity>()
4✔
477
                        .SingleOrDefaultAsync(item => item.Name == schedule.Name, cancellationToken)
4✔
478
                        .ConfigureAwait(false);
4✔
479
                if (entity is null || entity.IsPaused || entity.NextRunAt != schedule.NextRunAt)
4✔
480
                        return false;
×
481

482
                entity.LastRunAt = schedule.NextRunAt;
4✔
483
                entity.NextRunAt = nextRunAt;
4✔
484
                entity.ConcurrencyStamp = Guid.NewGuid();
4✔
485
                _ = context.Add(ToEntity(job));
4✔
486
                try
487
                {
488
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
489
                        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
4✔
490
                        return true;
4✔
491
                }
492
                catch (DbUpdateException)
493
                {
494
                        await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
×
495
                        return false;
×
496
                }
497
        }
4✔
498

499
        /// <inheritdoc />
500
        public async ValueTask<JobMonitoringSnapshot> GetMonitoringSnapshotAsync(CancellationToken cancellationToken = default)
501
        {
502
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
503
                var rawCounts = await context.Set<ImmediateJobEntity>()
4✔
504
                        .AsNoTracking()
4✔
505
                        .GroupBy(job => job.State)
4✔
506
                        .Select(group => new { State = group.Key, Count = group.LongCount() })
4✔
507
                        .ToListAsync(cancellationToken)
4✔
508
                        .ConfigureAwait(false);
4✔
509
                var counts = Enum.GetValues<JobState>().ToDictionary(static state => state, static _ => 0L);
4✔
510
                foreach (var item in rawCounts)
4✔
511
                        counts[item.State] = item.Count;
4✔
512

513
                var recurring = await context.Set<ImmediateRecurringJobEntity>()
4✔
514
                        .AsNoTracking()
4✔
515
                        .OrderBy(schedule => schedule.Name)
4✔
516
                        .Select(schedule => new RecurringJobSchedule
4✔
517
                        {
4✔
518
                                Name = schedule.Name,
4✔
519
                                JobName = schedule.JobName,
4✔
520
                                Cron = schedule.Cron,
4✔
521
                                TimeZone = schedule.TimeZone,
4✔
522
                                IsCodeDefined = schedule.IsCodeDefined,
4✔
523
                                IsPaused = schedule.IsPaused,
4✔
524
                                NextRunAt = schedule.NextRunAt,
4✔
525
                                LastRunAt = schedule.LastRunAt,
4✔
526
                        })
4✔
527
                        .ToListAsync(cancellationToken)
4✔
528
                        .ConfigureAwait(false);
4✔
529
                var servers = await context.Set<ImmediateJobServerEntity>()
4✔
530
                        .AsNoTracking()
4✔
531
                        .OrderBy(server => server.WorkerId)
4✔
532
                        .Select(server => new JobServerSnapshot(server.WorkerId, server.LastHeartbeat, server.ActiveWorkers, server.MaxWorkers))
4✔
533
                        .ToListAsync(cancellationToken)
4✔
534
                        .ConfigureAwait(false);
4✔
535
                return new(_timeProvider.GetUtcNow(), counts, recurring, servers);
4✔
536
        }
4✔
537

538
        /// <inheritdoc />
539
        public async ValueTask<IReadOnlyList<JobRecord>> QueryJobsAsync(JobQuery query, CancellationToken cancellationToken = default)
540
        {
541
                ArgumentNullException.ThrowIfNull(query);
4✔
542
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
4✔
543
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
4✔
544
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
545
                IQueryable<ImmediateJobEntity> jobs = context.Set<ImmediateJobEntity>().AsNoTracking();
4✔
546
                if (query.Id is { } id)
4✔
547
                        jobs = jobs.Where(job => job.Id == id);
×
548
                if (query.State is { } state)
4✔
549
                        jobs = jobs.Where(job => job.State == state);
4✔
550
                if (!string.IsNullOrWhiteSpace(query.QueueName))
4✔
551
                        jobs = jobs.Where(job => job.QueueName == query.QueueName);
×
552
                if (!string.IsNullOrWhiteSpace(query.Search))
4✔
553
                {
554
                        var search = query.Search.ToUpperInvariant();
×
555
                        // The parameterless form is intentionally used because relational providers translate it to SQL.
556
#pragma warning disable CA1304, CA1311, CA1862
557
                        jobs = jobs.Where(job => job.JobName.ToUpper().Contains(search));
×
558
#pragma warning restore CA1304, CA1311, CA1862
559
                }
560

561
                var entities = await jobs.OrderByDescending(job => job.CreatedAt)
4✔
562
                        .Skip(query.Skip)
4✔
563
                        .Take(query.Take)
4✔
564
                        .ToListAsync(cancellationToken)
4✔
565
                        .ConfigureAwait(false);
4✔
566
                return [.. entities.Select(ToRecord)];
4✔
567
        }
4✔
568

569
        /// <inheritdoc />
570
        public async ValueTask<BatchStatus?> GetBatchStatusAsync(
571
                string batchId,
572
                CancellationToken cancellationToken = default
573
        )
574
        {
575
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
576
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
577
                var batch = await context.Set<ImmediateJobBatchEntity>()
4✔
578
                        .AsNoTracking()
4✔
579
                        .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
4✔
580
                        .ConfigureAwait(false);
4✔
581
                return batch is null ? null : ToStatus(batch);
4✔
582
        }
4✔
583

584
        /// <inheritdoc />
585
        public async ValueTask<IReadOnlyList<BatchStatus>> QueryBatchesAsync(
586
                JobBatchQuery query,
587
                CancellationToken cancellationToken = default
588
        )
589
        {
NEW
590
                ArgumentNullException.ThrowIfNull(query);
×
NEW
591
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
×
NEW
592
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
×
NEW
593
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
NEW
594
                IQueryable<ImmediateJobBatchEntity> batches = context.Set<ImmediateJobBatchEntity>().AsNoTracking();
×
NEW
595
                if (query.State is { } state)
×
NEW
596
                        batches = batches.Where(batch => batch.State == state);
×
NEW
597
                var entities = await batches.OrderByDescending(batch => batch.CreatedAt)
×
NEW
598
                        .ThenBy(batch => batch.Id)
×
NEW
599
                        .Skip(query.Skip)
×
NEW
600
                        .Take(query.Take)
×
NEW
601
                        .ToListAsync(cancellationToken)
×
NEW
602
                        .ConfigureAwait(false);
×
NEW
603
                return [.. entities.Select(ToStatus)];
×
NEW
604
        }
×
605

606
        /// <inheritdoc />
607
        public async ValueTask<IReadOnlyList<BatchMemberStatus>> QueryBatchMembersAsync(
608
                string batchId,
609
                BatchMemberQuery query,
610
                CancellationToken cancellationToken = default
611
        )
612
        {
613
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
614
                ArgumentNullException.ThrowIfNull(query);
4✔
615
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
4✔
616
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
4✔
617
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
618
                IQueryable<ImmediateJobEntity> jobs = context.Set<ImmediateJobEntity>()
4✔
619
                        .AsNoTracking()
4✔
620
                        .Where(job => job.BatchId == batchId);
4✔
621
                if (query.State is { } state)
4✔
NEW
622
                        jobs = jobs.Where(job => job.State == state);
×
623
                return await jobs.OrderBy(job => job.CreatedAt)
4✔
624
                        .ThenBy(job => job.Id)
4✔
625
                        .Skip(query.Skip)
4✔
626
                        .Take(query.Take)
4✔
627
                        .Select(job => new BatchMemberStatus(
4✔
628
                                job.Id,
4✔
629
                                job.JobName,
4✔
630
                                job.QueueName,
4✔
631
                                job.State,
4✔
632
                                job.Attempt,
4✔
633
                                job.CreatedAt,
4✔
634
                                job.CompletedAt,
4✔
635
                                job.LastError
4✔
636
                        ))
4✔
637
                        .ToListAsync(cancellationToken)
4✔
638
                        .ConfigureAwait(false);
4✔
639
        }
4✔
640

641
        /// <inheritdoc />
642
        public async ValueTask<BatchGraph?> GetBatchGraphAsync(
643
                string batchId,
644
                CancellationToken cancellationToken = default
645
        )
646
        {
647
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
648
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
649
                if (!await context.Set<ImmediateJobBatchEntity>()
4✔
650
                        .AnyAsync(batch => batch.Id == batchId, cancellationToken)
4✔
651
                        .ConfigureAwait(false))
4✔
652
                {
653
                        return null;
4✔
654
                }
655

NEW
656
                var jobs = await context.Set<ImmediateJobEntity>()
×
NEW
657
                        .AsNoTracking()
×
NEW
658
                        .Where(job => job.BatchId == batchId)
×
NEW
659
                        .OrderBy(job => job.CreatedAt)
×
NEW
660
                        .ThenBy(job => job.Id)
×
NEW
661
                        .Select(job => new BatchGraphNode(job.Id, job.JobName, job.State))
×
NEW
662
                        .ToListAsync(cancellationToken)
×
NEW
663
                        .ConfigureAwait(false);
×
NEW
664
                var ids = jobs.Select(static job => job.JobId).ToArray();
×
NEW
665
                var edges = ids.Length == 0
×
NEW
666
                        ? []
×
NEW
667
                        : await context.Set<ImmediateJobContinuationEntity>()
×
NEW
668
                                .AsNoTracking()
×
NEW
669
                                .Where(edge => ids.Contains(edge.ChildJobId))
×
NEW
670
                                .OrderBy(edge => edge.ChildJobId)
×
NEW
671
                                .ThenBy(edge => edge.ParentKind)
×
NEW
672
                                .ThenBy(edge => edge.ParentId)
×
NEW
673
                                .ToListAsync(cancellationToken)
×
NEW
674
                                .ConfigureAwait(false);
×
675
                return new(batchId, jobs, [.. edges.Select(ToGraphEdge)]);
4✔
676
        }
4✔
677

678
        /// <inheritdoc />
679
        public async ValueTask<JobStatus?> GetJobStatusAsync(
680
                string jobId,
681
                CancellationToken cancellationToken = default
682
        )
683
        {
684
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
4✔
685
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
686
                var job = await context.Set<ImmediateJobEntity>()
4✔
687
                        .AsNoTracking()
4✔
688
                        .SingleOrDefaultAsync(item => item.Id == jobId, cancellationToken)
4✔
689
                        .ConfigureAwait(false);
4✔
690
                if (job is null)
4✔
NEW
691
                        return null;
×
692
                var edges = await context.Set<ImmediateJobContinuationEntity>()
4✔
693
                        .AsNoTracking()
4✔
694
                        .Where(edge => edge.ChildJobId == jobId)
4✔
695
                        .OrderBy(edge => edge.ParentKind)
4✔
696
                        .ThenBy(edge => edge.ParentId)
4✔
697
                        .ToListAsync(cancellationToken)
4✔
698
                        .ConfigureAwait(false);
4✔
699
                return new(
4✔
700
                        job.Id,
4✔
701
                        job.JobName,
4✔
702
                        job.QueueName,
4✔
703
                        job.State,
4✔
704
                        job.Attempt,
4✔
705
                        MaxAttempts: 0,
4✔
706
                        job.CreatedAt,
4✔
707
                        job.DueAt,
4✔
708
                        job.CompletedAt,
4✔
709
                        job.LastError,
4✔
710
                        job.BatchId,
4✔
711
                        [.. edges.Select(ToGraphEdge)]
4✔
712
                );
4✔
713
        }
4✔
714

715
        /// <inheritdoc />
716
        public ValueTask CancelBatchAsync(string batchId, CancellationToken cancellationToken = default)
717
        {
718
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
719
                return ExecuteWithStrategyAsync(
4✔
720
                        operationCancellationToken => CancelBatchCoreAsync(batchId, operationCancellationToken),
4✔
721
                        cancellationToken
4✔
722
                );
4✔
723
        }
724

725
        private async Task CancelBatchCoreAsync(string batchId, CancellationToken cancellationToken)
726
        {
727
                var now = _timeProvider.GetUtcNow();
4✔
728
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
729
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
4✔
730
                var batch = await context.Set<ImmediateJobBatchEntity>()
4✔
731
                        .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
4✔
732
                        .ConfigureAwait(false)
4✔
733
                        ?? throw new KeyNotFoundException($"Batch '{batchId}' was not found.");
4✔
734
                if (batch.State != BatchState.Executing)
4✔
NEW
735
                        throw new InvalidOperationException("Only an executing batch can be cancelled.");
×
736

737
                var jobs = await context.Set<ImmediateJobEntity>()
4✔
738
                        .Where(job => job.BatchId == batchId)
4✔
739
                        .ToListAsync(cancellationToken)
4✔
740
                        .ConfigureAwait(false);
4✔
741
                foreach (var job in jobs)
4✔
742
                {
743
                        if (IsTerminal(job.State))
4✔
744
                                continue;
745
                        job.State = JobState.Cancelled;
4✔
746
                        job.CompletedAt = now;
4✔
747
                        job.WorkerId = null;
4✔
748
                        job.LeaseExpiresAt = null;
4✔
749
                        job.ConcurrencyStamp = Guid.NewGuid();
4✔
750
                        await PropagateTerminalAsync(context, job, now, cancellationToken).ConfigureAwait(false);
4✔
751
                }
752

753
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
754
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
4✔
755
        }
4✔
756

757
        /// <inheritdoc />
758
        public ValueTask DeleteBatchAsync(string batchId, CancellationToken cancellationToken = default)
759
        {
760
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
761
                return ExecuteWithStrategyAsync(
4✔
762
                        operationCancellationToken => DeleteBatchCoreAsync(batchId, operationCancellationToken),
4✔
763
                        cancellationToken
4✔
764
                );
4✔
765
        }
766

767
        private async Task DeleteBatchCoreAsync(string batchId, CancellationToken cancellationToken)
768
        {
769
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
770
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
4✔
771
                var batch = await context.Set<ImmediateJobBatchEntity>()
4✔
772
                        .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
4✔
773
                        .ConfigureAwait(false)
4✔
774
                        ?? throw new KeyNotFoundException($"Batch '{batchId}' was not found.");
4✔
775
                if (batch.State == BatchState.Executing)
4✔
NEW
776
                        throw new InvalidOperationException("Only a terminal batch can be deleted.");
×
777

778
                var jobs = await context.Set<ImmediateJobEntity>()
4✔
779
                        .Where(job => job.BatchId == batchId)
4✔
780
                        .ToListAsync(cancellationToken)
4✔
781
                        .ConfigureAwait(false);
4✔
782
                var jobIds = jobs.Select(static job => job.Id).ToArray();
4✔
783
                var edges = await context.Set<ImmediateJobContinuationEntity>()
4✔
784
                        .Where(edge => jobIds.Contains(edge.ChildJobId)
4✔
785
                                || edge.ParentKind == ContinuationParentKind.Job && jobIds.Contains(edge.ParentId)
4✔
786
                                || edge.ParentKind == ContinuationParentKind.Batch && edge.ParentId == batchId)
4✔
787
                        .ToListAsync(cancellationToken)
4✔
788
                        .ConfigureAwait(false);
4✔
789
                context.RemoveRange(edges);
4✔
790
                context.RemoveRange(jobs);
4✔
791
                _ = context.Remove(batch);
4✔
792
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
793
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
4✔
794
        }
4✔
795

796
        /// <inheritdoc />
797
        public ValueTask RetryAsync(string jobId, CancellationToken cancellationToken = default)
798
        {
NEW
799
                return ExecuteWithStrategyAsync(
×
NEW
800
                        operationCancellationToken => RetryCoreAsync(jobId, operationCancellationToken),
×
NEW
801
                        cancellationToken
×
NEW
802
                );
×
803
        }
804

805
        private async Task RetryCoreAsync(string jobId, CancellationToken cancellationToken)
806
        {
NEW
807
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
NEW
808
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
×
809
                var job = await context.Set<ImmediateJobEntity>()
×
810
                        .SingleOrDefaultAsync(item => item.Id == jobId && item.State == JobState.Failed, cancellationToken)
×
811
                        .ConfigureAwait(false);
×
812
                if (job is null)
×
813
                        return;
NEW
814
                if (job.BatchId is { } batchId)
×
815
                {
NEW
816
                        var batch = await context.Set<ImmediateJobBatchEntity>()
×
NEW
817
                                .SingleAsync(item => item.Id == batchId, cancellationToken)
×
NEW
818
                                .ConfigureAwait(false);
×
NEW
819
                        batch.PendingCount++;
×
NEW
820
                        batch.FailedCount = Math.Max(0, batch.FailedCount - 1);
×
NEW
821
                        batch.State = BatchState.Executing;
×
NEW
822
                        batch.CompletedAt = null;
×
NEW
823
                        batch.ConcurrencyStamp = Guid.NewGuid();
×
824
                }
825

826
                job.State = JobState.Pending;
×
827
                job.DueAt = _timeProvider.GetUtcNow();
×
828
                job.WorkerId = null;
×
829
                job.LeaseExpiresAt = null;
×
830
                job.CompletedAt = null;
×
831
                job.LastError = null;
×
832
                job.ConcurrencyStamp = Guid.NewGuid();
×
833
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
NEW
834
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
×
835
        }
×
836

837
        /// <inheritdoc />
838
        public async ValueTask DeleteAsync(string jobId, CancellationToken cancellationToken = default)
839
        {
840
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
841
                var job = await context.Set<ImmediateJobEntity>()
×
842
                        .SingleOrDefaultAsync(item => item.Id == jobId
×
843
                                && (item.State == JobState.Succeeded || item.State == JobState.Failed || item.State == JobState.Cancelled), cancellationToken)
×
844
                        .ConfigureAwait(false);
×
845
                if (job is not null)
×
846
                {
NEW
847
                        if (job.BatchId is not null)
×
NEW
848
                                throw new InvalidOperationException("Batch members are deleted with their batch so the workflow remains coherent.");
×
NEW
849
                        var outgoing = await context.Set<ImmediateJobContinuationEntity>()
×
NEW
850
                                .Where(edge => edge.ParentKind == ContinuationParentKind.Job && edge.ParentId == jobId)
×
NEW
851
                                .ToListAsync(cancellationToken)
×
NEW
852
                                .ConfigureAwait(false);
×
NEW
853
                        context.RemoveRange(outgoing);
×
854
                        _ = context.Remove(job);
×
855
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
856
                }
857
        }
×
858

859
        /// <inheritdoc />
860
        public ValueTask PurgeAsync(
861
                TimeSpan succeededRetention,
862
                TimeSpan failedRetention,
863
                TimeSpan batchSucceededRetention,
864
                TimeSpan batchFailedRetention,
865
                CancellationToken cancellationToken = default
866
        )
867
        {
868
                var now = _timeProvider.GetUtcNow();
4✔
869
                return ExecuteWithStrategyAsync(
4✔
870
                        operationCancellationToken => PurgeCoreAsync(
4✔
871
                                now - succeededRetention,
4✔
872
                                now - failedRetention,
4✔
873
                                now - batchSucceededRetention,
4✔
874
                                now - batchFailedRetention,
4✔
875
                                operationCancellationToken
4✔
876
                        ),
4✔
877
                        cancellationToken
4✔
878
                );
4✔
879
        }
880

881
        private async Task PurgeCoreAsync(
882
                DateTimeOffset succeededBefore,
883
                DateTimeOffset failedBefore,
884
                DateTimeOffset batchSucceededBefore,
885
                DateTimeOffset batchFailedBefore,
886
                CancellationToken cancellationToken
887
        )
888
        {
889
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
890
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
4✔
891
                var batches = await context.Set<ImmediateJobBatchEntity>()
4✔
892
                        .Where(batch => (batch.State == BatchState.Succeeded && batch.CompletedAt < batchSucceededBefore)
4✔
893
                                || ((batch.State == BatchState.Failed || batch.State == BatchState.Cancelled)
4✔
894
                                        && batch.CompletedAt < batchFailedBefore))
4✔
895
                        .ToListAsync(cancellationToken)
4✔
896
                        .ConfigureAwait(false);
4✔
897
                if (batches.Count != 0)
4✔
898
                {
NEW
899
                        var batchIds = batches.Select(static batch => batch.Id).ToArray();
×
NEW
900
                        var memberIds = await context.Set<ImmediateJobEntity>()
×
NEW
901
                                .Where(job => job.BatchId != null && batchIds.Contains(job.BatchId))
×
NEW
902
                                .Select(job => job.Id)
×
NEW
903
                                .ToListAsync(cancellationToken)
×
NEW
904
                                .ConfigureAwait(false);
×
NEW
905
                        var edges = await context.Set<ImmediateJobContinuationEntity>()
×
NEW
906
                                .Where(edge => batchIds.Contains(edge.ParentId) && edge.ParentKind == ContinuationParentKind.Batch
×
NEW
907
                                        || memberIds.Contains(edge.ChildJobId)
×
NEW
908
                                        || memberIds.Contains(edge.ParentId) && edge.ParentKind == ContinuationParentKind.Job)
×
NEW
909
                                .ToListAsync(cancellationToken)
×
NEW
910
                                .ConfigureAwait(false);
×
NEW
911
                        context.RemoveRange(edges);
×
NEW
912
                        context.RemoveRange(batches);
×
NEW
913
                }
×
914

915
                var jobs = await context.Set<ImmediateJobEntity>()
4✔
916
                        .Where(job => job.BatchId == null
4✔
917
                                && ((job.State == JobState.Succeeded && job.CompletedAt < succeededBefore)
4✔
918
                                || ((job.State == JobState.Failed || job.State == JobState.Cancelled) && job.CompletedAt < failedBefore))
4✔
919
                        )
4✔
920
                        .ToListAsync(cancellationToken)
4✔
921
                        .ConfigureAwait(false);
4✔
922
                if (jobs.Count != 0)
4✔
923
                {
NEW
924
                        var jobIds = jobs.Select(static job => job.Id).ToArray();
×
NEW
925
                        var edges = await context.Set<ImmediateJobContinuationEntity>()
×
NEW
926
                                .Where(edge => jobIds.Contains(edge.ChildJobId)
×
NEW
927
                                        || jobIds.Contains(edge.ParentId) && edge.ParentKind == ContinuationParentKind.Job)
×
NEW
928
                                .ToListAsync(cancellationToken)
×
NEW
929
                                .ConfigureAwait(false);
×
NEW
930
                        context.RemoveRange(edges);
×
931
                }
932

933
                context.RemoveRange(jobs);
4✔
934
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
935
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
4✔
936
        }
4✔
937

938
        /// <inheritdoc />
939
        public async ValueTask HeartbeatAsync(JobServerSnapshot server, CancellationToken cancellationToken = default)
940
        {
941
                ArgumentNullException.ThrowIfNull(server);
×
942
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
943
                var entity = await context.Set<ImmediateJobServerEntity>().FindAsync([server.WorkerId], cancellationToken).ConfigureAwait(false);
×
944
                if (entity is null)
×
945
                {
946
                        _ = context.Add(new ImmediateJobServerEntity
×
947
                        {
×
948
                                WorkerId = server.WorkerId,
×
949
                                LastHeartbeat = server.LastHeartbeat,
×
950
                                ActiveWorkers = server.ActiveWorkers,
×
951
                                MaxWorkers = server.MaxWorkers,
×
952
                        });
×
953
                }
954
                else
955
                {
956
                        entity.LastHeartbeat = server.LastHeartbeat;
×
957
                        entity.ActiveWorkers = server.ActiveWorkers;
×
958
                        entity.MaxWorkers = server.MaxWorkers;
×
959
                }
960

961
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
962
        }
×
963

964
        /// <inheritdoc />
965
        public async ValueTask<bool> IsHealthyAsync(CancellationToken cancellationToken = default)
966
        {
967
                try
968
                {
969
                        await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
970
                        return await context.Database.CanConnectAsync(cancellationToken).ConfigureAwait(false);
×
971
                }
×
972
                catch (Exception exception) when (exception is DbException or InvalidOperationException)
×
973
                {
974
                        return false;
×
975
                }
976
        }
×
977

978
        private async ValueTask MutateOwnedWithDependenciesAsync(
979
                string jobId,
980
                string workerId,
981
                string? error,
982
                DateTimeOffset? nextRetryAt,
983
                bool succeeded,
984
                IReadOnlyList<JobContinuationAddition> additions,
985
                CancellationToken cancellationToken
986
        )
987
        {
988
                const int MaxConcurrencyAttempts = 5;
989
                for (var attempt = 0; attempt < MaxConcurrencyAttempts; attempt++)
4✔
990
                {
991
                        try
992
                        {
993
                                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
994
                                var strategy = strategyContext.Database.CreateExecutionStrategy();
4✔
995
                                await strategy.ExecuteAsync(
4✔
996
                                        operationCancellationToken => MutateOwnedCoreAsync(
4✔
997
                                                jobId,
4✔
998
                                                workerId,
4✔
999
                                                error,
4✔
1000
                                                nextRetryAt,
4✔
1001
                                                succeeded,
4✔
1002
                                                additions,
4✔
1003
                                                operationCancellationToken
4✔
1004
                                        ),
4✔
1005
                                        cancellationToken
4✔
1006
                                ).ConfigureAwait(false);
4✔
1007
                                return;
4✔
NEW
1008
                        }
×
NEW
1009
                        catch (DbUpdateConcurrencyException) when (attempt + 1 < MaxConcurrencyAttempts)
×
1010
                        {
1011
                                // A competing parent completion changed a shared child or batch counter. The whole
1012
                                // transaction rolled back, so retrying re-evaluates the graph from durable state.
NEW
1013
                        }
×
1014
                }
1015
        }
4✔
1016

1017
        private async ValueTask ExecuteWithStrategyAsync(
1018
                Func<CancellationToken, Task> operation,
1019
                CancellationToken cancellationToken
1020
        )
1021
        {
1022
                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
1023
                var strategy = strategyContext.Database.CreateExecutionStrategy();
4✔
1024
                await strategy.ExecuteAsync(operation, cancellationToken).ConfigureAwait(false);
4✔
1025
        }
4✔
1026

1027
        private async ValueTask MutateOwnedAsync(
1028
                string jobId,
1029
                string workerId,
1030
                Action<ImmediateJobEntity> mutate,
1031
                CancellationToken cancellationToken
1032
        )
1033
        {
1034
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
1035
                var job = await context.Set<ImmediateJobEntity>()
×
1036
                        .SingleOrDefaultAsync(item => item.Id == jobId && item.State == JobState.Active && item.WorkerId == workerId, cancellationToken)
×
1037
                        .ConfigureAwait(false);
×
1038
                if (job is null)
×
1039
                        return;
1040
                mutate(job);
×
1041
                job.ConcurrencyStamp = Guid.NewGuid();
×
1042
                try
1043
                {
1044
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1045
                }
×
1046
                catch (DbUpdateConcurrencyException)
×
1047
                {
1048
                        // A stale worker must not update a lease that has been reclaimed.
1049
                }
×
1050
        }
×
1051

1052
        private async Task MutateOwnedCoreAsync(
1053
                string jobId,
1054
                string workerId,
1055
                string? error,
1056
                DateTimeOffset? nextRetryAt,
1057
                bool succeeded,
1058
                IReadOnlyList<JobContinuationAddition> additions,
1059
                CancellationToken cancellationToken
1060
        )
1061
        {
1062
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
1063
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
4✔
1064
                var job = await context.Set<ImmediateJobEntity>()
4✔
1065
                        .SingleOrDefaultAsync(item => item.Id == jobId && item.State == JobState.Active && item.WorkerId == workerId, cancellationToken)
4✔
1066
                        .ConfigureAwait(false);
4✔
1067
                if (job is null)
4✔
1068
                        return;
1069

1070
                var now = _timeProvider.GetUtcNow();
4✔
1071
                job.WorkerId = null;
4✔
1072
                job.LeaseExpiresAt = null;
4✔
1073
                job.LastError = error;
4✔
1074
                job.ConcurrencyStamp = Guid.NewGuid();
4✔
1075
                if (!succeeded && nextRetryAt is { } retryAt)
4✔
1076
                {
NEW
1077
                        job.State = retryAt <= now ? JobState.Pending : JobState.Scheduled;
×
NEW
1078
                        job.DueAt = retryAt;
×
NEW
1079
                        job.CompletedAt = null;
×
NEW
1080
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
NEW
1081
                        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
×
NEW
1082
                        return;
×
1083
                }
1084

1085
                if (succeeded && additions.Count != 0)
4✔
1086
                {
NEW
1087
                        await FlushContinuationAdditionsAsync(context, job, additions, cancellationToken).ConfigureAwait(false);
×
NEW
1088
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1089
                }
1090

1091
                job.State = succeeded ? JobState.Succeeded : JobState.Failed;
4✔
1092
                job.CompletedAt = now;
4✔
1093
                await PropagateTerminalAsync(context, job, now, cancellationToken).ConfigureAwait(false);
4✔
1094
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
1095
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
4✔
1096
        }
4✔
1097

1098
        private async Task AddBatchJobCoreAsync(
1099
                string currentJobId,
1100
                JobRecord record,
1101
                ContinuationOptions options,
1102
                CancellationToken cancellationToken
1103
        )
1104
        {
NEW
1105
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
NEW
1106
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
×
NEW
1107
                var current = await context.Set<ImmediateJobEntity>()
×
NEW
1108
                        .SingleOrDefaultAsync(job => job.Id == currentJobId && job.State == JobState.Active, cancellationToken)
×
NEW
1109
                        .ConfigureAwait(false)
×
NEW
1110
                        ?? throw new InvalidOperationException($"The current active job '{currentJobId}' was not found.");
×
NEW
1111
                if (current.BatchId is not { } batchId)
×
NEW
1112
                        throw new InvalidOperationException("The current job does not belong to a batch.");
×
NEW
1113
                var batch = await context.Set<ImmediateJobBatchEntity>()
×
NEW
1114
                        .SingleAsync(item => item.Id == batchId && item.State == BatchState.Executing, cancellationToken)
×
NEW
1115
                        .ConfigureAwait(false);
×
NEW
1116
                var job = ToEntity(record with { BatchId = batchId });
×
NEW
1117
                _ = context.Add(job);
×
NEW
1118
                batch.TotalJobs++;
×
NEW
1119
                batch.PendingCount++;
×
NEW
1120
                batch.ConcurrencyStamp = Guid.NewGuid();
×
1121

NEW
1122
                if (options == ContinuationOptions.BeforeContinuations)
×
1123
                {
NEW
1124
                        var waiters = await GetActiveWaitersAsync(context, currentJobId, cancellationToken).ConfigureAwait(false);
×
NEW
1125
                        foreach (var waiter in waiters)
×
1126
                        {
NEW
1127
                                _ = context.Add(new ImmediateJobContinuationEntity
×
NEW
1128
                                {
×
NEW
1129
                                        ChildJobId = waiter.Id,
×
NEW
1130
                                        ParentKind = ContinuationParentKind.Job,
×
NEW
1131
                                        ParentId = job.Id,
×
NEW
1132
                                        Trigger = ContinuationTrigger.AllSucceeded,
×
NEW
1133
                                });
×
NEW
1134
                                waiter.RemainingDependencies++;
×
NEW
1135
                                waiter.ConcurrencyStamp = Guid.NewGuid();
×
1136
                        }
1137
                }
1138

NEW
1139
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
NEW
1140
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
×
NEW
1141
        }
×
1142

1143
        private static async Task FlushContinuationAdditionsAsync(
1144
                TContext context,
1145
                ImmediateJobEntity current,
1146
                IReadOnlyList<JobContinuationAddition> additions,
1147
                CancellationToken cancellationToken
1148
        )
1149
        {
NEW
1150
                var ids = additions.Select(static addition => addition.Job.Id).ToHashSet(StringComparer.Ordinal);
×
NEW
1151
                if (ids.Count != additions.Count)
×
NEW
1152
                        throw new InvalidOperationException("Buffered continuations contain duplicate job identifiers.");
×
NEW
1153
                var waiters = additions.Any(static addition => addition.Options == ContinuationOptions.BeforeContinuations)
×
NEW
1154
                        ? await GetActiveWaitersAsync(context, current.Id, cancellationToken).ConfigureAwait(false)
×
NEW
1155
                        : [];
×
1156
                ImmediateJobBatchEntity? batch = null;
NEW
1157
                var trackedAdditions = additions.Count(static addition => addition.Options != ContinuationOptions.Detached);
×
NEW
1158
                if (trackedAdditions != 0)
×
1159
                {
NEW
1160
                        if (current.BatchId is not { } batchId)
×
NEW
1161
                                throw new InvalidOperationException("The current job does not belong to a batch.");
×
NEW
1162
                        batch = await context.Set<ImmediateJobBatchEntity>()
×
NEW
1163
                                .SingleAsync(item => item.Id == batchId && item.State == BatchState.Executing, cancellationToken)
×
NEW
1164
                                .ConfigureAwait(false);
×
NEW
1165
                        batch.TotalJobs += trackedAdditions;
×
NEW
1166
                        batch.PendingCount += trackedAdditions;
×
NEW
1167
                        batch.ConcurrencyStamp = Guid.NewGuid();
×
1168
                }
1169

NEW
1170
                foreach (var addition in additions)
×
1171
                {
NEW
1172
                        var inBatch = addition.Options != ContinuationOptions.Detached;
×
NEW
1173
                        var job = ToEntity(addition.Job with
×
NEW
1174
                        {
×
NEW
1175
                                BatchId = inBatch ? current.BatchId : null,
×
NEW
1176
                                State = JobState.AwaitingContinuation,
×
NEW
1177
                                RemainingDependencies = 1,
×
NEW
1178
                        });
×
NEW
1179
                        _ = context.Add(job);
×
NEW
1180
                        _ = context.Add(new ImmediateJobContinuationEntity
×
NEW
1181
                        {
×
NEW
1182
                                ChildJobId = job.Id,
×
NEW
1183
                                ParentKind = ContinuationParentKind.Job,
×
NEW
1184
                                ParentId = current.Id,
×
NEW
1185
                                Trigger = addition.Trigger,
×
NEW
1186
                        });
×
1187

NEW
1188
                        if (addition.Options != ContinuationOptions.BeforeContinuations)
×
1189
                                continue;
NEW
1190
                        foreach (var waiter in waiters)
×
1191
                        {
NEW
1192
                                _ = context.Add(new ImmediateJobContinuationEntity
×
NEW
1193
                                {
×
NEW
1194
                                        ChildJobId = waiter.Id,
×
NEW
1195
                                        ParentKind = ContinuationParentKind.Job,
×
NEW
1196
                                        ParentId = job.Id,
×
NEW
1197
                                        Trigger = ContinuationTrigger.AllSucceeded,
×
NEW
1198
                                });
×
NEW
1199
                                waiter.RemainingDependencies++;
×
NEW
1200
                                waiter.ConcurrencyStamp = Guid.NewGuid();
×
1201
                        }
1202
                }
NEW
1203
        }
×
1204

1205
        private static async Task<List<ImmediateJobEntity>> GetActiveWaitersAsync(
1206
                TContext context,
1207
                string currentJobId,
1208
                CancellationToken cancellationToken
1209
        )
1210
        {
NEW
1211
                var waiterIds = await context.Set<ImmediateJobContinuationEntity>()
×
NEW
1212
                        .Where(edge => edge.ParentKind == ContinuationParentKind.Job && edge.ParentId == currentJobId)
×
NEW
1213
                        .Select(edge => edge.ChildJobId)
×
NEW
1214
                        .Distinct()
×
NEW
1215
                        .ToArrayAsync(cancellationToken)
×
NEW
1216
                        .ConfigureAwait(false);
×
NEW
1217
                return waiterIds.Length == 0
×
NEW
1218
                        ? []
×
NEW
1219
                        : await context.Set<ImmediateJobEntity>()
×
NEW
1220
                                .Where(job => waiterIds.Contains(job.Id) && job.State == JobState.AwaitingContinuation)
×
NEW
1221
                                .ToListAsync(cancellationToken)
×
NEW
1222
                                .ConfigureAwait(false);
×
NEW
1223
        }
×
1224

1225
        private static async Task PropagateTerminalAsync(
1226
                TContext context,
1227
                ImmediateJobEntity terminalJob,
1228
                DateTimeOffset now,
1229
                CancellationToken cancellationToken
1230
        )
1231
        {
1232
                var parents = new Queue<(ContinuationParentKind Kind, string Id, bool Succeeded)>();
4✔
1233
                var processed = new HashSet<(ContinuationParentKind Kind, string Id)>();
4✔
1234
                parents.Enqueue((ContinuationParentKind.Job, terminalJob.Id, terminalJob.State == JobState.Succeeded));
4✔
1235
                await UpdateBatchForTerminalJobAsync(context, terminalJob, now, parents, cancellationToken).ConfigureAwait(false);
4✔
1236

1237
                while (parents.TryDequeue(out var parent))
4✔
1238
                {
1239
                        if (!processed.Add((parent.Kind, parent.Id)))
4✔
1240
                                continue;
1241
                        var edges = await context.Set<ImmediateJobContinuationEntity>()
4✔
1242
                                .Where(edge => edge.ParentKind == parent.Kind && edge.ParentId == parent.Id)
4✔
1243
                                .ToListAsync(cancellationToken)
4✔
1244
                                .ConfigureAwait(false);
4✔
1245
                        foreach (var edge in edges)
4✔
1246
                        {
1247
                                var child = await context.Set<ImmediateJobEntity>()
4✔
1248
                                        .SingleOrDefaultAsync(job => job.Id == edge.ChildJobId, cancellationToken)
4✔
1249
                                        .ConfigureAwait(false);
4✔
1250
                                if (child is null || IsTerminal(child.State))
4✔
1251
                                        continue;
1252

1253
                                if (edge.Trigger == ContinuationTrigger.AllSucceeded && !parent.Succeeded)
4✔
1254
                                {
NEW
1255
                                        child.State = JobState.Cancelled;
×
NEW
1256
                                        child.RemainingDependencies = 0;
×
NEW
1257
                                        child.CompletedAt = now;
×
NEW
1258
                                        child.WorkerId = null;
×
NEW
1259
                                        child.LeaseExpiresAt = null;
×
NEW
1260
                                        child.ConcurrencyStamp = Guid.NewGuid();
×
NEW
1261
                                        parents.Enqueue((ContinuationParentKind.Job, child.Id, Succeeded: false));
×
NEW
1262
                                        await UpdateBatchForTerminalJobAsync(context, child, now, parents, cancellationToken).ConfigureAwait(false);
×
NEW
1263
                                        continue;
×
1264
                                }
1265

1266
                                if (child.State != JobState.AwaitingContinuation || child.RemainingDependencies <= 0)
4✔
1267
                                        continue;
1268
                                child.RemainingDependencies--;
4✔
1269
                                if (child.RemainingDependencies == 0)
4✔
1270
                                        child.State = child.DueAt <= now ? JobState.Pending : JobState.Scheduled;
4✔
1271
                                child.ConcurrencyStamp = Guid.NewGuid();
4✔
1272
                        }
4✔
1273
                }
4✔
1274
        }
4✔
1275

1276
        private static async Task UpdateBatchForTerminalJobAsync(
1277
                TContext context,
1278
                ImmediateJobEntity job,
1279
                DateTimeOffset now,
1280
                Queue<(ContinuationParentKind Kind, string Id, bool Succeeded)> parents,
1281
                CancellationToken cancellationToken
1282
        )
1283
        {
1284
                if (job.BatchId is not { } batchId)
4✔
NEW
1285
                        return;
×
1286
                var batch = await context.Set<ImmediateJobBatchEntity>()
4✔
1287
                        .SingleAsync(item => item.Id == batchId, cancellationToken)
4✔
1288
                        .ConfigureAwait(false);
4✔
1289
                batch.PendingCount = Math.Max(0, batch.PendingCount - 1);
4✔
1290
                switch (job.State)
4✔
1291
                {
1292
                        case JobState.Succeeded:
1293
                                batch.SucceededCount++;
4✔
1294
                                break;
4✔
1295
                        case JobState.Failed:
NEW
1296
                                batch.FailedCount++;
×
NEW
1297
                                break;
×
1298
                        case JobState.Cancelled:
1299
                                batch.CancelledCount++;
4✔
1300
                                break;
4✔
1301
                        case JobState.AwaitingContinuation:
1302
                        case JobState.AwaitingParameters:
1303
                        case JobState.Scheduled:
1304
                        case JobState.Pending:
1305
                        case JobState.Active:
NEW
1306
                                throw new InvalidOperationException($"Job '{job.Id}' is not terminal.");
×
1307
                        default:
NEW
1308
                                throw new ArgumentOutOfRangeException(nameof(job), job.State, "Unknown job state.");
×
1309
                }
1310

1311
                batch.ConcurrencyStamp = Guid.NewGuid();
4✔
1312
                if (batch.PendingCount != 0)
4✔
1313
                        return;
4✔
1314
                batch.State = GetTerminalBatchState(batch.FailedCount, batch.CancelledCount);
4✔
1315
                batch.CompletedAt = now;
4✔
1316
                parents.Enqueue((ContinuationParentKind.Batch, batch.Id, batch.State == BatchState.Succeeded));
4✔
1317
        }
4✔
1318

1319
        private static async Task EvaluateInitialDependenciesAsync(
1320
                TContext context,
1321
                Dictionary<string, ImmediateJobEntity> jobs,
1322
                ImmediateJobContinuationEntity[] edges,
1323
                DateTimeOffset now,
1324
                CancellationToken cancellationToken
1325
        )
1326
        {
1327
                var externalJobIds = edges
4✔
1328
                        .Where(edge => edge.ParentKind == ContinuationParentKind.Job && !jobs.ContainsKey(edge.ParentId))
4✔
1329
                        .Select(static edge => edge.ParentId)
4✔
1330
                        .Distinct(StringComparer.Ordinal)
4✔
1331
                        .ToArray();
4✔
1332
                var externalBatchIds = edges
4✔
1333
                        .Where(static edge => edge.ParentKind == ContinuationParentKind.Batch)
4✔
NEW
1334
                        .Select(static edge => edge.ParentId)
×
1335
                        .Distinct(StringComparer.Ordinal)
4✔
1336
                        .ToArray();
4✔
1337
                var externalJobs = externalJobIds.Length == 0
4✔
1338
                        ? []
4✔
1339
                        : await context.Set<ImmediateJobEntity>()
4✔
1340
                                .AsNoTracking()
4✔
1341
                                .Where(job => externalJobIds.Contains(job.Id))
4✔
NEW
1342
                                .ToDictionaryAsync(job => job.Id, job => job.State, StringComparer.Ordinal, cancellationToken)
×
1343
                                .ConfigureAwait(false);
4✔
1344
                var externalBatches = externalBatchIds.Length == 0
4✔
1345
                        ? []
4✔
1346
                        : await context.Set<ImmediateJobBatchEntity>()
4✔
1347
                                .AsNoTracking()
4✔
1348
                                .Where(batch => externalBatchIds.Contains(batch.Id))
4✔
NEW
1349
                                .ToDictionaryAsync(batch => batch.Id, batch => batch.State, StringComparer.Ordinal, cancellationToken)
×
1350
                                .ConfigureAwait(false);
4✔
1351
                if (externalJobs.Count != externalJobIds.Length || externalBatches.Count != externalBatchIds.Length)
4✔
1352
                        throw new InvalidOperationException("A continuation parent does not exist.");
4✔
1353

1354
                var incoming = edges.ToLookup(static edge => edge.ChildJobId, StringComparer.Ordinal);
4✔
1355
                var changed = true;
4✔
1356
                while (changed)
4✔
1357
                {
1358
                        changed = false;
4✔
1359
                        foreach (var job in jobs.Values)
4✔
1360
                        {
1361
                                var dependencies = incoming[job.Id];
4✔
1362
                                if (!dependencies.Any() || IsTerminal(job.State))
4✔
1363
                                        continue;
1364
                                var remaining = 0;
4✔
1365
                                var violated = false;
4✔
1366
                                foreach (var edge in dependencies)
4✔
1367
                                {
1368
                                        var (terminal, parentSucceeded) = GetParentState(edge, jobs, externalJobs, externalBatches);
4✔
1369
                                        if (!terminal)
4✔
1370
                                        {
1371
                                                remaining++;
4✔
1372
                                                continue;
4✔
1373
                                        }
1374

NEW
1375
                                        if (edge.Trigger == ContinuationTrigger.AllSucceeded && !parentSucceeded)
×
NEW
1376
                                                violated = true;
×
1377
                                }
1378

1379
                                if (violated)
4✔
1380
                                {
NEW
1381
                                        job.State = JobState.Cancelled;
×
NEW
1382
                                        job.RemainingDependencies = 0;
×
NEW
1383
                                        job.CompletedAt = now;
×
NEW
1384
                                        changed = true;
×
1385
                                }
1386
                                else if (remaining == 0)
4✔
1387
                                {
NEW
1388
                                        job.State = job.DueAt <= now ? JobState.Pending : JobState.Scheduled;
×
NEW
1389
                                        job.RemainingDependencies = 0;
×
1390
                                }
1391
                                else
1392
                                {
1393
                                        job.State = JobState.AwaitingContinuation;
4✔
1394
                                        job.RemainingDependencies = remaining;
4✔
1395
                                }
1396
                        }
1397
                }
1398
        }
4✔
1399

1400
        private static (bool Terminal, bool Succeeded) GetParentState(
1401
                ImmediateJobContinuationEntity edge,
1402
                Dictionary<string, ImmediateJobEntity> jobs,
1403
                Dictionary<string, JobState> externalJobs,
1404
                Dictionary<string, BatchState> externalBatches
1405
        )
1406
        {
1407
                if (edge.ParentKind == ContinuationParentKind.Batch)
4✔
1408
                {
NEW
1409
                        var state = externalBatches[edge.ParentId];
×
NEW
1410
                        return (state != BatchState.Executing, state == BatchState.Succeeded);
×
1411
                }
1412

1413
                var jobState = jobs.TryGetValue(edge.ParentId, out var job) ? job.State : externalJobs[edge.ParentId];
4✔
1414
                return (IsTerminal(jobState), jobState == JobState.Succeeded);
4✔
1415
        }
1416

1417
        private static void ThrowIfCyclic(
1418
                HashSet<string> jobIds,
1419
                IReadOnlyList<ImmediateJobContinuationEntity> edges
1420
        )
1421
        {
1422
                var indegree = jobIds.ToDictionary(static id => id, static _ => 0, StringComparer.Ordinal);
4✔
1423
                var children = new Dictionary<string, List<string>>(StringComparer.Ordinal);
4✔
1424
                foreach (var edge in edges.Where(edge =>
4✔
1425
                        edge.ParentKind == ContinuationParentKind.Job && jobIds.Contains(edge.ParentId)))
4✔
1426
                {
1427
                        indegree[edge.ChildJobId]++;
4✔
1428
                        if (!children.TryGetValue(edge.ParentId, out var values))
4✔
1429
                                children[edge.ParentId] = values = [];
4✔
1430
                        values.Add(edge.ChildJobId);
4✔
1431
                }
1432

1433
                var ready = new Queue<string>(indegree.Where(static pair => pair.Value == 0).Select(static pair => pair.Key));
4✔
1434
                var visited = 0;
4✔
1435
                while (ready.TryDequeue(out var parent))
4✔
1436
                {
1437
                        visited++;
4✔
1438
                        if (!children.TryGetValue(parent, out var values))
4✔
1439
                                continue;
1440
                        foreach (var child in values)
4✔
1441
                        {
1442
                                if (--indegree[child] == 0)
4✔
1443
                                        ready.Enqueue(child);
4✔
1444
                        }
1445
                }
1446

1447
                if (visited != jobIds.Count)
4✔
NEW
1448
                        throw new InvalidOperationException("IJOB018: The continuation graph contains a dependency cycle.");
×
1449
        }
4✔
1450

1451
        private static bool IsTerminal(JobState state) =>
1452
                state is JobState.Succeeded or JobState.Failed or JobState.Cancelled;
4✔
1453

1454
        private static BatchState GetTerminalBatchState(int failed, int cancelled) =>
1455
                failed != 0 ? BatchState.Failed : cancelled != 0 ? BatchState.Cancelled : BatchState.Succeeded;
4✔
1456

1457
        private async ValueTask MutateRecurringAsync(string name, Action<ImmediateRecurringJobEntity> mutate, CancellationToken cancellationToken)
1458
        {
1459
                ArgumentException.ThrowIfNullOrWhiteSpace(name);
×
1460
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
1461
                var schedule = await context.Set<ImmediateRecurringJobEntity>().FindAsync([name], cancellationToken).ConfigureAwait(false);
×
1462
                if (schedule is null)
×
1463
                        return;
1464
                mutate(schedule);
×
1465
                schedule.ConcurrencyStamp = Guid.NewGuid();
×
1466
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1467
        }
×
1468

1469
        private static Task<int> UpdateRecurringAsync(
1470
                TContext context,
1471
                RecurringJobSchedule schedule,
1472
                CancellationToken cancellationToken
1473
        )
1474
        {
1475
                var concurrencyStamp = Guid.NewGuid();
4✔
1476
                return context.Set<ImmediateRecurringJobEntity>()
4✔
1477
                        .Where(entity => entity.Name == schedule.Name && (schedule.IsCodeDefined || !entity.IsCodeDefined))
4✔
1478
                        .ExecuteUpdateAsync(setters => setters
4✔
1479
                                .SetProperty(entity => entity.JobName, schedule.JobName)
4✔
1480
                                .SetProperty(entity => entity.Cron, schedule.Cron)
4✔
1481
                                .SetProperty(entity => entity.TimeZone, schedule.TimeZone)
4✔
1482
                                .SetProperty(entity => entity.IsCodeDefined, schedule.IsCodeDefined)
4✔
1483
                                .SetProperty(entity => entity.NextRunAt, schedule.NextRunAt)
4✔
1484
                                .SetProperty(entity => entity.ConcurrencyStamp, concurrencyStamp),
4✔
1485
                                cancellationToken);
4✔
1486
        }
1487

1488
        private static async Task ThrowIfReplacingCodeDefinedScheduleAsync(
1489
                TContext context,
1490
                RecurringJobSchedule schedule,
1491
                CancellationToken cancellationToken
1492
        )
1493
        {
1494
                if (!schedule.IsCodeDefined && await context.Set<ImmediateRecurringJobEntity>()
4✔
1495
                        .AnyAsync(entity => entity.Name == schedule.Name && entity.IsCodeDefined, cancellationToken)
4✔
1496
                        .ConfigureAwait(false))
4✔
1497
                {
1498
                        throw new InvalidOperationException("Code-defined recurring schedules cannot be replaced by dynamic schedules.");
4✔
1499
                }
1500
        }
4✔
1501

1502
        private static ImmediateJobEntity ToEntity(JobRecord job) => new()
4✔
1503
        {
4✔
1504
                Id = job.Id,
4✔
1505
                QueueName = job.QueueName,
4✔
1506
                JobName = job.JobName,
4✔
1507
                Payload = job.Payload,
4✔
1508
                Context = job.Context,
4✔
1509
                State = job.State,
4✔
1510
                DueAt = job.DueAt,
4✔
1511
                CreatedAt = job.CreatedAt,
4✔
1512
                Attempt = job.Attempt,
4✔
1513
                WorkerId = job.WorkerId,
4✔
1514
                LeaseExpiresAt = job.LeaseExpiresAt,
4✔
1515
                LastError = job.LastError,
4✔
1516
                CompletedAt = job.CompletedAt,
4✔
1517
                RecurringKey = job.RecurringKey,
4✔
1518
                TraceParent = job.TraceParent,
4✔
1519
                TraceState = job.TraceState,
4✔
1520
                BatchId = job.BatchId,
4✔
1521
                RemainingDependencies = job.RemainingDependencies,
4✔
1522
                ConcurrencyStamp = Guid.NewGuid(),
4✔
1523
        };
4✔
1524

1525
        private static ImmediateJobContinuationEntity ToEntity(JobContinuationEdge edge)
1526
        {
1527
                var hasJobParent = edge.ParentJobId is not null;
4✔
1528
                var hasBatchParent = edge.ParentBatchId is not null;
4✔
1529
                if (hasJobParent == hasBatchParent)
4✔
NEW
1530
                        throw new InvalidOperationException("A continuation edge must identify exactly one parent job or batch.");
×
1531
                return new()
4✔
1532
                {
4✔
1533
                        ChildJobId = edge.ChildJobId,
4✔
1534
                        ParentKind = hasJobParent ? ContinuationParentKind.Job : ContinuationParentKind.Batch,
4✔
1535
                        ParentId = edge.ParentJobId ?? edge.ParentBatchId!,
4✔
1536
                        Trigger = edge.Trigger,
4✔
1537
                };
4✔
1538
        }
1539

1540
        private static ImmediateJobEntity Copy(ImmediateJobEntity job) => new()
4✔
1541
        {
4✔
1542
                Id = job.Id,
4✔
1543
                QueueName = job.QueueName,
4✔
1544
                JobName = job.JobName,
4✔
1545
                Payload = job.Payload,
4✔
1546
                Context = job.Context,
4✔
1547
                State = job.State,
4✔
1548
                DueAt = job.DueAt,
4✔
1549
                CreatedAt = job.CreatedAt,
4✔
1550
                Attempt = job.Attempt,
4✔
1551
                WorkerId = job.WorkerId,
4✔
1552
                LeaseExpiresAt = job.LeaseExpiresAt,
4✔
1553
                LastError = job.LastError,
4✔
1554
                CompletedAt = job.CompletedAt,
4✔
1555
                RecurringKey = job.RecurringKey,
4✔
1556
                TraceParent = job.TraceParent,
4✔
1557
                TraceState = job.TraceState,
4✔
1558
                BatchId = job.BatchId,
4✔
1559
                RemainingDependencies = job.RemainingDependencies,
4✔
1560
                ConcurrencyStamp = job.ConcurrencyStamp,
4✔
1561
        };
4✔
1562

1563
        private static JobRecord ToRecord(ImmediateJobEntity job) => new()
4✔
1564
        {
4✔
1565
                Id = job.Id,
4✔
1566
                QueueName = job.QueueName,
4✔
1567
                JobName = job.JobName,
4✔
1568
                Payload = job.Payload,
4✔
1569
                Context = job.Context,
4✔
1570
                State = job.State,
4✔
1571
                DueAt = job.DueAt,
4✔
1572
                CreatedAt = job.CreatedAt,
4✔
1573
                Attempt = job.Attempt,
4✔
1574
                WorkerId = job.WorkerId,
4✔
1575
                LeaseExpiresAt = job.LeaseExpiresAt,
4✔
1576
                LastError = job.LastError,
4✔
1577
                CompletedAt = job.CompletedAt,
4✔
1578
                RecurringKey = job.RecurringKey,
4✔
1579
                TraceParent = job.TraceParent,
4✔
1580
                TraceState = job.TraceState,
4✔
1581
                BatchId = job.BatchId,
4✔
1582
                RemainingDependencies = job.RemainingDependencies,
4✔
1583
        };
4✔
1584

1585
        private static BatchStatus ToStatus(ImmediateJobBatchEntity batch) => new(
4✔
1586
                batch.Id,
4✔
1587
                batch.State,
4✔
1588
                batch.TotalJobs,
4✔
1589
                batch.SucceededCount,
4✔
1590
                batch.FailedCount,
4✔
1591
                batch.CancelledCount,
4✔
1592
                batch.PendingCount,
4✔
1593
                batch.CreatedAt,
4✔
1594
                batch.StartedAt,
4✔
1595
                batch.CompletedAt,
4✔
1596
                batch.TotalJobs == 0 ? 1 : (double)(batch.TotalJobs - batch.PendingCount) / batch.TotalJobs
4✔
1597
        );
4✔
1598

1599
        private static BatchGraphEdge ToGraphEdge(ImmediateJobContinuationEntity edge) => new(
4✔
1600
                edge.ChildJobId,
4✔
1601
                edge.ParentKind == ContinuationParentKind.Job ? edge.ParentId : null,
4✔
1602
                edge.ParentKind == ContinuationParentKind.Batch ? edge.ParentId : null,
4✔
1603
                edge.Trigger
4✔
1604
        );
4✔
1605

1606
        private static ImmediateRecurringJobEntity ToEntity(RecurringJobSchedule schedule) => new()
4✔
1607
        {
4✔
1608
                Name = schedule.Name,
4✔
1609
                JobName = schedule.JobName,
4✔
1610
                Cron = schedule.Cron,
4✔
1611
                TimeZone = schedule.TimeZone,
4✔
1612
                IsCodeDefined = schedule.IsCodeDefined,
4✔
1613
                IsPaused = schedule.IsPaused,
4✔
1614
                NextRunAt = schedule.NextRunAt,
4✔
1615
                LastRunAt = schedule.LastRunAt,
4✔
1616
                ConcurrencyStamp = Guid.NewGuid(),
4✔
1617
        };
4✔
1618
}
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