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

ImmediatePlatform / Immediate.Jobs / 30300691995

27 Jul 2026 08:00PM UTC coverage: 64.845%. First build
30300691995

Pull #20

github

web-flow
Merge a73d5f4ed into e8a73c280
Pull Request #20: Initial draft

1300 of 1892 new or added lines in 29 files covered. (68.71%)

1649 of 2543 relevant lines covered (64.84%)

2.58 hits per line

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

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

4
namespace Immediate.Jobs.EntityFrameworkCore;
5

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

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

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

33
        /// <inheritdoc />
34
        public async ValueTask<IReadOnlyList<JobRecord>> AcquireDueJobsAsync(
35
                JobAcquisitionRequest request,
36
                CancellationToken cancellationToken = default
37
        )
38
        {
39
                ArgumentNullException.ThrowIfNull(request);
4✔
40
                ArgumentException.ThrowIfNullOrWhiteSpace(request.WorkerId);
4✔
41
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(request.Lease, TimeSpan.Zero);
4✔
42
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(request.BatchSize, 0);
4✔
43
                var now = _timeProvider.GetUtcNow();
4✔
44
                var acquired = new List<JobRecord>(request.BatchSize);
4✔
45
                foreach (var queue in request.Queues)
4✔
46
                {
47
                        var queueCapacity = Math.Min(queue.Capacity, request.BatchSize - acquired.Count);
4✔
48
                        if (queueCapacity <= 0)
4✔
49
                                continue;
50

51
                        var jobCapacities = queue.JobCapacities.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal);
4✔
52
                        while (queueCapacity > 0)
4✔
53
                        {
54
                                var eligibleNames = jobCapacities.Where(static pair => pair.Value > 0).Select(static pair => pair.Key).ToArray();
4✔
55
                                if (eligibleNames.Length == 0)
4✔
56
                                        break;
57

58
                                await using var readContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
59
                                var candidates = await readContext.Set<ImmediateJobEntity>()
4✔
60
                                        .AsNoTracking()
4✔
61
                                        .Where(job => job.QueueName == queue.QueueName && eligibleNames.Contains(job.JobName) &&
4✔
62
                                                (((job.State == JobState.Scheduled || job.State == JobState.Pending) && job.DueAt <= now)
4✔
63
                                                        || (job.State == JobState.Active && job.LeaseExpiresAt <= now)))
4✔
64
                                        .OrderBy(job => job.DueAt)
4✔
65
                                        .ThenBy(job => job.CreatedAt)
4✔
66
                                        .ThenBy(job => job.Id)
4✔
67
                                        .Take(queueCapacity)
4✔
68
                                        .ToListAsync(cancellationToken)
4✔
69
                                        .ConfigureAwait(false);
4✔
70
                                if (candidates.Count == 0)
4✔
71
                                        break;
72

73
                                var selected = new List<ImmediateJobEntity>(candidates.Count);
4✔
74
                                var selectionCapacities = new Dictionary<string, int>(jobCapacities, StringComparer.Ordinal);
4✔
75
                                foreach (var candidate in candidates)
4✔
76
                                {
77
                                        if (selectionCapacities[candidate.JobName] <= 0)
4✔
78
                                                continue;
79
                                        selectionCapacities[candidate.JobName]--;
4✔
80
                                        selected.Add(candidate);
4✔
81
                                }
82

83
                                var claimed = await AcquireCandidatesAsync(
4✔
84
                                        selected,
4✔
85
                                        request.WorkerId,
4✔
86
                                        request.Lease,
4✔
87
                                        now,
4✔
88
                                        cancellationToken
4✔
89
                                ).ConfigureAwait(false);
4✔
90
                                foreach (var job in claimed)
4✔
91
                                {
92
                                        jobCapacities[job.JobName]--;
4✔
93
                                        queueCapacity--;
4✔
94
                                        acquired.Add(job);
4✔
95
                                }
96

97
                                if (claimed.Count == 0)
4✔
98
                                        break;
99
                        }
4✔
100
                }
4✔
101

