Skip to content

Instantly share code, notes, and snippets.

@neon-sunset
Last active September 29, 2025 20:02
Show Gist options
  • Select an option

  • Save neon-sunset/132803699b3cdcc5a34c2af9ec46fcbe to your computer and use it in GitHub Desktop.

Select an option

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
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