Last active
September 29, 2025 20:02
-
-
Save neon-sunset/132803699b3cdcc5a34c2af9ec46fcbe to your computer and use it in GitHub Desktop.
A very simple job runner with logging and persistence, in case you don't want to deal with bringing in Quartz.net or Hangfire
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| public sealed class OperationExecutionJob( | |
| IDbContextFactory<MegaEnterpriseFactoryContext> contextFactory, | |
| ILogger<OperationExecutionJob> logger | |
| ) : IHostedService, IDisposable | |
| { | |
| readonly SemaphoreSlim rescheduleMutex = new(1, 1); | |
| CancellationTokenSource executionCancellation = new(); | |
| Task joinedExecutionCompletion = Task.CompletedTask; | |
| bool disposed; | |
| public Task StartAsync(CancellationToken token) | |
| { | |
| return RescheduleExecution(token); | |
| } | |
| public async Task RescheduleExecution(CancellationToken token = default) | |
| { | |
| ObjectDisposedException.ThrowIf(disposed, this); | |
| await rescheduleMutex.WaitAsync(token); | |
| try | |
| { | |
| await using | |
| var context = await contextFactory.CreateDbContextAsync(token); | |
| var operations = await context.Operations | |
| .Select(o => new | |
| { | |
| o.Id, | |
| o.Name, | |
| o.ExecutionInterval, | |
| o.LastExecuted | |
| }) | |
| .ToListAsync(token); | |
| logger.LogInformation("(Re)scheduling execution for {Count} operations", operations.Count); | |
| executionCancellation.Cancel(); | |
| await joinedExecutionCompletion; | |
| executionCancellation.Dispose(); | |
| executionCancellation = new(); | |
| joinedExecutionCompletion = Task.WhenAll(operations.Select(async operation => | |
| { | |
| logger.LogInformation("Starting an execution runner for operation '{Name}' ({operationId})", operation.Name, operation.Id); | |
| await RunOperation(operation.Id, operation.ExecutionInterval, operation.LastExecuted ?? new(1970, 1, 1)) | |
| .ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); | |
| logger.LogInformation("An execution runner for operation '{Name}' ({operationId}) has been stopped", operation.Name, operation.Id); | |
| })); | |
| } | |
| finally | |
| { | |
| rescheduleMutex.Release(); | |
| } | |
| } | |
| async Task RunOperation(long operationId, TimeSpan interval, DateTime lastExecuted) | |
| { | |
| var now = DateTime.UtcNow; | |
| var nextRun = lastExecuted + interval; | |
| if (nextRun > now) | |
| { | |
| logger.LogInformation("Operation {operationId} is scheduled to execute at {NextRun}, waiting until then", operationId, nextRun); | |
| await Task.Delay(nextRun - now, executionCancellation.Token); | |
| } | |
| using var timer = new PeriodicTimer(interval); | |
| do | |
| { | |
| try | |
| { | |
| await using | |
| var context = await contextFactory.CreateDbContextAsync(executionCancellation.Token); | |
| var operation = await context.Operations | |
| .Where(o => o.Id == operationId) | |
| .FirstOrDefaultAsync(executionCancellation.Token); | |
| if (operation is null) | |
| { | |
| logger.LogWarning("Operation with ID {operationId} not found, terminating execution runner", operationId); | |
| return; | |
| } | |
| logger.LogInformation("Executing operation {operationId} '{Name}'", operationId, operation.Name); | |
| // Operation logic! | |
| logger.LogInformation("Operation {operationId} '{Name}' executed successfully", operationId, operation.Name); | |
| } | |
| catch (Exception e) when (!executionCancellation.IsCancellationRequested) | |
| { | |
| logger.LogError(e, "An error occurred while executing operation {operationId}", operationId); | |
| } | |
| } while (await timer.WaitForNextTickAsync(executionCancellation.Token)); | |
| } | |
| public async Task StopAsync(CancellationToken token) | |
| { | |
| await rescheduleMutex.WaitAsync(token); | |
| try | |
| { | |
| if (!executionCancellation.IsCancellationRequested) | |
| executionCancellation.Cancel(); | |
| if (joinedExecutionCompletion != null) | |
| await joinedExecutionCompletion; | |
| } | |
| finally | |
| { | |
| rescheduleMutex.Release(); | |
| } | |
| } | |
| public void Dispose() | |
| { | |
| if (disposed) | |
| return; | |
| disposed = true; | |
| executionCancellation.Dispose(); | |
| rescheduleMutex.Dispose(); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment