v0.17.4 긴급 저장 복구 — #1432
상위 추적: #1432. 이 hotfix는 v0.18.0 Phase 3~5와 분리한다. 2026-09-09 01:33 KST 운영 반영 확인. 원본 저장과 기존 종목 통계 조회를 복구했으며, 큰 이력의 최신 통계 수렴은 미완료다.
원인·영향·타임라인·테스트 누락·HQ 대응 평가·재발 방지의 정본은 BUG-084 포스트모템이다. 초기의 “timeout 테스트는 오류 코드 주입만 검사했다”는 설명은 실제 dblink/timeout 검증이 존재하므로 정정했다. 빠진 조건은 global cron의 장기 계산 취소와 지속 잠금 중 CRUD였다.
장애와 실제 복구
v0.17.3의 세트 점수 정책 변경이 전체 과거 통계 재계산을 만들었다. 긴 계산이 저장의 dirty job 적재와 같은 잠금을 잡았다. cron의 실제 statement timeout(57014)은 WHEN OTHERS에 잡히지 않아 작업 상태와 attempts까지 롤백됐다. 그 결과 같은 작업이 다시 pending/0으로 반복됐다.
9월 9일 00:18~00:19 KST 운영 조회에서는 00:02~00:04의 긴 계산 두 번이 끝나 활성 작업과 당시의 stale 세대가 없었다. 원인이 수정됐다는 증거는 아니며 해당 함수 정의는 그대로였다. 00:22경 lift-guild-stats-refresh와 barbelic-stats-backfill-scan 두 cron을 cron.alter_job(..., active := false)로 일시 중지했다. 사용자 원본이나 통계 작업을 삭제하지 않았다.
휴대폰의 기존 기록은 이미 blocked로 분류돼 화면 복귀만으로 재전송되지 않았다. 서버에 해당 기록이 없고 활성 작업도 없음을 확인한 뒤 기존 복구 버튼을 사용했다. 00:39 KST 조회에서 문제의 9월 8일 22:37~22:39 기록은 1건·4세트·40회·영수증 1건으로 확인됐다. 별도로 새 기록도 저장됐고, 두 저장분의 통계 세대는 아직 미반영이었다. 개인 계정 식별자와 원문 payload는 이 문서에 남기지 않는다.
이번 hotfix의 계약
- 두 worker core가 실제 statement timeout을 명시적으로 처리한다. 계산 savepoint는 롤백하지만 attempts와 failed 상태는 보존한다. 만료된 문장에서 다음 무거운 작업을 시작하지 않는다. 수동 취소는 삼키지 않는다. 기존 3회 재시도 상한이 적용된다.
- 일반 authenticated 통계 RPC는 작업을 실행하지 않고 자기 계정의 대기 건수와 stale 여부를 반환한다. 권한 검사는 먼저 실행한다. 과거 클라이언트도 같은 RPC 응답으로 대기를 식별할 수 있다. privileged 유지보수 경로는 보존한다.
- cron은
SET statement_timeout = '3s'; SELECT ... (1)로 문장 시작 전에 제한을 설정한다. 함수 안의set_config(statement_timeout, ...)로 이미 시작한 문장의 타이머를 바꿀 수 있다고 가정하지 않는다. 취소 후 실패 상태 기록까지 절대 3초라는 보장은 아니며, 동시 저장 검증이 별도로 필요하다. - 브라우저는 deferred 응답에 즉시 drain 반복을 하지 않고 다음 reconciliation에서 다시 확인한다. 아직 반영되지 않은 통계를 성공/최신으로 캐시하지 않는다.
- WebKit의
TypeError: Load failed를 일시적인 전송 오류로 분류한다. 동일 payload·mutation ID·sourceRef·hash를 보존해 재전송한다. 명시적 권한/검증/계약 오류의 차단 우선순위는 유지한다. 이미 blocked인 모든 행을 일괄 해제하지 않는다. - 세트 점수 기준 조회에서 불필요한 전체 행 materialization과 중량 후보의 반복수 eligibility 계산을 줄인다. 정책, 기준 우선순위, 수치 의미는 바꾸지 않는다.
- 종목 상세는 계정 통계가 stale이어도 마지막 게시 세대의 연간 통계·PR 기록·히스토리를 조회한다. 기존 코드는 stale이면 이 세 요청 자체를 막아 모든 종목 상세가 비었다. owner, applied_version, asOf, fragment revision 검증은 유지하고 다른 세대의 늦은 응답은 버린다. 내부 stale·오류 상태는 유지한다. 새 통계가 이미 계산됐다는 의미는 아니며 별도 시각 배너를 추가한 변경도 아니다.
검증 근거
- Safari 회귀는 수정 전 실패했고, 관련 테스트 31개가 수정 후 통과했다. 타임아웃 → WebKit 연결 실패 → 원래 쓰기 재시도 → 영수증 ACK 흐름을 검증했다.
- 실제 PostgreSQL 별도 연결이 잠금을 보유하고 외부 statement timeout을 발생시키는 신규 검증 18개: attempts 1→2→3 보존, 재시도 상한, batch 중단, 다음 사용자 처리, 실패 세대 미게시. 원래 worker에서는 실제 57014가 빠져나와 실패한다.
- 기존 timeout 검증 15개: 잠금 중 authenticated 상태 조회의 즉시 반환, 정확한 대기 건수, privileged 타임아웃·복구, 완료 후 fresh 상태.
- 세트 점수 기존/신규 검증 67개. 합성 660세션·7,920세트에서 전체 관측 계산 32.045→16.428초. 실행 순서를 뒤집어도 32.562→15.210초. 결과 7,908행의 양방향
EXCEPT ALL차이는 0이었다. - 186개 migration 전체 재생과 schema snapshot 생성 후 pgTAP 126파일·2,232개 전체 통과.
- 660세션·7,920세트와 실제 기존 projection/FK 대상 행을 준비한 별도 로컬 DB에서 worker의 user/queue advisory lock 및 projection 쓰기 잠금 보유를 관찰한 뒤 authenticated 저장 RPC를 실행했다. 생성 2,871ms, 수정 2,924ms, 삭제 2,863ms로 모두 8초 제한 내 성공했다. 각 영수증 1건, 수정 revision 증가, 삭제 원본·세트 제거, 실패 계산의 applied 세대 미게시와 dirty 보존을 확인했다. 재실행 도구:
scripts/performance/probe/stats-save-under-refresh.mjs. - 실제 Chromium/React 종목 상세 회귀 5개 통과. 이전 controller로 같은 테스트를 실행하면 year/records/history 요청 0건으로 실패했다. stale 기존 데이터 표시뿐 아니라 다른 세대·asOf의 응답 거절 및 새 세대 수신 후 늦은 이전 응답 거절을 검증했다.
- repository 관련 73개, 변경 source contract 관련 검사 통과. 타입·lint·unused·migration·deployment·test manifest 검사 및 Production 대상 앱 빌드 통과. 전체
npm run check1회에서 발견한 5개 실패는 계약 문자열·DB 원장 생성물 불일치로 수정한 뒤 해당 검사를 통과했다. 이후 전체 로컬 CI를 다시 실행한 것으로 기록하지 않는다. - staging·Production 배포 결과는 아래 실행 기록을 따른다.
남은 구조적 한계와 후속 완료 조건
이 변경은 저장 가용성을 우선하는 긴급 완화다. 전체 이력이 3초 계산 예산을 넘는 계정은 통계가 stale로 남을 수 있다. 원본과 dirty 세대는 보존하며 이를 최신 통계로 표시하지 않는다. 계산 최적화는 약 절반의 비용 감소일 뿐 과거 재스캔의 O(N²)를 제거하지 않는다.
queue advisory lock만 없애도 calendar dirty 날짜 행과 projection의 FK/cascade 잠금이 수정·삭제를 막을 수 있다. 따라서 이번에는 generation fence를 임의로 제거하지 않는다. 후속 작업은 계산과 게시를 분리하고, 저장과 경쟁하지 않는 짧은 게시 단계, 정확한 범위 분할, 대규모 이력의 시간 예산을 실제로 검증해야 한다. backfill 스캔의 주기만 줄이거나 90일 시작일을 넣는 것만으로는 현재 from-date 기반 전체 suffix 작업이 분할되지 않는다.
전체 #1432 종결 조건은 저장 복구 외에, 큰 계정의 통계가 제한 안에서 정상 수렴하고 실제 동시 생성·수정·삭제가 성공하는 것이다. 재시도 실패를 숨기거나 통계 복구를 v0.18.0 완료까지 방치하지 않는다.
추가 병목 확인과 다음 수정
이후 합성 660세션 이력에 실제 저장 두 번으로 최신 날짜 dirty를 만들었어도 전체 worker는 55.162초였다. 첫 쓰기의 통계가 미게시인 동안 두 번째 쓰기가 들어오면, state가 날짜만 추적하므로 enqueue는 누락 방지를 위해 전체 종목으로 범위를 넓힌다. 이 안전장치만 제거하지 않는다.
미출시 배치 관측 후보는 전체 16.448초, 여기에 PR 변경 날짜만 dirty로 남기는 후보를 합치면 15.713초였다. JIT off는 17.614초로 도움이 없었다. calendar는 1.748초에서 16ms로 줄었지만 strength core 6~9초와 report 약 4초가 남는다. 후보만으로 3초 예산을 충족하지 못하므로 운영에 추가 적용하지 않았다.
후속 hotfix는 이전 확정 상태를 이어받는 strength 증분 계산, 한 세대의 점수 관측 재사용, 무거운 계산과 짧은 원자적 게시 분리를 다룬다. 과거 수정·삭제의 연쇄 영향, 새 쓰기의 dirty 보존, 혼합 세대 미게시, 실제 큰 이력의 동시 저장과 최종 통계 수렴을 함께 검증해야 한다. 이 복구를 v0.18.0 Phase 5까지 미루지 않는다.
운영 실행 기록
- #1437 staging merge
476127b3, #1439 운영 mergec0eb68eb507724267db4199c0310303c0532a549. main은 staging의 조상이었고 운영 병합 결과 tree가 검증한 staging과 같음을 확인했다. - staging Deploy 34250308374: DB·Edge·앱·docs·CRUD smoke 성공.
- 운영 Deploy 34250824657: DB·Edge·docs 성공, 앱 신규 배포 생성 후 최초 도메인 비교에서 이전 파일을 관측해 실패. 이후 01:33:35 KST 운영 도메인과 immutable 배포 모두 HTTP 200,
app-Brk2dmbc.js, SHA-256c0e8064e58b04d5de12c8dcc669f641e1f7db4f18d807d5afc5b504f5512723b일치 확인. Vercel은 해당 배포가 이미 current Production이라고 응답했고 autoAssignCustomDomains=true·lastRollbackTarget=null이었다. 최초 실패가 롤백 잔존 때문이라고 확정하지 않는다. - 운영 자동 CRUD smoke 34251289310: 같은 운영 커밋으로 성공. 실패한 Deploy를 초록으로 만들기 위한 전체 CI/배포 재실행은 하지 않았다.
- Migration
20260913140100운영 적용 확인. 두 cron은 새 함수·3초 명령 검증 후 01:26:45 KST 재개했다. 실제 01:27/28/29 계산이 각각 약 3초에 취소되고 attempts 1→2→3을 보존한 뒤 failed로 중단했다. 다른 작업은 01:30 완료했다. - 해당 계정 최신 통계는 requested 508 / applied 506으로 미반영. 기존 authenticated detail/year/history는 같은 게시 세대의 데이터를 반환한다. 최신 통계 정상화로 표시하거나 #1432를 닫지 않았다.
- 오너가 이번 hotfix의 전체 CI 반복 생략과 긴급 관리자 병합을 승인했다. staging에는 기존 예외가 없어 #1437 한 건 병합 때 PR 전용 임시 예외를 적용하고 직후 원본 ruleset 전체를 복원·일치 확인했다. 미실행 검사를 성공으로 제출하지 않았다.
- 휴대폰 기록 복구: 서버 원본·세트·영수증·중복 없음 확인 완료.
- v0.17.4 릴리스는 실제 운영 배포 커밋을 가리킨다.