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:
@@ -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 J31–J40.
|
||||
- **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.
|
||||
@@ -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
|
||||
|
||||
|
@@ -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."
|
||||
|
||||
|
@@ -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)
|
||||
[xUnit.net 00:00:00.52] Discovering: KArtSell.ModelOperations.UnitTests
|
||||
[xUnit.net 00:00:00.58] Discovered: KArtSell.ModelOperations.UnitTests
|
||||
[xUnit.net 00:00:00.61] Starting: KArtSell.ModelOperations.UnitTests
|
||||
[xUnit.net 00:00:00.67] Finished: KArtSell.ModelOperations.UnitTests
|
||||
</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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user