문제와 원인
수동 백업은 D1을 100행씩 읽어도 모든 페이지를 한 생성 호출 안에서 처리했다. cursor·파트 목록·검증 위치가 메모리에만 남아 중간 PUT이 실패하면 진행이 사라졌고, 재시도는 새 UUID로 백업을 만들었다.
수정 전 코드
createExternalBackup
const id = crypto.randomUUID();
const generatedAt = now();
scanStableTable
while (true) {
const query = stablePageQuery(table, cursor);
const page = await allRows(query.sql, query.values);
if (!page.length) break;
for (const row of page) validateRowShape(table, row);
for (const rows of backupPartGroups(page)) {
await visit(rows, partIndex, rowStart);
partIndex += 1;
rowStart += rows.length;
}
cursor = page.at(-1) ?? null;
if (page.length < BACKUP_PAGE_ROWS) break;
}
수정 후 코드
advanceManualExternalBackup
backup_snapshots.payload에 collect → verify → publish 단계, 테이블 위치, cursor, 파트 목록, 검증 위치와 revision을 저장했다. 수집·원본 재검증은 호출당 최대 1페이지·100행을 처리하고 페이지 완료 뒤 진행을 저장한다.
const MANUAL_BACKUP_PAGES_PER_REQUEST = 1;
진행 갱신은 이전 payload와 유효한 lease가 일치할 때만 허용한다.
const result = await execute(`UPDATE backup_snapshots
SET payload = ?, counts = ?, byte_size = ?
WHERE id = ? AND status = 'creating' AND payload = ?
AND EXISTS (SELECT 1 FROM maintenance_runs WHERE task = 'backup_create'
AND lease_owner = ? AND julianday(lease_until) > julianday('now'))`, [
serialized, JSON.stringify(next.counts), next.dataByteSize,
backupId, row!.payload, leaseOwner,
]);
if (Number(result.meta.changes ?? 0) !== 1) {
throw new Error("백업 진행 상태가 다른 요청과 충돌했습니다.");
}
파트 키에도 UUID를 붙여 이전 요청의 늦은 PUT이 다음 요청의 파트를 덮어쓰는 일을 막는다. 실패 요청은 자신이 만든 미저장 객체를 정리한다. 전체 수집 뒤 원본을 다시 읽어 행 수·본문 SHA·파트 순서를 비교하고 매니페스트 검증을 마친 뒤 completed를 게시한다. 수동 단계 lease는 30분에서 3분으로 조정했다.
검증 결과
Node.js·SQLite의 로컬 메모리 DB와 D1·R2 대역에서 수정 전후 원본 백업 함수를 버전별 1회 시나리오로 실행했다. 기본 설정 2행·합성 설정 510행, 총 512행을 넣고 두 번째 파트 PUT을 한 번 실패시킨 뒤 재시도·다운로드·SHA 검증을 수행했다.
| 지표 | 수정 전 | 수정 후 |
|---|---|---|
| 중단 후 저장된 수집 진행 | 0행 | 100행 |
| 중단 직후 스냅샷 상태 | failed | creating |
| 재시도 완료까지 생성된 스냅샷 | 2개 | 1개 |
| 재시도 백업 ID | 새 ID | 같은 ID |
| 최종 다운로드 행 수 | 512행 | 512행 |
| 다운로드 SHA 검증 오류 | 0개 | 0개 |
| 실패를 포함한 생성·진행 함수 호출 | 2회 | 15회 |
로컬 Swagger UI의 1280×720 CSS px·DPR 2 화면에서도 새 합성 D1·R2 대역의 같은 시나리오를 1회 HTTP 요청으로 실행했다. HTTP 200 응답에서 체크포인트 0→100행, 스냅샷 2→1개, 같은 ID 재개, 최종 512행·SHA 오류 0개를 얻었다. 표의 2회·15회는 페이지 처리와 단계 전환을 포함한 내부 함수 호출 수다.
수정 전에는 중단 뒤 체크포인트가 0행·상태가 failed였다. 재시도까지 스냅샷 2개가 생성됐고 백업 ID도 달라졌다.
수정 후에는 100행 체크포인트·creating 상태를 유지해 같은 ID로 재개했다. 스냅샷 1개로 512행을 다운로드했고 SHA 오류는 0개였다.
재개 조건
| 조건 | 처리 |
|---|---|
| 체크포인트 저장 후 다음 파트 PUT 실패 | 저장된 100행·cursor를 유지하고 같은 ID에서 해당 페이지 재시도 |
| 같은 ID의 완료 결과 재조회 | 기존 완료 결과 반환 |
| 다른 관리자·백업 유형·포함 범위 | 최초 작업과 일치할 때 재개 |
| 소스·스키마 버전 또는 원본 데이터 변경 | 계약 검사·재검증에서 완료 게시 거부 |
| 늦게 끝난 이전 단계 | lease·이전 payload 불일치 시 갱신 거부 |
재개 경로에는 backupId를 전달한다. ID 없는 기존 단일 생성 경로도 남아 있으며, 페이지당 100행 제한은 행 크기·R2 파트 수 제한과 함께 적용한다.