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
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ jobs:
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
run: |
python3 -m unittest discover -s scripts/opensql -p 'test_dr_recovery.py' -v
python3 -m unittest discover -s scripts/opensql -p 'test_barman_offsite_sync.py' -v

backend:
runs-on: ubuntu-latest
Expand Down
71 changes: 71 additions & 0 deletions docs/design/issue-563-wal-offsite-freshness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# 특정 WAL의 외부 보존 완료 경계

관련 이슈: #563. #562의 성공 응답 65건 중 33건 복원 결과에서 확인한 외부 WAL 최신성 공백을 다룬다. 공개 PostgreSQL·Patroni·Barman 설정이나 개인 환경 파일을 변경하지 않는다.

## 기존 동작과 보완 범위

기존 외부 복사 명령은 Barman `wals` 디렉터리의 완성 파일을 복사하고 명령 완료 시각을 기록한다. 이는 복사 작업의 성공이며, 특정 업무의 최신 변경을 포함한다는 뜻은 아니다. 앞선 격리 시험은 WAL 전환 뒤 2초 대기와 `archive-wal` 한 번으로 완료를 추정했다.

설치된 Barman 3.19.1의 `StreamingWalArchiver.get_next_batch`는 미완성 파일을 보류하고, `.partial`이 없는 경우 마지막 완성 파일 하나도 재시작 위치를 위해 남긴다. 수신·파일 전환·archive 호출의 시간 차이를 완료 조건으로 취급하면 안 된다. 실제 재현 결과와 준비 중단은 `docs/test-results/`에 별도로 기록한다.

```text
그림 1 — 기존 판정과 필요한 완료 조건 [설계]

호출자: 성공 응답 구간의 마지막 쓰기가 완료됨
→ PostgreSQL WAL 전환 → 필요한 완성 WAL 이름 확보
→ 수신 프로세스가 파일을 닫고 다음 위치로 진행
→ Barman 보관 디렉터리에 해당 파일 도착
→ 외부 저장소 복사 → 해당 파일의 원격 바이트 무결성 확인
→ 이 구간의 보존 완료로 기록

고정 2초 대기나 archive 명령 종료 코드만으로는
위의 '필요한 파일 도착'과 '원격 바이트 일치'를 대신할 수 없다.
```

## 기존 스크립트에 추가할 계약

`barman_offsite_sync.py`에 `wait_for_archived_wal`을 추가한다. 호출자가 이미 정한 24자리 대문자 WAL 이름과 Barman 보관 디렉터리를 입력한다. 최대 300초 이내의 단조 시계 제한을 두고 `archive-wal`을 반복하며 그 파일이 실제 보관되기까지 기다린다. 다른 파일·history·활성 `.partial`·빈 파일·심볼릭 링크를 완료로 세지 않는다.

```text
그림 2 — wait_for_archived_wal과 sync 내부 [설계]

sync(required_wal)
→ WAL 이름·제한 시간 검증
→ archive-wal 반복
├─ 정확한 완성 파일 미도착: 남은 시간 내 다시 확인
├─ 명령 오류·시간 초과: 실패 반환
└─ 정확한 파일 도착: 크기·CRC32C 확보
→ 기존 sync_wals 실행
→ 외부의 같은 경로 크기·CRC32C 대조
├─ 누락·불일치: 실패, last_success_utc 유지
└─ 일치: required_wal_verified=true와 완료 시각 저장
```

CLI는 명시적인 `sync --required-wal <WAL> --wait-timeout 120` 옵션으로 사용한다. 인자 없는 기존 복사 타이머의 동작·설정 파일·환경변수는 유지한다. 그 타이머의 health는 계속 복사 최근성 점검이며 업무 데이터 최신성이나 RPO 보장이 아니다. 이 함수는 원본 WAL 전환이나 PostgreSQL 설정 변경을 직접 수행하지 않는다.

호출자는 전환 뒤 수신의 다음 파일 진행도 확보해야 한다. 격리 시험에서는 전환 뒤 합성 `pg_create_restore_point` WAL을 생성하는 방식을 별도로 비교한다. 이를 위해 공개 `archive_timeout`·`synchronous_commit`·Patroni 정책을 바꾸거나 Barman의 마지막 파일 보호 로직을 제거하지 않는다. 실제 효과는 재현 로그로 판정한다.

