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:
@@ -17,6 +17,7 @@ public sealed record DueModelOperation(
|
||||
string Queue,
|
||||
string IdempotencyKey,
|
||||
int ScheduleVersion,
|
||||
int DispatchRevision,
|
||||
DateTimeOffset ScheduledFor,
|
||||
string CatchUpPolicy,
|
||||
int MaxCatchUp);
|
||||
@@ -52,6 +53,7 @@ public interface IModelScheduleRepository
|
||||
Task MarkDispatchedAsync(
|
||||
Guid scheduleId,
|
||||
string leaseOwner,
|
||||
int expectedDispatchRevision,
|
||||
string backgroundJobId,
|
||||
DateTimeOffset dispatchedAt,
|
||||
DateTimeOffset nextDueAt,
|
||||
@@ -60,6 +62,7 @@ public interface IModelScheduleRepository
|
||||
Task AdvanceWithoutDispatchAsync(
|
||||
Guid scheduleId,
|
||||
string leaseOwner,
|
||||
int expectedDispatchRevision,
|
||||
DateTimeOffset advancedAt,
|
||||
DateTimeOffset nextDueAt,
|
||||
CancellationToken cancellationToken);
|
||||
@@ -67,6 +70,7 @@ public interface IModelScheduleRepository
|
||||
Task ReleaseAsync(
|
||||
Guid scheduleId,
|
||||
string leaseOwner,
|
||||
int expectedDispatchRevision,
|
||||
string reasonCode,
|
||||
DateTimeOffset releasedAt,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
+16
-9
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ public sealed class ModelOperationsDispatcherJob(
|
||||
var plan = ScheduleOccurrencePlanner.Plan(item.ScheduledFor, item.Cadence, item.CatchUpPolicy, item.MaxCatchUp, now);
|
||||
if (plan.OccurrencesToDispatch.Count == 0)
|
||||
{
|
||||
await schedules.AdvanceWithoutDispatchAsync(item.ScheduleId, leaseOwner, now, plan.NextDueAt, CancellationToken.None);
|
||||
await schedules.AdvanceWithoutDispatchAsync(item.ScheduleId, leaseOwner, item.DispatchRevision, now, plan.NextDueAt, CancellationToken.None);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ public sealed class ModelOperationsDispatcherJob(
|
||||
await schedules.MarkDispatchedAsync(
|
||||
item.ScheduleId,
|
||||
leaseOwner,
|
||||
item.DispatchRevision,
|
||||
backgroundJobId,
|
||||
now,
|
||||
plan.NextDueAt,
|
||||
@@ -70,6 +71,7 @@ public sealed class ModelOperationsDispatcherJob(
|
||||
await schedules.ReleaseAsync(
|
||||
item.ScheduleId,
|
||||
leaseOwner,
|
||||
item.DispatchRevision,
|
||||
"DISPATCH_FAILED",
|
||||
now,
|
||||
CancellationToken.None);
|
||||
|
||||
Reference in New Issue
Block a user