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

ImmediatePlatform / Immediate.Jobs / 30304771671

27 Jul 2026 08:56PM UTC coverage: 65.995%. First build
30304771671

Pull #33

github

web-flow
Merge 46c3047f1 into e9a147742
Pull Request #33: Improve Storage API

1521 of 2481 new or added lines in 28 files covered. (61.31%)

4326 of 6555 relevant lines covered (66.0%)

2.22 hits per line

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

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

4
namespace Immediate.Jobs.EntityFrameworkCore;
5

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

16
        /// <inheritdoc />
17
        public 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 ImmediateJobException("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);
5✔
28
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
29
                _ = context.Set<ImmediateJobEntity>().Add(ToEntity(job));
5✔
30
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
31
        }
5✔
32

33
        /// <inheritdoc />
34
        public async ValueTask EnqueueContinuationAsync(
35
                JobRecord job,
36
                IReadOnlyList<JobContinuationEdge> edges,
37
                CancellationToken cancellationToken = default
38
        )
39
        {
40
                ArgumentNullException.ThrowIfNull(job);
5✔
41
                ArgumentNullException.ThrowIfNull(edges);
5✔
42
                await ExecuteGraphInsertAsync(batch: null, [job], edges, cancellationToken).ConfigureAwait(false);
5✔
43
        }
4✔
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);
5✔
54
                ArgumentNullException.ThrowIfNull(jobs);
5✔
55
                ArgumentNullException.ThrowIfNull(edges);
5✔
56
                if (jobs.Count == 0)
5✔
NEW
57
                        throw new ImmediateJobException("IJOB016: An atomic batch cannot be committed without jobs.");
×
58
                await ExecuteGraphInsertAsync(batch, jobs, edges, cancellationToken).ConfigureAwait(false);
5✔
59
        }
5✔
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);
5✔
69
                var strategy = strategyContext.Database.CreateExecutionStrategy();
5✔
70
                await strategy.ExecuteAsync(
5✔
71
                        operationCancellationToken => InsertGraphCoreAsync(batch, jobs, edges, operationCancellationToken),
5✔
72
                        cancellationToken
5✔
73
                ).ConfigureAwait(false);
5✔
74
        }
5✔
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);
5✔
84
                if (jobIds.Count != jobs.Count)
5✔
NEW
85
                        throw new ImmediateJobException("A batch or continuation insert contains duplicate job identifiers.");
×
86
                if (batch is not null && jobs.Any(job => job.BatchId != batch.Id))
5✔
NEW
87
                        throw new ImmediateJobException("Every atomic batch member must carry the committed batch identifier.");
×
88

89
                var edgeEntities = edges.Select(ToEntity).ToArray();
5✔
90
                if (edgeEntities.Any(edge => !jobIds.Contains(edge.ChildJobId)))
5✔
NEW
91
                        throw new ImmediateJobException("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)
5✔
NEW
93
                        throw new ImmediateJobException("Duplicate continuation edges are not allowed.");
×
94
                ThrowIfCyclic(jobIds, edgeEntities);
5✔
95

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

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

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

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

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

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

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

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

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

205
                return acquired;
5✔
206
        }
5✔
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);
5✔
245
                foreach (var candidate in candidates)
5✔
246
                {
247
                        await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
248
                        var entity = Copy(candidate);
5✔
249
                        _ = context.Attach(entity);
5✔
250
                        entity.State = JobState.Active;
5✔
251
                        entity.WorkerId = workerId;
5✔
252
                        entity.LeaseExpiresAt = now + lease;
5✔
253
                        entity.Attempt++;
5✔
254
                        entity.CompletedAt = null;
5✔
255
                        entity.ExecutionTraceId = null;
5✔
256
                        entity.ExecutionSpanId = null;
5✔
257
                        entity.ExecutionStartedAt = null;
5✔
258
                        entity.ConcurrencyStamp = Guid.NewGuid();
5✔
259
                        if (entity.BatchId is { } batchId)
5✔
260
                        {
261
                                var batch = await context.Set<ImmediateJobBatchEntity>()
5✔
262
                                        .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
5✔
263
                                        .ConfigureAwait(false);
5✔
264
                                if (batch is not null && batch.StartedAt is null)
5✔
265
                                {
266
                                        batch.StartedAt = now;
5✔
267
                                        batch.ConcurrencyStamp = Guid.NewGuid();
5✔
268
                                }
269
                        }
270

271
                        try
272
                        {
273
                                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
274
                                acquired.Add(ToRecord(entity));
5✔
275
                        }
5✔
276
                        catch (DbUpdateConcurrencyException)
1✔
277
                        {
278
                                // Another scheduler claimed or changed this candidate first.
279
                        }
1✔
280
                }
5✔
281

282
                return acquired;
5✔
283
        }
5✔
284

285
        /// <inheritdoc />
286
        public ValueTask SetExecutionTelemetryAsync(
287
                string jobId,
288
                string workerId,
289
                string? traceId,
290
                string? spanId,
291
                DateTimeOffset startedAt,
292
                CancellationToken cancellationToken = default
293
        ) => MutateOwnedAsync(jobId, workerId, job =>
1✔
294
        {
1✔
295
                job.ExecutionTraceId = traceId;
1✔
296
                job.ExecutionSpanId = spanId;
1✔
297
                job.ExecutionStartedAt = startedAt;
1✔
298
        }, cancellationToken);
1✔
299

300
        /// <inheritdoc />
301
        public ValueTask RenewLeaseAsync(string jobId, string workerId, TimeSpan lease, CancellationToken cancellationToken = default)
302
        {
303
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lease, TimeSpan.Zero);
×
304
                return MutateOwnedAsync(jobId, workerId, job => job.LeaseExpiresAt = _timeProvider.GetUtcNow() + lease, cancellationToken);
×
305
        }
306

307
        /// <inheritdoc />
308
        public ValueTask CompleteAsync(string jobId, string workerId, CancellationToken cancellationToken = default) =>
309
                CompleteWithContinuationsAsync(jobId, workerId, [], cancellationToken);
5✔
310

311
        /// <inheritdoc />
312
        public ValueTask CompleteWithContinuationsAsync(
313
                string jobId,
314
                string workerId,
315
                IReadOnlyList<JobContinuationAddition> additions,
316
                CancellationToken cancellationToken = default
317
        )
318
        {
319
                ArgumentNullException.ThrowIfNull(additions);
5✔
320
                return MutateOwnedWithDependenciesAsync(
5✔
321
                        jobId,
5✔
322
                        workerId,
5✔
323
                        error: null,
5✔
324
                        nextRetryAt: null,
5✔
325
                        succeeded: true,
5✔
326
                        additions,
5✔
327
                        cancellationToken
5✔
328
                );
5✔
329
        }
330

331
        /// <inheritdoc />
332
        public async ValueTask AddBatchJobAsync(
333
                string currentJobId,
334
                JobRecord job,
335
                ContinuationOptions options,
336
                CancellationToken cancellationToken = default
337
        )
338
        {
339
                ArgumentNullException.ThrowIfNull(job);
×
340
                if (options == ContinuationOptions.Detached)
×
NEW
341
                        throw new ImmediateJobException("IJOB020: AddToBatchAsync cannot create a detached job.");
×
342
                const int MaxConcurrencyAttempts = 5;
343
                for (var attempt = 0; attempt < MaxConcurrencyAttempts; attempt++)
×
344
                {
345
                        try
346
                        {
347
                                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
348
                                var strategy = strategyContext.Database.CreateExecutionStrategy();
×
349
                                await strategy.ExecuteAsync(
×
350
                                        operationCancellationToken => AddBatchJobCoreAsync(
×
351
                                                currentJobId,
×
352
                                                job,
×
353
                                                options,
×
354
                                                operationCancellationToken
×
355
                                        ),
×
356
                                        cancellationToken
×
357
                                ).ConfigureAwait(false);
×
358
                                return;
×
359
                        }
×
360
                        catch (DbUpdateConcurrencyException) when (attempt + 1 < MaxConcurrencyAttempts)
×
361
                        {
362
                        }
×
363
                }
364
        }
×
365

366
        /// <inheritdoc />
367
        public ValueTask FailAsync(
368
                string jobId,
369
                string workerId,
370
                string error,
371
                DateTimeOffset? nextRetryAt,
372
                CancellationToken cancellationToken = default
373
        ) => MutateOwnedWithDependenciesAsync(jobId, workerId, error, nextRetryAt, succeeded: false, [], cancellationToken);
4✔
374

375
        /// <inheritdoc />
376
        public async ValueTask UpsertRecurringAsync(RecurringJobSchedule schedule, CancellationToken cancellationToken = default)
377
        {
378
                ArgumentNullException.ThrowIfNull(schedule);
5✔
379
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
380
                if (await UpdateRecurringAsync(context, schedule, cancellationToken).ConfigureAwait(false) != 0)
5✔
381
                        return;
382
                await ThrowIfReplacingCodeDefinedScheduleAsync(context, schedule, cancellationToken).ConfigureAwait(false);
5✔
383

384
                _ = context.Add(ToEntity(schedule));
5✔
385
                try
386
                {
387
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
388
                }
5✔
389
                catch (DbUpdateException)
×
390
                {
391
                        // A competing node inserted the same schedule after our update attempt.
392
                        await using var retryContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
393
                        if (await UpdateRecurringAsync(retryContext, schedule, cancellationToken).ConfigureAwait(false) != 0)
×
394
                                return;
395
                        await ThrowIfReplacingCodeDefinedScheduleAsync(retryContext, schedule, cancellationToken).ConfigureAwait(false);
×
396
                        throw;
×
397
                }
×
398
        }