## 검증·재시험

- 단위 계약: 지연 도착, `.partial`·다른 파일 제외, 시간 초과·잘못된 시간 설정, archive 오류, 원격 누락·불일치, 이전 성공 시각 유지, 기존 복사 동작 회귀.
- 실제 재현: 전용 논리 DB·같은 Barman 3.19.1 수집기로 기존 방식과 이름 기반 완료 대기를 대조한다. 수집기 준비 실패를 WAL 경합 재현으로 세지 않는다.
- 같은 복구 시험: 동일 Java JAR, 완료 문서 4건·중단 Job 1건·손실 관측 60건, 목표 11초 간격, 300초 외부 전송 구간 2회. 수신 대기·전송 때문에 달라진 실제 간격은 그대로 기록한다.

```text
그림 3 — 복구 회차의 합격 범위와 원복 [설계]

마지막 보존 완료 구간의 성공 응답 원장
→ 필요한 WAL·원본 generation 고정
→ 원본 데이터 추가 취득 중단
→ 새 PGDATA·실제 OpenSQL 복원
→ Worker·Flyway·수선 전 최초 감사
├─ 완료 구간의 요청 누락: 최신성 보완 검증 미달
└─ 누락 0: 복원 DB 활성화 허용
→ 기존 기능·Job 재개·새 업로드·최종 정합성 확인
→ 원래 3노드·설정·소유 자원 정리

보존 완료 뒤 추가된 요청은 별도 손실 관측으로 남긴다.
최종 입력을 뒤늦게 보충해 손실 0건을 만들지 않는다.
```

이 재시험은 격리 복원 기능과 해당 구간의 손실 측정이다. 공개 서비스 전환·전체 사이트 소실·Ollama 생성 답변·운영 RPO 0을 주장하지 않는다. 실패·중단·재시도는 새 run ID와 별도 원장으로 보존한다.
108 changes: 108 additions & 0 deletions docs/test-results/wal-offsite-boundary-20261018/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# 필요한 WAL의 보관·외부 복사 완료 경계 검증

관련 이슈: #563. 앞선 [복구 계측](../dr-functional-recovery-20261018/README.md)은 성공 응답 65건 중 33건만 복원했고, 외부에 동결된 WAL 끝까지만 재생했다. 이번 검증은 그 공백을 만들 수 있는 **고정 대기·명령 종료 코드 기반 완료 판정**을 조사한다. 전체 복구 재시험 결과는 이 문서의 재현 결과와 구분해 별도 기록한다.

## 목적

`pg_switch_wal()` 뒤 2초를 기다리고 `archive-wal` 한 번이 성공하면 최신 WAL까지 보관됐다고 볼 수 있는지 확인한다. 필요한 파일이 실제 보관되기 전에 성공으로 판정하면 외부 원본이 있어도 대응하는 DB 행을 복구하지 못할 수 있다.

## 테스트 환경

| 항목 | 실제 사용·범위 |
| --- | --- |
| DB | 기존 OpenSQL 17.8 클러스터에 소유 마커가 있는 별도 논리 DB·합성 marker 테이블 |
| 수신·보관 | 로컬 Docker의 실제 Barman 3.19.1·PostgreSQL 17 수신 도구, 새 전용 카탈로그·터널 |
| 공개 자원 | 기존 Primary·동기 Standby 설정 변경 없음. 이 재현에서는 복원용 노드 중단 없음 |
| 단위 시험 | 로컬 Python, 제어한 파일·시계·명령 대역. 실제 클라우드 시험과 구분 |
| 외부 복사 | 아래 LIVE2는 **로컬 Barman 보관 완료**까지 검증. 실제 GCS 재읽기는 별도 통합 복구 회차에서 검증 |
| 개인 파일 | 기존 `.env`·프런트엔드 개인 설정 변경 없음 |

## 테스트 방법

