Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ jobs:
- name: Test demo decisions without external services
run: python3 -m unittest discover -s scripts/demo -p 'test_*.py' -v

recovery-contract:
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- name: Check out repository
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Test isolated recovery decisions without cloud access
run: python3 -m unittest discover -s scripts/opensql -p 'test_dr_recovery.py' -v

backend:
runs-on: ubuntu-latest
timeout-minutes: 15
Expand Down
102 changes: 102 additions & 0 deletions docs/design/issue-560-upload-recovery-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# 업로드 성공 응답의 DB·GCS 복원 판정 계약

## 목적과 범위

업로드 201 응답을 받았다는 사실과, 복원 DB·원본에서 해당 업로드가 보존됐다는 사실을 분리한다. 기존 Barman/WAL·GCS 보존 도구를 다시 만들지 않고, 클라이언트 응답 원장과 단계별 관문을 추가한다. 관련 이슈는 #560이다.

이 변경은 로컬 Python 계약과 실행 제어기다. 실제 OpenSQL 복원, 실제 GCS 다운로드, BGE-M3 검색의 성공 증거는 후속 격리 실행에서 수집해야 한다. `PASS`는 입력한 연산자의 계약 충족을 뜻하며 RPO 0이나 공개 서비스 RTO를 보장하지 않는다.

## 재사용할 기존 도구

| 구성요소 | 재사용 범위 | 추가로 필요한 관측 |
|---|---|---|
| `barman_wal_follow_primary.py` | Primary 대상 선택과 수신 재연결 | 실제 수신 프로세스·새 WAL 진행; dry-run만으로 정상 수신 판정 금지 |
| `barman_offsite_sync.py` | DONE 기준 백업과 완결 WAL 외부 전송 | 복원 가능한 WAL 연결·외부 identity; 마지막 전송 성공만으로 판정 금지 |
| `verify_gcs_original_preservation.py` | 장애 전 객체 복사·generation·해시 원장 | 장애 뒤에는 원본 접근 없이 고정 보존본만 읽어야 함 |
| `ha_evidence.py` | 0600 쓰기·fsync·원자적 파일 교체 | 이번 실험 전용 요청 ID·응답 ID·최초 감사 스키마 |
| `ha_worker_documents.py` / `analyze_worker_recovery.py` | 합성 문서와 최종 Job·Chunk·Embedding 대조 | 업로드 응답 ID 4종과 최초 복원 상태를 별도 보존 |

```text
그림 1 — 장애 이전 응답 원장과 고정 입력 [실행 설계]

시험 클라이언트 → record_sent(request_id, SHA-256, 크기, cohort)
→ 실제 업로드 API 호출
→ record_response(HTTP 상태, 문서·버전·파일·Job ID)
├─ 200/201 + ID 4종: ACKNOWLEDGED
├─ 응답 미수신: UNKNOWN
└─ 다른 HTTP 상태: HTTP_ERROR (DB 롤백을 뜻하지 않음)

백업 연산자 → 기준 백업·완결 WAL·원본 보존본 준비
→ identity 해시·객체 generation·SHA-256 고정
시험 제어기 → validate_inputs: 준비 조건 7종 모두 참인지 확인
→ freeze_inputs: inputs.json 단독 생성 + cutoff 이벤트
→ 이후 새 요청 등록 금지, 입력 해시 변경 시 실행 금지
```

응답 원장은 DB 밖에 두며 전송 시각과 응답 수신 시각은 해당 이벤트를 기록한 시계 기준이다. cutoff는 제어기가 입력을 고정한 시각이다. 실제 장애 발생·원본 접근 차단 시각과 같다고 추정하면 안 된다. 실제 시험은 두 시각과 차이를 별도 기록한다.

## 복원 판정과 최초 감사

`dr_recovery.py`는 신규 실행 디렉터리만 만들고, 해시 연결 JSONL 원장에 순서·실행 ID·UTC 시각을 기록한다. 이는 누락·변경 검출을 위한 체크이며, 원장을 재작성할 수 있는 공격자에 대한 전자서명은 아니다.