5✔
399

400
        /// <inheritdoc />
401
        public async ValueTask RemoveObsoleteCodeDefinedRecurringAsync(
402
                IReadOnlyCollection<string> activeScheduleNames,
403
                CancellationToken cancellationToken = default
404
        )
405
        {
406
                ArgumentNullException.ThrowIfNull(activeScheduleNames);
4✔
407
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
408
                var schedules = context.Set<ImmediateRecurringJobEntity>()
4✔
409
                        .Where(schedule => schedule.IsCodeDefined);
4✔
410
                if (activeScheduleNames.Count != 0)
4✔
411
                        schedules = schedules.Where(schedule => !activeScheduleNames.Contains(schedule.Name));
4✔
412
                _ = await schedules.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
4✔
413
        }
4✔
414

415
        /// <inheritdoc />
416
        public async ValueTask RemoveRecurringAsync(string name, CancellationToken cancellationToken = default)
417
        {
418
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
419
                var entity = await context.Set<ImmediateRecurringJobEntity>()
×
420
                        .SingleOrDefaultAsync(schedule => schedule.Name == name && !schedule.IsCodeDefined, cancellationToken)
×
421
                        .ConfigureAwait(false);
×
422
                if (entity is not null)
×
423
                {
424
                        _ = context.Remove(entity);
×
425
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
426
                }
427
        }
×
428

429
        /// <inheritdoc />
430
        public ValueTask PauseRecurringAsync(string name, CancellationToken cancellationToken = default)
431
                => MutateRecurringAsync(name, schedule => schedule.IsPaused = true, cancellationToken);
×
432

433
        /// <inheritdoc />
434
        public ValueTask ResumeRecurringAsync(string name, CancellationToken cancellationToken = default)
435
                => MutateRecurringAsync(name, schedule => schedule.IsPaused = false, cancellationToken);
×
436

437
        /// <inheritdoc />
438
        public async ValueTask<IReadOnlyList<RecurringJobSchedule>> GetDueRecurringAsync(DateTimeOffset now, int batchSize, CancellationToken cancellationToken = default)
439
        {
440
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(batchSize, 0);
×
441
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
442
                return await context.Set<ImmediateRecurringJobEntity>()
×
443
                        .AsNoTracking()
×
444
                        .Where(schedule => !schedule.IsPaused && schedule.NextRunAt <= now)
×
445
                        .OrderBy(schedule => schedule.NextRunAt)
×
446
                        .Take(batchSize)
×
447
                        .Select(schedule => new RecurringJobSchedule
×
448
                        {
×
449
                                Name = schedule.Name,
×
450
                                JobName = schedule.JobName,
×
451
                                Cron = schedule.Cron,
×
452
                                TimeZone = schedule.TimeZone,
×
453
                                IsCodeDefined = schedule.IsCodeDefined,
×
454
                                IsPaused = schedule.IsPaused,
×
455
                                NextRunAt = schedule.NextRunAt,
×
456
                                LastRunAt = schedule.LastRunAt,
×
457
                        })
×
458
                        .ToListAsync(cancellationToken)
×
459
                        .ConfigureAwait(false);
×
460
        }
×
461

462
        /// <inheritdoc />
463
        public async ValueTask<bool> MaterializeRecurringAsync(
464
                RecurringJobSchedule schedule,
465
                JobRecord job,
466
                DateTimeOffset nextRunAt,
467
                CancellationToken cancellationToken = default
468
        )
469
        {
470
                ArgumentNullException.ThrowIfNull(schedule);
5✔
471
                ArgumentNullException.ThrowIfNull(job);
5✔
472
                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
473
                var strategy = strategyContext.Database.CreateExecutionStrategy();
5✔
474
                return await strategy.ExecuteAsync(
5✔
475
                        operationCancellationToken => MaterializeRecurringCoreAsync(
5✔
476
                                schedule,
5✔
477
                                job,
5✔
478
                                nextRunAt,
5✔
479
                                operationCancellationToken
5✔
480
                        ),
5✔
481
                        cancellationToken
5✔
482
                ).ConfigureAwait(false);
5✔
483
        }
5✔
484

485
        private async Task<bool> MaterializeRecurringCoreAsync(
486
                RecurringJobSchedule schedule,
487
                JobRecord job,
488
                DateTimeOffset nextRunAt,
489
                CancellationToken cancellationToken
490
        )
491
        {
492
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
493
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
494
                var entity = await context.Set<ImmediateRecurringJobEntity>()
5✔
495
                        .SingleOrDefaultAsync(item => item.Name == schedule.Name, cancellationToken)
5✔
496
                        .ConfigureAwait(false);
5✔
497
                if (entity is null || entity.IsPaused || entity.NextRunAt != schedule.NextRunAt)
5✔
498
                        return false;
1✔
499

500
                entity.LastRunAt = schedule.NextRunAt;
5✔
501
                entity.NextRunAt = nextRunAt;
5✔
502
                entity.ConcurrencyStamp = Guid.NewGuid();
5✔
503
                _ = context.Add(ToEntity(job));
5✔
504
                try
505
                {
506
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
507
                        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
5✔
508
                        return true;
5✔
509
                }
510
                catch (DbUpdateException)
511
                {
512
                        await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
×
513
                        return false;
×
514
                }
515
        }
5✔
516

517
        /// <inheritdoc />
518
        public async ValueTask<JobMonitoringSnapshot> GetMonitoringSnapshotAsync(CancellationToken cancellationToken = default)
519
        {
520
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
521
                var rawCounts = await context.Set<ImmediateJobEntity>()
5✔
522
                        .AsNoTracking()
5✔
523
                        .GroupBy(job => job.State)
5✔
524
                        .Select(group => new { State = group.Key, Count = group.LongCount() })
5✔
525
                        .ToListAsync(cancellationToken)
5✔
526
                        .ConfigureAwait(false);
5✔
527
                var counts = Enum.GetValues<JobState>().ToDictionary(static state => state, static _ => 0L);
5✔
528
                foreach (var item in rawCounts)
5✔
529
                        counts[item.State] = item.Count;
5✔
530

531
                var recurring = await context.Set<ImmediateRecurringJobEntity>()
5✔
532
                        .AsNoTracking()
5✔
533
                        .OrderBy(schedule => schedule.Name)
5✔
534
                        .Select(schedule => new RecurringJobSchedule
5✔
535
                        {
5✔
536
                                Name = schedule.Name,
5✔
537
                                JobName = schedule.JobName,
5✔
538
                                Cron = schedule.Cron,
5✔
539
                                TimeZone = schedule.TimeZone,
5✔
540
                                IsCodeDefined = schedule.IsCodeDefined,
5✔
541
                                IsPaused = schedule.IsPaused,
5✔
542
                                NextRunAt = schedule.NextRunAt,
5✔
543
                                LastRunAt = schedule.LastRunAt,
5✔
544
                        })
5✔
545
                        .ToListAsync(cancellationToken)
5✔
546
                        .ConfigureAwait(false);
5✔
547
                var servers = await context.Set<ImmediateJobServerEntity>()
5✔
548
                        .AsNoTracking()
5✔
549
                        .OrderBy(server => server.WorkerId)
5✔
550
                        .Select(server => new JobServerSnapshot(server.WorkerId, server.LastHeartbeat, server.ActiveWorkers, server.MaxWorkers))
5✔
551
                        .ToListAsync(cancellationToken)
5✔
552
                        .ConfigureAwait(false);
5✔
553
                return new(_timeProvider.GetUtcNow(), counts, recurring, servers)
5✔
554
                {
5✔
555
                        Capabilities = this.GetCapabilities(),
5✔
556
                };
5✔
557
        }
5✔
558

559
        /// <inheritdoc />
560
        public async ValueTask<IReadOnlyList<JobRecord>> QueryJobsAsync(JobQuery query, CancellationToken cancellationToken = default)
561
        {
562
                ArgumentNullException.ThrowIfNull(query);
5✔
563
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
5✔
564
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
5✔
565
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
566
                IQueryable<ImmediateJobEntity> jobs = context.Set<ImmediateJobEntity>().AsNoTracking();
5✔
567
                if (query.Id is { } id)
5✔
568
                        jobs = jobs.Where(job => job.Id == id);
1✔
569
                if (query.State is { } state)
5✔
570
                        jobs = jobs.Where(job => job.State == state);
4✔
571
                if (!string.IsNullOrWhiteSpace(query.QueueName))
5✔
572
                        jobs = jobs.Where(job => job.QueueName == query.QueueName);
×
573
                if (!string.IsNullOrWhiteSpace(query.Search))
5✔
574
                {
575
                        var search = query.Search.ToUpperInvariant();
×
576
                        // The parameterless form is intentionally used because relational providers translate it to SQL.
577
#pragma warning disable CA1304, CA1311, CA1862
578
                        jobs = jobs.Where(job => job.JobName.ToUpper().Contains(search));
×
579
#pragma warning restore CA1304, CA1311, CA1862
580
                }
581

582
                var entities = await jobs.OrderByDescending(job => job.CreatedAt)
5✔
583
                        .Skip(query.Skip)
5✔
584
                        .Take(query.Take)
5✔
585
                        .ToListAsync(cancellationToken)
5✔
586
                        .ConfigureAwait(false);
5✔
587
                return [.. entities.Select(ToRecord)];
5✔
588
        }
