feat: bound scheduler catch-up dispatch (AEG-V15-034)
Implements LATEST_ONLY, SKIP_MISSED, and ALL_WITH_LIMIT dispatch plans anchored to scheduledFor. Evidence: targeted Release tests 4/4 passed; TRX SHA256 DC28BE4F2FCF511D5859B9FC3A0ADDF8CE3A566262C9848F05B06D825EA944AD. Schedules remain disabled; DEC-083 is not resolved.
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
# AEG-V15-034 — Catch-up policy
|
||||
|
||||
## Scope
|
||||
|
||||
- **WBS ID:** `AEG-V15-034`; **Slice:** VS-18 / Scheduler
|
||||
- **Requirement / API / DB / Job / UI / Test:** `REQ-V15-SCH-02` / `V15-SCH-API` / `MIG-0020` / `J31~J40` / `Cross` / `T-V15-SCH-02`
|
||||
- **Dependency / Gate:** `AEG-V15-033` (completed, commit `6a86997`) / `G3`
|
||||
- **Acceptance:** `LATEST_ONLY`, `SKIP_MISSED`, and `ALL_WITH_LIMIT` bound recovery work so outage recovery does not create a job storm.
|
||||
|
||||
## Source / Assumption / Unknown / Decision Required
|
||||
|
||||
- **Source:** `contracts/schedules/model-operations.v3.json`; `DEC-071` (scheduledFor anchor); `DEC-072` (default LATEST_ONLY, no unbounded replay); `ScheduleOccurrencePlanner`; `ModelOperationsDispatcherJob`; schedule repository contract.
|
||||
- **Assumption:** `ALL_WITH_LIMIT` means dispatch at most the configured `maxCatchUp` most-recent missed anchored occurrences in one dispatcher pass. `LATEST_ONLY` dispatches only the most-recent due occurrence. `SKIP_MISSED` advances without dispatching missed occurrences.
|
||||
- **Unknown:** there is no approval to enable any schedule. This slice must remain test-only with scheduler defaults unchanged.
|
||||
- **Decision Required:** `DEC-083` (enqueue/mark atomicity) remains unresolved. The implementation retains the existing lease/release boundary and does not claim a transactional outbox solution.
|
||||
|
||||
## Actual evidence
|
||||
|
||||
- `dotnet test tests/KArtSell.ModelOperations.UnitTests/KArtSell.ModelOperations.UnitTests.csproj -c Release --filter FullyQualifiedName~ScheduleOccurrencePlannerTests --logger "trx;LogFileName=ScheduleOccurrencePlannerTests_20260809.trx" --results-directory evidence/AEG-V15-034`
|
||||
- Result: passed `4/4`; artifact: `evidence/AEG-V15-034/ScheduleOccurrencePlannerTests_20260809.trx`; SHA-256: `DC28BE4F2FCF511D5859B9FC3A0ADDF8CE3A566262C9848F05B06D825EA944AD`.
|
||||
- The targeted build compiled the modified dispatcher and Dapper schedule repository. No database integration test, schedule activation, market-calendar claim, or transactional enqueue/mark claim is made.
|
||||
@@ -512,7 +512,7 @@ AEG-V15-030,S6,W13-14,Cross,Cross-cutting,UX/Layout,LAY,REQ-V15-LAY-06,Cross,Cro
|
||||
AEG-V15-031,S6,W13-14,Cross,Cross-cutting,UX/Layout,LAY,REQ-V15-LAY-07,Cross,Cross,Cross,UI-V15-LAY-07,T-V15-LAY-07,반응형 운영화면 기준,responsive evidence,1024/1440/1920/모바일 업무순서 유지,UX Lead,FE/QA,4,AEG-V15-030,MVP-A,SOURCE+DESIGN_PROPOSAL,P1,PLANNED
|
||||
AEG-V15-032,S6,W13-14,Cross,Cross-cutting,UX/Layout,LAY,REQ-V15-LAY-08,Cross,Cross,Cross,UI-V15-LAY-08,T-V15-LAY-08,디자인 token 변경 Gate,semantic token contract,기능 CSS 직접 색상/간격 drift 0,UX Lead,FE/QA,4,AEG-V15-031,MVP-A,SOURCE+DESIGN_PROPOSAL,P1,PLANNED
|
||||
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,PLANNED
|
||||
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,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,PLANNED
|
||||
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
|
||||
|
||||
|
@@ -7,6 +7,7 @@ AEG-X-005,S0,Cross,Security auth 고도화,COMPLETED,2026-08-04,"docs/decisions/
|
||||
AEG-X-006,S0,Cross,Outbox publisher 고도화,COMPLETED,2026-08-04,"docs/CURRENT/ARTIFACTS/AEG-X-006_ACCEPTANCE_EVIDENCE.md + src/KArtSell.BuildingBlocks/Reliability/DapperOutboxWriter.cs + OutboxPollerJob.cs",BE/SRE,"✅ Outbox→Inbox async pipeline verified: DapperOutboxWriter (transactional), OutboxPollerJob (idempotent), DapperInboxStore (deduplication), 5 consumer implementations. Acceptance_Evidence: All criteria met. 177/177 tests PASS."
|
||||
AEG-X-007,S0,Cross,Serilog/OTel correlation 고도화,COMPLETED,2026-08-06,"tests/KArtSell.ArchitectureTests/PiiRedactionTests.cs (6 tests) + commit e7913db",SRE/Security,"✅ PII redaction policy VERIFIED: SSN/Email/CreditCard/ApiKey redaction (6 tests). Commit e7913db adds pattern-based sanitization validation. All tests PASS (249/253)."
|
||||
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-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."
|
||||
|
||||
|
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TestRun id="77a3ab75-cf0b-4c5c-a6a5-ec7398b393f5" name="kjh20@KIMJAEHYUN-NOTE 2026-08-09 02:00:41" runUser="KIMJAEHYUN-NOTE\kjh20" xmlns="http://microsoft.com/schemas/VisualStudio/TeamTest/2010">
|
||||
<Times creation="2026-08-09T02:00:41.8424006+09:00" queuing="2026-08-09T02:00:41.8424009+09:00" start="2026-08-09T02:00:40.8302731+09:00" finish="2026-08-09T02:00:41.8492243+09:00" />
|
||||
<TestSettings name="default" id="47bb99a1-94d8-4f7b-ab10-583e9f2af380">
|
||||
<Deployment runDeploymentRoot="kjh20_KIMJAEHYUN-NOTE_2026-08-09_02_00_41" />
|
||||
</TestSettings>
|
||||
<Results>
|
||||
<UnitTestResult executionId="8d95017d-2adb-41c1-bd93-14855c9abdb0" testId="4cc4638d-542d-f501-1295-718da6fcbfd1" testName="KArtSell.ModelOperations.UnitTests.ScheduleOccurrencePlannerTests.Daily_anchor_does_not_drift_to_dispatch_time" computerName="KIMJAEHYUN-NOTE" duration="00:00:00.0002603" startTime="2026-08-09T02:00:41.7427457+09:00" endTime="2026-08-09T02:00:41.7427979+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="8d95017d-2adb-41c1-bd93-14855c9abdb0" />
|
||||
<UnitTestResult executionId="291659b3-0a58-4935-864d-52115be9383a" testId="734f2a89-2b23-0864-3bdc-dd5d2c1e065a" testName="KArtSell.ModelOperations.UnitTests.ScheduleOccurrencePlannerTests.All_with_limit_dispatches_only_the_most_recent_configured_occurrences" computerName="KIMJAEHYUN-NOTE" duration="00:00:00.0220892" startTime="2026-08-09T02:00:41.7304303+09:00" endTime="2026-08-09T02:00:41.7419252+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="291659b3-0a58-4935-864d-52115be9383a" />
|
||||
<UnitTestResult executionId="34c54294-804c-4bee-a236-f8d5f32f08f9" testId="4a0acb4c-da35-f6d9-1253-fba8b2ec4ae4" testName="KArtSell.ModelOperations.UnitTests.ScheduleOccurrencePlannerTests.Missed_occurrences_are_skipped_without_dispatch_storm" computerName="KIMJAEHYUN-NOTE" duration="00:00:00.0021823" startTime="2026-08-09T02:00:41.7429153+09:00" endTime="2026-08-09T02:00:41.7445150+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="34c54294-804c-4bee-a236-f8d5f32f08f9" />
|
||||
<UnitTestResult executionId="8e27298f-1a57-4cde-a8cf-735cd10ed43a" testId="42622f9c-8837-0fa6-cb49-0be424719bcd" testName="KArtSell.ModelOperations.UnitTests.ScheduleOccurrencePlannerTests.Skip_missed_advances_without_dispatching_a_stale_occurrence" computerName="KIMJAEHYUN-NOTE" duration="00:00:00.0088843" startTime="2026-08-09T02:00:41.7031192+09:00" endTime="2026-08-09T02:00:41.7196234+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="8e27298f-1a57-4cde-a8cf-735cd10ed43a" />
|
||||
</Results>
|
||||
<TestDefinitions>
|
||||
<UnitTest name="KArtSell.ModelOperations.UnitTests.ScheduleOccurrencePlannerTests.All_with_limit_dispatches_only_the_most_recent_configured_occurrences" storage="d:\jobroomz\kartsell.aegis\tests\kartsell.modeloperations.unittests\bin\release\net10.0\kartsell.modeloperations.unittests.dll" id="734f2a89-2b23-0864-3bdc-dd5d2c1e065a">
|
||||
<Execution id="291659b3-0a58-4935-864d-52115be9383a" />
|
||||
<TestMethod codeBase="D:\JobRoomz\KArtSell.Aegis\tests\KArtSell.ModelOperations.UnitTests\bin\Release\net10.0\KArtSell.ModelOperations.UnitTests.dll" adapterTypeName="executor://xunit/VsTestRunner3/netcore/" className="KArtSell.ModelOperations.UnitTests.ScheduleOccurrencePlannerTests" name="All_with_limit_dispatches_only_the_most_recent_configured_occurrences" />
|
||||
</UnitTest>
|
||||
<UnitTest name="KArtSell.ModelOperations.UnitTests.ScheduleOccurrencePlannerTests.Skip_missed_advances_without_dispatching_a_stale_occurrence" storage="d:\jobroomz\kartsell.aegis\tests\kartsell.modeloperations.unittests\bin\release\net10.0\kartsell.modeloperations.unittests.dll" id="42622f9c-8837-0fa6-cb49-0be424719bcd">
|
||||
<Execution id="8e27298f-1a57-4cde-a8cf-735cd10ed43a" />
|
||||
<TestMethod codeBase="D:\JobRoomz\KArtSell.Aegis\tests\KArtSell.ModelOperations.UnitTests\bin\Release\net10.0\KArtSell.ModelOperations.UnitTests.dll" adapterTypeName="executor://xunit/VsTestRunner3/netcore/" className="KArtSell.ModelOperations.UnitTests.ScheduleOccurrencePlannerTests" name="Skip_missed_advances_without_dispatching_a_stale_occurrence" />
|
||||
</UnitTest>
|
||||
<UnitTest name="KArtSell.ModelOperations.UnitTests.ScheduleOccurrencePlannerTests.Missed_occurrences_are_skipped_without_dispatch_storm" storage="d:\jobroomz\kartsell.aegis\tests\kartsell.modeloperations.unittests\bin\release\net10.0\kartsell.modeloperations.unittests.dll" id="4a0acb4c-da35-f6d9-1253-fba8b2ec4ae4">
|
||||
<Execution id="34c54294-804c-4bee-a236-f8d5f32f08f9" />
|
||||
<TestMethod codeBase="D:\JobRoomz\KArtSell.Aegis\tests\KArtSell.ModelOperations.UnitTests\bin\Release\net10.0\KArtSell.ModelOperations.UnitTests.dll" adapterTypeName="executor://xunit/VsTestRunner3/netcore/" className="KArtSell.ModelOperations.UnitTests.ScheduleOccurrencePlannerTests" name="Missed_occurrences_are_skipped_without_dispatch_storm" />
|
||||
</UnitTest>
|
||||
<UnitTest name="KArtSell.ModelOperations.UnitTests.ScheduleOccurrencePlannerTests.Daily_anchor_does_not_drift_to_dispatch_time" storage="d:\jobroomz\kartsell.aegis\tests\kartsell.modeloperations.unittests\bin\release\net10.0\kartsell.modeloperations.unittests.dll" id="4cc4638d-542d-f501-1295-718da6fcbfd1">
|
||||
<Execution id="8d95017d-2adb-41c1-bd93-14855c9abdb0" />
|
||||
<TestMethod codeBase="D:\JobRoomz\KArtSell.Aegis\tests\KArtSell.ModelOperations.UnitTests\bin\Release\net10.0\KArtSell.ModelOperations.UnitTests.dll" adapterTypeName="executor://xunit/VsTestRunner3/netcore/" className="KArtSell.ModelOperations.UnitTests.ScheduleOccurrencePlannerTests" name="Daily_anchor_does_not_drift_to_dispatch_time" />
|
||||
</UnitTest>
|
||||
</TestDefinitions>
|
||||
<TestEntries>
|
||||
<TestEntry testId="4cc4638d-542d-f501-1295-718da6fcbfd1" executionId="8d95017d-2adb-41c1-bd93-14855c9abdb0" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
<TestEntry testId="734f2a89-2b23-0864-3bdc-dd5d2c1e065a" executionId="291659b3-0a58-4935-864d-52115be9383a" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
<TestEntry testId="4a0acb4c-da35-f6d9-1253-fba8b2ec4ae4" executionId="34c54294-804c-4bee-a236-f8d5f32f08f9" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
<TestEntry testId="42622f9c-8837-0fa6-cb49-0be424719bcd" executionId="8e27298f-1a57-4cde-a8cf-735cd10ed43a" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
</TestEntries>
|
||||
<TestLists>
|
||||
<TestList name="목록에 없는 결과" id="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
<TestList name="로드된 모든 결과" id="19431567-8539-422a-85d7-44ee4e166bda" />
|
||||
</TestLists>
|
||||
<ResultSummary outcome="Completed">
|
||||
<Counters total="4" executed="4" passed="4" failed="0" error="0" timeout="0" aborted="0" inconclusive="0" passedButRunAborted="0" notRunnable="0" notExecuted="0" disconnected="0" warning="0" completed="0" inProgress="0" pending="0" />
|
||||
<Output>
|
||||
<StdOut>[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.29] Discovering: KArtSell.ModelOperations.UnitTests
|
||||
[xUnit.net 00:00:00.34] Discovered: KArtSell.ModelOperations.UnitTests
|
||||
[xUnit.net 00:00:00.36] Starting: KArtSell.ModelOperations.UnitTests
|
||||
[xUnit.net 00:00:00.43] Finished: KArtSell.ModelOperations.UnitTests
|
||||
</StdOut>
|
||||
</Output>
|
||||
</ResultSummary>
|
||||
</TestRun>
|
||||
@@ -54,6 +54,13 @@ public interface IModelScheduleRepository
|
||||
DateTimeOffset nextDueAt,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task AdvanceWithoutDispatchAsync(
|
||||
Guid scheduleId,
|
||||
string leaseOwner,
|
||||
DateTimeOffset advancedAt,
|
||||
DateTimeOffset nextDueAt,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task ReleaseAsync(
|
||||
Guid scheduleId,
|
||||
string leaseOwner,
|
||||
|
||||
@@ -2,29 +2,56 @@ namespace KArtSell.Modules.ModelOperations.Domain;
|
||||
|
||||
public sealed class ScheduleOccurrencePlanner
|
||||
{
|
||||
public sealed record CatchUpPlan(
|
||||
IReadOnlyList<DateTimeOffset> OccurrencesToDispatch,
|
||||
DateTimeOffset NextDueAt);
|
||||
|
||||
public static DateTimeOffset GetNextDueAt(
|
||||
DateTimeOffset scheduledFor,
|
||||
string cadence,
|
||||
string catchUpPolicy,
|
||||
int maxCatchUp,
|
||||
DateTimeOffset now)
|
||||
=> Plan(scheduledFor, cadence, catchUpPolicy, maxCatchUp, now).NextDueAt;
|
||||
|
||||
public static CatchUpPlan Plan(
|
||||
DateTimeOffset scheduledFor,
|
||||
string cadence,
|
||||
string catchUpPolicy,
|
||||
int maxCatchUp,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
if (maxCatchUp is < 0 or > 31) throw new ArgumentOutOfRangeException(nameof(maxCatchUp));
|
||||
var next = Advance(scheduledFor, cadence);
|
||||
|
||||
var due = GetDueOccurrences(scheduledFor, cadence, now);
|
||||
var nextDueAt = due.Count == 0 ? scheduledFor : Advance(due[^1], cadence);
|
||||
|
||||
if (catchUpPolicy.Equals("LATEST_ONLY", StringComparison.OrdinalIgnoreCase))
|
||||
return new CatchUpPlan(due.Count == 0 ? [scheduledFor] : [due[^1]], nextDueAt);
|
||||
|
||||
if (catchUpPolicy.Equals("SKIP_MISSED", StringComparison.OrdinalIgnoreCase))
|
||||
return new CatchUpPlan(scheduledFor < now ? [] : [scheduledFor], nextDueAt);
|
||||
|
||||
if (catchUpPolicy.Equals("ALL_WITH_LIMIT", StringComparison.OrdinalIgnoreCase))
|
||||
return next;
|
||||
return new CatchUpPlan(due.Count <= maxCatchUp ? due : due.TakeLast(maxCatchUp).ToArray(), nextDueAt);
|
||||
|
||||
if (!catchUpPolicy.Equals("LATEST_ONLY", StringComparison.OrdinalIgnoreCase)
|
||||
&& !catchUpPolicy.Equals("SKIP_MISSED", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException($"Unsupported catch-up policy '{catchUpPolicy}'.");
|
||||
throw new InvalidOperationException($"Unsupported catch-up policy '{catchUpPolicy}'.");
|
||||
}
|
||||
|
||||
private static IReadOnlyList<DateTimeOffset> GetDueOccurrences(DateTimeOffset scheduledFor, string cadence, DateTimeOffset now)
|
||||
{
|
||||
if (scheduledFor > now) return [];
|
||||
|
||||
var due = new List<DateTimeOffset>();
|
||||
var occurrence = scheduledFor;
|
||||
var guard = 0;
|
||||
while (next <= now)
|
||||
while (occurrence <= now)
|
||||
{
|
||||
next = Advance(next, cadence);
|
||||
due.Add(occurrence);
|
||||
occurrence = Advance(occurrence, cadence);
|
||||
if (++guard > 5000) throw new InvalidOperationException("Schedule catch-up exceeded safety guard.");
|
||||
}
|
||||
return next;
|
||||
return due;
|
||||
}
|
||||
|
||||
private static DateTimeOffset Advance(DateTimeOffset value, string cadence) => cadence.ToUpperInvariant() switch
|
||||
|
||||
@@ -59,6 +59,16 @@ public sealed class DapperModelScheduleRepository(IDbConnectionFactory connectio
|
||||
where schedule_id = @ScheduleId and lease_owner = @LeaseOwner;
|
||||
""";
|
||||
|
||||
private const string AdvanceSql = """
|
||||
update evaluation.model_operation_schedule
|
||||
set next_due_at = @NextDueAt,
|
||||
lease_owner = null,
|
||||
lease_until = null,
|
||||
last_error_code = null,
|
||||
updated_at = @AdvancedAt
|
||||
where schedule_id = @ScheduleId and lease_owner = @LeaseOwner;
|
||||
""";
|
||||
|
||||
public async Task<IReadOnlyList<DueModelOperation>> AcquireDueAsync(DateTimeOffset now, string leaseOwner, TimeSpan leaseDuration, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||
@@ -73,6 +83,13 @@ public sealed class DapperModelScheduleRepository(IDbConnectionFactory connectio
|
||||
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)
|
||||
{
|
||||
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));
|
||||
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)
|
||||
{
|
||||
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||
|
||||
@@ -33,21 +33,32 @@ public sealed class ModelOperationsDispatcherJob(
|
||||
{
|
||||
try
|
||||
{
|
||||
var job = Job.FromExpression<ScheduledModelOperationJob>(handler => handler.ExecuteAsync(
|
||||
item.ScheduleId,
|
||||
item.OperationCode,
|
||||
item.ScopeKey,
|
||||
item.AutomationMode,
|
||||
item.IdempotencyKey));
|
||||
var backgroundJobId = jobs.Create(job, new EnqueuedState(item.Queue));
|
||||
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);
|
||||
continue;
|
||||
}
|
||||
|
||||
var backgroundJobId = string.Empty;
|
||||
foreach (var occurrence in plan.OccurrencesToDispatch)
|
||||
{
|
||||
var idempotencyKey = $"{item.OperationCode}:{item.ScopeKey}:{item.ScheduleVersion}:{occurrence.UtcDateTime:yyyyMMddHHmmss}Z";
|
||||
var job = Job.FromExpression<ScheduledModelOperationJob>(handler => handler.ExecuteAsync(
|
||||
item.ScheduleId,
|
||||
item.OperationCode,
|
||||
item.ScopeKey,
|
||||
item.AutomationMode,
|
||||
idempotencyKey));
|
||||
backgroundJobId = jobs.Create(job, new EnqueuedState(item.Queue));
|
||||
}
|
||||
|
||||
var nextDueAt = ScheduleOccurrencePlanner.GetNextDueAt(item.ScheduledFor, item.Cadence, item.CatchUpPolicy, item.MaxCatchUp, now);
|
||||
await schedules.MarkDispatchedAsync(
|
||||
item.ScheduleId,
|
||||
leaseOwner,
|
||||
backgroundJobId,
|
||||
now,
|
||||
nextDueAt,
|
||||
plan.NextDueAt,
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch (Exception exception)
|
||||
|
||||
@@ -15,4 +15,22 @@ public sealed class ScheduleOccurrencePlannerTests
|
||||
Assert.True(next > new DateTimeOffset(2026, 8, 1, 4, 0, 0, TimeSpan.Zero));
|
||||
Assert.Equal(1, next.Hour);
|
||||
}
|
||||
|
||||
[Fact] public void All_with_limit_dispatches_only_the_most_recent_configured_occurrences()
|
||||
{
|
||||
var scheduled = new DateTimeOffset(2026, 8, 1, 1, 0, 0, TimeSpan.Zero);
|
||||
var plan = ScheduleOccurrencePlanner.Plan(scheduled, "DAILY", "ALL_WITH_LIMIT", 2, new DateTimeOffset(2026, 8, 4, 4, 0, 0, TimeSpan.Zero));
|
||||
|
||||
Assert.Equal([scheduled.AddDays(2), scheduled.AddDays(3)], plan.OccurrencesToDispatch);
|
||||
Assert.Equal(scheduled.AddDays(4), plan.NextDueAt);
|
||||
}
|
||||
|
||||
[Fact] public void Skip_missed_advances_without_dispatching_a_stale_occurrence()
|
||||
{
|
||||
var scheduled = new DateTimeOffset(2026, 8, 1, 1, 0, 0, TimeSpan.Zero);
|
||||
var plan = ScheduleOccurrencePlanner.Plan(scheduled, "DAILY", "SKIP_MISSED", 1, new DateTimeOffset(2026, 8, 3, 4, 0, 0, TimeSpan.Zero));
|
||||
|
||||
Assert.Empty(plan.OccurrencesToDispatch);
|
||||
Assert.Equal(scheduled.AddDays(3), plan.NextDueAt);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user