```text
그림 2 — _execute 내부의 변경 전 관문 [구현]

execute.lock 획득 → 여섯 연산자·timeout·정리 명령 전체 검증
│
▼
restore → 고정 입력 해시 일치 + source_access_used=false 확인
│
▼
snapshot → Worker/Flyway/수선/원본 접근이 모두 false인지 검사
│ → 요청마다 DB·버전·파일·Job ID 및 관계 대조
│ → 보존 객체의 generation·실제 SHA-256·크기 대조
▼
initial-snapshot.json + initial-audit.json 단독 생성·fsync
│
├─ 기능 코호트 누락 / 부분 DB / 원본 누락 / 해시 불일치
│ → activate 호출 금지 → cleanup
└─ 기능 코호트 복원 + 손실 코호트의 DB 누락만 허용
→ activate → 격리 DB 버킷 재지정·Flyway·Worker 시작 허용
```

`DB_MISSING`은 실제 조회가 누락을 확인한 경우에만 기록한다. 관측 행 자체가 없으면 증거 부족으로 중단한다. 최초 감사에 손실이 있더라도 후속 수선 결과로 이를 덮어쓰지 않는다. `generation`은 원본 버킷의 과거 숫자와 비교하는 것이 아니라 **동결한 보존 객체의 버전**과 비교한다. 별도 버킷 복사 시 새 generation을 동결 원장에 기록해야 한다.

## 기능별 시간과 정리

```text
그림 3 — 최초 감사 이후와 종료 [구현·실행 설계]

activate 완료 시각
→ functional 연산자
├─ download: 기존 원본 다운로드
├─ search: 기존 문서 검색
├─ job_resume: 중단 Job 재개
├─ new_upload: 새 업로드
├─ new_indexing: 새 문서 INDEXED
└─ new_download: 새 원본 다운로드
각 항목: 통과 여부·표본 수·관측 UTC·cutoff 이후 초
→ final_snapshot
→ 미완료 Job·중복 Chunk·중복 Embedding·누락 Embedding 모두 0
→ final-audit 별도 생성 (initial-audit 보존)
→ finally cleanup: 공개 설정 보존·원본 클러스터 복귀·소유 자원 정리
├─ 정리 확인 + 필수 단계 완료: PASS
└─ 단계 실패·중단·정리 미확인: INCOMPLETE
```

단계별 연산자 명령은 비공개 JSON의 argv 배열로만 전달한다. stdout JSON은 메모리에서 검증하며, stderr·명령문·SSH 주소·자격 증명을 증거에 복사하지 않는다. 스냅샷은 필드와 값 형식을 허용 목록으로 검사한 후 저장한다. 제어기는 연산자의 진술이 실제 환경과 같은지 독립적으로 증명하지 않는다. 후속 실행에서 DB SQL·보존 객체 읽기·HTTP 결과와 함께 근거를 남겨야 한다.

## 사용 절차와 실행 전 조건

```bash
python3 scripts/opensql/dr_recovery.py init --run-dir "$RUN_DIR" --run-id "$RUN_ID" --revision "$REVISION"
python3 scripts/opensql/dr_recovery.py sent --run-dir "$RUN_DIR" --input "$SENT_JSON"
python3 scripts/opensql/dr_recovery.py response --run-dir "$RUN_DIR" --input "$RESPONSE_JSON"
python3 scripts/opensql/dr_recovery.py preflight --input "$INPUT_MANIFEST"
python3 scripts/opensql/dr_recovery.py freeze --run-dir "$RUN_DIR" --input "$INPUT_MANIFEST"
python3 scripts/opensql/dr_recovery.py execute --run-dir "$RUN_DIR" --input "$PRIVATE_OPERATOR_PLAN"
```

`RUN_DIR`는 존재하지 않는 새 디렉터리이며 부모는 준비되어 있어야 한다. 실제 실행 계획은 `restore`, `snapshot`, `activate`, `functional`, `final_snapshot`, `cleanup`을 모두 포함하고, 각 항목에 `argv`와 최대 1,800초의 `timeout_seconds`를 둔다. private plan 자체는 커밋하지 않는다. 같은 실행을 재시도하지 말고 새로운 run ID·PGDATA·원장으로 시작한다.