102
                return acquired;
4✔
103
        }
4✔
104

105
        /// <inheritdoc />
106
        public async ValueTask<IReadOnlyList<JobRecord>> AcquireJobsAsync(
107
                IReadOnlyCollection<string> jobIds,
108
                string workerId,
109
                TimeSpan lease,
110
                CancellationToken cancellationToken = default
111
        )
112
        {
113
                ArgumentNullException.ThrowIfNull(jobIds);
4✔
114
                ArgumentException.ThrowIfNullOrWhiteSpace(workerId);
4✔
115
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lease, TimeSpan.Zero);
4✔
116
                if (jobIds.Count == 0)
4✔
NEW
117
                        return [];
×
118

119
                var now = _timeProvider.GetUtcNow();
4✔
120
                var ids = jobIds.ToArray();
4✔
121
                await using var readContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
122
                var candidates = await readContext.Set<ImmediateJobEntity>()
4✔
123
                        .AsNoTracking()
4✔
124
                        .Where(job => ids.Contains(job.Id) &&
4✔
125
                                (((job.State == JobState.Scheduled || job.State == JobState.Pending) && job.DueAt <= now)
4✔
126
                                || (job.State == JobState.Active && job.LeaseExpiresAt <= now)))
4✔
127
                        .ToListAsync(cancellationToken)
4✔
128
                        .ConfigureAwait(false);
4✔
129

130
                return await AcquireCandidatesAsync(candidates, workerId, lease, now, cancellationToken).ConfigureAwait(false);
4✔
131
        }
4✔
132

133
        private async ValueTask<IReadOnlyList<JobRecord>> AcquireCandidatesAsync(
134
                List<ImmediateJobEntity> candidates,
135
                string workerId,
136
                TimeSpan lease,
137
                DateTimeOffset now,
138
                CancellationToken cancellationToken
139
        )
140
        {
141
                var acquired = new List<JobRecord>(candidates.Count);
4✔
142
                foreach (var candidate in candidates)
4✔
143
                {
144
                        await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
145
                        var entity = Copy(candidate);
4✔
146
                        _ = context.Attach(entity);
4✔
147
                        entity.State = JobState.Active;
4✔
148
                        entity.WorkerId = workerId;
4✔
149
                        entity.LeaseExpiresAt = now + lease;
4✔
150
                        entity.Attempt++;
4✔
151
                        entity.CompletedAt = null;
4✔
152
                        entity.ConcurrencyStamp = Guid.NewGuid();
4✔
153
                        try
154
                        {
155
                                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
156
                                acquired.Add(ToRecord(entity));
4✔
157
                        }
4✔
NEW
158
                        catch (DbUpdateConcurrencyException)
×
159
                        {
160
                                // Another scheduler claimed or changed this candidate first.
NEW
161
                        }
×
162
                }
4✔
163

164
                return acquired;
4✔
165
        }
4✔
166

167
        /// <inheritdoc />
168
        public ValueTask RenewLeaseAsync(string jobId, string workerId, TimeSpan lease, CancellationToken cancellationToken = default)
169
        {
NEW
170
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lease, TimeSpan.Zero);
×
NEW
171
                return MutateOwnedAsync(jobId, workerId, job => job.LeaseExpiresAt = _timeProvider.GetUtcNow() + lease, cancellationToken);
×
172
        }
173

174
        /// <inheritdoc />
175
        public ValueTask CompleteAsync(string jobId, string workerId, CancellationToken cancellationToken = default)
NEW
176
                => MutateOwnedAsync(jobId, workerId, job =>
×
NEW
177
                {
×
NEW
178
                        job.State = JobState.Succeeded;
×
NEW
179
                        job.CompletedAt = _timeProvider.GetUtcNow();
×
NEW
180
                        job.WorkerId = null;
×
NEW
181
                        job.LeaseExpiresAt = null;
×
NEW
182
                }, cancellationToken);
×
183

184
        /// <inheritdoc />
