feat: fence scheduler next-due updates (AEG-V15-036)

Adds dispatch revision CAS to dispatched, skip, and release schedule mutations. Targeted Release evidence: 8/8 passed. PostgreSQL concurrency rehearsal remains required; WBS stays IN_PROGRESS.
This commit is contained in:
2026-08-09 02:06:37 +09:00
parent d18f6a7a67
commit 5a1570790c
8 changed files with 144 additions and 11 deletions
@@ -32,6 +32,7 @@ public sealed class DapperModelScheduleRepository(IDbConnectionFactory connectio
s.queue_name as Queue,
concat(s.operation_code, ':', s.scope_key, ':', s.schedule_version, ':', to_char(s.next_due_at at time zone 'UTC', 'YYYYMMDDHH24MISS')) as IdempotencyKey,
s.schedule_version as ScheduleVersion,
s.dispatch_revision as DispatchRevision,
s.next_due_at as ScheduledFor,
s.catch_up_policy as CatchUpPolicy,
s.max_catch_up as MaxCatchUp;
@@ -46,7 +47,9 @@ public sealed class DapperModelScheduleRepository(IDbConnectionFactory connectio
lease_until = null,
last_error_code = null,
updated_at = @DispatchedAt
where schedule_id = @ScheduleId and lease_owner = @LeaseOwner;
where schedule_id = @ScheduleId
and lease_owner = @LeaseOwner
and dispatch_revision = @ExpectedDispatchRevision;
""";
private const string ReleaseSql = """
@@ -56,7 +59,9 @@ public sealed class DapperModelScheduleRepository(IDbConnectionFactory connectio
last_error_code = @ReasonCode,
next_due_at = greatest(next_due_at, @ReleasedAt) + interval '1 hour',
updated_at = @ReleasedAt
where schedule_id = @ScheduleId and lease_owner = @LeaseOwner;
where schedule_id = @ScheduleId
and lease_owner = @LeaseOwner
and dispatch_revision = @ExpectedDispatchRevision;
""";
private const string AdvanceSql = """
@@ -66,7 +71,9 @@ public sealed class DapperModelScheduleRepository(IDbConnectionFactory connectio
lease_until = null,
last_error_code = null,
updated_at = @AdvancedAt
where schedule_id = @ScheduleId and lease_owner = @LeaseOwner;
where schedule_id = @ScheduleId
and lease_owner = @LeaseOwner
and dispatch_revision = @ExpectedDispatchRevision;
""";
public async Task<IReadOnlyList<DueModelOperation>> AcquireDueAsync(DateTimeOffset now, string leaseOwner, TimeSpan leaseDuration, int limit, CancellationToken cancellationToken)
@@ -76,23 +83,23 @@ public sealed class DapperModelScheduleRepository(IDbConnectionFactory connectio
return items.AsList();
}
public async Task MarkDispatchedAsync(Guid scheduleId, string leaseOwner, string backgroundJobId, DateTimeOffset dispatchedAt, DateTimeOffset nextDueAt, CancellationToken cancellationToken)
public async Task MarkDispatchedAsync(Guid scheduleId, string leaseOwner, int expectedDispatchRevision, string backgroundJobId, DateTimeOffset dispatchedAt, DateTimeOffset nextDueAt, CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
var affected = await connection.ExecuteAsync(new CommandDefinition(DispatchedSql, new { ScheduleId = scheduleId, LeaseOwner = leaseOwner, BackgroundJobId = backgroundJobId, DispatchedAt = dispatchedAt, NextDueAt = nextDueAt }, cancellationToken: cancellationToken));
var affected = await connection.ExecuteAsync(new CommandDefinition(DispatchedSql, new { ScheduleId = scheduleId, LeaseOwner = leaseOwner, ExpectedDispatchRevision = expectedDispatchRevision, BackgroundJobId = backgroundJobId, DispatchedAt = dispatchedAt, NextDueAt = nextDueAt }, cancellationToken: cancellationToken));
if (affected != 1) throw new InvalidOperationException("Schedule lease was lost before dispatch completion.");
}
public async Task AdvanceWithoutDispatchAsync(Guid scheduleId, string leaseOwner, DateTimeOffset advancedAt, DateTimeOffset nextDueAt, CancellationToken cancellationToken)
public async Task AdvanceWithoutDispatchAsync(Guid scheduleId, string leaseOwner, int expectedDispatchRevision, DateTimeOffset advancedAt, DateTimeOffset nextDueAt, CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
var affected = await connection.ExecuteAsync(new CommandDefinition(AdvanceSql, new { ScheduleId = scheduleId, LeaseOwner = leaseOwner, AdvancedAt = advancedAt, NextDueAt = nextDueAt }, cancellationToken: cancellationToken));
var affected = await connection.ExecuteAsync(new CommandDefinition(AdvanceSql, new { ScheduleId = scheduleId, LeaseOwner = leaseOwner, ExpectedDispatchRevision = expectedDispatchRevision, AdvancedAt = advancedAt, NextDueAt = nextDueAt }, cancellationToken: cancellationToken));
if (affected != 1) throw new InvalidOperationException("Schedule lease was lost before advance completion.");
}
public async Task ReleaseAsync(Guid scheduleId, string leaseOwner, string reasonCode, DateTimeOffset releasedAt, CancellationToken cancellationToken)
public async Task ReleaseAsync(Guid scheduleId, string leaseOwner, int expectedDispatchRevision, string reasonCode, DateTimeOffset releasedAt, CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
await connection.ExecuteAsync(new CommandDefinition(ReleaseSql, new { ScheduleId = scheduleId, LeaseOwner = leaseOwner, ReasonCode = reasonCode, ReleasedAt = releasedAt }, cancellationToken: cancellationToken));
await connection.ExecuteAsync(new CommandDefinition(ReleaseSql, new { ScheduleId = scheduleId, LeaseOwner = leaseOwner, ExpectedDispatchRevision = expectedDispatchRevision, ReasonCode = reasonCode, ReleasedAt = releasedAt }, cancellationToken: cancellationToken));
}
}