준비 조건은 기준 백업 확인, WAL 연결 확인, 실제 수신 정상, 외부 identity 확보, 복원 대상 격리, 원복 준비, 공개 설정 보존이다. 모두 참인 boolean 입력은 실제 관측을 대신하지 않는다. 각 조건의 실제 근거가 없으면 false로 남기고 NO-GO를 기록한다. 이번 변경은 서비스 환경 파일·공개 버킷·운영 DB 정책을 바꾸지 않는다.
44 changes: 44 additions & 0 deletions docs/test-results/dr-upload-recovery-contract-20261018/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# 업로드 복원 판정 계약 검증 — 2026-10-18

## 목적

업로드 응답 원장을 복원 DB·원본과 대조하고, 최초 상태 감사 전에 Worker가 켜지지 않는지 검증한다. 이번 기록은 로컬 Python의 합성 JSON·제어 자식 프로세스 시험이다. 실제 OpenSQL·GCS·BGE-M3 복구를 실행한 결과가 아니다.

## 테스트 환경

- 기반 리비전: `4cfe9666ac3a670bb72c8cd4d933bc7d7238fce0`와 이 PR의 계약 코드.
- 명령: `python3 -m unittest discover -s scripts/opensql -p test_dr_recovery.py -v`
- 외부 서비스: DB·Docker·GCP·모델 사용 안 함. 제어 연산자는 합성 JSON을 반환하는 로컬 Python 자식 프로세스.
- 원래 개인 환경 파일과 공개 서비스 설정 수정 없음. 파일 경로는 공개 로그 저장 전에 마스킹했다.

## 테스트 방법

정상·불명 HTTP 결과, 중간 성공 응답 유실, DB 관계 불일치, 보존 객체 누락·해시·generation 불일치, 최초 감사 순서, 정리 누락을 각각 주입했다. 합격 기준은 판정 계약 전체 통과, 감사 미통과 시 activate 미호출, 정리 실패 시 PASS 금지다.

## 관측 결과

| 실행 ID / 근거 | 시작 시각 (UTC, +00:00) | 표본·결과 | 해석 |
|---|---|---|---|
| [DR-CONTRACT-20261018-R1](runs/contract-r1/result.json) / [로그](runs/contract-r1/test.log) | 2026-10-18 08:13:06 | 26개: 성공 21·실패 3·오류 2, 종료 1 | 자식 연산자 시험 준비 오류. 미통과 회차로 보존 |
| [DR-CONTRACT-20261018-R2](runs/contract-r2/result.json) / [로그](runs/contract-r2/test.log) | 2026-10-18 08:13:55 | 26개: 성공 26·실패 0·오류 0, 종료 0 | 연산자 파일명 수정 후 재실행 통과 |
| [DR-CONTRACT-20261018-R3](runs/contract-r3/result.json) / [로그](runs/contract-r3/test.log) | 2026-10-18 08:21:28 | 33개: 성공 33·실패 0·오류 0, 0.689초, 종료 0 | 증거 필드 허용 목록·고정 객체 generation 대조 포함 로컬 통과 |
| [DR-CONTRACT-20261018-R4](runs/contract-r4/result.json) / [로그](runs/contract-r4/test.log) | 2026-10-18 08:27:14 | 36개: 성공 36·실패 0·오류 0, 0.769초, 종료 0 | 최종 감사의 원본 접근·잘못된 기능 응답 기록·감사 후 경합 차단 포함 최종 로컬 통과 |

위 UTC 시작 시각의 한국 시각은 각각 17:13:06·17:13:55·17:21:28·17:27:14 KST(+09:00)다. 각 회차의 종료 시각은 result.json에 별도 기록했다.

확인한 동작:

- DB에서 사라진 중간 ACK 1건은 이후 ACK가 복원됐어도 손실 집계에서 빠지지 않는다.
- HTTP 500·응답 미수신과 실제 DB 복원 존재 여부를 분리한다.
- 최초 감사에 Worker/Flyway/수선/원본 접근 흔적이 있으면 기동 금지.
- 처음에 ACK 1건이 누락됐고 나중에 복구된 합성 회차에서도 초기 손실 1건을 보존한다.
- 고정 보존 객체 generation과 다른 객체는 해시가 같아도 최초 감사가 기동을 허용하지 않는다.
- 정리 명령 누락·잘못된 후속 명령은 복원 시작 전에 거부한다. 정리 실패와 실행 중단은 INCOMPLETE다.