185
        public ValueTask FailAsync(string jobId, string workerId, string error, DateTimeOffset? nextRetryAt, CancellationToken cancellationToken = default)
NEW
186
                => MutateOwnedAsync(jobId, workerId, job =>
×
NEW
187
                {
×
NEW
188
                        var now = _timeProvider.GetUtcNow();
×
NEW
189
                        if (nextRetryAt is null)
×
NEW
190
                        {
×
NEW
191
                                job.State = JobState.Failed;
×
NEW
192
                                job.CompletedAt = now;
×
NEW
193
                        }
×
NEW
194
                        else
×
NEW
195
                        {
×
NEW
196
                                job.State = nextRetryAt <= now ? JobState.Pending : JobState.Scheduled;
×
NEW
197
                                job.DueAt = nextRetryAt.Value;
×
NEW
198
                                job.CompletedAt = null;
×
NEW
199
                        }
×
NEW
200

×
NEW
201
                        job.LastError = error;
×
NEW
202
                        job.WorkerId = null;
×
NEW
203
                        job.LeaseExpiresAt = null;
×
NEW
204
                }, cancellationToken);
×
205

206
        /// <inheritdoc />
207
        public async ValueTask UpsertRecurringAsync(RecurringJobSchedule schedule, CancellationToken cancellationToken = default)
208
        {
209
                ArgumentNullException.ThrowIfNull(schedule);
4✔
210
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
211
                if (await UpdateRecurringAsync(context, schedule, cancellationToken).ConfigureAwait(false) != 0)
4✔
212
                        return;
213
                await ThrowIfReplacingCodeDefinedScheduleAsync(context, schedule, cancellationToken).ConfigureAwait(false);
4✔
214

215
                _ = context.Add(ToEntity(schedule));
4✔
216
                try
217
                {
218
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
219
                }
4✔
NEW
220
                catch (DbUpdateException)
×
221
                {
222
                        // A competing node inserted the same schedule after our update attempt.
NEW
223
                        await using var retryContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
NEW
224
                        if (await UpdateRecurringAsync(retryContext, schedule, cancellationToken).ConfigureAwait(false) != 0)
×
225
                                return;
NEW
226
                        await ThrowIfReplacingCodeDefinedScheduleAsync(retryContext, schedule, cancellationToken).ConfigureAwait(false);
×
NEW
227
                        throw;
×
NEW
228
                }
×
229
        }
4✔
230

231
        /// <inheritdoc />
232
        public async ValueTask RemoveObsoleteCodeDefinedRecurringAsync(
233
                IReadOnlyCollection<string> activeScheduleNames,
234
                CancellationToken cancellationToken = default
235
        )
236
        {
237
                ArgumentNullException.ThrowIfNull(activeScheduleNames);
4✔
238
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
239
                var schedules = context.Set<ImmediateRecurringJobEntity>()
4✔
240
                        .Where(schedule => schedule.IsCodeDefined);
4✔
241
                if (activeScheduleNames.Count != 0)
4✔
242
                        schedules = schedules.Where(schedule => !activeScheduleNames.Contains(schedule.Name));
4✔
243
                _ = await schedules.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
4✔
244
        }
4✔
245

246
        /// <inheritdoc />
247
        public async ValueTask RemoveRecurringAsync(string name, CancellationToken cancellationToken = default)
248
        {
NEW
249
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
NEW
250
                var entity = await context.Set<ImmediateRecurringJobEntity>()
×
NEW
251
                        .SingleOrDefaultAsync(schedule => schedule.Name == name && !schedule.IsCodeDefined, cancellationToken)
×
NEW
252
                        .ConfigureAwait(false);
×
NEW
253
                if (entity is not null)
×
254
                {
NEW
255
                        _ = context.Remove(entity);
×
NEW
256
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
257
                }
NEW
258
        }
×
259

260
        /// <inheritdoc />
261
        public ValueTask PauseRecurringAsync(string name, CancellationToken cancellationToken = default)
NEW
262
                => MutateRecurringAsync(name, schedule => schedule.IsPaused = true, cancellationToken);
×
263

