feat: add deterministic execution heartbeats (AEG-V15-038)

Adds a pure, monotonic execution heartbeat and caller-supplied staleness cutoff without inventing alert thresholds. Evidence: targeted Release tests 5/5 passed; TRX SHA256 2ADBB526FAF6E5D924EB3F53C7E582E736199A59E0DA4E4FD25DCAA82A661BBC. WBS remains IN_PROGRESS pending approved alert contract.
This commit is contained in:
2026-08-09 02:20:14 +09:00
parent d38dc32e7a
commit ec80337389
6 changed files with 124 additions and 1 deletions
@@ -0,0 +1,21 @@
# AEG-V15-038 — Schedule heartbeat and aging
## Scope
- **WBS ID:** `AEG-V15-038`; **Requirement / API / DB / Job / UI / Test:** `REQ-V15-SCH-06` / `V15-SCH-API` / `MIG-0020` / `J31~J40` / `Cross` / `T-V15-SCH-06`.
- **Dependency / Gate:** `AEG-V15-037` (completed, commit `d38dc32`) / `G3`.
- **Acceptance:** detect a stuck request and produce Owner-alert evidence.
## Source / Assumption / Unknown / Decision Required
- **Source:** `MIG-0020` provides `last_heartbeat_at`; the execution state machine has no heartbeat contract; schedule rows provide Primary/Secondary owners.
- **Assumption:** a heartbeat is valid only for a running execution and cannot move time backwards. Staleness is evaluated against a caller-supplied cutoff, not a hidden default threshold.
- **Unknown:** no approved stale duration, alert transport, severity, or escalation policy exists for J31J40.
- **Decision Required:** an approved owner-alert contract is required before an I/O alert sender or automatic action may be added. This Slice deliberately creates no default threshold, alert, or schedule activation.
## Actual evidence and remaining acceptance
- `dotnet test tests/KArtSell.ModelOperations.UnitTests/KArtSell.ModelOperations.UnitTests.csproj -c Release --filter FullyQualifiedName~ModelOperationExecutionTests --logger "trx;LogFileName=ModelOperationExecutionHeartbeatTests_20260809.trx" --results-directory evidence/AEG-V15-038`
- Result: passed `5/5`; artifact: `evidence/AEG-V15-038/ModelOperationExecutionHeartbeatTests_20260809.trx`; SHA-256: `2ADBB526FAF6E5D924EB3F53C7E582E736199A59E0DA4E4FD25DCAA82A661BBC`.
- A running execution alone can write a monotonic heartbeat; a caller-provided cutoff deterministically identifies staleness. No magic number, wall clock, alert sender, retry, or activation was introduced.
- **Not complete:** persist heartbeat to `last_heartbeat_at`, query it with schedule owners, and produce Owner-alert evidence after the stale-duration and alert-contract decision is approved.
+1 -1
View File
@@ -516,7 +516,7 @@ AEG-V15-034,S8,W17-18,VS-18,BacktestingEvaluation,Scheduler,SCH,REQ-V15-SCH-02,V
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,COMPLETED
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,COMPLETED
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-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,IN_PROGRESS
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
AEG-V15-040,S8,W17-18,VS-18,BacktestingEvaluation,Scheduler,SCH,REQ-V15-SCH-08,V15-SCH-API,MIG-0020,J31~J40,Cross,T-V15-SCH-08,Scheduler chaos rehearsal,dispatcher chaos tests,enqueue/mark 실패·재시작 중복 side effect 0,BE Lead,SRE/QA,4,AEG-V15-039,G3,SOURCE+DESIGN_PROPOSAL,P1,PLANNED
AEG-V15-041,S8,W17-18,VS-18,BacktestingEvaluation,Evaluation Windows,EVA,REQ-V15-EVA-01,V15-EVA-API,MIG-0020,J31~J40,Cross,T-V15-EVA-01,1/5/20/63/126/252 window planner,EvaluationWindowPlanner,calendar day가 아닌 거래세션 사용,Quant Lead,Data/QA,3,AEG-X-001,G4,SOURCE+DESIGN_PROPOSAL,P0,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
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 COMPLETED
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 COMPLETED
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 COMPLETED
519 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 IN_PROGRESS
520 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
521 AEG-V15-040 S8 W17-18 VS-18 BacktestingEvaluation Scheduler SCH REQ-V15-SCH-08 V15-SCH-API MIG-0020 J31~J40 Cross T-V15-SCH-08 Scheduler chaos rehearsal dispatcher chaos tests enqueue/mark 실패·재시작 중복 side effect 0 BE Lead SRE/QA 4 AEG-V15-039 G3 SOURCE+DESIGN_PROPOSAL P1 PLANNED
522 AEG-V15-041 S8 W17-18 VS-18 BacktestingEvaluation Evaluation Windows EVA REQ-V15-EVA-01 V15-EVA-API MIG-0020 J31~J40 Cross T-V15-EVA-01 1/5/20/63/126/252 window planner EvaluationWindowPlanner calendar day가 아닌 거래세션 사용 Quant Lead Data/QA 3 AEG-X-001 G4 SOURCE+DESIGN_PROPOSAL P0 PLANNED
@@ -11,6 +11,7 @@ AEG-V15-034,S8,VS-18,Catch-up policy 구현,COMPLETED,2026-08-09,"docs/CURRENT/A
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,COMPLETED,2026-08-09,"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; tests/KArtSell.Integration.Tests/Scheduling/ModelScheduleCasTests.cs; evidence/AEG-V15-036/DispatcherCasContractTests_20260809.trx; evidence/AEG-V15-036/ModelScheduleCasTests_20260809.trx",BE Lead,"Actual evidence: unit contract tests 8/8 passed and PostgreSQL integration ModelScheduleCasTests 1/1 passed. The integration test acquires an isolated schedule, expires/reacquires its lease, and verifies a stale owner/revision cannot mutate next_due_at (0-row CAS) while the current owner/revision remains. It found and fixed Dapper positional record materialization by mapping a SQL row DTO explicitly to DueModelOperation. Schedules remain disabled; DEC-083 enqueue/mark atomicity remains a separate later Slice."
AEG-V15-037,S8,VS-18,BusinessHold와 기술실패 분리,COMPLETED,2026-08-09,"docs/CURRENT/AEG-V15-037_EXECUTION_HOLD_SLICE_NOTE.md; src/KArtSell.Modules.ModelOperations/Domain/ModelOperationExecution.cs; tests/KArtSell.ModelOperations.UnitTests/ModelOperationExecutionTests.cs; evidence/AEG-V15-037/ModelOperationExecutionTests_20260809.trx",BE Lead,"Actual Release evidence: ModelOperationExecutionTests 3/3 passed. The pure state machine requires a future holdUntil plus reason for BUSINESS_HOLD, clears it only through explicit resume, and rejects holdUntil for FAILED. This prevents a business hold from becoming a blind technical retry. No unapproved retry/backoff, schedule activation, persistence workflow, or threshold was added."
AEG-V15-038,S8,VS-18,Schedule heartbeat/aging,IN_PROGRESS,TBD,"docs/CURRENT/AEG-V15-038_HEARTBEAT_AGING_SLICE_NOTE.md; src/KArtSell.Modules.ModelOperations/Domain/ModelOperationExecution.cs; tests/KArtSell.ModelOperations.UnitTests/ModelOperationExecutionTests.cs; evidence/AEG-V15-038/ModelOperationExecutionHeartbeatTests_20260809.trx",BE Lead,"Implemented and verified the pure heartbeat/aging contract: only RUNNING accepts monotonic heartbeats, and staleness uses an explicit caller-supplied cutoff (5/5 targeted Release tests passed). Still IN_PROGRESS: the approved stale-duration, alert channel/owner/escalation contract is absent, so no magic timeout, alert sender, persistence workflow, or schedule activation was invented."
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
11 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.
12 AEG-V15-036 S8 VS-18 Dispatcher nextDue CAS COMPLETED 2026-08-09 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; tests/KArtSell.Integration.Tests/Scheduling/ModelScheduleCasTests.cs; evidence/AEG-V15-036/DispatcherCasContractTests_20260809.trx; evidence/AEG-V15-036/ModelScheduleCasTests_20260809.trx BE Lead Actual evidence: unit contract tests 8/8 passed and PostgreSQL integration ModelScheduleCasTests 1/1 passed. The integration test acquires an isolated schedule, expires/reacquires its lease, and verifies a stale owner/revision cannot mutate next_due_at (0-row CAS) while the current owner/revision remains. It found and fixed Dapper positional record materialization by mapping a SQL row DTO explicitly to DueModelOperation. Schedules remain disabled; DEC-083 enqueue/mark atomicity remains a separate later Slice.
13 AEG-V15-037 S8 VS-18 BusinessHold와 기술실패 분리 COMPLETED 2026-08-09 docs/CURRENT/AEG-V15-037_EXECUTION_HOLD_SLICE_NOTE.md; src/KArtSell.Modules.ModelOperations/Domain/ModelOperationExecution.cs; tests/KArtSell.ModelOperations.UnitTests/ModelOperationExecutionTests.cs; evidence/AEG-V15-037/ModelOperationExecutionTests_20260809.trx BE Lead Actual Release evidence: ModelOperationExecutionTests 3/3 passed. The pure state machine requires a future holdUntil plus reason for BUSINESS_HOLD, clears it only through explicit resume, and rejects holdUntil for FAILED. This prevents a business hold from becoming a blind technical retry. No unapproved retry/backoff, schedule activation, persistence workflow, or threshold was added.
14 AEG-V15-038 S8 VS-18 Schedule heartbeat/aging IN_PROGRESS TBD docs/CURRENT/AEG-V15-038_HEARTBEAT_AGING_SLICE_NOTE.md; src/KArtSell.Modules.ModelOperations/Domain/ModelOperationExecution.cs; tests/KArtSell.ModelOperations.UnitTests/ModelOperationExecutionTests.cs; evidence/AEG-V15-038/ModelOperationExecutionHeartbeatTests_20260809.trx BE Lead Implemented and verified the pure heartbeat/aging contract: only RUNNING accepts monotonic heartbeats, and staleness uses an explicit caller-supplied cutoff (5/5 targeted Release tests passed). Still IN_PROGRESS: the approved stale-duration, alert channel/owner/escalation contract is absent, so no magic timeout, alert sender, persistence workflow, or schedule activation was invented.
15 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.
16 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.
17 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,58 @@
<?xml version="1.0" encoding="utf-8"?>
<TestRun id="8976b6f7-2599-4c71-8edf-96d9d363c4a1" name="kjh20@KIMJAEHYUN-NOTE 2026-08-09 02:19:36" runUser="KIMJAEHYUN-NOTE\kjh20" xmlns="http://microsoft.com/schemas/VisualStudio/TeamTest/2010">
<Times creation="2026-08-09T02:19:36.5669605+09:00" queuing="2026-08-09T02:19:36.5669610+09:00" start="2026-08-09T02:19:35.3289226+09:00" finish="2026-08-09T02:19:36.5743814+09:00" />
<TestSettings name="default" id="1b508f2f-f153-4374-87e3-e7c0203ba253">
<Deployment runDeploymentRoot="kjh20_KIMJAEHYUN-NOTE_2026-08-09_02_19_36" />
</TestSettings>
<Results>
<UnitTestResult executionId="14789a69-ea09-4cd7-aba1-5b6e76b56f97" testId="8cd7f1ee-4c9d-d1f2-992a-04e36f602014" testName="KArtSell.ModelOperations.UnitTests.ModelOperationExecutionTests.Running_execution_records_monotonic_heartbeats_and_uses_the_caller_cutoff" computerName="KIMJAEHYUN-NOTE" duration="00:00:00.0011167" startTime="2026-08-09T02:19:36.4676751+09:00" endTime="2026-08-09T02:19:36.4677304+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="14789a69-ea09-4cd7-aba1-5b6e76b56f97" />
<UnitTestResult executionId="fc0f44c6-777e-4dbe-83a1-a98753a520c4" testId="fb07f491-ffe5-b6cc-1d25-ca1d5d81e6d4" testName="KArtSell.ModelOperations.UnitTests.ModelOperationExecutionTests.Business_hold_persists_reason_and_expiry_until_an_explicit_resume" computerName="KIMJAEHYUN-NOTE" duration="00:00:00.0135315" startTime="2026-08-09T02:19:36.4352123+09:00" endTime="2026-08-09T02:19:36.4565794+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="fc0f44c6-777e-4dbe-83a1-a98753a520c4" />
<UnitTestResult executionId="13fa42ba-63ad-4460-ba38-35f4c9f149c4" testId="3ad1ce15-14cb-0bca-8778-f23485ec4642" testName="KArtSell.ModelOperations.UnitTests.ModelOperationExecutionTests.Business_hold_can_resume_but_success_is_terminal" computerName="KIMJAEHYUN-NOTE" duration="00:00:00.0020236" startTime="2026-08-09T02:19:36.4670179+09:00" endTime="2026-08-09T02:19:36.4670857+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="13fa42ba-63ad-4460-ba38-35f4c9f149c4" />
<UnitTestResult executionId="3a1db35a-9caf-43a4-a7e1-c2ae382409a4" testId="29eb9b38-c1c2-def5-ee12-9d36c3ca99fd" testName="KArtSell.ModelOperations.UnitTests.ModelOperationExecutionTests.Business_hold_requires_a_future_expiry_but_technical_failure_cannot_set_one" computerName="KIMJAEHYUN-NOTE" duration="00:00:00.0003838" startTime="2026-08-09T02:19:36.4678463+09:00" endTime="2026-08-09T02:19:36.4678862+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="3a1db35a-9caf-43a4-a7e1-c2ae382409a4" />
<UnitTestResult executionId="24d700ed-3ea1-430f-a45d-03a7c4a04a87" testId="bc560972-cb07-d3a1-8c5e-682ba5561a6d" testName="KArtSell.ModelOperations.UnitTests.ModelOperationExecutionTests.Non_running_execution_cannot_record_a_heartbeat" computerName="KIMJAEHYUN-NOTE" duration="00:00:00.0002105" startTime="2026-08-09T02:19:36.4679745+09:00" endTime="2026-08-09T02:19:36.4680181+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="24d700ed-3ea1-430f-a45d-03a7c4a04a87" />
</Results>
<TestDefinitions>
<UnitTest name="KArtSell.ModelOperations.UnitTests.ModelOperationExecutionTests.Business_hold_persists_reason_and_expiry_until_an_explicit_resume" storage="d:\jobroomz\kartsell.aegis\tests\kartsell.modeloperations.unittests\bin\release\net10.0\kartsell.modeloperations.unittests.dll" id="fb07f491-ffe5-b6cc-1d25-ca1d5d81e6d4">
<Execution id="fc0f44c6-777e-4dbe-83a1-a98753a520c4" />
<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.ModelOperationExecutionTests" name="Business_hold_persists_reason_and_expiry_until_an_explicit_resume" />
</UnitTest>
<UnitTest name="KArtSell.ModelOperations.UnitTests.ModelOperationExecutionTests.Business_hold_can_resume_but_success_is_terminal" storage="d:\jobroomz\kartsell.aegis\tests\kartsell.modeloperations.unittests\bin\release\net10.0\kartsell.modeloperations.unittests.dll" id="3ad1ce15-14cb-0bca-8778-f23485ec4642">
<Execution id="13fa42ba-63ad-4460-ba38-35f4c9f149c4" />
<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.ModelOperationExecutionTests" name="Business_hold_can_resume_but_success_is_terminal" />
</UnitTest>
<UnitTest name="KArtSell.ModelOperations.UnitTests.ModelOperationExecutionTests.Non_running_execution_cannot_record_a_heartbeat" storage="d:\jobroomz\kartsell.aegis\tests\kartsell.modeloperations.unittests\bin\release\net10.0\kartsell.modeloperations.unittests.dll" id="bc560972-cb07-d3a1-8c5e-682ba5561a6d">
<Execution id="24d700ed-3ea1-430f-a45d-03a7c4a04a87" />
<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.ModelOperationExecutionTests" name="Non_running_execution_cannot_record_a_heartbeat" />
</UnitTest>
<UnitTest name="KArtSell.ModelOperations.UnitTests.ModelOperationExecutionTests.Running_execution_records_monotonic_heartbeats_and_uses_the_caller_cutoff" storage="d:\jobroomz\kartsell.aegis\tests\kartsell.modeloperations.unittests\bin\release\net10.0\kartsell.modeloperations.unittests.dll" id="8cd7f1ee-4c9d-d1f2-992a-04e36f602014">
<Execution id="14789a69-ea09-4cd7-aba1-5b6e76b56f97" />
<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.ModelOperationExecutionTests" name="Running_execution_records_monotonic_heartbeats_and_uses_the_caller_cutoff" />
</UnitTest>
<UnitTest name="KArtSell.ModelOperations.UnitTests.ModelOperationExecutionTests.Business_hold_requires_a_future_expiry_but_technical_failure_cannot_set_one" storage="d:\jobroomz\kartsell.aegis\tests\kartsell.modeloperations.unittests\bin\release\net10.0\kartsell.modeloperations.unittests.dll" id="29eb9b38-c1c2-def5-ee12-9d36c3ca99fd">
<Execution id="3a1db35a-9caf-43a4-a7e1-c2ae382409a4" />
<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.ModelOperationExecutionTests" name="Business_hold_requires_a_future_expiry_but_technical_failure_cannot_set_one" />
</UnitTest>
</TestDefinitions>
<TestEntries>
<TestEntry testId="8cd7f1ee-4c9d-d1f2-992a-04e36f602014" executionId="14789a69-ea09-4cd7-aba1-5b6e76b56f97" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="fb07f491-ffe5-b6cc-1d25-ca1d5d81e6d4" executionId="fc0f44c6-777e-4dbe-83a1-a98753a520c4" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="3ad1ce15-14cb-0bca-8778-f23485ec4642" executionId="13fa42ba-63ad-4460-ba38-35f4c9f149c4" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="29eb9b38-c1c2-def5-ee12-9d36c3ca99fd" executionId="3a1db35a-9caf-43a4-a7e1-c2ae382409a4" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="bc560972-cb07-d3a1-8c5e-682ba5561a6d" executionId="24d700ed-3ea1-430f-a45d-03a7c4a04a87" 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="5" executed="5" passed="5" 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.52] Discovering: KArtSell.ModelOperations.UnitTests&#xD;
[xUnit.net 00:00:00.58] Discovered: KArtSell.ModelOperations.UnitTests&#xD;
[xUnit.net 00:00:00.61] Starting: KArtSell.ModelOperations.UnitTests&#xD;
[xUnit.net 00:00:00.67] Finished: KArtSell.ModelOperations.UnitTests&#xD;
</StdOut>
</Output>
</ResultSummary>
</TestRun>
@@ -48,6 +48,7 @@ public sealed class ModelOperationExecution
public string IdempotencyKey { get; }
public DateTimeOffset RequestedAt { get; }
public DateTimeOffset LastOccurredAt { get; private set; }
public DateTimeOffset? LastHeartbeatAt { get; private set; }
public ModelOperationExecutionState State { get; private set; }
public DateTimeOffset? HoldUntil { get; private set; }
public IReadOnlyList<ModelOperationExecutionTransition> Transitions => transitions;
@@ -73,8 +74,28 @@ public sealed class ModelOperationExecution
State = next;
LastOccurredAt = occurredAt;
HoldUntil = holdUntil;
if (next == ModelOperationExecutionState.Running) LastHeartbeatAt = occurredAt;
return transition;
}
public void RecordHeartbeat(DateTimeOffset occurredAt)
{
if (State != ModelOperationExecutionState.Running)
throw new InvalidOperationException("Only a running execution may record a heartbeat.");
if (occurredAt < LastHeartbeatAt)
throw new InvalidOperationException("Execution heartbeat time cannot move backwards.");
LastHeartbeatAt = occurredAt;
}
public bool HasExceededHeartbeatCutoff(DateTimeOffset asOf, TimeSpan allowedSilence)
{
ArgumentOutOfRangeException.ThrowIfLessThan(allowedSilence, TimeSpan.Zero);
if (asOf < LastHeartbeatAt) throw new InvalidOperationException("Heartbeat cutoff cannot precede the latest heartbeat.");
return State == ModelOperationExecutionState.Running
&& LastHeartbeatAt.HasValue
&& asOf - LastHeartbeatAt.Value > allowedSilence;
}
private static HashSet<ModelOperationExecutionState> Set(params ModelOperationExecutionState[] states) => new(states);
}
@@ -40,4 +40,26 @@ public sealed class ModelOperationExecutionTests
execution.MoveTo(ModelOperationExecutionState.Running, "CALENDAR_APPROVED", "h2", holdUntil);
Assert.Null(execution.HoldUntil);
}
[Fact]
public void Running_execution_records_monotonic_heartbeats_and_uses_the_caller_cutoff()
{
var now = new DateTimeOffset(2026, 8, 9, 0, 0, 0, TimeSpan.Zero);
var execution = new ModelOperationExecution(Guid.NewGuid(), "key", now);
execution.MoveTo(ModelOperationExecutionState.Running, "STARTED", "h1", now);
execution.RecordHeartbeat(now.AddMinutes(2));
Assert.False(execution.HasExceededHeartbeatCutoff(now.AddMinutes(6), TimeSpan.FromMinutes(5)));
Assert.True(execution.HasExceededHeartbeatCutoff(now.AddMinutes(8), TimeSpan.FromMinutes(5)));
Assert.Throws<InvalidOperationException>(() => execution.RecordHeartbeat(now.AddMinutes(1)));
}
[Fact]
public void Non_running_execution_cannot_record_a_heartbeat()
{
var now = new DateTimeOffset(2026, 8, 9, 0, 0, 0, TimeSpan.Zero);
var execution = new ModelOperationExecution(Guid.NewGuid(), "key", now);
Assert.Throws<InvalidOperationException>(() => execution.RecordHeartbeat(now));
}
}