## 검증 과정에서 확인된 사항

첫 회차에서 자식 파일을 `operator.py`로 만든 것이 Python 표준 `operator` 모듈을 가렸다. `functools` import 순환 때문에 연산자 실행 5개가 실패·오류로 끝났다. 파일명을 `controlled_driver.py`로 바꾼 뒤 R2를 별도 실행했다. R3는 복원 입력 해시·민감 필드·보존 객체 버전 조건을 보강한 별도 회차다. R4는 최종 감사에서도 원본 접근을 금지하고, 거부된 기능 응답이 결과 파일에 남지 않으며 감사 뒤 늦은 응답이 합격 판정에 섞이지 않는지 검증했다.

제어기가 받는 `source_access_used=false` 등은 연산자 확인값이다. 이 단위 시험은 실제 VM의 원본 접근 차단, 실제 WAL 연속성, OpenSQL 라이선스 복원 가능성, 검색 품질, 실제 복구 시간, RPO 또는 공개 서비스 전환을 증명하지 않는다. 후속 실험에서 각 조건과 연산자를 실제로 검증해야 한다.

설계와 실제 실행은 [업로드 복원 판정 설계](../../design/issue-560-upload-recovery-contract.md)에서 구분한다. 초기 유실 집계와 최종 기능 수렴이 서로 다른 기록이라는 점을 유지한다.
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"run_id": "DR-CONTRACT-20261018-R1",
"purpose": "Synthetic contract and isolated child-process lifecycle",
"location": "local Python; no Docker/GCP/DB",
"started_at_utc": "2026-10-18T08:13:06.433309+00:00",
"ended_at_utc": "2026-10-18T08:13:06.717076+00:00",
"exit_code": 1,
"command": "python3 -m unittest discover -s scripts/opensql -p test_dr_recovery.py -v",
"revision": "4cfe9666ac3a670bb72c8cd4d933bc7d7238fce0 + submitted recovery contract changes",
"test_count": 26,
"passed": 21,
"failures": 3,
"errors": 2,
"verdict": "FAIL"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
test_audit_failure_never_activates_and_cleans_up (test_dr_recovery.RecoveryContractTest) ... ERROR
test_bad_later_command_blocks_restore (test_dr_recovery.RecoveryContractTest) ... ok
test_changed_inputs_block_operator (test_dr_recovery.RecoveryContractTest) ... ok
test_cleanup_failure_is_not_pass (test_dr_recovery.RecoveryContractTest) ... FAIL
test_corrupt_ledger_is_not_summarized (test_dr_recovery.RecoveryContractTest) ... ok
test_duplicate_response_rejected (test_dr_recovery.RecoveryContractTest) ... ok
test_duplicate_wal_rejected (test_dr_recovery.RecoveryContractTest) ... ok
test_finished_run_cannot_be_executed_again (test_dr_recovery.RecoveryContractTest) ... ok
test_frozen_inputs_cannot_be_replaced (test_dr_recovery.RecoveryContractTest) ... ok
test_http_error_may_be_present_in_restored_db (test_dr_recovery.RecoveryContractTest) ... ok
test_initial_loss_preserved_when_final_snapshot_is_repaired (test_dr_recovery.RecoveryContractTest) ... FAIL
test_initial_state_rejects_started_worker_or_flyway (test_dr_recovery.RecoveryContractTest) ... ok
test_inputs_missing_check_is_no_go (test_dr_recovery.RecoveryContractTest) ... ok
test_missing_cleanup_blocks_all_actions (test_dr_recovery.RecoveryContractTest) ... ok
test_missing_observation_is_not_db_loss (test_dr_recovery.RecoveryContractTest) ... ok
test_missing_response_is_unknown_not_failure (test_dr_recovery.RecoveryContractTest) ... ok
test_object_missing_blocks_worker (test_dr_recovery.RecoveryContractTest) ... ok
test_old_and_new_success_do_not_hide_middle_loss (test_dr_recovery.RecoveryContractTest) ... ok
test_original_identity_must_be_preserved (test_dr_recovery.RecoveryContractTest) ... ok
test_partial_wal_rejected (test_dr_recovery.RecoveryContractTest) ... ok
test_real_hash_mismatch_is_not_restored (test_dr_recovery.RecoveryContractTest) ... ok
test_response_ledger_is_durable_and_private (test_dr_recovery.RecoveryContractTest) ... ok
test_restore_failure_cleans_up (test_dr_recovery.RecoveryContractTest) ... ERROR
test_success_requires_all_four_ids (test_dr_recovery.RecoveryContractTest) ... ok
test_success_response_ids_must_match_restored_relations (test_dr_recovery.RecoveryContractTest) ... ok
test_success_stages_audit_before_activation_then_final_audit (test_dr_recovery.RecoveryContractTest) ... FAIL