5✔
589

590
        /// <inheritdoc />
591
        public async ValueTask<BatchStatus?> GetBatchStatusAsync(
592
                string batchId,
593
                CancellationToken cancellationToken = default
594
        )
595
        {
596
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
5✔
597
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
598
                var batch = await context.Set<ImmediateJobBatchEntity>()
5✔
599
                        .AsNoTracking()
5✔
600
                        .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
5✔
601
                        .ConfigureAwait(false);
5✔
602
                return batch is null ? null : ToStatus(batch);
5✔
603
        }
5✔
604

605
        /// <inheritdoc />
606
        public async ValueTask<IReadOnlyList<BatchStatus>> QueryBatchesAsync(
607
                JobBatchQuery query,
608
                CancellationToken cancellationToken = default
609
        )
610
        {
611
                ArgumentNullException.ThrowIfNull(query);
×
612
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
×
613
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
×
614
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
615
                IQueryable<ImmediateJobBatchEntity> batches = context.Set<ImmediateJobBatchEntity>().AsNoTracking();
×
616
                if (query.State is { } state)
×
617
                        batches = batches.Where(batch => batch.State == state);
×
618
                var entities = await batches.OrderByDescending(batch => batch.CreatedAt)
×
619
                        .ThenBy(batch => batch.Id)
×
620
                        .Skip(query.Skip)
×
621
                        .Take(query.Take)
×
622
                        .ToListAsync(cancellationToken)
×
623
                        .ConfigureAwait(false);
×
624
                return [.. entities.Select(ToStatus)];
×
625
        }
×
626

627
        /// <inheritdoc />
628
        public async ValueTask<IReadOnlyList<BatchMemberStatus>> QueryBatchMembersAsync(
629
                string batchId,
630
                BatchMemberQuery query,
631
                CancellationToken cancellationToken = default
632
        )
633
        {
634
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
635
                ArgumentNullException.ThrowIfNull(query);
4✔
636
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
4✔
637
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
4✔
638
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
639
                IQueryable<ImmediateJobEntity> jobs = context.Set<ImmediateJobEntity>()
4✔
640
                        .AsNoTracking()
4✔
641
                        .Where(job => job.BatchId == batchId);
4✔
642
                if (query.State is { } state)
4✔
643
                        jobs = jobs.Where(job => job.State == state);
×
644
                return await jobs.OrderBy(job => job.CreatedAt)
4✔
645
                        .ThenBy(job => job.Id)
4✔
646
                        .Skip(query.Skip)
4✔
647
                        .Take(query.Take)
4✔
648
                        .Select(job => new BatchMemberStatus(
4✔
649
                                job.Id,
4✔
650
                                job.JobName,
4✔
651
                                job.QueueName,
4✔
652
                                job.State,
4✔
653
                                job.Attempt,
4✔
654
                                job.CreatedAt,
4✔
655
                                job.CompletedAt,
4✔
656
                                job.LastError
4✔
657
                        ))
4✔
658
                        .ToListAsync(cancellationToken)
4✔
659
                        .ConfigureAwait(false);
4✔
660
        }
4✔
661

662
        /// <inheritdoc />
663
        public async ValueTask<BatchGraph?> GetBatchGraphAsync(
664
                string batchId,
665
                CancellationToken cancellationToken = default
666
        )
667
        {
668
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
669
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
670
                if (!await context.Set<ImmediateJobBatchEntity>()
4✔
671
                        .AnyAsync(batch => batch.Id == batchId, cancellationToken)
4✔
672
                        .ConfigureAwait(false))
4✔
673
                {
674
                        return null;
4✔
675
                }
676

677
                var jobs = await context.Set<ImmediateJobEntity>()
×
678
                        .AsNoTracking()
×
679
                        .Where(job => job.BatchId == batchId)
×
680
                        .OrderBy(job => job.CreatedAt)
×
681
                        .ThenBy(job => job.Id)
×
682
                        .Select(job => new BatchGraphNode(job.Id, job.JobName, job.State))
×
683
                        .ToListAsync(cancellationToken)
×
684
                        .ConfigureAwait(false);
×
685
                var ids = jobs.Select(static job => job.JobId).ToArray();
×
686
                var edges = ids.Length == 0
×
687
                        ? []
×
688
                        : await context.Set<ImmediateJobContinuationEntity>()
×
689
                                .AsNoTracking()
×
690
                                .Where(edge => ids.Contains(edge.ChildJobId))
×
691
                                .OrderBy(edge => edge.ChildJobId)
×
692
                                .ThenBy(edge => edge.ParentKind)
×
693
                                .ThenBy(edge => edge.ParentId)
×
694
                                .ToListAsync(cancellationToken)
×
695
                                .ConfigureAwait(false);
×
696
                return new(batchId, jobs, [.. edges.Select(ToGraphEdge)]);
4✔
697
        }
4✔
698

699
        /// <inheritdoc />
700
        public async ValueTask<JobStatus?> GetJobStatusAsync(
701
                string jobId,
702
                CancellationToken cancellationToken = default
703
        )
704
        {
705
                ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
5✔
706
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
707
                var job = await context.Set<ImmediateJobEntity>()
5✔
708
                        .AsNoTracking()
5✔
709
                        .SingleOrDefaultAsync(item => item.Id == jobId, cancellationToken)
5✔
710
                        .ConfigureAwait(false);
5✔
711
                if (job is null)
5✔
712
                        return null;
1✔
713
                var edges = await context.Set<ImmediateJobContinuationEntity>()
4✔
714
                        .AsNoTracking()
4✔
715
                        .Where(edge => edge.ChildJobId == jobId)
4✔
716
                        .OrderBy(edge => edge.ParentKind)
4✔
717
                        .ThenBy(edge => edge.ParentId)
4✔
718
                        .ToListAsync(cancellationToken)
4✔
719
                        .ConfigureAwait(false);
4✔
720
                return new(
5✔
721
                        job.Id,
5✔
722
                        job.JobName,
5✔
723
                        job.QueueName,
5✔
724
                        job.State,
5✔
725
                        job.Attempt,
5✔
726
                        MaxAttempts: 0,
5✔
727
                        job.CreatedAt,
5✔
728
                        job.DueAt,
5✔
729
                        job.CompletedAt,
5✔
730
                        job.LastError,
5✔
731
                        job.BatchId,
5✔
732
                        [.. edges.Select(ToGraphEdge)]
5✔
733
                );
5✔
734
        }
5✔
735

736
        /// <inheritdoc />
737
        public ValueTask CancelBatchAsync(string batchId, CancellationToken cancellationToken = default)
738
        {
739
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
740
                return ExecuteWithStrategyAsync(
4✔
741
                        operationCancellationToken => CancelBatchCoreAsync(batchId, operationCancellationToken),
4✔
742
                        cancellationToken
4✔
743
                );
4✔
744
        }
745

746
        private async Task CancelBatchCoreAsync(string batchId, CancellationToken cancellationToken)
747
        {
748
                var now = _timeProvider.GetUtcNow();
4✔
749
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
750
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
4✔
751
                var batch = await context.Set<ImmediateJobBatchEntity>()
4✔
752
                        .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
4✔
753
                        .ConfigureAwait(false)
4✔
754
                        ?? throw new KeyNotFoundException($"Batch '{batchId}' was not found.");
4✔
755
                if (batch.State != BatchState.Executing)
4✔
NEW
756
                        throw new ImmediateJobException("Only an executing batch can be cancelled.");
×
757

758
                var jobs = await context.Set<ImmediateJobEntity>()
4✔
759
                        .Where(job => job.BatchId == batchId)
4✔
760
                        .ToListAsync(cancellationToken)
4✔
761
                        .ConfigureAwait(false);
4✔
762
                foreach (var job in jobs)
4✔
763
                {
764
                        if (IsTerminal(job.State))
4✔
765
                                continue;
766
                        job.State = JobState.Cancelled;
4✔
767
                        job.CompletedAt = now;
4✔
768
                        job.WorkerId = null;
4✔
769
                        job.LeaseExpiresAt = null;
4✔
770
                        job.ConcurrencyStamp = Guid.NewGuid();
4✔
771
                        await PropagateTerminalAsync(context, job, now, cancellationToken).ConfigureAwait(false);
4✔
772
                }
773

774
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
775
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
4✔
776
        }
4✔
777

778
        /// <inheritdoc />
779
        public ValueTask DeleteBatchAsync(string batchId, CancellationToken cancellationToken = default)
780
        {
781
                ArgumentException.ThrowIfNullOrWhiteSpace(batchId);
4✔
782
                return ExecuteWithStrategyAsync(
4✔
783
                        operationCancellationToken => DeleteBatchCoreAsync(batchId, operationCancellationToken),
4✔
784
                        cancellationToken
4✔
785
                );
4✔
786
        }
787