264
        /// <inheritdoc />
265
        public ValueTask ResumeRecurringAsync(string name, CancellationToken cancellationToken = default)
NEW
266
                => MutateRecurringAsync(name, schedule => schedule.IsPaused = false, cancellationToken);
×
267

268
        /// <inheritdoc />
269
        public async ValueTask<IReadOnlyList<RecurringJobSchedule>> GetDueRecurringAsync(DateTimeOffset now, int batchSize, CancellationToken cancellationToken = default)
270
        {
NEW
271
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(batchSize, 0);
×
NEW
272
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
NEW
273
                return await context.Set<ImmediateRecurringJobEntity>()
×
NEW
274
                        .AsNoTracking()
×
NEW
275
                        .Where(schedule => !schedule.IsPaused && schedule.NextRunAt <= now)
×
NEW
276
                        .OrderBy(schedule => schedule.NextRunAt)
×
NEW
277
                        .Take(batchSize)
×
NEW
278
                        .Select(schedule => new RecurringJobSchedule
×
NEW
279
                        {
×
NEW
280
                                Name = schedule.Name,
×
NEW
281
                                JobName = schedule.JobName,
×
NEW
282
                                Cron = schedule.Cron,
×
NEW
283
                                TimeZone = schedule.TimeZone,
×
NEW
284
                                IsCodeDefined = schedule.IsCodeDefined,
×
NEW
285
                                IsPaused = schedule.IsPaused,
×
NEW
286
                                NextRunAt = schedule.NextRunAt,
×
NEW
287
                                LastRunAt = schedule.LastRunAt,
×
NEW
288
                        })
×
NEW
289
                        .ToListAsync(cancellationToken)
×
NEW
290
                        .ConfigureAwait(false);
×
NEW
291
        }
×
292

293
        /// <inheritdoc />
294
        public async ValueTask<bool> MaterializeRecurringAsync(
295
                RecurringJobSchedule schedule,
296
                JobRecord job,
297
                DateTimeOffset nextRunAt,
298
                CancellationToken cancellationToken = default
299
        )
300
        {
301
                ArgumentNullException.ThrowIfNull(schedule);
4✔
302
                ArgumentNullException.ThrowIfNull(job);
4✔
303
                await using var strategyContext = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
304
                var strategy = strategyContext.Database.CreateExecutionStrategy();
4✔
305
                return await strategy.ExecuteAsync(
4✔
306
                        operationCancellationToken => MaterializeRecurringCoreAsync(
4✔
307
                                schedule,
4✔
308
                                job,
4✔
309
                                nextRunAt,
4✔
310
                                operationCancellationToken
4✔
311
                        ),
4✔
312
                        cancellationToken
4✔
313
                ).ConfigureAwait(false);
4✔
314
        }
4✔
315

316
        private async Task<bool> MaterializeRecurringCoreAsync(
317
                RecurringJobSchedule schedule,
318
                JobRecord job,
319
                DateTimeOffset nextRunAt,
320
                CancellationToken cancellationToken
321
        )
322
        {
323
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
324
                await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
4✔
325
                var entity = await context.Set<ImmediateRecurringJobEntity>()
4✔
326
                        .SingleOrDefaultAsync(item => item.Name == schedule.Name, cancellationToken)
4✔
327
                        .ConfigureAwait(false);
4✔
328
                if (entity is null || entity.IsPaused || entity.NextRunAt != schedule.NextRunAt)
4✔
NEW
329
                        return false;
×
330

331
                entity.LastRunAt = schedule.NextRunAt;
4✔
332
                entity.NextRunAt = nextRunAt;
4✔
333
                entity.ConcurrencyStamp = Guid.NewGuid();
4✔
334
                _ = context.Add(ToEntity(job));
4✔
335
                try
336
                {
337
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
4✔
338
                        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
4✔
339
                        return true;
4✔
340
                }
341
                catch (DbUpdateException)
342
                {
NEW
343
                        await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
×
NEW
344
                        return false;
×
345
                }
346
        }
4✔
347