1. 각 run ID의 목적·위치·성공 기준을 먼저 저장했다. 명령 출력은 메모리에서 처리하고 공개 기록에는 파일명·시각·종료 코드·카운터만 남겼다.
2. 설치 이미지에서 네트워크 없이 `inspect.getsource(StreamingWalArchiver.get_next_batch)`로 보류 조건을 읽었다. [동일 버전의 공식 소스](https://github.com/EnterpriseDB/barman/blob/release/3.19.1/barman/wal_archiver.py)와 대조했다.
3. 실제 marker를 커밋하고 WAL을 전환했다. 기존 방식인 2초 대기·archive 1회의 종료 코드와 정확한 대상 파일의 위치를 각각 기록했다.
4. 첫 사례에서는 명시적인 다음 쓰기 없이 최대 20초 동안 필요한 파일을 기다렸다. 이후 각 사례에서 다음 구간의 합성 `pg_create_restore_point`를 생성하고 이름 기반 완료 대기를 실행했다.
5. 소유한 DB·두 역할·수집기·터널·키를 정리했다. 원래 클러스터와 공개 Patroni 설정의 보존을 확인했다.

```text
그림 1 — 명령 성공과 보관 완료의 차이 [실제 관측]

시험 호출자: marker 커밋 → pg_switch_wal → 대상 이름 확보
│
├─ 2초 후 archive-wal → 종료 코드 0 (3/3)
│ ├─ 사례 1·3: 대상 파일은 streaming에 남음
│ │ wals 보관 디렉터리에는 없음
│ └─ 사례 2: 다음 .partial 존재 → 대상 보관 완료
│
└─ 따라서 종료 코드만으로 최신성 완료를 판정하면
이번 3회 중 2회에서 실제 파일 상태와 어긋남
```

코드 0은 명령이 정상 실행됐다는 관측이다. 그 시점에 특정 업무까지 포함하는 WAL 파일이 보관됐다는 증거는 아니었다.

## 관측 결과

| Run ID | 시간(UTC)·절차 | 수치·판정 | 해석 |
| --- | --- | --- | --- |
| WAL-BARMAN-STATIC-20261018-R1 | 2026-10-18 14:06:45, 설치 코드 확인 | OBSERVED | 활성 `.partial` 제외. `.partial`이 없으면 마지막 완성 파일 하나도 수신기 재시작 위치를 위해 보류 |
| WAL-ARCHIVE-BOUNDARY-20261018-LIVE1 | 13:59:29~14:01:09, 새 전용 수신 역할 준비 | **INCOMPLETE** | 수신기 준비가 끝나지 않아 비교 미실행. WAL 경합 재현 횟수에서 제외. 원복·정리 확인 |
| WAL-ARCHIVE-BOUNDARY-20261018-LIVE2 | 14:03:42~14:06:19, 실제 전환 3회 | 기존 방식의 조기 성공 2/3, 이름 기반 최종 확인 3/3 | 정확한 파일의 보관 완료를 직접 확인해야 함 |
| WAL-FRESHNESS-UNIT-20261018-R1 | 13:56:16, WAL 계약 | 15/15, 실패 0, 0.078초 | 기존 8개·신규 7개 계약 |
| WAL-RECOVERY-CONTRACT-20261018-R2 | 14:12:48, 형식 수정 후 회귀 | WAL 15/15·기존 복구 40/40, 실패 0, 0.823초 | 최초 감사·활성화 순서의 기존 계약도 유지 |

| LIVE2 사례 | 필요 WAL 끝자리 | 기존 archive 종료 코드 | 기존 경과 | 당시 wals에 있음 | 최종 이름 확인 | 파일 크기 |
| --- | --- | --- | --- | --- | --- | --- |
| 1 | 26 | 0 | 2.171초 | 아니오 | 통과 | 16,777,216바이트 |
| 2 | 27 | 0 | 2.154초 | 예 | 통과 | 16,777,216바이트 |
| 3 | 28 | 0 | 2.152초 | 아니오 | 통과 | 16,777,216바이트 |

**첫 사례의 추가 대기는 20초 시간 초과가 아니다.** 명시적인 다음 쓰기를 넣지 않아도 **6.966초 뒤 도착**했다. 이후 다음 파일 진행과 이름 확인을 결합한 방식은 3/3 통과했지만, 사례 1·2는 그 전에 이미 보관된 상태이므로 restore point가 모든 사례에 반드시 필요했다는 인과 결론은 내리지 않는다. 백그라운드 WAL 진행의 정확한 발생원도 이번에 계측하지 않았다.

```text
그림 2 — 추가한 완료 계약 [구현]

호출자: 필요한 24자리 WAL 이름 지정
→ wait_for_archived_wal(server, directory, required_wal)
→ archive-wal 반복, 단조 시계 제한 120초(최대 300초)
├─ 다른 파일·partial·빈 파일·심볼릭 링크: 완료 아님
├─ 오류·제한 초과: 실패, 보존 완료 시각 갱신 안 함
└─ 정확한 완성 파일: 반환
→ 기존 sync_wals로 복사
→ 원격 같은 파일의 크기·CRC32C 대조
├─ 누락·불일치: 실패
└─ 일치: required_wal_verified=true 기록
```

새 `sync --required-wal <WAL> --wait-timeout 120` 옵션을 쓸 때만 이 계약을 적용한다. 기존 타이머·설정·환경변수는 바꾸지 않았다. 인자 없는 복사의 health는 복사 최근성이고, 업무 최신성이나 RPO 보장이 아니다.

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

- **직접 원인은 시험의 완료 판정 공백이다.** Barman의 수신기 재시작 보호 동작을 제품 결함으로 판단하지 않았다. 수신 중 마지막 파일이 보류된 상태에서 기존 시험이 외부 전송 완료를 추정한 경로를 실제로 재현했다.
- 앞선 32건 전체를 같은 순간의 동일 경합 하나로 소급 확정하지 않는다. 앞선 동결 WAL과 복원 끝 위치가 일치했고, 이번에는 그 입력 최신성이 부족해질 수 있는 구체적인 경로를 재현했다. 같은 부하의 새 복구 결과로 개선 효과를 확인해야 한다.
- LIVE1은 새 역할의 실제 수신 준비가 되지 않았다. 정확한 인증 거부 원인을 별도로 확정하지 않았으며 공개 HBA·Patroni 설정을 바꾸지 않았다. 이후 기존에 검증된 수신 인증 경로로 별도 LIVE2를 실행했다. 인증 값은 기록·게시하지 않았다.
- 실제 LIVE2는 공개 함수 `wait_for_archived_wal`과 실제 Docker Barman 명령을 연결했다. 명령 대역으로 결과를 꾸미지 않았다. 그러나 전체 `gsutil sync` CLI 경로의 실제 실행 증거는 아니다. 해당 원격 메타데이터 계약은 단위 시험으로 검증했다.
- 두 실제 run의 정리 결과 모두 공개 설정 보존·원래 클러스터 복귀·소유 자원 제거가 참이다. 시험용 빈 버킷은 다음 직렬 복구 회차를 위해 유지한다. 활성 객체 삭제와 soft-delete 보존 가능성은 구분한다.

```text
그림 3 — 이어지는 통합 복구 시험의 판정 범위 [계획]

같은 부하: 완료 4 + 중단 Job 1 + 추가 업로드 60
→ 300초 구간마다 필요한 WAL 보관 완료·외부 바이트 확인
→ 마지막 확인 시점의 ACK 개수 기록
→ 원본 경로 취득 중단 → 새 PGDATA 복원
→ Worker·Flyway·수선 전 최초 감사
├─ 확인 구간 ACK 누락 > 0: 불합격, Worker 활성화 거부
└─ 누락 0: Worker 활성화 → 기능별 시간 계측
→ 마지막 확인 뒤 요청은 별도 손실 코호트로 보고
→ 기존 3노드·설정 복구·시험 자원 정리
```

최종 전송을 임의로 추가해 전체 65/65를 만드는 시험이 아니다. 운영 RPO 0·공개 서비스 전환·Ollama 생성 답변까지 검증했다고 주장하지 않는다.

## 증거

- [실제 재현 요약](evidence/WAL-ARCHIVE-BOUNDARY-20261018-LIVE2/result.json), [사례 1](evidence/WAL-ARCHIVE-BOUNDARY-20261018-LIVE2/probe-1-result.json), [사례 2](evidence/WAL-ARCHIVE-BOUNDARY-20261018-LIVE2/probe-2-result.json), [사례 3](evidence/WAL-ARCHIVE-BOUNDARY-20261018-LIVE2/probe-3-result.json)
- [준비 중단 회차](evidence/WAL-ARCHIVE-BOUNDARY-20261018-LIVE1/result.json), [그 회차 정리](evidence/WAL-ARCHIVE-BOUNDARY-20261018-LIVE1/runtime-cleanup-observations.json), [비교 회차 정리](evidence/WAL-ARCHIVE-BOUNDARY-20261018-LIVE2/runtime-cleanup-observations.json)
- [설치 코드 관측](evidence/static-source-r1/result.json), [단위 최초 실행](evidence/unit-r1/result.json), [회귀 실행](evidence/unit-r2/result.json), [WAL 로그](evidence/unit-r2/suite-1.log), [복구 계약 로그](evidence/unit-r2/suite-2.log)
- [비식별 파생물·해시 목록](evidence/export-index.json): 선택한 20개만 게시. 비공개 원본 설정·자격증명·클라우드 주소는 포함하지 않는다.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"owned_resources_removed": true,
"public_config_preserved": true,
"source_cluster_restored": true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"error_type": "RuntimeError",
"finished_at_utc": "2026-10-18T14:01:09.850991+00:00",
"location": "owned cloud OpenSQL logical database + local Barman 3.19.1 collector",
"method": "합성 marker commit·pg_switch_wal → 2초·archive-wal 1회 → 실제 파일 상태 → 같은 WAL 제한 시간 대기",
"purpose": "기존 2초 대기와 실제 특정 WAL 보관 완료 대기의 차이를 동일한 실시간 스트리밍에서 재현",
"run_id": "WAL-ARCHIVE-BOUNDARY-20261018-LIVE1",
"stage": "LIVE_RECEIVER_NOT_READY",
"started_at_utc": "2026-10-18T13:59:29.173663+00:00",
"success_criteria": "실제 required WAL 상태·보관 시각 수집; 끝에는 소유 DB/역할/수집기/터널 정리·원래 클러스터 복귀",
"verdict": "INCOMPLETE"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"containers_removed": true,
"failures": [],
"finished_at_utc": "2026-10-18T14:01:53.823695+00:00",
"gcs": {
"active_objects_remaining": 0,
"deleted_active_objects": {
"database": 0,
"preserved": 0,
"source": 0
},
"immediate_physical_erasure_claimed": false,
"soft_delete_policy_may_retain_deleted_objects": true
},
"private_runtime_data_removed": true,
"processes_removed": true,
"public_config_unchanged": true,
"shared_trial_buckets_retained_for_next_serial_run": true,
"source_objects_removed": true,
"ssh_keys_removed": true,
"started_at_utc": "2026-10-18T14:01:09.852797+00:00",
"target": {
"scratch_removed": true,
"scratch_stopped": true,
"source_cluster_restored": true,
"target_never_borrowed": true,
"temporary_key_removed": true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"location": "owned cloud OpenSQL logical database + local Barman 3.19.1 collector",
"method": "합성 marker commit·pg_switch_wal → 2초·archive-wal 1회 → 실제 파일 상태 → 같은 WAL 제한 시간 대기",
"purpose": "기존 2초 대기와 실제 특정 WAL 보관 완료 대기의 차이를 동일한 실시간 스트리밍에서 재현",
"run_id": "WAL-ARCHIVE-BOUNDARY-20261018-LIVE1",
"started_at_utc": "2026-10-18T13:59:29.173663+00:00",
"success_criteria": "실제 required WAL 상태·보관 시각 수집; 끝에는 소유 DB/역할/수집기/터널 정리·원래 클러스터 복귀"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"owned_resources_removed": true,
"public_config_preserved": true,
"source_cluster_restored": true
}
Loading
Loading