788
        private async Task DeleteBatchCoreAsync(string batchId, CancellationToken cancellationToken)
789
        {
790
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
791
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
4✔
792
                var batch = await context.Set<ImmediateJobBatchEntity>()
4✔
793
                        .SingleOrDefaultAsync(item => item.Id == batchId, cancellationToken)
4✔
794
                        .ConfigureAwait(false)
4✔
795
                        ?? throw new KeyNotFoundException($"Batch '{batchId}' was not found.");
4✔
796
                if (batch.State == BatchState.Executing)
4✔
NEW
797
                        throw new ImmediateJobException("Only a terminal batch can be deleted.");
×
798

799
                var jobs = await context.Set<ImmediateJobEntity>()
4✔
800
                        .Where(job => job.BatchId == batchId)
4✔
801
                        .ToListAsync(cancellationToken)
4✔
802
                        .ConfigureAwait(false);
4✔
803
                var jobIds = jobs.Select(static job => job.Id).ToArray();
4✔
804
                var edges = await context.Set<ImmediateJobContinuationEntity>()
4✔
805
                        .Where(edge => jobIds.Contains(edge.ChildJobId)
4✔
806
                                || edge.ParentKind == ContinuationParentKind.Job && jobIds.Contains(edge.ParentId)
4✔
807
                                || edge.ParentKind == ContinuationParentKind.Batch && edge.ParentId == batchId)
4✔
808
                        .ToListAsync(cancellationToken)
4✔
809
                        .ConfigureAwait(false);
4✔
810
                context.RemoveRange(edges);
4✔
811
                context.RemoveRange(jobs);
4✔
812
                _ = context.Remove(batch);
4✔
813
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
814
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
4✔
815
        }
4✔
816

817
        /// <inheritdoc />
818
        public ValueTask RetryAsync(string jobId, CancellationToken cancellationToken = default)
819
        {
820
                return ExecuteWithStrategyAsync(
×
821
                        operationCancellationToken => RetryCoreAsync(jobId, operationCancellationToken),
×
822
                        cancellationToken
×
823
                );
×
824
        }
825

826
        private async Task RetryCoreAsync(string jobId, CancellationToken cancellationToken)
827
        {
828
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
829
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
×
830
                var job = await context.Set<ImmediateJobEntity>()
×
831
                        .SingleOrDefaultAsync(item => item.Id == jobId && item.State == JobState.Failed, cancellationToken)
×
832
                        .ConfigureAwait(false);
×
833
                if (job is null)
×
834
                        return;
835
                if (job.BatchId is { } batchId)
×
836
                {
837
                        var batch = await context.Set<ImmediateJobBatchEntity>()
×
838
                                .SingleAsync(item => item.Id == batchId, cancellationToken)
×
839
                                .ConfigureAwait(false);
×
840
                        batch.PendingCount++;
×
841
                        batch.FailedCount = Math.Max(0, batch.FailedCount - 1);
×
842
                        batch.State = BatchState.Executing;
×
843
                        batch.CompletedAt = null;
×
844
                        batch.ConcurrencyStamp = Guid.NewGuid();
×
845
                }
846

847
                job.State = JobState.Pending;
×
848
                job.DueAt = _timeProvider.GetUtcNow();
×
849
                job.WorkerId = null;
×
850
                job.LeaseExpiresAt = null;
×
851
                job.CompletedAt = null;
×
852
                job.LastError = null;
×
853
                job.ConcurrencyStamp = Guid.NewGuid();
×
854
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
855
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
×
856
        }
×
857

858
        /// <inheritdoc />
859
        public async ValueTask DeleteAsync(string jobId, CancellationToken cancellationToken = default)
860
        {
861
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
862
                var job = await context.Set<ImmediateJobEntity>()
×
863
                        .SingleOrDefaultAsync(item => item.Id == jobId
×
864
                                && (item.State == JobState.Succeeded || item.State == JobState.Failed || item.State == JobState.Cancelled), cancellationToken)
×
865
                        .ConfigureAwait(false);
×
866
                if (job is not null)
×
867
                {
868
                        if (job.BatchId is not null)
×
NEW
869
                                throw new ImmediateJobException("Batch members are deleted with their batch so the workflow remains coherent.");
×
870
                        var outgoing = await context.Set<ImmediateJobContinuationEntity>()
×
871
                                .Where(edge => edge.ParentKind == ContinuationParentKind.Job && edge.ParentId == jobId)
×
872
                                .ToListAsync(cancellationToken)
×
873
                                .ConfigureAwait(false);
×
874
                        context.RemoveRange(outgoing);
×
875
                        _ = context.Remove(job);
×
876
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
877
                }
878
        }
×
879

880
        /// <inheritdoc />
881
        public ValueTask PurgeJobsAsync(
882
                TimeSpan succeededRetention,
883
                TimeSpan failedRetention,
884
                CancellationToken cancellationToken = default
885
        )
886
        {
887
                var now = _timeProvider.GetUtcNow();
4✔
888
                return ExecuteWithStrategyAsync(
4✔
889
                        operationCancellationToken => PurgeJobsCoreAsync(
4✔
890
                                now - succeededRetention,
4✔
891
                                now - failedRetention,
4✔
892
                                operationCancellationToken
4✔
893
                        ),
4✔
894
                        cancellationToken
4✔
895
                );
4✔
896
        }
897

898
        /// <inheritdoc />
899
        public ValueTask PurgeBatchesAsync(
900
                TimeSpan batchSucceededRetention,
901
                TimeSpan batchFailedRetention,
902
                CancellationToken cancellationToken = default
903
        )
904
        {
905
                var now = _timeProvider.GetUtcNow();
4✔
906
                return ExecuteWithStrategyAsync(
4✔
907
                        operationCancellationToken => PurgeBatchesCoreAsync(
4✔
908
                                now - batchSucceededRetention,
4✔
909
                                now - batchFailedRetention,
4✔
910
                                operationCancellationToken
4✔
911
                        ),
4✔
912
                        cancellationToken
4✔
913
                );
4✔
914
        }
915

916
        private async Task PurgeJobsCoreAsync(
917
                DateTimeOffset succeededBefore,
918
                DateTimeOffset failedBefore,
919
                CancellationToken cancellationToken
920
        )
921
        {
922
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
923
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
4✔
924
                var jobs = await context.Set<ImmediateJobEntity>()
4✔
925
                        .Where(job => job.BatchId == null
4✔
926
                                && ((job.State == JobState.Succeeded && job.CompletedAt < succeededBefore)
4✔
927
                                || ((job.State == JobState.Failed || job.State == JobState.Cancelled) && job.CompletedAt < failedBefore))
4✔
928
                        )
4✔
929
                        .ToListAsync(cancellationToken)
4✔
930
                        .ConfigureAwait(false);
4✔
931
                if (jobs.Count != 0)
4✔
932
                {
NEW
933
                        var jobIds = jobs.Select(static job => job.Id).ToArray();
×
NEW
934
                        var edges = await context.Set<ImmediateJobContinuationEntity>()
×
NEW
935
                                .Where(edge => jobIds.Contains(edge.ChildJobId)
×
NEW
936
                                        || jobIds.Contains(edge.ParentId) && edge.ParentKind == ContinuationParentKind.Job)
×
NEW
937
                                .ToListAsync(cancellationToken)
×
NEW
938
                                .ConfigureAwait(false);
×
NEW
939
                        context.RemoveRange(edges);
×
940
                }
941

942
                context.RemoveRange(jobs);
4✔
943
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
944
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
4✔
945
        }
4✔
946

947
        private async Task PurgeBatchesCoreAsync(
948
                DateTimeOffset batchSucceededBefore,
949
                DateTimeOffset batchFailedBefore,
950
                CancellationToken cancellationToken
951
        )
952
        {
953
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
954
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
4✔
955
                var batches = await context.Set<ImmediateJobBatchEntity>()
4✔
956
                        .Where(batch => (batch.State == BatchState.Succeeded && batch.CompletedAt < batchSucceededBefore)
4✔
957
                                || ((batch.State == BatchState.Failed || batch.State == BatchState.Cancelled)
4✔
958
                                        && batch.CompletedAt < batchFailedBefore))
4✔
959
                        .ToListAsync(cancellationToken)
4✔
960
                        .ConfigureAwait(false);
4✔
961
                if (batches.Count != 0)
4✔
962
                {
963
                        var batchIds = batches.Select(static batch => batch.Id).ToArray();
×
964
                        var memberIds = await context.Set<ImmediateJobEntity>()
×
965
                                .Where(job => job.BatchId != null && batchIds.Contains(job.BatchId))
×
966
                                .Select(job => job.Id)
×
967
                                .ToListAsync(cancellationToken)
×
968
                                .ConfigureAwait(false);
×
969
                        var edges = await context.Set<ImmediateJobContinuationEntity>()
×
970
                                .Where(edge => batchIds.Contains(edge.ParentId) && edge.ParentKind == ContinuationParentKind.Batch
×
971
                                        || memberIds.Contains(edge.ChildJobId)
×
972
                                        || memberIds.Contains(edge.ParentId) && edge.ParentKind == ContinuationParentKind.Job)
×
973
                                .ToListAsync(cancellationToken)
×
974
                                .ConfigureAwait(false);
×
975
                        context.RemoveRange(edges);
×
976
                        context.RemoveRange(batches);
×
977
                }
×
978

979
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
980
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
4✔
981
        }
