From 5a1570790ca1f17888d2281cb54a09c12ffbc2fc Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Sun, 9 Aug 2026 02:06:37 +0900 Subject: [PATCH] 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. --- .../AEG-V15-036_DISPATCH_CAS_SLICE_NOTE.md | 21 +++++ docs/CURRENT/CATALOGS/WBS_MASTER.csv | 2 +- .../CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv | 1 + .../DispatcherCasContractTests_20260809.trx | 76 +++++++++++++++++++ .../Application/ModelOperationsContracts.cs | 4 + .../DapperModelScheduleRepository.cs | 25 +++--- .../ModelOperationsDispatcherJob.cs | 4 +- ...perModelScheduleRepositoryContractTests.cs | 22 ++++++ 8 files changed, 144 insertions(+), 11 deletions(-) create mode 100644 docs/CURRENT/AEG-V15-036_DISPATCH_CAS_SLICE_NOTE.md create mode 100644 evidence/AEG-V15-036/DispatcherCasContractTests_20260809.trx create mode 100644 tests/KArtSell.ModelOperations.UnitTests/DapperModelScheduleRepositoryContractTests.cs diff --git a/docs/CURRENT/AEG-V15-036_DISPATCH_CAS_SLICE_NOTE.md b/docs/CURRENT/AEG-V15-036_DISPATCH_CAS_SLICE_NOTE.md new file mode 100644 index 00000000..a8d41e5c --- /dev/null +++ b/docs/CURRENT/AEG-V15-036_DISPATCH_CAS_SLICE_NOTE.md @@ -0,0 +1,21 @@ +# AEG-V15-036 — Dispatcher next-due CAS + +## Scope + +- **WBS ID:** `AEG-V15-036`; **Requirement / API / DB / Job / UI / Test:** `REQ-V15-SCH-04` / `V15-SCH-API` / `MIG-0020` / `J31~J40` / `Cross` / `T-V15-SCH-04`. +- **Dependency / Gate:** `AEG-V15-035` (completed, commit `d18f6a7`) / `G3`. +- **Acceptance:** a lost lease or stale dispatcher must make zero next-due advances. + +## Source / Assumption / Unknown / Decision Required + +- **Source:** `MIG-0020` supplies `dispatch_revision`; acquisition increments it, but the returned due-operation contract and both advance queries currently omit it. +- **Assumption:** the acquisition revision is an optimistic-concurrency token. A transition may mutate `next_due_at` only when both the lease owner and acquired revision match. +- **Unknown:** a live PostgreSQL runner is unavailable in this session, so a concurrent integration rehearsal cannot be asserted. +- **Decision Required:** `DEC-083` transactionality between enqueue and mark is separate. This Slice prevents stale state advance but does not make Hangfire enqueue transactional. + +## Actual evidence and remaining acceptance + +- `dotnet test tests/KArtSell.ModelOperations.UnitTests/KArtSell.ModelOperations.UnitTests.csproj -c Release --filter "FullyQualifiedName~DapperModelScheduleRepositoryContractTests|FullyQualifiedName~ScheduleOccurrencePlannerTests|FullyQualifiedName~ModelOperationRequestServiceTests" --logger "trx;LogFileName=DispatcherCasContractTests_20260809.trx" --results-directory evidence/AEG-V15-036` +- Result: passed `8/8`; artifact: `evidence/AEG-V15-036/DispatcherCasContractTests_20260809.trx`; SHA-256: `148FF7904CAE7372DD4728C24B845E280CEEE3116E5787BE621EC8E2E9005921`. +- The test locks the repository contract: dispatched, no-dispatch advance, and release SQL must all match both the lease owner and the acquired `dispatch_revision`. +- **Not complete:** a PostgreSQL concurrency test must acquire/reacquire the same schedule and show the stale revision updates zero rows. Until that artifact exists, the WBS remains `IN_PROGRESS`. diff --git a/docs/CURRENT/CATALOGS/WBS_MASTER.csv b/docs/CURRENT/CATALOGS/WBS_MASTER.csv index e31c538b..26e048ff 100644 --- a/docs/CURRENT/CATALOGS/WBS_MASTER.csv +++ b/docs/CURRENT/CATALOGS/WBS_MASTER.csv @@ -514,7 +514,7 @@ AEG-V15-032,S6,W13-14,Cross,Cross-cutting,UX/Layout,LAY,REQ-V15-LAY-08,Cross,Cro AEG-V15-033,S8,W17-18,VS-18,BacktestingEvaluation,Scheduler,SCH,REQ-V15-SCH-01,V15-SCH-API,MIG-0020,J31~J40,Cross,T-V15-SCH-01,Schedule anchor 계산기,ScheduleOccurrencePlanner,dispatch 지연이 cadence drift를 만들지 않음,BE Lead,SRE/QA,3,AEG-X-001,G3,SOURCE+DESIGN_PROPOSAL,P0,COMPLETED AEG-V15-034,S8,W17-18,VS-18,BacktestingEvaluation,Scheduler,SCH,REQ-V15-SCH-02,V15-SCH-API,MIG-0020,J31~J40,Cross,T-V15-SCH-02,Catch-up policy 구현,LATEST/SKIP/ALL_LIMIT,장애 복구 후 job storm 0,BE Lead,SRE/QA,5,AEG-V15-033,G3,SOURCE+DESIGN_PROPOSAL,P0,COMPLETED AEG-V15-035,S8,W17-18,VS-18,BacktestingEvaluation,Scheduler,SCH,REQ-V15-SCH-03,V15-SCH-API,MIG-0020,J31~J40,Cross,T-V15-SCH-03,Due operation 계약 확장,DueModelOperation v15,scheduledFor/catchUp/maxCatchUp 추적,BE Lead,SRE/QA,5,AEG-V15-034,G3,SOURCE+DESIGN_PROPOSAL,P1,COMPLETED -AEG-V15-036,S8,W17-18,VS-18,BacktestingEvaluation,Scheduler,SCH,REQ-V15-SCH-04,V15-SCH-API,MIG-0020,J31~J40,Cross,T-V15-SCH-04,Dispatcher nextDue CAS,schedule repository,lease 상실·중복 advance 0,BE Lead,SRE/QA,6,AEG-V15-035,G3,SOURCE+DESIGN_PROPOSAL,P0,PLANNED +AEG-V15-036,S8,W17-18,VS-18,BacktestingEvaluation,Scheduler,SCH,REQ-V15-SCH-04,V15-SCH-API,MIG-0020,J31~J40,Cross,T-V15-SCH-04,Dispatcher nextDue CAS,schedule repository,lease 상실·중복 advance 0,BE Lead,SRE/QA,6,AEG-V15-035,G3,SOURCE+DESIGN_PROPOSAL,P0,IN_PROGRESS AEG-V15-037,S8,W17-18,VS-18,BacktestingEvaluation,Scheduler,SCH,REQ-V15-SCH-05,V15-SCH-API,MIG-0020,J31~J40,Cross,T-V15-SCH-05,BusinessHold와 기술실패 분리,execution state machine,"blind retry 0, reason/holdUntil 기록",BE Lead,SRE/QA,5,AEG-V15-036,G3,SOURCE+DESIGN_PROPOSAL,P1,PLANNED AEG-V15-038,S8,W17-18,VS-18,BacktestingEvaluation,Scheduler,SCH,REQ-V15-SCH-06,V15-SCH-API,MIG-0020,J31~J40,Cross,T-V15-SCH-06,Schedule heartbeat/aging,execution heartbeat,stuck request 탐지와 Owner alert,BE Lead,SRE/QA,6,AEG-V15-037,G3,SOURCE+DESIGN_PROPOSAL,P0,PLANNED AEG-V15-039,S8,W17-18,VS-18,BacktestingEvaluation,Scheduler,SCH,REQ-V15-SCH-07,V15-SCH-API,MIG-0020,J31~J40,Cross,T-V15-SCH-07,시장 timezone/calendar 계약,calendar/timezone contract,UTC 저장·시장세션 계산·DST 테스트,BE Lead,SRE/QA,4,AEG-V15-038,G3,SOURCE+DESIGN_PROPOSAL,P1,PLANNED diff --git a/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv b/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv index 5defd335..8687c907 100644 --- a/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv +++ b/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv @@ -9,6 +9,7 @@ AEG-X-007,S0,Cross,Serilog/OTel correlation 고도화,COMPLETED,2026-08-06,"test AEG-V15-033,S8,VS-18,Schedule anchor 계산기,COMPLETED,2026-08-09,"docs/CURRENT/AEG-V15-033_SCHEDULE_ANCHOR_SLICE_NOTE.md; src/KArtSell.Modules.ModelOperations/Domain/ScheduleOccurrencePlanner.cs; tests/KArtSell.ModelOperations.UnitTests/ScheduleOccurrencePlannerTests.cs; evidence/AEG-V15-033/ScheduleOccurrencePlannerTests_20260809.trx",BE Lead,"Actual Release run: dotnet test tests/KArtSell.ModelOperations.UnitTests/KArtSell.ModelOperations.UnitTests.csproj -c Release --filter FullyQualifiedName~ScheduleOccurrencePlannerTests; 2/2 passed. Characterizes the approved scheduledFor anchor and missed-occurrence skip behavior. No schedule was enabled; market-calendar/timezone (DEC-079), dispatch enqueue/mark atomicity (DEC-083), leases, database integration, and later scheduler WBS evidence remain out of scope." AEG-V15-034,S8,VS-18,Catch-up policy 구현,COMPLETED,2026-08-09,"docs/CURRENT/AEG-V15-034_CATCH_UP_POLICY_SLICE_NOTE.md; src/KArtSell.Modules.ModelOperations/Domain/ScheduleOccurrencePlanner.cs; src/KArtSell.Modules.ModelOperations/Scheduling/ModelOperationsDispatcherJob.cs; src/KArtSell.Modules.ModelOperations/Infrastructure/DapperModelScheduleRepository.cs; tests/KArtSell.ModelOperations.UnitTests/ScheduleOccurrencePlannerTests.cs; evidence/AEG-V15-034/ScheduleOccurrencePlannerTests_20260809.trx",BE Lead,"Actual Release run: targeted ScheduleOccurrencePlannerTests 4/4 passed. LATEST_ONLY dispatches only the latest anchored occurrence; SKIP_MISSED advances the lease-held schedule without enqueueing stale work; ALL_WITH_LIMIT dispatches only the configured most-recent occurrences, each with an occurrence-specific UTC idempotency key. Schedules remain disabled. Database integration, lease CAS, and DEC-083 enqueue/mark atomicity are not claimed and remain owned by later WBS items." AEG-V15-035,S8,VS-18,Due operation 계약 확장,COMPLETED,2026-08-09,"docs/CURRENT/AEG-V15-035_DUE_OPERATION_CONTRACT_SLICE_NOTE.md; src/KArtSell.Modules.ModelOperations/Application/ModelOperationsContracts.cs; src/KArtSell.Modules.ModelOperations/Application/ModelOperationRequestService.cs; src/KArtSell.Modules.ModelOperations/Scheduling/ScheduledModelOperationJob.cs; src/KArtSell.Modules.ModelOperations/Infrastructure/DapperModelOperationRequestRepository.cs; tests/KArtSell.ModelOperations.UnitTests/ModelOperationRequestServiceTests.cs; evidence/AEG-V15-035/DueModelOperationContractTests_20260809.trx",BE Lead,"Actual Release run: 5/5 targeted unit tests passed. The scheduler occurrence, catch-up policy, and max catch-up flow from due schedule through the serialized job and validated application request; scheduled_for is inserted in the normalized request model and all three values are retained in the transactional outbox payload. Schedules remain disabled. No new migration or PostgreSQL integration evidence is claimed: MIG-0020 already provides scheduled_for; policy and limit provenance is immutable in the event payload, while schedule configuration remains the normalized source referenced by schedule_id/version." +AEG-V15-036,S8,VS-18,Dispatcher nextDue CAS,IN_PROGRESS,TBD,"docs/CURRENT/AEG-V15-036_DISPATCH_CAS_SLICE_NOTE.md; src/KArtSell.Modules.ModelOperations/Application/ModelOperationsContracts.cs; src/KArtSell.Modules.ModelOperations/Infrastructure/DapperModelScheduleRepository.cs; src/KArtSell.Modules.ModelOperations/Scheduling/ModelOperationsDispatcherJob.cs; tests/KArtSell.ModelOperations.UnitTests/DapperModelScheduleRepositoryContractTests.cs; evidence/AEG-V15-036/DispatcherCasContractTests_20260809.trx",BE Lead,"Implemented acquired dispatch_revision as an optimistic CAS token across dispatched, no-dispatch advance, and release paths; dispatcher passes the acquired revision. Actual targeted Release evidence: 8/8 passed. Remains IN_PROGRESS: PostgreSQL concurrent lease-loss/reacquire integration proof (stale revision affects 0 rows) is required before Acceptance_Evidence 'duplicate advance 0' can be claimed. Schedules remain disabled; DEC-083 remains unresolved." AEG-V16-017,S6,Cross,FieldShell 표준,IN_PROGRESS,TBD,"docs/CURRENT/AEG-V16-017_FIELDSHELL_SLICE_NOTE.md; frontend/src/shared/ui/components/FieldShell.vue; frontend/src/shared/ui/components/tests/FieldShell.spec.ts","FE Lead","2026-08-08: FieldShell now owns label/error/help/ARIA relationships for KsTextField, KsTextArea, KsSelect, KsDateField, and KsNumberField. Actual evidence: frontend pnpm typecheck PASS; pnpm test PASS (19 files, 42 tests); pnpm build PASS. Build emitted unrelated tracked .js drift, excluded from this Slice. COMPLETED is blocked pending WBS Master/tracker reconciliation and AEG-V16-016 vendor-boundary acceptance evidence." AEG-V16-016,S0,VS-00,Vendor boundary fitness,IN_PROGRESS,TBD,"docs/CURRENT/AEG-V16-016_VENDOR_BOUNDARY_SLICE_NOTE.md; tools/validate_v16.py; frontend/src/shared/ui/adapter/tests/uiAdapter.contract.spec.ts; evidence/AEG-V16-016/validate_v16_20260808.log; evidence/AEG-V16-016/ui-adapter-tests_20260808.log; evidence/AEG-V16-016/frontend-typecheck_20260808.log","FE Lead","2026-08-08: Removed stale fixed WBS row-count assertion; validator now verifies WBS ID integrity and reports vendor imports outside the approved adapter boundary. Re-executed actual evidence: python tools/validate_v16.py PASS=1 WARN=2 FAIL=0; targeted adapter tests 4/4 PASS; frontend typecheck PASS. COMPLETED is blocked because dependency AEG-V16-015 has no approved acceptance evidence in the tracker." AEG-V16-015,S0,VS-00,Adapter rollback runbook,BLOCKED,-,"docs/CURRENT/ui-provider-switch.md","FE Lead","2026-08-08: Runbook exists, but status is BLOCKED before completion: acceptance requires visual/a11y/performance rollback rehearsal evidence, which is not present; direct dependency AEG-V16-014 has no tracker evidence. A runbook does not substitute for an approved visual baseline, keyboard/focus and accessible-name report, state-matrix result, agreed performance budget, immutable-artifact rollback rehearsal, and append-only release evidence. No build/test/migration claimed by this status correction." diff --git a/evidence/AEG-V15-036/DispatcherCasContractTests_20260809.trx b/evidence/AEG-V15-036/DispatcherCasContractTests_20260809.trx new file mode 100644 index 00000000..923c8ec8 --- /dev/null +++ b/evidence/AEG-V15-036/DispatcherCasContractTests_20260809.trx @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + [xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.1.5+1b188a7b0a (64-bit .NET 10.0.10) +[xUnit.net 00:00:00.43] Discovering: KArtSell.ModelOperations.UnitTests +[xUnit.net 00:00:00.49] Discovered: KArtSell.ModelOperations.UnitTests +[xUnit.net 00:00:00.52] Starting: KArtSell.ModelOperations.UnitTests +[xUnit.net 00:00:00.58] Finished: KArtSell.ModelOperations.UnitTests + + + + \ No newline at end of file diff --git a/src/KArtSell.Modules.ModelOperations/Application/ModelOperationsContracts.cs b/src/KArtSell.Modules.ModelOperations/Application/ModelOperationsContracts.cs index be8d7455..f17ba079 100644 --- a/src/KArtSell.Modules.ModelOperations/Application/ModelOperationsContracts.cs +++ b/src/KArtSell.Modules.ModelOperations/Application/ModelOperationsContracts.cs @@ -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); diff --git a/src/KArtSell.Modules.ModelOperations/Infrastructure/DapperModelScheduleRepository.cs b/src/KArtSell.Modules.ModelOperations/Infrastructure/DapperModelScheduleRepository.cs index 34fb2085..a971eb99 100644 --- a/src/KArtSell.Modules.ModelOperations/Infrastructure/DapperModelScheduleRepository.cs +++ b/src/KArtSell.Modules.ModelOperations/Infrastructure/DapperModelScheduleRepository.cs @@ -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> 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)); } } diff --git a/src/KArtSell.Modules.ModelOperations/Scheduling/ModelOperationsDispatcherJob.cs b/src/KArtSell.Modules.ModelOperations/Scheduling/ModelOperationsDispatcherJob.cs index b1ec0ded..3efe9471 100644 --- a/src/KArtSell.Modules.ModelOperations/Scheduling/ModelOperationsDispatcherJob.cs +++ b/src/KArtSell.Modules.ModelOperations/Scheduling/ModelOperationsDispatcherJob.cs @@ -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); diff --git a/tests/KArtSell.ModelOperations.UnitTests/DapperModelScheduleRepositoryContractTests.cs b/tests/KArtSell.ModelOperations.UnitTests/DapperModelScheduleRepositoryContractTests.cs new file mode 100644 index 00000000..6087df34 --- /dev/null +++ b/tests/KArtSell.ModelOperations.UnitTests/DapperModelScheduleRepositoryContractTests.cs @@ -0,0 +1,22 @@ +using System.Reflection; +using KArtSell.Modules.ModelOperations.Infrastructure; + +namespace KArtSell.ModelOperations.UnitTests; + +public sealed class DapperModelScheduleRepositoryContractTests +{ + [Theory] + [InlineData("DispatchedSql")] + [InlineData("AdvanceSql")] + [InlineData("ReleaseSql")] + public void Every_next_due_mutation_requires_the_acquired_dispatch_revision(string fieldName) + { + var sql = (string?)typeof(DapperModelScheduleRepository) + .GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Static) + ?.GetRawConstantValue(); + + Assert.NotNull(sql); + Assert.Contains("dispatch_revision = @ExpectedDispatchRevision", sql, StringComparison.Ordinal); + Assert.Contains("lease_owner = @LeaseOwner", sql, StringComparison.Ordinal); + } +}