======================================================================
ERROR: test_audit_failure_never_activates_and_cleans_up (test_dr_recovery.RecoveryContractTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "<local-path>", line 204, in test_audit_failure_never_activates_and_cleans_up
self.assertEqual((self.root/'calls.txt').read_text().splitlines(), ['restore', 'snapshot', 'cleanup'])
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/pathlib.py", line 1256, in read_text
with self.open(mode='r', encoding=encoding, errors=errors) as f:
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/pathlib.py", line 1242, in open
return io.open(self, mode, buffering, encoding, errors, newline,
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/pathlib.py", line 1110, in _opener
return self._accessor.open(self, flags, mode)
FileNotFoundError: [Errno 2] No such file or directory: '<temporary-path>

======================================================================
ERROR: test_restore_failure_cleans_up (test_dr_recovery.RecoveryContractTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "<local-path>", line 210, in test_restore_failure_cleans_up
self.assertEqual((self.root/'calls.txt').read_text().splitlines(), ['restore', 'cleanup'])
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/pathlib.py", line 1256, in read_text
with self.open(mode='r', encoding=encoding, errors=errors) as f:
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/pathlib.py", line 1242, in open
return io.open(self, mode, buffering, encoding, errors, newline,
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/pathlib.py", line 1110, in _opener
return self._accessor.open(self, flags, mode)
FileNotFoundError: [Errno 2] No such file or directory: '<temporary-path>

======================================================================
FAIL: test_cleanup_failure_is_not_pass (test_dr_recovery.RecoveryContractTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "<local-path>", line 227, in test_cleanup_failure_is_not_pass
self.assertEqual(result['reason'], 'OPERATOR_CLEANUP_FAILED')
AssertionError: 'OPERATOR_RESTORE_FAILED' != 'OPERATOR_CLEANUP_FAILED'
- OPERATOR_RESTORE_FAILED
? ^ ^^^^^
+ OPERATOR_CLEANUP_FAILED
? ^^ ^^^^


======================================================================
FAIL: test_initial_loss_preserved_when_final_snapshot_is_repaired (test_dr_recovery.RecoveryContractTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "<local-path>", line 196, in test_initial_loss_preserved_when_final_snapshot_is_repaired
self.assertEqual(result['execution'], 'PASS')
AssertionError: 'INCOMPLETE' != 'PASS'
- INCOMPLETE
+ PASS


======================================================================
FAIL: test_success_stages_audit_before_activation_then_final_audit (test_dr_recovery.RecoveryContractTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "<local-path>", line 185, in test_success_stages_audit_before_activation_then_final_audit
self.assertEqual(result["execution"], "PASS")
AssertionError: 'INCOMPLETE' != 'PASS'
- INCOMPLETE
+ PASS


----------------------------------------------------------------------
Ran 26 tests in 0.236s

FAILED (failures=3, errors=2)
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"run_id": "DR-CONTRACT-20261018-R2",
"purpose": "Contract rerun after child-driver module naming fix",
"location": "local Python; no Docker/GCP/DB",
"started_at_utc": "2026-10-18T08:13:55.061338+00:00",
"ended_at_utc": "2026-10-18T08:13:55.649096+00:00",
"exit_code": 0,
"command": "python3 -m unittest discover -s scripts/opensql -p test_dr_recovery.py -v",
"revision": "4cfe9666ac3a670bb72c8cd4d933bc7d7238fce0 + submitted recovery contract changes",
"test_count": 26,
"passed": 26,
"failures": 0,
"errors": 0,
"verdict": "PASS"
}
Loading
Loading