4✔
982

983
        /// <inheritdoc />
984
        public async ValueTask HeartbeatAsync(JobServerSnapshot server, CancellationToken cancellationToken = default)
985
        {
986
                ArgumentNullException.ThrowIfNull(server);
×
987
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
988
                var entity = await context.Set<ImmediateJobServerEntity>().FindAsync([server.WorkerId], cancellationToken).ConfigureAwait(false);
×
989
                if (entity is null)
×
990
                {
991
                        _ = context.Add(new ImmediateJobServerEntity
×
992
                        {
×
993
                                WorkerId = server.WorkerId,
×
994
                                LastHeartbeat = server.LastHeartbeat,
×
995
                                ActiveWorkers = server.ActiveWorkers,
×
996
                                MaxWorkers = server.MaxWorkers,
×
997
                        });
×
998
                }
999
                else
1000
                {
1001
                        entity.LastHeartbeat = server.LastHeartbeat;
×
1002
                        entity.ActiveWorkers = server.ActiveWorkers;
×
1003
                        entity.MaxWorkers = server.MaxWorkers;
×
1004
                }
1005

1006
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1007
        }
×
1008

1009
        /// <inheritdoc />
1010
        public async ValueTask<bool> IsHealthyAsync(CancellationToken cancellationToken = default)
1011
        {
1012
                try
1013
                {
1014
                        await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
1015
                        return await context.Database.CanConnectAsync(cancellationToken).ConfigureAwait(false);
×
1016
                }
×
1017
                catch (Exception exception) when (exception is DbException or InvalidOperationException)
×
1018
                {
1019
                        return false;
×
1020
                }
1021
        }
×
1022

1023
        private async ValueTask MutateOwnedWithDependenciesAsync(
1024
                string jobId,
1025
                string workerId,
1026
                string? error,
1027
                DateTimeOffset? nextRetryAt,
1028
                bool succeeded,
1029
                IReadOnlyList<JobContinuationAddition> additions,
1030
                CancellationToken cancellationToken
1031
        )
1032
        {
1033
                const int MaxConcurrencyAttempts = 5;
1034
                for (var attempt = 0; attempt < MaxConcurrencyAttempts; attempt++)
5✔
1035
                {
1036
                        try
1037
                        {
1038
                                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1039
                                var strategy = strategyContext.Database.CreateExecutionStrategy();
5✔
1040
                                await strategy.ExecuteAsync(
5✔
1041
                                        operationCancellationToken => MutateOwnedCoreAsync(
5✔
1042
                                                jobId,
5✔
1043
                                                workerId,
5✔
1044
                                                error,
5✔
1045
                                                nextRetryAt,
5✔
1046
                                                succeeded,
5✔
1047
                                                additions,
5✔
1048
                                                operationCancellationToken
5✔
1049
                                        ),
5✔
1050
                                        cancellationToken
5✔
1051
                                ).ConfigureAwait(false);
5✔
1052
                                return;
5✔
1053
                        }
×
1054
                        catch (DbUpdateConcurrencyException) when (attempt + 1 < MaxConcurrencyAttempts)
×
1055
                        {
1056
                                // A competing parent completion changed a shared child or batch counter. The whole
1057
                                // transaction rolled back, so retrying re-evaluates the graph from durable state.
1058
                        }
×
1059
                }
1060
        }
5✔
1061

1062
        private async ValueTask ExecuteWithStrategyAsync(
1063
                Func<CancellationToken, Task> operation,
1064
                CancellationToken cancellationToken
1065
        )
1066
        {
1067
                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
1068
                var strategy = strategyContext.Database.CreateExecutionStrategy();
4✔
1069
                await strategy.ExecuteAsync(operation, cancellationToken).ConfigureAwait(false);
4✔
1070
        }
4✔
1071

1072
        private async ValueTask MutateOwnedAsync(
1073
                string jobId,
1074
                string workerId,
1075
                Action<ImmediateJobEntity> mutate,
1076
                CancellationToken cancellationToken
1077
        )
1078
        {
1079
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
1✔
1080
                var job = await context.Set<ImmediateJobEntity>()
1✔
1081
                        .SingleOrDefaultAsync(item => item.Id == jobId && item.State == JobState.Active && item.WorkerId == workerId, cancellationToken)
1✔
1082
                        .ConfigureAwait(false);
1✔
1083
                if (job is null)
1✔
1084
                        return;
1085
                mutate(job);
1✔
1086
                job.ConcurrencyStamp = Guid.NewGuid();
1✔
1087
                try
1088
                {
1089
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
1✔
1090
                }
1✔
1091
                catch (DbUpdateConcurrencyException)
×
1092
                {
1093
                        // A stale worker must not update a lease that has been reclaimed.
1094
                }
1✔
1095
        }
1✔
1096

1097
        private async Task MutateOwnedCoreAsync(
1098
                string jobId,
1099
                string workerId,
1100
                string? error,
1101
                DateTimeOffset? nextRetryAt,
1102
                bool succeeded,
1103
                IReadOnlyList<JobContinuationAddition> additions,
1104
                CancellationToken cancellationToken
1105
        )
1106
        {
1107
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
5✔
1108
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
5✔
1109
                var job = await context.Set<ImmediateJobEntity>()
5✔
1110
                        .SingleOrDefaultAsync(item => item.Id == jobId && item.State == JobState.Active && item.WorkerId == workerId, cancellationToken)
5✔
1111
                        .ConfigureAwait(false);
5✔
1112
                if (job is null)
5✔
1113
                        return;
1114

1115
                var now = _timeProvider.GetUtcNow();
5✔
1116
                job.WorkerId = null;
5✔
1117
                job.LeaseExpiresAt = null;
5✔
1118
                job.LastError = error;
5✔
1119
                job.ConcurrencyStamp = Guid.NewGuid();
5✔
1120
                if (!succeeded && nextRetryAt is { } retryAt)
5✔
1121
                {
1122
                        job.State = retryAt <= now ? JobState.Pending : JobState.Scheduled;
×
1123
                        job.DueAt = retryAt;
×
1124
                        job.CompletedAt = null;
×
1125
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1126
                        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
×
1127
                        return;
×
1128
                }
1129

1130
                if (succeeded && additions.Count != 0)
5✔
1131
                {
1132
                        await FlushContinuationAdditionsAsync(context, job, additions, cancellationToken).ConfigureAwait(false);
×
1133
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1134
                }
1135

1136
                job.State = succeeded ? JobState.Succeeded : JobState.Failed;
5✔
1137
                job.CompletedAt = now;
5✔
1138
                await PropagateTerminalAsync(context, job, now, cancellationToken).ConfigureAwait(false);
5✔
1139
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
5✔
1140
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
5✔
1141
        }
5✔
1142

1143
        private async Task AddBatchJobCoreAsync(
1144
                string currentJobId,
1145
                JobRecord record,
1146
                ContinuationOptions options,
1147
                CancellationToken cancellationToken
1148
        )
1149
        {
1150
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
1151
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
×
1152
                var current = await context.Set<ImmediateJobEntity>()
×
1153
                        .SingleOrDefaultAsync(job => job.Id == currentJobId && job.State == JobState.Active, cancellationToken)
×
1154
                        .ConfigureAwait(false)
×
NEW
1155
                        ?? throw new ImmediateJobException($"The current active job '{currentJobId}' was not found.");
×
1156
                if (current.BatchId is not { } batchId)
×
NEW
1157
                        throw new ImmediateJobException("The current job does not belong to a batch.");
×
1158
                var batch = await context.Set<ImmediateJobBatchEntity>()
×
1159
                        .SingleAsync(item => item.Id == batchId && item.State == BatchState.Executing, cancellationToken)
×
1160
                        .ConfigureAwait(false);
×
1161
                var job = ToEntity(record with { BatchId = batchId });
×
1162
                _ = context.Add(job);
×
1163
                batch.TotalJobs++;
×
1164
                batch.PendingCount++;
×
1165
                batch.ConcurrencyStamp = Guid.NewGuid();
×
1166

1167
                if (options == ContinuationOptions.BeforeContinuations)
×
1168
                {
1169
                        var waiters = await GetActiveWaitersAsync(context, currentJobId, cancellationToken).ConfigureAwait(false);
×
1170
                        foreach (var waiter in waiters)
×
1171
                        {
1172
                                _ = context.Add(new ImmediateJobContinuationEntity
×
1173
                                {
×
1174
                                        ChildJobId = waiter.Id,
×
1175
                                        ParentKind = ContinuationParentKind.Job,
×
1176
                                        ParentId = job.Id,
×
NEW
1177
                                        Trigger = ContinuationTrigger.Success,
×
1178
                                });
×
1179
                                waiter.RemainingDependencies++;
×
1180
                                waiter.ConcurrencyStamp = Guid.NewGuid();
×
1181
                        }
1182
                }
1183

1184
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1185
                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
×
1186
        }
×
1187

1188
        private static async Task FlushContinuationAdditionsAsync(
1189
                TContext context,
1190
                ImmediateJobEntity current,
1191
                IReadOnlyList<JobContinuationAddition> additions,
1192
                CancellationToken cancellationToken
1193
        )
