diff --git a/docs/CURRENT/AEG-V15-034_CATCH_UP_POLICY_SLICE_NOTE.md b/docs/CURRENT/AEG-V15-034_CATCH_UP_POLICY_SLICE_NOTE.md new file mode 100644 index 00000000..e3c2e2a8 --- /dev/null +++ b/docs/CURRENT/AEG-V15-034_CATCH_UP_POLICY_SLICE_NOTE.md @@ -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. diff --git a/docs/CURRENT/CATALOGS/WBS_MASTER.csv b/docs/CURRENT/CATALOGS/WBS_MASTER.csv index a3fcd310..dc6b413e 100644 --- a/docs/CURRENT/CATALOGS/WBS_MASTER.csv +++ b/docs/CURRENT/CATALOGS/WBS_MASTER.csv @@ -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 diff --git a/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv b/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv index 484f6bcd..7a83e990 100644 --- a/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv +++ b/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv @@ -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." diff --git a/evidence/AEG-V15-034/ScheduleOccurrencePlannerTests_20260809.trx b/evidence/AEG-V15-034/ScheduleOccurrencePlannerTests_20260809.trx new file mode 100644 index 00000000..05d4d15d --- /dev/null +++ b/evidence/AEG-V15-034/ScheduleOccurrencePlannerTests_20260809.trx @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + [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 + + + + \ 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 d77acbf0..0343e5a3 100644 --- a/src/KArtSell.Modules.ModelOperations/Application/ModelOperationsContracts.cs +++ b/src/KArtSell.Modules.ModelOperations/Application/ModelOperationsContracts.cs @@ -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, diff --git a/src/KArtSell.Modules.ModelOperations/Domain/ScheduleOccurrencePlanner.cs b/src/KArtSell.Modules.ModelOperations/Domain/ScheduleOccurrencePlanner.cs index c3f383d8..8305c850 100644 --- a/src/KArtSell.Modules.ModelOperations/Domain/ScheduleOccurrencePlanner.cs +++ b/src/KArtSell.Modules.ModelOperations/Domain/ScheduleOccurrencePlanner.cs @@ -2,29 +2,56 @@ namespace KArtSell.Modules.ModelOperations.Domain; public sealed class ScheduleOccurrencePlanner { + public sealed record CatchUpPlan( + IReadOnlyList 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 GetDueOccurrences(DateTimeOffset scheduledFor, string cadence, DateTimeOffset now) + { + if (scheduledFor > now) return []; + + var due = new List(); + 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 diff --git a/src/KArtSell.Modules.ModelOperations/Infrastructure/DapperModelScheduleRepository.cs b/src/KArtSell.Modules.ModelOperations/Infrastructure/DapperModelScheduleRepository.cs index 753bc744..34fb2085 100644 --- a/src/KArtSell.Modules.ModelOperations/Infrastructure/DapperModelScheduleRepository.cs +++ b/src/KArtSell.Modules.ModelOperations/Infrastructure/DapperModelScheduleRepository.cs @@ -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> 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); diff --git a/src/KArtSell.Modules.ModelOperations/Scheduling/ModelOperationsDispatcherJob.cs b/src/KArtSell.Modules.ModelOperations/Scheduling/ModelOperationsDispatcherJob.cs index 57950a1c..c3c0acbc 100644 --- a/src/KArtSell.Modules.ModelOperations/Scheduling/ModelOperationsDispatcherJob.cs +++ b/src/KArtSell.Modules.ModelOperations/Scheduling/ModelOperationsDispatcherJob.cs @@ -33,21 +33,32 @@ public sealed class ModelOperationsDispatcherJob( { try { - var job = Job.FromExpression(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(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) diff --git a/tests/KArtSell.ModelOperations.UnitTests/ScheduleOccurrencePlannerTests.cs b/tests/KArtSell.ModelOperations.UnitTests/ScheduleOccurrencePlannerTests.cs index 50e2946d..6eeac2ee 100644 --- a/tests/KArtSell.ModelOperations.UnitTests/ScheduleOccurrencePlannerTests.cs +++ b/tests/KArtSell.ModelOperations.UnitTests/ScheduleOccurrencePlannerTests.cs @@ -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); + } }