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:
2026-08-09 02:01:25 +09:00
parent 6a86997438
commit dd352596fc
9 changed files with 172 additions and 18 deletions
@@ -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.
+1 -1
View File
@@ -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
1 WBS_ID Sprint Weeks Slice_ID Slice Module Workstream Requirement_ID API_ID DB_Migration_ID Job_Event_ID UI_ID Test_ID Task Artifact Acceptance_Evidence Primary_Owner Secondary PD Dependency Gate Evidence_Class Risk Status
512 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
513 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
514 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
515 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 COMPLETED
516 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
517 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
518 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."
1 WBS_ID Sprint Slice_ID Task Status Completion_Date Evidence_Link Owner Notes
7 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.
8 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).
9 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.
10 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.
11 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.
12 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.
13 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)&#xD;
[xUnit.net 00:00:00.29] Discovering: KArtSell.ModelOperations.UnitTests&#xD;
[xUnit.net 00:00:00.34] Discovered: KArtSell.ModelOperations.UnitTests&#xD;
[xUnit.net 00:00:00.36] Starting: KArtSell.ModelOperations.UnitTests&#xD;
[xUnit.net 00:00:00.43] Finished: KArtSell.ModelOperations.UnitTests&#xD;
</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);
}
}