348
        /// <inheritdoc />
349
        public async ValueTask<JobMonitoringSnapshot> GetMonitoringSnapshotAsync(CancellationToken cancellationToken = default)
350
        {
351
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
352
                var rawCounts = await context.Set<ImmediateJobEntity>()
4✔
353
                        .AsNoTracking()
4✔
354
                        .GroupBy(job => job.State)
4✔
355
                        .Select(group => new { State = group.Key, Count = group.LongCount() })
4✔
356
                        .ToListAsync(cancellationToken)
4✔
357
                        .ConfigureAwait(false);
4✔
358
                var counts = Enum.GetValues<JobState>().ToDictionary(static state => state, static _ => 0L);
4✔
359
                foreach (var item in rawCounts)
4✔
360
                        counts[item.State] = item.Count;
4✔
361

362
                var recurring = await context.Set<ImmediateRecurringJobEntity>()
4✔
363
                        .AsNoTracking()
4✔
364
                        .OrderBy(schedule => schedule.Name)
4✔
365
                        .Select(schedule => new RecurringJobSchedule
4✔
366
                        {
4✔
367
                                Name = schedule.Name,
4✔
368
                                JobName = schedule.JobName,
4✔
369
                                Cron = schedule.Cron,
4✔
370
                                TimeZone = schedule.TimeZone,
4✔
371
                                IsCodeDefined = schedule.IsCodeDefined,
4✔
372
                                IsPaused = schedule.IsPaused,
4✔
373
                                NextRunAt = schedule.NextRunAt,
4✔
374
                                LastRunAt = schedule.LastRunAt,
4✔
375
                        })
4✔
376
                        .ToListAsync(cancellationToken)
4✔
377
                        .ConfigureAwait(false);
4✔
378
                var servers = await context.Set<ImmediateJobServerEntity>()
4✔
379
                        .AsNoTracking()
4✔
380
                        .OrderBy(server => server.WorkerId)
4✔
381
                        .Select(server => new JobServerSnapshot(server.WorkerId, server.LastHeartbeat, server.ActiveWorkers, server.MaxWorkers))
4✔
382
                        .ToListAsync(cancellationToken)
4✔
383
                        .ConfigureAwait(false);
4✔
384
                return new(_timeProvider.GetUtcNow(), counts, recurring, servers);
4✔
385
        }
4✔
386

387
        /// <inheritdoc />
388
        public async ValueTask<IReadOnlyList<JobRecord>> QueryJobsAsync(JobQuery query, CancellationToken cancellationToken = default)
389
        {
390
                ArgumentNullException.ThrowIfNull(query);
4✔
391
                ArgumentOutOfRangeException.ThrowIfNegative(query.Skip);
4✔
392
                ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(query.Take, 0);
4✔
393
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
4✔
394
                IQueryable<ImmediateJobEntity> jobs = context.Set<ImmediateJobEntity>().AsNoTracking();
4✔
395
                if (query.Id is { } id)
4✔
NEW
396
                        jobs = jobs.Where(job => job.Id == id);
×
397
                if (query.State is { } state)
4✔
398
                        jobs = jobs.Where(job => job.State == state);
4✔
399
                if (!string.IsNullOrWhiteSpace(query.QueueName))
4✔
NEW
400
                        jobs = jobs.Where(job => job.QueueName == query.QueueName);
×
401
                if (!string.IsNullOrWhiteSpace(query.Search))
4✔
402
                {
NEW
403
                        var search = query.Search.ToUpperInvariant();
×
404
                        // The parameterless form is intentionally used because relational providers translate it to SQL.
405
#pragma warning disable CA1304, CA1311, CA1862
NEW
406
                        jobs = jobs.Where(job => job.JobName.ToUpper().Contains(search));
×
407
#pragma warning restore CA1304, CA1311, CA1862
408
                }
409

410
                var entities = await jobs.OrderByDescending(job => job.CreatedAt)
4✔
411
                        .Skip(query.Skip)
4✔
412
                        .Take(query.Take)
4✔
413
                        .ToListAsync(cancellationToken)
4✔
414
                        .ConfigureAwait(false);
4✔
415
                return [.. entities.Select(ToRecord)];
4✔
416
        }
