166 lines
5.8 KiB
C#
166 lines
5.8 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using Npgsql;
|
|
using Tiku.Infrastructure.Observability;
|
|
|
|
namespace Tiku.Infrastructure.Jobs;
|
|
|
|
internal sealed record BackgroundJobLeaseTiming(
|
|
TimeSpan LeaseDuration,
|
|
TimeSpan RenewalInterval,
|
|
TimeSpan RetryInterval)
|
|
{
|
|
internal static readonly BackgroundJobLeaseTiming Default = new(
|
|
TimeSpan.FromMinutes(5),
|
|
TimeSpan.FromMinutes(1),
|
|
TimeSpan.FromSeconds(10));
|
|
}
|
|
|
|
internal sealed class BackgroundJobLeaseManager(
|
|
NpgsqlDataSource dataSource,
|
|
BackgroundJobLeaseTiming timing,
|
|
TimeProvider timeProvider,
|
|
ILogger<BackgroundJobLeaseManager> logger)
|
|
{
|
|
internal BackgroundJobLease Start(
|
|
Guid jobId,
|
|
string workerId,
|
|
DateTimeOffset leaseExpiresAt,
|
|
CancellationToken executionCancellationToken)
|
|
{
|
|
return new BackgroundJobLease(
|
|
jobId,
|
|
workerId,
|
|
leaseExpiresAt,
|
|
dataSource,
|
|
timing,
|
|
timeProvider,
|
|
logger,
|
|
executionCancellationToken);
|
|
}
|
|
}
|
|
|
|
internal sealed class BackgroundJobLease : IAsyncDisposable
|
|
{
|
|
private readonly Guid jobId;
|
|
private readonly string workerId;
|
|
private readonly NpgsqlDataSource dataSource;
|
|
private readonly BackgroundJobLeaseTiming timing;
|
|
private readonly TimeProvider timeProvider;
|
|
private readonly ILogger logger;
|
|
private readonly CancellationTokenSource executionCancellation;
|
|
private readonly CancellationTokenSource renewalCancellation = new();
|
|
private readonly Task renewalTask;
|
|
private DateTimeOffset confirmedExpiresAt;
|
|
private int ownershipLost;
|
|
private int stopped;
|
|
|
|
internal BackgroundJobLease(
|
|
Guid jobId,
|
|
string workerId,
|
|
DateTimeOffset leaseExpiresAt,
|
|
NpgsqlDataSource dataSource,
|
|
BackgroundJobLeaseTiming timing,
|
|
TimeProvider timeProvider,
|
|
ILogger logger,
|
|
CancellationToken executionCancellationToken)
|
|
{
|
|
this.jobId = jobId;
|
|
this.workerId = workerId;
|
|
this.dataSource = dataSource;
|
|
this.timing = timing;
|
|
this.timeProvider = timeProvider;
|
|
this.logger = logger;
|
|
confirmedExpiresAt = leaseExpiresAt;
|
|
executionCancellation = CancellationTokenSource.CreateLinkedTokenSource(executionCancellationToken);
|
|
renewalTask = RenewAsync();
|
|
}
|
|
|
|
internal CancellationToken ExecutionToken => executionCancellation.Token;
|
|
internal bool OwnershipLost => Volatile.Read(ref ownershipLost) != 0;
|
|
|
|
internal async Task StopAsync()
|
|
{
|
|
if (Interlocked.Exchange(ref stopped, 1) != 0) return;
|
|
|
|
await renewalCancellation.CancelAsync();
|
|
try
|
|
{
|
|
await renewalTask;
|
|
}
|
|
catch (OperationCanceledException) when (renewalCancellation.IsCancellationRequested)
|
|
{
|
|
}
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await StopAsync();
|
|
renewalCancellation.Dispose();
|
|
executionCancellation.Dispose();
|
|
}
|
|
|
|
private async Task RenewAsync()
|
|
{
|
|
var delay = timing.RenewalInterval;
|
|
while (!renewalCancellation.IsCancellationRequested)
|
|
{
|
|
await Task.Delay(delay, timeProvider, renewalCancellation.Token);
|
|
try
|
|
{
|
|
var now = timeProvider.GetUtcNow();
|
|
var nextExpiresAt = now.Add(timing.LeaseDuration);
|
|
await using var connection = await dataSource.OpenConnectionAsync(renewalCancellation.Token);
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = """
|
|
UPDATE background_jobs
|
|
SET lock_expires_at = @lock_expires_at,
|
|
updated_at = @updated_at
|
|
WHERE id = @id
|
|
AND locked_by = @worker_id
|
|
AND status = 'processing'
|
|
""";
|
|
command.Parameters.AddWithValue("lock_expires_at", nextExpiresAt);
|
|
command.Parameters.AddWithValue("updated_at", now);
|
|
command.Parameters.AddWithValue("id", jobId);
|
|
command.Parameters.AddWithValue("worker_id", workerId);
|
|
var renewed = await command.ExecuteNonQueryAsync(renewalCancellation.Token);
|
|
if (renewed == 0)
|
|
{
|
|
LoseOwnership("The job is no longer owned by this worker.");
|
|
return;
|
|
}
|
|
|
|
confirmedExpiresAt = nextExpiresAt;
|
|
delay = timing.RenewalInterval;
|
|
WorkerTelemetry.RecordJobLeaseRenewal("succeeded");
|
|
}
|
|
catch (OperationCanceledException) when (renewalCancellation.IsCancellationRequested)
|
|
{
|
|
return;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
WorkerTelemetry.RecordJobLeaseRenewal("failed");
|
|
logger.LogWarning(exception, "Failed to renew background job {JobId} lease for {WorkerId}.", jobId,
|
|
workerId);
|
|
if (timeProvider.GetUtcNow() >= confirmedExpiresAt)
|
|
{
|
|
LoseOwnership("The last confirmed lease expired before it could be renewed.");
|
|
return;
|
|
}
|
|
|
|
delay = timing.RetryInterval;
|
|
}
|
|
}
|
|
}
|
|
|
|
private void LoseOwnership(string reason)
|
|
{
|
|
if (Interlocked.Exchange(ref ownershipLost, 1) != 0) return;
|
|
|
|
WorkerTelemetry.RecordJobLeaseRenewal("lost");
|
|
logger.LogError("Background job {JobId} lease was lost by {WorkerId}. {Reason}", jobId, workerId, reason);
|
|
executionCancellation.Cancel();
|
|
}
|
|
}
|