From 736951526ba9855ca9d27dad75e13f757fb793d7 Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Mon, 13 Jul 2026 00:32:48 +0900 Subject: [PATCH] feat(dotnet): add idempotency contract harness --- .gitea/workflows/ci.yml | 3 + AGENTS.md | 2 + docs/ROADMAP_WBS.md | 1 + docs/WBS_10_DOTNET_IDEMPOTENCY_CONTRACT.yaml | 53 +++++++++++++ spec/41_release_dag.yaml | 16 ++++ ...validate_dotnet_idempotency_contract_v1.py | 38 ++++++++++ ...validate_dotnet_idempotency_contract_v1.py | 76 +++++++++++++++++++ 7 files changed, 189 insertions(+) create mode 100644 docs/WBS_10_DOTNET_IDEMPOTENCY_CONTRACT.yaml create mode 100644 tests/unit/test_validate_dotnet_idempotency_contract_v1.py create mode 100644 tools/validate_dotnet_idempotency_contract_v1.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b21b5783..abc10bd9 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -184,6 +184,9 @@ jobs: - name: Validate Dotnet Normalization Contract run: python3 tools/validate_dotnet_normalization_contract_v1.py + - name: Validate Dotnet Idempotency Contract + run: python3 tools/validate_dotnet_idempotency_contract_v1.py + - name: Build Calibration Priority Backlog diff --git a/AGENTS.md b/AGENTS.md index 6bced5aa..bd4059d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,12 +103,14 @@ - `docs/WBS_10_DOTNET_PROVENANCE_CONTRACT.yaml`: WBS-10 provenance payload 표준 계약. - `docs/WBS_10_DOTNET_SCHEDULER_CONTRACT.yaml`: WBS-10 scheduler state machine 계약. - `docs/WBS_10_DOTNET_NORMALIZATION_CONTRACT.yaml`: WBS-10 normalization/read model 계약. +- `docs/WBS_10_DOTNET_IDEMPOTENCY_CONTRACT.yaml`: WBS-10 idempotency/lock 계약. - `tools/validate_dotnet_migration_roadmap_v1.py`: WBS-10 상세 로드맵 YAML validator. - `tools/validate_dotnet_migration_execution_plan_v1.py`: WBS-10 실행 분해 계획 validator. - `tools/validate_dotnet_parity_contract_v1.py`: WBS-10 parity 계약 validator. - `tools/validate_dotnet_provenance_contract_v1.py`: WBS-10 provenance 계약 validator. - `tools/validate_dotnet_scheduler_contract_v1.py`: WBS-10 scheduler 계약 validator. - `tools/validate_dotnet_normalization_contract_v1.py`: WBS-10 normalization 계약 validator. +- `tools/validate_dotnet_idempotency_contract_v1.py`: WBS-10 idempotency 계약 validator. - `Temp/snapshot_admin_approval_packet_v1.json`: snapshot admin approval packet export. - `Temp/snapshot_admin_approval_packet_v1.md`: snapshot admin approval packet summary. - `Temp/`: 실행 결과와 캐시. 라우팅 대상은 아니며 runtime consumer만 읽는다. diff --git a/docs/ROADMAP_WBS.md b/docs/ROADMAP_WBS.md index 6ed66cf1..3c02437d 100644 --- a/docs/ROADMAP_WBS.md +++ b/docs/ROADMAP_WBS.md @@ -1472,6 +1472,7 @@ WBS-8.8 (KIS 리팩터) — 독립적 (원격 병행) > provenance 계약: [WBS_10_DOTNET_PROVENANCE_CONTRACT.yaml](./WBS_10_DOTNET_PROVENANCE_CONTRACT.yaml) > scheduler contract: [WBS_10_DOTNET_SCHEDULER_CONTRACT.yaml](./WBS_10_DOTNET_SCHEDULER_CONTRACT.yaml) > normalization contract: [WBS_10_DOTNET_NORMALIZATION_CONTRACT.yaml](./WBS_10_DOTNET_NORMALIZATION_CONTRACT.yaml) +> idempotency contract: [WBS_10_DOTNET_IDEMPOTENCY_CONTRACT.yaml](./WBS_10_DOTNET_IDEMPOTENCY_CONTRACT.yaml) > 현황 진단(2026-06-26): .NET 프로젝트는 Python 엔진(41 모듈, 14,500 LOC) 대비 5~10%(~1,400 LOC) 수준. > Domain 계산기 6개·데이터 모델 8개·KIS/Naver/Yahoo 클라이언트·PostgreSQL 마이그레이션·Razor Pages 어드민 대시보드 기본 구현 완료. diff --git a/docs/WBS_10_DOTNET_IDEMPOTENCY_CONTRACT.yaml b/docs/WBS_10_DOTNET_IDEMPOTENCY_CONTRACT.yaml new file mode 100644 index 00000000..aa75f780 --- /dev/null +++ b/docs/WBS_10_DOTNET_IDEMPOTENCY_CONTRACT.yaml @@ -0,0 +1,53 @@ +formula_id: WBS_10_DOTNET_IDEMPOTENCY_CONTRACT_V1 +owner: QuantEngine +status: draft +goal: "중복 실행 방지, lock/lease 정책, 재시도 경계를 표준화한다." + +lock_domain: + canonical_table: quantengine.workspace_lock + fields: + - domain + - target_ref + - locked_by + - reason + - locked_at + invariant: + - "같은 domain + target_ref 조합은 동시에 하나만 존재" + - "잠금 해제는 동일 domain + target_ref 로만 수행" + - "잠금 없는 실행은 retryable 작업으로 취급하지 않는다" + +idempotency_key: + required: true + pattern: "{job_id}:{resource_key}:{run_scope}" + scope_examples: + - "daily-collection:collection:yyyyMMdd" + - "hourly-price-update:ticker:yyyyMMddHH" + - "weekly-report:report:yyyy-'W'ww" + - "monthly-optimization:optimization:yyyy-MM" + +lease_policy: + required: true + fields: + - lease_owner + - timeout_policy + - retry_policy + retry_policy: + max_attempts: 3 + backoff: exponential + retryable_errors: + - transient network failure + - upstream timeout + - deadlock detected + non_retryable_errors: + - validation failure + - contract failure + - missing configuration + +duplicate_execution_guards: + - "동일 job_id/resource_key/run_scope 중복 호출 금지" + - "동일 lock_domain이 점유 중이면 새 실행은 blocked" + - "중복 실행이 발생하면 audit는 남기되 write path는 재진입 금지" + +notes: + - "idempotency는 hash 추정이 아니라 명시된 key 조합만 사용한다." + - "lock은 수동 승인 워크플로와 동일한 canonical table을 사용한다." diff --git a/spec/41_release_dag.yaml b/spec/41_release_dag.yaml index c3e0b7e2..6922291e 100644 --- a/spec/41_release_dag.yaml +++ b/spec/41_release_dag.yaml @@ -2377,6 +2377,22 @@ dag: - Temp/wbs_10_dotnet_normalization_contract_v1.json strict: true timeout_sec: 60 + validate_dotnet_idempotency_contract: + artifact_policy: keep + cache_key: validate_dotnet_idempotency_contract_v1 + command: + - python + - tools/validate_dotnet_idempotency_contract_v1.py + depends_on: [] + id: validate_dotnet_idempotency_contract + inputs: + - tools/validate_dotnet_idempotency_contract_v1.py + - docs/WBS_10_DOTNET_IDEMPOTENCY_CONTRACT.yaml + note: WBS-10 lock/lease/idempotency 경계를 검증한다. + outputs: + - Temp/wbs_10_dotnet_idempotency_contract_v1.json + strict: true + timeout_sec: 60 validate_specs: artifact_policy: keep cache_key: validate_specs_v1 diff --git a/tests/unit/test_validate_dotnet_idempotency_contract_v1.py b/tests/unit/test_validate_dotnet_idempotency_contract_v1.py new file mode 100644 index 00000000..68f8e938 --- /dev/null +++ b/tests/unit/test_validate_dotnet_idempotency_contract_v1.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + + +def test_validate_dotnet_idempotency_contract_passes() -> None: + root = Path(__file__).resolve().parents[2] + proc = subprocess.run( + [sys.executable, str(root / "tools" / "validate_dotnet_idempotency_contract_v1.py")], + cwd=root, + capture_output=True, + text=True, + ) + assert proc.returncode == 0, proc.stdout + proc.stderr + payload = json.loads(proc.stdout) + assert payload["gate"] == "PASS" + + +def test_validate_dotnet_idempotency_contract_reports_missing_table() -> None: + root = Path(__file__).resolve().parents[2] + temp = root / "Temp" / "test_wbs_10_idempotency_contract_bad.yaml" + temp.write_text( + "formula_id: WBS_10_DOTNET_IDEMPOTENCY_CONTRACT_V1\ngoal: 중복 실행 방지, lock/lease 정책, 재시도 경계를 표준화한다.\nlock_domain:\n canonical_table: wrong\nidempotency_key:\n required: true\nlease_policy:\n required: true\n retry_policy:\n max_attempts: 3\n backoff: exponential\nduplicate_execution_guards: [x]\n", + encoding="utf-8", + ) + proc = subprocess.run( + [sys.executable, str(root / "tools" / "validate_dotnet_idempotency_contract_v1.py"), "--contract", str(temp)], + cwd=root, + capture_output=True, + text=True, + ) + assert proc.returncode != 0 + payload = json.loads(proc.stdout) + assert payload["gate"] == "FAIL" + assert "lock_domain.canonical_table" in payload["missing"] diff --git a/tools/validate_dotnet_idempotency_contract_v1.py b/tools/validate_dotnet_idempotency_contract_v1.py new file mode 100644 index 00000000..8ef29f61 --- /dev/null +++ b/tools/validate_dotnet_idempotency_contract_v1.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +import yaml + + +def load_yaml(path: Path) -> dict[str, Any]: + if not path.exists(): + raise FileNotFoundError(path) + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Validate WBS-10 dotnet idempotency contract") + parser.add_argument("--contract", default="docs/WBS_10_DOTNET_IDEMPOTENCY_CONTRACT.yaml") + args = parser.parse_args(argv) + + contract_path = Path(args.contract).resolve() + payload: dict[str, Any] = { + "formula_id": "WBS_10_DOTNET_IDEMPOTENCY_CONTRACT_V1", + "gate": "FAIL", + "missing": [], + "evidence": {"contract": str(contract_path)}, + } + + try: + data = load_yaml(contract_path) + except FileNotFoundError: + payload["missing"].append("contract missing") + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 1 + + if data.get("formula_id") != "WBS_10_DOTNET_IDEMPOTENCY_CONTRACT_V1": + payload["missing"].append("formula_id") + if data.get("goal") != "중복 실행 방지, lock/lease 정책, 재시도 경계를 표준화한다.": + payload["missing"].append("goal") + + if data.get("lock_domain", {}).get("canonical_table") != "quantengine.workspace_lock": + payload["missing"].append("lock_domain.canonical_table") + if data.get("idempotency_key", {}).get("required") is not True: + payload["missing"].append("idempotency_key.required") + if data.get("lease_policy", {}).get("required") is not True: + payload["missing"].append("lease_policy.required") + + policy = data.get("lease_policy") or {} + retry = policy.get("retry_policy") or {} + if retry.get("max_attempts") != 3: + payload["missing"].append("lease_policy.retry_policy.max_attempts") + if retry.get("backoff") != "exponential": + payload["missing"].append("lease_policy.retry_policy.backoff") + + guards = data.get("duplicate_execution_guards") or [] + if not guards: + payload["missing"].append("duplicate_execution_guards") + + payload["gate"] = "PASS" if not payload["missing"] else "FAIL" + payload["message"] = ( + "WBS-10 dotnet idempotency contract validation passed." + if payload["gate"] == "PASS" + else "WBS-10 dotnet idempotency contract validation failed." + ) + + out_path = contract_path.parent.parent / "Temp" / "wbs_10_dotnet_idempotency_contract_v1.json" + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 if payload["gate"] == "PASS" else 1 + + +if __name__ == "__main__": + raise SystemExit(main())