4✔
417

418
        /// <inheritdoc />
419
        public async ValueTask RetryAsync(string jobId, CancellationToken cancellationToken = default)
420
        {
NEW
421
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
NEW
422
                var job = await context.Set<ImmediateJobEntity>()
×
NEW
423
                        .SingleOrDefaultAsync(item => item.Id == jobId && item.State == JobState.Failed, cancellationToken)
×
NEW
424
                        .ConfigureAwait(false);
×
NEW
425
                if (job is null)
×
426
                        return;
NEW
427
                job.State = JobState.Pending;
×
NEW
428
                job.DueAt = _timeProvider.GetUtcNow();
×
NEW
429
                job.WorkerId = null;
×
NEW
430
                job.LeaseExpiresAt = null;
×
NEW
431
                job.CompletedAt = null;
×
NEW
432
                job.LastError = null;
×
NEW
433
                job.ConcurrencyStamp = Guid.NewGuid();
×
NEW
434
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
NEW
435
        }
×
436

437
        /// <inheritdoc />
438
        public async ValueTask DeleteAsync(string jobId, CancellationToken cancellationToken = default)
439
        {
NEW
440
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
NEW
441
                var job = await context.Set<ImmediateJobEntity>()
×
NEW
442
                        .SingleOrDefaultAsync(item => item.Id == jobId
×
NEW
443
                                && (item.State == JobState.Succeeded || item.State == JobState.Failed || item.State == JobState.Cancelled), cancellationToken)
×
NEW
444
                        .ConfigureAwait(false);
×
NEW
445
                if (job is not null)
×
446
                {
NEW
447
                        _ = context.Remove(job);
×
NEW
448
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
449
                }
NEW
450
        }
×
451

452
        /// <inheritdoc />
453
        public async ValueTask PurgeAsync(TimeSpan succeededRetention, TimeSpan failedRetention, CancellationToken cancellationToken = default)
454
        {
NEW
455
                var now = _timeProvider.GetUtcNow();
×
NEW
456
                var succeededBefore = now - succeededRetention;
×
NEW
457
                var failedBefore = now - failedRetention;
×
NEW
458
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
NEW
459
                var jobs = await context.Set<ImmediateJobEntity>()
×
NEW
460
                        .Where(job => (job.State == JobState.Succeeded && job.CompletedAt < succeededBefore)
×
NEW
461
                                || ((job.State == JobState.Failed || job.State == JobState.Cancelled) && job.CompletedAt < failedBefore))
×
NEW
462
                        .ToListAsync(cancellationToken)
×
NEW
463
                        .ConfigureAwait(false);
×
NEW
464
                context.RemoveRange(jobs);
×
NEW
465
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
NEW
466
        }
×
467

468
        /// <inheritdoc />
469
        public async ValueTask HeartbeatAsync(JobServerSnapshot server, CancellationToken cancellationToken = default)
470
        {
NEW
471
                ArgumentNullException.ThrowIfNull(server);
×
NEW
472
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
NEW
473
                var entity = await context.Set<ImmediateJobServerEntity>().FindAsync([server.WorkerId], cancellationToken).ConfigureAwait(false);
×
NEW
474
                if (entity is null)
×
475
                {
NEW
476
                        _ = context.Add(new ImmediateJobServerEntity
×
NEW
477
                        {
×
NEW
478
                                WorkerId = server.WorkerId,
×
NEW
479
                                LastHeartbeat = server.LastHeartbeat,
×
NEW
480
                                ActiveWorkers = server.ActiveWorkers,
×
NEW
481
                                MaxWorkers = server.MaxWorkers,
×
NEW
482
                        });
×
483
                }
484
                else
485
                {
NEW
486
                        entity.LastHeartbeat = server.LastHeartbeat;
×
NEW
487
                        entity.ActiveWorkers = server.ActiveWorkers;
×
NEW
488
                        entity.MaxWorkers = server.MaxWorkers;
×
489
                }
490

NEW
491
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
NEW
492
        }