1194
        {
1195
                var ids = additions.Select(static addition => addition.Job.Id).ToHashSet(StringComparer.Ordinal);
×
1196
                if (ids.Count != additions.Count)
×
NEW
1197
                        throw new ImmediateJobException("Buffered continuations contain duplicate job identifiers.");
×
1198
                var waiters = additions.Any(static addition => addition.Options == ContinuationOptions.BeforeContinuations)
×
1199
                        ? await GetActiveWaitersAsync(context, current.Id, cancellationToken).ConfigureAwait(false)
×
1200
                        : [];
×
1201
                ImmediateJobBatchEntity? batch = null;
1202
                var trackedAdditions = additions.Count(static addition => addition.Options != ContinuationOptions.Detached);
×
1203
                if (trackedAdditions != 0)
×
1204
                {
1205
                        if (current.BatchId is not { } batchId)
×
NEW
1206
                                throw new ImmediateJobException("The current job does not belong to a batch.");
×
1207
                        batch = await context.Set<ImmediateJobBatchEntity>()
×
1208
                                .SingleAsync(item => item.Id == batchId && item.State == BatchState.Executing, cancellationToken)
×
1209
                                .ConfigureAwait(false);
×
1210
                        batch.TotalJobs += trackedAdditions;
×
1211
                        batch.PendingCount += trackedAdditions;
×
1212
                        batch.ConcurrencyStamp = Guid.NewGuid();
×
1213
                }
1214

1215
                foreach (var addition in additions)
×
1216
                {
1217
                        var inBatch = addition.Options != ContinuationOptions.Detached;
×
1218
                        var job = ToEntity(addition.Job with
×
1219
                        {
×
1220
                                BatchId = inBatch ? current.BatchId : null,
×
1221
                                State = JobState.AwaitingContinuation,
×
1222
                                RemainingDependencies = 1,
×
1223
                        });
×
1224
                        _ = context.Add(job);
×
1225
                        _ = context.Add(new ImmediateJobContinuationEntity
×
1226
                        {
×
1227
                                ChildJobId = job.Id,
×
1228
                                ParentKind = ContinuationParentKind.Job,
×
1229
                                ParentId = current.Id,
×
1230
                                Trigger = addition.Trigger,
×
1231
                        });
×
1232

1233
                        if (addition.Options != ContinuationOptions.BeforeContinuations)
×
1234
                                continue;
1235
                        foreach (var waiter in waiters)
×
1236
                        {
1237
                                _ = context.Add(new ImmediateJobContinuationEntity
×
1238
                                {
×
1239
                                        ChildJobId = waiter.Id,
×
1240
                                        ParentKind = ContinuationParentKind.Job,
×
1241
                                        ParentId = job.Id,
×
NEW
1242
                                        Trigger = ContinuationTrigger.Success,
×
1243
                                });
×
1244
                                waiter.RemainingDependencies++;
×
1245
                                waiter.ConcurrencyStamp = Guid.NewGuid();
×
1246
                        }
1247
                }
1248
        }
×
1249

1250
        private static async Task<List<ImmediateJobEntity>> GetActiveWaitersAsync(
1251
                TContext context,
1252
                string currentJobId,
1253
                CancellationToken cancellationToken
1254
        )
1255
        {
1256
                var waiterIds = await context.Set<ImmediateJobContinuationEntity>()
×
1257
                        .Where(edge => edge.ParentKind == ContinuationParentKind.Job && edge.ParentId == currentJobId)
×
1258
                        .Select(edge => edge.ChildJobId)
×
1259
                        .Distinct()
×
1260
                        .ToArrayAsync(cancellationToken)
×
1261
                        .ConfigureAwait(false);
×
1262
                return waiterIds.Length == 0
×
1263
                        ? []
×
1264
                        : await context.Set<ImmediateJobEntity>()
×
1265
                                .Where(job => waiterIds.Contains(job.Id) && job.State == JobState.AwaitingContinuation)
×
1266
                                .ToListAsync(cancellationToken)
×
1267
                                .ConfigureAwait(false);
×
1268
        }
×
1269

1270
        private static async Task PropagateTerminalAsync(
1271
                TContext context,
1272
                ImmediateJobEntity terminalJob,
1273
                DateTimeOffset now,
1274
                CancellationToken cancellationToken
1275
        )
1276
        {
1277
                var parents = new Queue<(ContinuationParentKind Kind, string Id, bool Succeeded, bool Failed)>();
5✔
1278
                var processed = new HashSet<(ContinuationParentKind Kind, string Id)>();
5✔
1279
                parents.Enqueue((
5✔
1280
                        ContinuationParentKind.Job,
5✔
1281
                        terminalJob.Id,
5✔
1282
                        terminalJob.State == JobState.Succeeded,
5✔
1283
                        terminalJob.State == JobState.Failed
5✔
1284
                ));
5✔
1285
                await UpdateBatchForTerminalJobAsync(context, terminalJob, now, parents, cancellationToken).ConfigureAwait(false);
5✔
1286

1287
                while (parents.TryDequeue(out var parent))
5✔
1288
                {
1289
                        if (!processed.Add((parent.Kind, parent.Id)))
5✔
1290
                                continue;
1291
                        var edges = await context.Set<ImmediateJobContinuationEntity>()
5✔
1292
                                .Where(edge => edge.ParentKind == parent.Kind && edge.ParentId == parent.Id)
5✔
1293
                                .ToListAsync(cancellationToken)
5✔
1294
                                .ConfigureAwait(false);
5✔
1295
                        foreach (var edge in edges)
5✔
1296
                        {
1297
                                var child = await context.Set<ImmediateJobEntity>()
5✔
1298
                                        .SingleOrDefaultAsync(job => job.Id == edge.ChildJobId, cancellationToken)
5✔
1299
                                        .ConfigureAwait(false);
5✔
1300
                                if (child is null || IsTerminal(child.State))
5✔
1301
                                        continue;
1302

1303
                                if (edge.Trigger == ContinuationTrigger.Success && !parent.Succeeded)
5✔
1304
                                {
1305
                                        child.State = JobState.Cancelled;
×
1306
                                        child.RemainingDependencies = 0;
×
1307
                                        child.CompletedAt = now;
×
1308
                                        child.WorkerId = null;
×
1309
                                        child.LeaseExpiresAt = null;
×
1310
                                        child.ConcurrencyStamp = Guid.NewGuid();
×
NEW
1311
                                        parents.Enqueue((ContinuationParentKind.Job, child.Id, Succeeded: false, Failed: false));
×
1312
                                        await UpdateBatchForTerminalJobAsync(context, child, now, parents, cancellationToken).ConfigureAwait(false);
×
1313
                                        continue;
×
1314
                                }
1315

1316
                                if (child.State != JobState.AwaitingContinuation || child.RemainingDependencies <= 0)
5✔
1317
                                        continue;
1318
                                child.RemainingDependencies--;
5✔
1319
                                if (parent.Failed)
5✔
1320
                                        child.FailedDependencies++;
4✔
1321
                                if (child.RemainingDependencies == 0)
5✔
1322
                                {
1323
                                        if (edge.Trigger == ContinuationTrigger.Failure && child.FailedDependencies == 0)
5✔
1324
                                        {
1325
                                                child.State = JobState.Cancelled;
4✔
1326
                                                child.CompletedAt = now;
4✔
1327
                                                parents.Enqueue((ContinuationParentKind.Job, child.Id, Succeeded: false, Failed: false));
4✔
1328
                                                await UpdateBatchForTerminalJobAsync(context, child, now, parents, cancellationToken).ConfigureAwait(false);
4✔
1329
                                        }
1330
                                        else
1331
                                        {
1332
                                                child.State = child.DueAt <= now ? JobState.Pending : JobState.Scheduled;
5✔
1333
                                        }
1334
                                }
1335

1336
                                child.ConcurrencyStamp = Guid.NewGuid();
5✔
1337
                        }
5✔
1338
                }
5✔
1339
        }
5✔
1340

1341
        private static async Task UpdateBatchForTerminalJobAsync(
1342
                TContext context,
1343
                ImmediateJobEntity job,
1344
                DateTimeOffset now,
1345
                Queue<(ContinuationParentKind Kind, string Id, bool Succeeded, bool Failed)> parents,
1346
                CancellationToken cancellationToken
1347
        )
1348
        {
1349
                if (job.BatchId is not { } batchId)
5✔
1350
                        return;
5✔
1351
                var batch = await context.Set<ImmediateJobBatchEntity>()
5✔
1352
                        .SingleAsync(item => item.Id == batchId, cancellationToken)
5✔
1353
                        .ConfigureAwait(false);
5✔
1354
                batch.PendingCount = Math.Max(0, batch.PendingCount - 1);
5✔
1355
                switch (job.State)
5✔
1356
                {
1357
                        case JobState.Succeeded:
1358
                                batch.SucceededCount++;
5✔
1359
                                break;
5✔
1360
                        case JobState.Failed:
1361
                                batch.FailedCount++;
×
1362
                                break;
×
1363
                        case JobState.Cancelled:
1364
                                batch.CancelledCount++;
4✔
1365
                                break;
4✔
1366
                        case JobState.AwaitingContinuation:
1367
                        case JobState.AwaitingParameters:
1368
                        case JobState.Scheduled:
1369
                        case JobState.Pending:
1370
                        case JobState.Active:
NEW
1371
                                throw new ImmediateJobException($"Job '{job.Id}' is not terminal.");
×
1372
                        default:
1373
                                throw new ArgumentOutOfRangeException(nameof(job), job.State, "Unknown job state.");
×
1374
                }
1375

1376
                batch.ConcurrencyStamp = Guid.NewGuid();
5✔
1377
                if (batch.PendingCount != 0)
5✔
1378
                        return;
5✔
1379
                batch.State = GetTerminalBatchState(batch.FailedCount, batch.CancelledCount);
5✔
1380
                batch.CompletedAt = now;
5✔
1381
                parents.Enqueue((
5✔
1382
                        ContinuationParentKind.Batch,
5✔
1383
                        batch.Id,
5✔
1384
                        batch.State == BatchState.Succeeded,
5✔
1385
                        batch.State == BatchState.Failed
5✔
1386
                ));
5✔
1387
        }