×
493

494
        /// <inheritdoc />
495
        public async ValueTask<bool> IsHealthyAsync(CancellationToken cancellationToken = default)
496
        {
497
                try
498
                {
NEW
499
                        await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
NEW
500
                        return await context.Database.CanConnectAsync(cancellationToken).ConfigureAwait(false);
×
NEW
501
                }
×
NEW
502
                catch (Exception exception) when (exception is DbException or InvalidOperationException)
×
503
                {
NEW
504
                        return false;
×
505
                }
NEW
506
        }
×
507

508
        private async ValueTask MutateOwnedAsync(string jobId, string workerId, Action<ImmediateJobEntity> mutate, CancellationToken cancellationToken)
509
        {
NEW
510
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
NEW
511
                var job = await context.Set<ImmediateJobEntity>()
×
NEW
512
                        .SingleOrDefaultAsync(item => item.Id == jobId && item.State == JobState.Active && item.WorkerId == workerId, cancellationToken)
×
NEW
513
                        .ConfigureAwait(false);
×
NEW
514
                if (job is null)
×
515
                        return;
NEW
516
                mutate(job);
×
NEW
517
                job.ConcurrencyStamp = Guid.NewGuid();
×
518
                try
519
                {
NEW
520
                        _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
NEW
521
                }
×
NEW
522
                catch (DbUpdateConcurrencyException)
×
523
                {
524
                        // A stale worker must not update a lease that has been reclaimed.
NEW
525
                }
×
NEW
526
        }
×
527

528
        private async ValueTask MutateRecurringAsync(string name, Action<ImmediateRecurringJobEntity> mutate, CancellationToken cancellationToken)
529
        {
NEW
530
                ArgumentException.ThrowIfNullOrWhiteSpace(name);
×
NEW
531
                await using var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
×
NEW
532
                var schedule = await context.Set<ImmediateRecurringJobEntity>().FindAsync([name], cancellationToken).ConfigureAwait(false);
×
NEW
533
                if (schedule is null)
×
534
                        return;
NEW
535
                mutate(schedule);
×
NEW
536
                schedule.ConcurrencyStamp = Guid.NewGuid();
×
NEW
537
                _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
×
NEW
538
        }
×
539

540
        private static Task<int> UpdateRecurringAsync(
541
                TContext context,
542
                RecurringJobSchedule schedule,
543
                CancellationToken cancellationToken
544
        )
545
        {
546
                var concurrencyStamp = Guid.NewGuid();
4✔
547
                return context.Set<ImmediateRecurringJobEntity>()
4✔
548
                        .Where(entity => entity.Name == schedule.Name && (schedule.IsCodeDefined || !entity.IsCodeDefined))
4✔
549
                        .ExecuteUpdateAsync(setters => setters
4✔
550
                                .SetProperty(entity => entity.JobName, schedule.JobName)
4✔
551
                                .SetProperty(entity => entity.Cron, schedule.Cron)
4✔
552
                                .SetProperty(entity => entity.TimeZone, schedule.TimeZone)
4✔
553
                                .SetProperty(entity => entity.IsCodeDefined, schedule.IsCodeDefined)
4✔
554
                                .SetProperty(entity => entity.NextRunAt, schedule.NextRunAt)
4✔
555
                                .SetProperty(entity => entity.ConcurrencyStamp, concurrencyStamp),
4✔
556
                                cancellationToken);
4✔
557
        }
558

559
        private static async Task ThrowIfReplacingCodeDefinedScheduleAsync(
560
                TContext context,
561
                RecurringJobSchedule schedule,
562
                CancellationToken cancellationToken
563
        )
564
        {
565
                if (!schedule.IsCodeDefined && await context.Set<ImmediateRecurringJobEntity>()
4✔
566
                        .AnyAsync(entity => entity.Name == schedule.Name && entity.IsCodeDefined, cancellationToken)
4✔
567
                        .ConfigureAwait(false))
4✔
568
                {
569
                        throw new InvalidOperationException("Code-defined recurring schedules cannot be replaced by dynamic schedules.");
4✔
570
                }
571
        }
4✔
572

573
        private static ImmediateJobEntity ToEntity(JobRecord job) => new()
4✔
574
        {
4✔
575
                Id = job.Id,
4✔
576
                QueueName = job.QueueName,
4✔
577
                JobName = job.JobName,
4✔
578
                Payload = job.Payload,
4✔
579
                Context = job.Context,
4✔
580
                State = job.State,
4✔
581
                DueAt = job.DueAt,
4✔
582
                CreatedAt = job.CreatedAt,
4✔
583
                Attempt = job.Attempt,
4✔
584
                WorkerId = job.WorkerId,
4✔
585
                LeaseExpiresAt = job.LeaseExpiresAt,
4✔
586
                LastError = job.LastError,
4✔
587
                CompletedAt = job.CompletedAt,
4✔
588
                RecurringKey = job.RecurringKey,
4✔
589
                TraceParent = job.TraceParent,
4✔
590
                TraceState = job.TraceState,
4✔
591
                ConcurrencyStamp = Guid.NewGuid(),
4✔
592
        };
4✔
593

594
        private static ImmediateJobEntity Copy(ImmediateJobEntity job) => new()
4✔
595
        {
4✔
596
                Id = job.Id,
4✔
597
                QueueName = job.QueueName,
4✔
598
                JobName = job.JobName,
4✔
599
                Payload = job.Payload,
4✔
600
                Context = job.Context,
4✔
601
                State = job.State,
4✔
602
                DueAt = job.DueAt,
4✔
603
                CreatedAt = job.CreatedAt,
4✔
604
                Attempt = job.Attempt,
4✔
605
                WorkerId = job.WorkerId,
4✔
606
                LeaseExpiresAt = job.LeaseExpiresAt,
4✔
607
                LastError = job.LastError,
4✔
608
                CompletedAt = job.CompletedAt,
4✔
609
                RecurringKey = job.RecurringKey,
4✔
610
                TraceParent = job.TraceParent,
4✔
611
                TraceState = job.TraceState,
4✔
612
                ConcurrencyStamp = job.ConcurrencyStamp,
4✔
613
        };
4✔
614

615
        private static JobRecord ToRecord(ImmediateJobEntity job) => new()
4✔
616
        {
4✔
617
                Id = job.Id,
4✔
618
                QueueName = job.QueueName,
4✔
619
                JobName = job.JobName,
4✔
620
                Payload = job.Payload,
4✔
621
                Context = job.Context,
4✔
622
                State = job.State,
4✔
623
                DueAt = job.DueAt,
4✔
624
                CreatedAt = job.CreatedAt,
4✔
625
                Attempt = job.Attempt,
4✔
626
                WorkerId = job.WorkerId,
4✔
627
                LeaseExpiresAt = job.LeaseExpiresAt,
4✔
628
                LastError = job.LastError,
4✔
629
                CompletedAt = job.CompletedAt,
4✔
630
                RecurringKey = job.RecurringKey,
4✔
631
                TraceParent = job.TraceParent,
4✔
632
                TraceState = job.TraceState,
4✔
633
        };
4✔
634

635
        private static ImmediateRecurringJobEntity ToEntity(RecurringJobSchedule schedule) => new()
4✔
636
        {
4✔
637
                Name = schedule.Name,
4✔
638
                JobName = schedule.JobName,
4✔
639
                Cron = schedule.Cron,
4✔
640
                TimeZone = schedule.TimeZone,
4✔
641
                IsCodeDefined = schedule.IsCodeDefined,
4✔
642
                IsPaused = schedule.IsPaused,
4✔
643
                NextRunAt = schedule.NextRunAt,
4✔
644
                LastRunAt = schedule.LastRunAt,
4✔
645
                ConcurrencyStamp = Guid.NewGuid(),
4✔
646
        };
4✔
647
}
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