5✔
1388

1389
        private static async Task EvaluateInitialDependenciesAsync(
1390
                TContext context,
1391
                Dictionary<string, ImmediateJobEntity> jobs,
1392
                ImmediateJobContinuationEntity[] edges,
1393
                DateTimeOffset now,
1394
                CancellationToken cancellationToken
1395
        )
1396
        {
1397
                var externalJobIds = edges
5✔
1398
                        .Where(edge => edge.ParentKind == ContinuationParentKind.Job && !jobs.ContainsKey(edge.ParentId))
5✔
1399
                        .Select(static edge => edge.ParentId)
5✔
1400
                        .Distinct(StringComparer.Ordinal)
5✔
1401
                        .ToArray();
5✔
1402
                var externalBatchIds = edges
5✔
1403
                        .Where(static edge => edge.ParentKind == ContinuationParentKind.Batch)
5✔
1404
                        .Select(static edge => edge.ParentId)
×
1405
                        .Distinct(StringComparer.Ordinal)
5✔
1406
                        .ToArray();
5✔
1407
                var externalJobs = externalJobIds.Length == 0
5✔
1408
                        ? []
5✔
1409
                        : await context.Set<ImmediateJobEntity>()
5✔
1410
                                .AsNoTracking()
5✔
1411
                                .Where(job => externalJobIds.Contains(job.Id))
5✔
1412
                                .ToDictionaryAsync(job => job.Id, job => job.State, StringComparer.Ordinal, cancellationToken)
4✔
1413
                                .ConfigureAwait(false);
5✔
1414
                var externalBatches = externalBatchIds.Length == 0
5✔
1415
                        ? []
5✔
1416
                        : await context.Set<ImmediateJobBatchEntity>()
5✔
1417
                                .AsNoTracking()
5✔
1418
                                .Where(batch => externalBatchIds.Contains(batch.Id))
5✔
1419
                                .ToDictionaryAsync(batch => batch.Id, batch => batch.State, StringComparer.Ordinal, cancellationToken)
×
1420
                                .ConfigureAwait(false);
5✔
1421
                if (externalJobs.Count != externalJobIds.Length || externalBatches.Count != externalBatchIds.Length)
5✔
1422
                        throw new ImmediateJobException("A continuation parent does not exist.");
5✔
1423

1424
                var incoming = edges.ToLookup(static edge => edge.ChildJobId, StringComparer.Ordinal);
5✔
1425
                var changed = true;
5✔
1426
                while (changed)
5✔
1427
                {
1428
                        changed = false;
5✔
1429
                        foreach (var job in jobs.Values)
5✔
1430
                        {
1431
                                var dependencies = incoming[job.Id];
5✔
1432
                                if (!dependencies.Any() || IsTerminal(job.State))
5✔
1433
                                        continue;
1434
                                var remaining = 0;
5✔
1435
                                var failedDependencies = 0;
5✔
1436
                                var requiresFailure = false;
5✔
1437
                                var violated = false;
5✔
1438
                                foreach (var edge in dependencies)
5✔
1439
                                {
1440
                                        var (terminal, parentSucceeded, parentFailed) = GetParentState(
5✔
1441
                                                edge,
5✔
1442
                                                jobs,
5✔
1443
                                                externalJobs,
5✔
1444
                                                externalBatches
5✔
1445
                                        );
5✔
1446
                                        requiresFailure |= edge.Trigger == ContinuationTrigger.Failure;
5✔
1447
                                        if (!terminal)
5✔
1448
                                        {
1449
                                                remaining++;
5✔
1450
                                                continue;
5✔
1451
                                        }
1452

NEW
1453
                                        if (parentFailed)
×
NEW
1454
                                                failedDependencies++;
×
NEW
1455
                                        if (edge.Trigger == ContinuationTrigger.Success && !parentSucceeded)
×
1456
                                                violated = true;
×
1457
                                }
1458

1459
                                job.FailedDependencies = failedDependencies;
5✔
1460
                                if (violated || remaining == 0 && requiresFailure && failedDependencies == 0)
5✔
1461
                                {
1462
                                        job.State = JobState.Cancelled;
×
1463
                                        job.RemainingDependencies = 0;
×
1464
                                        job.CompletedAt = now;
×
1465
                                        changed = true;
×
1466
                                }
1467
                                else if (remaining == 0)
5✔
1468
                                {
1469
                                        job.State = job.DueAt <= now ? JobState.Pending : JobState.Scheduled;
×
1470
                                        job.RemainingDependencies = 0;
×
1471
                                }
1472
                                else
1473
                                {
1474
                                        job.State = JobState.AwaitingContinuation;
5✔
1475
                                        job.RemainingDependencies = remaining;
5✔
1476
                                }
1477
                        }
1478
                }
1479
        }
5✔
1480

1481
        private static (bool Terminal, bool Succeeded, bool Failed) GetParentState(
1482
                ImmediateJobContinuationEntity edge,
1483
                Dictionary<string, ImmediateJobEntity> jobs,
1484
                Dictionary<string, JobState> externalJobs,
1485
                Dictionary<string, BatchState> externalBatches
1486
        )
1487
        {
1488
                if (edge.ParentKind == ContinuationParentKind.Batch)
5✔
1489
                {
1490
                        var state = externalBatches[edge.ParentId];
×
NEW
1491
                        return (state != BatchState.Executing, state == BatchState.Succeeded, state == BatchState.Failed);
×
1492
                }
1493

1494
                var jobState = jobs.TryGetValue(edge.ParentId, out var job) ? job.State : externalJobs[edge.ParentId];
5✔
1495
                return (IsTerminal(jobState), jobState == JobState.Succeeded, jobState == JobState.Failed);
5✔
1496
        }
1497

1498
        private static void ThrowIfCyclic(
1499
                HashSet<string> jobIds,
1500
                IReadOnlyList<ImmediateJobContinuationEntity> edges
1501
        )
1502
        {
1503
                var indegree = jobIds.ToDictionary(static id => id, static _ => 0, StringComparer.Ordinal);
5✔
1504
                var children = new Dictionary<string, List<string>>(StringComparer.Ordinal);
5✔
1505
                foreach (var edge in edges.Where(edge =>
5✔
1506
                        edge.ParentKind == ContinuationParentKind.Job && jobIds.Contains(edge.ParentId)))
5✔
1507
                {
1508
                        indegree[edge.ChildJobId]++;
5✔
1509
                        if (!children.TryGetValue(edge.ParentId, out var values))
5✔
1510
                                children[edge.ParentId] = values = [];
5✔
1511
                        values.Add(edge.ChildJobId);
5✔
1512
                }
1513

1514
                var ready = new Queue<string>(indegree.Where(static pair => pair.Value == 0).Select(static pair => pair.Key));
5✔
1515
                var visited = 0;
5✔
1516
                while (ready.TryDequeue(out var parent))
5✔
1517
                {
1518
                        visited++;
5✔
1519
                        if (!children.TryGetValue(parent, out var values))
5✔
1520
                                continue;
1521
                        foreach (var child in values)
5✔
1522
                        {
1523
                                if (--indegree[child] == 0)
5✔
1524
                                        ready.Enqueue(child);
5✔
1525
                        }
1526
                }
1527

1528
                if (visited != jobIds.Count)
5✔
NEW
1529
                        throw new ImmediateJobException("IJOB018: The continuation graph contains a dependency cycle.");
×
1530
        }
5✔
1531

1532
        private static bool IsTerminal(JobState state) =>
1533
                state is JobState.Succeeded or JobState.Failed or JobState.Cancelled;
5✔
1534

1535
        private static BatchState GetTerminalBatchState(int failed, int cancelled) =>
1536
                failed != 0 ? BatchState.Failed : cancelled != 0 ? BatchState.Cancelled : BatchState.Succeeded;
5✔
1537

1538
        private async ValueTask MutateRecurringAsync(string name, Action<ImmediateRecurringJobEntity> mutate, CancellationToken cancellationToken)
1539
        {
1540
                ArgumentException.ThrowIfNullOrWhiteSpace(name);
×
1541
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
1542
                var schedule = await context.Set<ImmediateRecurringJobEntity>().FindAsync([name], cancellationToken).ConfigureAwait(false);
×
1543
                if (schedule is null)
×
1544
                        return;
1545
                mutate(schedule);
×
1546
                schedule.ConcurrencyStamp = Guid.NewGuid();
×
1547
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
1548
        }
×
1549

1550
        private static Task<int> UpdateRecurringAsync(
1551
                TContext context,
1552
                RecurringJobSchedule schedule,
1553
                CancellationToken cancellationToken
1554
        )
1555
        {
1556
                var concurrencyStamp = Guid.NewGuid();
5✔
1557
                return context.Set<ImmediateRecurringJobEntity>()
5✔
1558
                        .Where(entity => entity.Name == schedule.Name && (schedule.IsCodeDefined || !entity.IsCodeDefined))
5✔
1559
                        .ExecuteUpdateAsync(setters => setters
5✔
1560
                                .SetProperty(entity => entity.JobName, schedule.JobName)
5✔
1561
                                .SetProperty(entity => entity.Cron, schedule.Cron)
5✔
1562
                                .SetProperty(entity => entity.TimeZone, schedule.TimeZone)
5✔
1563
                                .SetProperty(entity => entity.IsCodeDefined, schedule.IsCodeDefined)
5✔
1564
                                .SetProperty(entity => entity.NextRunAt, schedule.NextRunAt)
5✔
1565
                                .SetProperty(entity => entity.ConcurrencyStamp, concurrencyStamp),
5✔
1566
                                cancellationToken);
5✔
1567
        }
1568

1569
        private static async Task ThrowIfReplacingCodeDefinedScheduleAsync(
1570
                TContext context,
1571
                RecurringJobSchedule schedule,
1572
                CancellationToken cancellationToken
1573
        )
1574
        {
1575
                if (!schedule.IsCodeDefined && await context.Set<ImmediateRecurringJobEntity>()
5✔
1576
                        .AnyAsync(entity => entity.Name == schedule.Name && entity.IsCodeDefined, cancellationToken)
5✔
1577
                        .ConfigureAwait(false))
5✔
1578
                {
1579
                        throw new ImmediateJobException("Code-defined recurring schedules cannot be replaced by dynamic schedules.");
4✔
1580
                }
1581
        }
5✔
1582

1583
        private static ImmediateJobEntity ToEntity(JobRecord job) => new()
5✔
1584
        {
5✔
1585
                Id = job.Id,
5✔
1586
                QueueName = job.QueueName,
5✔
1587
                JobName = job.JobName,
5✔
1588
                Payload = job.Payload,
5✔
1589
                Context = job.Context,
5✔
1590
                State = job.State,
5✔
1591
                DueAt = job.DueAt,
5✔
1592
                CreatedAt = job.CreatedAt,
5✔
1593
                Attempt = job.Attempt,
5✔
1594
                WorkerId = job.WorkerId,
5✔
1595
                LeaseExpiresAt = job.LeaseExpiresAt,
5✔
1596
                LastError = job.LastError,
5✔
1597
                CompletedAt = job.CompletedAt,
5✔
1598
                RecurringKey = job.RecurringKey,
5✔
1599
                TraceParent = job.TraceParent,
5✔
1600
                TraceState = job.TraceState,
5✔
1601
                ExecutionTraceId = job.ExecutionTraceId,
5✔
1602
                ExecutionSpanId = job.ExecutionSpanId,
5✔
1603
                ExecutionStartedAt = job.ExecutionStartedAt,
5✔
1604
                BatchId = job.BatchId,
5✔
1605
                RemainingDependencies = job.RemainingDependencies,
5✔
1606
                FailedDependencies = job.FailedDependencies,
5✔
1607
                ConcurrencyStamp = Guid.NewGuid(),
5✔
1608
        };
5✔
1609

1610
        private static ImmediateJobContinuationEntity ToEntity(JobContinuationEdge edge)
1611
        {
1612
                var hasJobParent = edge.ParentJobId is not null;
5✔
1613
                var hasBatchParent = edge.ParentBatchId is not null;
5✔
1614
                if (hasJobParent == hasBatchParent)
5✔
NEW
1615
                        throw new ImmediateJobException("A continuation edge must identify exactly one parent job or batch.");
×
1616
                return new()
5✔
1617
                {
5✔
1618
                        ChildJobId = edge.ChildJobId,
5✔
1619
                        ParentKind = hasJobParent ? ContinuationParentKind.Job : ContinuationParentKind.Batch,
5✔
1620
                        ParentId = edge.ParentJobId ?? edge.ParentBatchId!,
5✔
1621
                        Trigger = edge.Trigger,
5✔
1622
                };
5✔
1623
        }
1624

1625
        private static ImmediateJobEntity Copy(ImmediateJobEntity job) => new()
5✔
1626
        {
5✔
1627
                Id = job.Id,
5✔
1628
                QueueName = job.QueueName,
5✔
1629
                JobName = job.JobName,
5✔
1630
                Payload = job.Payload,
5✔
1631
                Context = job.Context,
5✔
1632
                State = job.State,
5✔
1633
                DueAt = job.DueAt,
5✔
1634
                CreatedAt = job.CreatedAt,
5✔
1635
                Attempt = job.Attempt,
5✔
1636
                WorkerId = job.WorkerId,
5✔
1637
                LeaseExpiresAt = job.LeaseExpiresAt,
5✔
1638
                LastError = job.LastError,
5✔
1639
                CompletedAt = job.CompletedAt,
5✔
1640
                RecurringKey = job.RecurringKey,
5✔
1641
                TraceParent = job.TraceParent,
5✔
1642
                TraceState = job.TraceState,
5✔
1643
                ExecutionTraceId = job.ExecutionTraceId,
5✔
1644
                ExecutionSpanId = job.ExecutionSpanId,
5✔
1645
                ExecutionStartedAt = job.ExecutionStartedAt,
5✔
1646
                BatchId = job.BatchId,
5✔
1647
                RemainingDependencies = job.RemainingDependencies,
5✔
1648
                FailedDependencies = job.FailedDependencies,
5✔
1649
                ConcurrencyStamp = job.ConcurrencyStamp,
5✔
1650
        };
5✔
1651

1652
        private static JobRecord ToRecord(ImmediateJobEntity job) => new()
5✔
1653
        {
5✔
1654
                Id = job.Id,
5✔
1655
                QueueName = job.QueueName,
5✔
1656
                JobName = job.JobName,
5✔
1657
                Payload = job.Payload,
5✔
1658
                Context = job.Context,
5✔
1659
                State = job.State,
5✔
1660
                DueAt = job.DueAt,
5✔
1661
                CreatedAt = job.CreatedAt,
5✔
1662
                Attempt = job.Attempt,
5✔
1663
                WorkerId = job.WorkerId,
5✔
1664
                LeaseExpiresAt = job.LeaseExpiresAt,
5✔
1665
                LastError = job.LastError,
5✔
1666
                CompletedAt = job.CompletedAt,
5✔
1667
                RecurringKey = job.RecurringKey,
5✔
1668
                TraceParent = job.TraceParent,
5✔
1669
                TraceState = job.TraceState,
5✔
1670
                ExecutionTraceId = job.ExecutionTraceId,
5✔
1671
                ExecutionSpanId = job.ExecutionSpanId,
5✔
1672
                ExecutionStartedAt = job.ExecutionStartedAt,
5✔
1673
                BatchId = job.BatchId,
5✔
1674
                RemainingDependencies = job.RemainingDependencies,
5✔
1675
                FailedDependencies = job.FailedDependencies,
5✔
1676
        };
5✔
1677

1678
        private static BatchStatus ToStatus(ImmediateJobBatchEntity batch) => new(
5✔
1679
                batch.Id,
5✔
1680
                batch.State,
5✔
1681
                batch.TotalJobs,
5✔
1682
                batch.SucceededCount,
5✔
1683
                batch.FailedCount,
5✔
1684
                batch.CancelledCount,
5✔
1685
                batch.PendingCount,
5✔
1686
                batch.CreatedAt,
5✔
1687
                batch.StartedAt,
5✔
1688
                batch.CompletedAt,
5✔
1689
                batch.TotalJobs == 0 ? 1 : (double)(batch.TotalJobs - batch.PendingCount) / batch.TotalJobs
5✔
1690
        );
5✔
1691

1692
        private static BatchGraphEdge ToGraphEdge(ImmediateJobContinuationEntity edge) => new(
4✔
1693
                edge.ChildJobId,
4✔
1694
                edge.ParentKind == ContinuationParentKind.Job ? edge.ParentId : null,
4✔
1695
                edge.ParentKind == ContinuationParentKind.Batch ? edge.ParentId : null,
4✔
1696
                edge.Trigger
4✔
1697
        );
4✔
1698

1699
        private static ImmediateRecurringJobEntity ToEntity(RecurringJobSchedule schedule) => new()
5✔
1700
        {
5✔
1701
                Name = schedule.Name,
5✔
1702
                JobName = schedule.JobName,
5✔
1703
                Cron = schedule.Cron,
5✔
1704
                TimeZone = schedule.TimeZone,
5✔
1705
                IsCodeDefined = schedule.IsCodeDefined,
5✔
1706
                IsPaused = schedule.IsPaused,
5✔
1707
                NextRunAt = schedule.NextRunAt,
5✔
1708
                LastRunAt = schedule.LastRunAt,
5✔
1709
                ConcurrencyStamp = Guid.NewGuid(),
5✔
1710
        };
5✔
1711
}
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