Skip to content

D11 통계 계산 통합·전환 실행 계약

대상: #1422, release/v0.18.0. 운영 cohort 전환·배포는 R05/R06에서 실행한다. 이 문서의 SQL을 작성했다는 것은 Production에서 실행했다는 뜻이 아니다.

계산과 게시

사용자가 저장하면 canonical 기록·영수증·dirty 세대는 기존 v5 command에서 함께 확정된다. 계산은 compute_stats_projection_job_engine_v1(job)의 공통 경로다. D05 관측·세션, D07 순차 파생, D06 기간·달력, PR 3일창을 차례로 만든다. 동기 문·cron·Edge는 호환 adapter이며 별도 public 계산을 하지 않는다. #1590은 이 계산을 run_stats_projection_step_v1으로 나눴고 run_stats_projection_v1은 전체 단계 호출을 위임한다. 아래 배치 계약은 main 반영·Production 배포와 운영 복구 확인을 마쳤다.

공통 결과 _orchestration은 owner·target generation·고정 anchor·source·단계 완료·출력 행 수를 담는다. D11의 18개 출력은 아래 R04 정규화 격리 이후 19개이며 #1590도 그 목록을 유지한다. source는 요청 세대, D10 전역/owner catalog token, D07 정책 fingerprint, 세트 목적 정책 fingerprint다. 계산 전후와 게시 직전에 같아야 한다. 이는 쓰기 command가 원본 변경과 요청 세대를 같은 트랜잭션에 넣는 D02/D04 계약을 소비하는 낙관적 버전 검사다. 여러 SQL 전체가 동일 MVCC 스냅샷을 읽었다고 주장하지 않는다. 중간 변경을 발견하면 결과를 버리고 재계산한다.

publish_stats_projection_job_engine_v1(job)만 user·queue 잠금 아래 출력과 completed/applied를 함께 확정한다. owner/lease/base/anchor/source/단계/행 수/세대가 맞지 않으면 공개 출력은 롤백되고 dirty가 남는다. 전체 payload에서 키가 사라진 행을 삭제하고 새 행·변경 행을 upsert한다. 변경 없는 행의 보존 범위는 아래 추가 승인 계약을 따른다. D05 정규화 세트 관측도 R04부터 비공개 계산과 최종 게시에 포함한다.

요청 세대가 target보다 크다는 사실만으로 앞 세대를 거부하지 않는다. 캡처 후 요청·원본판이 바뀌면 40001로 거부하며 다음 claim이 같은 최신 세대를 재시도하거나 후속 pending에 이전 실패 scope를 합친다. 캡처 전 이미 후속 pending이 있고 이후 원본판이 안정적이면 낮은 세대부터 게시하는 D04 계약을 유지한다. 낮은 세대가 먼저 게시돼도 requested가 더 높으면 화면은 계속 stale이다.

2026-09-10 추가 승인 — 변경 없는 행과 PR 요약 재사용

오너가 새 수용 기준을 이번 #1422에 포함하도록 승인했다. 앱 PR #1547, d058cbb7로 release/v0.18.0에 반영했으며 개별 검사 결과는 작업 기록에 남겼다. full 계산과 pg_temp 전체 복사는 유지하고 public 게시의 물리 쓰기를 줄인다.

  • snapshot header·summary·rollover를 제외한 15개 통계표는 계산값·정책·실제 원본 출처와 시각이 같으면 기존 행을 그대로 둔다. applied_version·materialized_at 등은 그 행을 실제로 만든 세대·시각으로 남고, 새 세대라는 이유만으로 UPDATE하지 않는다. 의미가 바뀐 행은 새 계산 출처를 기록한다. owner의 applied_version은 완성된 전체 결과의 게시 세대이며 개별 행의 생성 세대와 구분한다.
  • 달력의 source_updated_at는 실제 원본 변화 비교에 포함한다. 재계산 시각을 원본 시각으로 삼지 않으며, 같은 수치의 새 원본 세트로 교체되어 출처 시각만 바뀐 경우도 변경 행으로 게시한다. 과거 provenance는 정책·원본 경계·체크포인트 검증이 맞을 때만 재사용하고 새 후보의 세대를 임의로 낮춘 것은 거부한다.
  • PR 요약은 user_pr_overview_snapshot_headers.row_generations JSONB에 exercise_id → 실제 summary 행 generation을 기록한다. 예를 들어 게시 세대 42의 운동 요약이 41과 같으면 header 42가 summary 41을 참조한다. 최신·직전 publication 조회는 각각 자기 header의 membership을 사용하므로 같은 backing row를 공유해도 세대별 포함 운동·순서가 섞이지 않는다. 기존 header의 {}는 해당 header와 정확히 같은 generation 행을 읽는 호환 경로다.
  • 현재·직전 publication이 참조하는 summary 행과 그 행에 필요한 header는 더 오래된 generation이어도 보존한다. 그 참조가 사라진 오래된 backing row와 header만 정리한다. 신규 header·rollover의 게시 쓰기는 유지하고 동일 summary 자체의 INSERT/UPDATE/DELETE는 하지 않는다.

tests/db/statsDeltaPublication.test.mjs는 실제 publisher에 쓰기 계수 trigger를 붙여 동일 원본 새 세대의 16개 재사용표(15개 통계표+summary) I/U/D 0과 전체 행·물리 tuple 보존을 검증한다. 정상 v5 저장·삭제로 같은 source set 수정, 동일 수치의 새 source로 교체, 세트 제거·세션 삭제를 만들고 바뀐 수치·원본 시각 반영과 무관 종목·날짜의 행 보존을 함께 검사한다. 테스트 작성과 실행 통과는 작업 기록에서 구분한다.

전체 계산 후 부분 계산

#1590 종목 배치 — Production 반영 완료

2026-09-14 #1590은 full/incremental의 계산 범위와 계산을 끝내기 위한 트랜잭션 수를 분리한다. full 우선 전환·dirty 범위·순서·계산 정책·게시 세대의 의미는 보존한다. 큰 계정의 한 트랜잭션에서 종목 수만큼 임시 relation을 생성·삭제하며 잠금이 커밋까지 쌓이던 구조를 나눈다. 앱 a8e5871f·migration 20260915083000의 precheck와 staging 배포 34771151454가 성공했다. main 1711ae2e는 AGENTS.md 외 실행 파일이 a8e5871f와 동일하며 추가 정적·빌드·산출물 검사와 Production 배포 34771543350를 통과했다.

단계수행 범위와 커밋 결과
준비필요한 관측·세션/종목 요약을 비공개로 만들고 정확한 종목 작업 목록을 고정한다. claimed + _batch 진행 정보와 19개 typed 비공개 출력에 저장한다.
순차 배치최대 100종목의 PR·점수·근력·반복수 이력을 읽고 갱신한다. shared-set 문맥에 필요한 checkpoint를 읽되 현재 배치의 결과만 쓰고 커밋한다.
최종 집계기간·달력·PR 3일창과 최종 manifest를 완성하여 computed로 전환한다.
게시기존 단일 publisher가 완성된 19개 출력·source/lease/revision을 검증하고 public 결과·completed/applied를 원자적으로 바꾼다.

후보 종목이 100개 이하면 기존 단일 compute 경로를 쓴다. 그보다 크면 각 compute는 batch_pendingstage/next_offset/total_exercises를 반환한다. 같은 트랜잭션에서의 다음 호출은 transaction_boundary_required로 전진을 거절한다. Edge·cron의 다음 RPC가 새 트랜잭션에서 이어간다. 동기 adapter는 pending을 성공 완료로 세지 않고 deferred로 반환한다.

완료 배치의 입력판과 결과 revision을 고정해 재개 전후에 검사한다. 원본·정책·anchor·base가 바뀌거나 비공개 결과가 달라지면 실패 처리하고 기존 후속 scope 흡수/재계산 절차를 따른다. 재개 배치의 timeout/잠금 실패(57014/55P03)는 해당 배치만 롤백하며, 완료 배치와 다음 위치는 보존해 retry_pending·retry_not_before로 돌려준다. 같은 배치의 연속 실패와 만료 임대 재개 모두 기존 max_attempts 안에서 제한한다. 새 lease에는 기존 lease의 결과를 재사용하지 않는다.

임시 표 재사용은 배치 안의 relation 생성 누적을 줄이고, 배치 사이 커밋이 잠금을 해제한다. 100종목은 무제한 처리량·메모리 보장이 아니다. 최초 정규화, 최종 집계와 게시, 전체 출력 저장/정리 및 한 종목에 몰린 긴 이력은 여전히 전체 행·바이트에 따라 커질 수 있다. compute 60초·publish 6초 설정 예산을 올리지 않는다. 운영과 같은 max_connections=60, max_locks_per_transaction=64, shared_buffers=256MB에서 1,001종목과 한 종목 2,609세션×3세트 스트레스를 통과했다. 256MB는 PostgreSQL shared buffers 설정이며 컨테이너 전체 메모리 상한이 아니다. 상세 수치와 관측한 커밋 후 잠금 해제는 아래 작업 기록에 남긴다. 한 종목의 이력을 날짜별로 추가 분할하는 구조는 아니므로 그보다 더 긴 이력과 최대 메모리는 미측정이다.

최초 296종목 진단, 101종목의 같은 수치 정책을 사용하는 기존 전체 호출과의 동치, 793종목 실행 및 검증 단언 수리, 222개 migration 재생과 운영 설정 DB 회귀 8/8(운영 설정 46.379초)은 #1590 작업 기록에 남긴다. 101종목 동치와 별개로 1,001종목·집중 이력 스트레스에서는 프로필 볼륨 등 독립 기대값을 검사했다. 관계 잠금 표본 peak는 각각 658/741이며 커밋 뒤 임시 relation/advisory/write 잠금은 0이었다. 임시 표가 살아 있는 같은 backend만 검사한 결과로 재개·잠금 해제를 입증하지 않는다. 각 배치의 별도 커밋, 비공개 중간 상태, 다른 연결/중단 뒤 재개와 최종 세대 수렴을 함께 확인한다.

로컬 precheck는 1회 9분 24초로 통과했다. 단위 성공 3,799·조건부 skip 148(전체 3,947), pgTAP 122파일·2,444단언, 실제 DB Node 107/107(skip 0), 큰 이력 격리·동시 CRUD 수렴 probe를 포함한다. Populated upgrade B는 현행 222 migration의 테이블 상태에 구 runtime 10개 복원·신규 helper 2개 제거 후 296종목 53200을 재현했다. 전체 migration이 복구 job 1개를 만들고 5개 compute 트랜잭션·별도 publish 뒤 requested=applied=2로 수렴했으며 원본 5표 각 296행·hash를 보존했다. 2·3회 migration 재적용의 추가 enqueue는 0이었다. 구 221 schema 전체 재생과는 다른 검증 조건이다.

운영 복구 확인에서 해당 계정의 applied 633→requested=applied=639, stale 사용자 1→0, 미해결 53200 작업 1→0과 open job 0을 확인했다. 복구 compute 합계는 43,853.488ms, publish는 1,984.670ms였다. 화면 RPC 3개의 contract 4·stale=false도 DB 읽기 전용 트랜잭션으로 확인했다. 관리자 승인 직행으로 PR/Full CI·새 버전 태그는 없으며, Production browser journeys의 정책상 skip을 브라우저 성공 증거로 쓰지 않는다. 정확한 배포·읽기 검증 범위는 위 작업 기록을 따른다.

stats_projection_worker_settings.projection_modefull이 기본이다. 값은 full·incremental 두 가지이며 기존 문장 예산·임대·시도 상한을 바꾸지 않는다. 설정이 incremental이어도 D11 full 완료 이력이 없는 owner는 먼저 full을 사용한다. 넓히는 것은 계산 범위뿐이며 원래 job scope·target·attempts는 보존한다. full도 같은 projector와 publisher를 사용한다.

R05/R06 순서:

  1. 목적 후보의 precheck·upgrade·corpus 증거와 source SHA를 확인한다. 별도 환경에서 단일 사용자 claim→compute→publish가 완료되고 requested=applied, stale=false인지 확인한다. 전환 시 기본 mode=full을 유지한다.
  2. 후보 cohort에서 D01 손 계산·D06/D07 oracle·D11 shadow 차이 0, 660세션/7,920세트 수렴, 계산/게시 기본 60초/6초 예산 및 저장 8초 경계를 확인한다. 범위를 늘려 같은 원본·정책·anchor에서 관측하며 미측정을 통과로 적지 않는다.
  3. 해당 환경의 승인된 운영 실행에서 다음 설정만 바꾼다. 설정은 전역이며 owner별 cohort 스위치가 아니다. R05/R06의 후보 검증이 끝난 뒤 적용한다.
sql
update public.stats_projection_worker_settings
set projection_mode='incremental', updated_at=clock_timestamp()
where id=1;
  1. 새 저장·삭제·인입·날짜 이동이 실제 incremental 결과로 수렴하는지 completed job의 metadata.orchestration.mode, worker 시간, freshness를 확인한다. 새 owner는 계속 최초 full을 거친다.

검증되지 않은 큰 전체 계산을 통과시키려고 timeout·시도 상한을 늘리지 않는다. D10의 별도 3,650세션 전체 계산 60초 초과 관측은 원인이 미확정이며 이 구현의 통과 증거와 구분한다.

구 작업·scope·checkpoint 이관

전환 시 상태처리와 보존
pendingscope·target·요청 이력을 그대로 두고 새 계산기가 처리한다
processing + 구 claimed/computed run잠기지 않은 행만 실패(40001)로 전환하고 구 payload를 폐기한다. target·scope·소모 attempts와 이전 오류는 보존한다
processing/failed + run 없음retry 가능한 실패 run을 만든다. 이전 상태·오류를 metadata.d11_upgrade에 보존한다
살아 있는 옛 worker가 행을 잠금migration은 SKIP LOCKED로 건너뛴다. 구 payload의 새 publisher는 40001로 거부하고, 죽은 worker는 기존 lease expiry로 회수한다
이미 failed run기존 오류·backoff·시도 상한을 유지한다
더 높은 pending 존재D04 claim이 낮은 failed scope를 흡수한다. 새 저장의 dirty를 지우지 않는다
max_attempts 소진이관으로 횟수를 초기화하지 않는다. 원인 확인 후 승인된 full refresh 명령으로 새 generation을 요청하면 이전 범위를 흡수한다
구 scope/checkpointD04의 v1 이관 scope를 그대로 소비한다. 최초 full이 D07 체크포인트를 다시 만들며, 이후 부분 계산은 D07 정책·경계·입력 검증을 통과한 prefix만 재사용한다

DDL과 실행 상태 이관은 한 migration 트랜잭션이다. 중단하면 전체가 롤백되고 재실행한다. 이관 블록은 이미 처리한 failed run을 다시 변경하지 않는다. 원본·receipt·이력·tombstone은 UPDATE/DELETE하지 않는다.

비교 증거와 호환 경계

tests/db/statsOrchestrationShadow.test.mjs는 동일 owner/세션/세트 ID의 12개 원본 상태에서 REPEATABLE READ와 트랜잭션 날짜를 고정한다. 매 상태의 full/incremental 후보를 savepoint 안에서 실제 공통 계산·게시로 읽고 전부 롤백한다. 별도 연결이 같은 시간의 공개 상태가 그대로임을 확인한다. 운영 데이터에서 shadow를 활성화하는 기능은 없다.

수치·논리 키·PR provenance는 D01 장부의 기존 제외 열만 사용한다. 공개 PR 개요·종목 상세·볼륨·기록표 DTO는 배열 순서·업무 날짜·원본 생성 시각·null/0·세대를 유지한다. 무작위 PR 참조는 그 원본 종류/ID로 바꾸며 PR 요약/최근 기록의 updated_at과 freshness applied_at만 실행 시각으로 제외한다. PR 동률 정렬은 원본 논리 키, 개요의 최근 활동은 canonical 세션 수정 시각을 사용하여 계산 시각·무작위 이벤트 ID가 사용자 순서를 바꾸지 않는다.

구 baseline SQL은 기존 D06/D07 테스트 fixture에서만 임시 설치·롤백된다. 공개 저장 v5와 A06 조회 adapter, A10/A11의 stale/error/fresh 의미는 유지한다. 부분 성공을 fresh로 표시할 수 없으며 기존 확정 결과가 있으면 pending 동안 그것을 읽을 수 있다.

호환 adapter의 소유자는 DATA이고 v5/조회 서명의 소비자는 A06 및 앱 읽기 경계다. 공개 서명은 현재 소비자와 cron/Edge가 사용하는 동안 유지한다. R01에서 소비자·예약 호출의 이전을 확인하기 전에는 제거하지 않는다. 하위 계산기를 별도의 활성 baseline 체인으로 다시 연결하지 않는다.

격리 환경에서 증거 재실행

통상 재실행 절차는 앱 checkout의 clean·커밋된 후보에서 npm run ci:precheck-local -- --release origin/release/v0.18.0 --sandbox <전용-root> --keep을 실행하는 것이다. 이 명령은 최신 release를 반영하고 새 DB 재생·pgTAP·동시 저장·D06/D07/D11·worker·660세션 검사를 수행한다. 이번 #1422에서는 오너가 2026-09-10 “프리첵하지말고 구현 마무리해서 릴리스 병합”을 지시해 추가 precheck를 생략한다. 기존 5회 실패와 마지막 660세션 게시 6,119ms 실패는 통과로 바꾸지 않으며, 구현에 필요한 개별 검사와 Merge Check·실제 병합 결과를 별도로 기록한다. 이 한정된 지시는 전체 CI나 Production 전환 검증의 통과를 뜻하지 않는다.

실행 중 같은 DB를 reset하거나 다른 fixture를 적재하지 않는다. 다음 개별 검사는 다른 DB 검사 프로세스 종료를 확인한 뒤 사용한다.

powershell
$env:BARBELIC_DB_SANDBOX='<전용-root>/database'
$env:BARBELIC_REQUIRE_DB='1'
node --import tsx --import ./tests/support/registerNodeTestBudget.mjs --test --test-concurrency=1 tests/db/statsOrchestration.test.mjs tests/db/statsOrchestrationShadow.test.mjs tests/db/statsProjectionDeterminism.test.mjs
node --import tsx --import ./tests/support/registerNodeTestBudget.mjs --test --test-concurrency=1 tests/db/statsDeltaPublication.test.mjs

DB 대상 미지정에 따른 skip은 성공이 아니다. D11+기본 D01 묶음은 19개 pass, shadow 진단은 12 canonical snapshots/11 incremental candidates/차이 0을 기대한다. seed·정책·기준 날짜·논리 키를 고정한 D01의 1년 baseline을 함께 대조한다. high migration 재검증은 G04의 history-4y populated fixture와 db:upgrade --from <직전-low-커밋> --to worktree --sandbox <database> --workload <history-4y> --interrupt <full-first-cutover-번호>:4 --check-definitions --out <증거-폴더>를 사용한다. 이 작업은 별도 전용 DB에서 직렬 실행하고 원본 hash·probe·rollback 결과를 보존한다.

후퇴와 재개

수치/근거/DTO 차이 또는 실행 예산 실패가 나면 해당 환경에서 projection_mode='full'로 돌린다. 이미 완료된 generation을 덮어쓰거나 옛 baseline writer를 다시 켜지 않는다. 실패 원인을 고친 forward migration 뒤 새 full generation을 요청한다. 원본 수리·DB rollback·Production 승격은 이 설정 변경에 포함되지 않는다.

실제 명령/수치/커밋/배포 상태는 D11 작업 기록에 누적한다. 운영 환경별 최종 검증과 승격 증거는 R05/R06 인계다.

R04 후속 수리 — 정규화 관측의 계산 격리

#1543의 검증 중, 기존 D05 정규화 관측을 compute에서 public DELETE/INSERT하는 경로와 원본 삭제의 FK cascade가 역순 잠금으로 교착하는 것이 재현됐다. R04 작업 브랜치는 이 관측도 기존 private compute→검증→원자적 게시에 포함하고 소비자가 비공개 관측을 읽도록 변경한다. 게시 대상은18개에서19개가 된다. 원본FK와 기존 owner 게시 잠금,60초/6초 예산은 그대로다.

새 결과 증명의 normalization_isolated=true가 없는 computed payload는40001로 재계산한다. 원본이나 이미 완료된 세대를 되돌리지 않는다. 실제 release 반영 및 성능 판정은 R04 결과에 연결하며, 이 설명만으로 수리·성능·운영 전환 완료를 주장하지 않는다.

R04 계산 결과 저장 방식 — release 반영

10년 이력에서 큰 JSON을 저장하고 게시 때 19개 표로 다시 구성하는 비용이 기존 게시6초를 넘었다. R04 후보는 기존 계산기와 pg_temp 계산을 유지하면서, 계산 결과를 stats_output_<공개 표 이름>인 19개 비공개 파생 표에 원래 타입 그대로 저장한다. 기본 키는 job·lease와 원래 행 키이며 canonical 원본을 향한 FK는 없다. run의 FK에만 종속되고 run 삭제 시 함께 정리된다. 기존 공개 투영의 원본FK·트리거·사용자 읽기 권한은 유지한다.

run에는 owner·generation·base·anchor·source/policy·완료 단계·표별 행 수·results_revision의 작은 manifest만 둔다(_storage_version=2). 결과 적재와 manifest의 computed 전환은 같은 계산 트랜잭션이다. 새 lease의 대량 적재를 한 행으로 잘못 추정해 공개 표를 반복 조회하지 않도록, 결과 표의 planner 통계도 계산60초 안에서 준비한다. 게시6초 밖에 별도 준비 단계를 만들지 않는다.

비공개 표는 RLS를 켜고 PUBLIC·anon·authenticated·service_role의 직접 읽기/쓰기를 모두 회수한다. 내부 결과 변경은 같은 run 행 잠금과 현재 lease를 요구한다. claimed 동안 허용한 변경은 revision을 올리고 이전 manifest를 무효화한다. computed 결과의 변경은 거부한다. 따라서 게시가 보유한 run 잠금·revision·동일 트랜잭션의 검증 증명은 실제로 같은 불변 결과를 가리킨다. 완료 단계에서도 현재 source/policy와 세대 조건을 다시 확인한다.

성공·실패·새 lease 준비 시 비공개 결과를 정리한다. 성공 정리와 run 완료·공개 투영·applied 세대 갱신은 모두 같은 게시 예산과 롤백 경계 안에 있다. 정리 중 취소도 공개 결과를 일부 완료시키지 않으며, 기존 실패·backoff 절차로 기록한다. 오래된 lease는 새 결과를 바꾸거나 지울 수 없다. 이전 JSON-only 결과 및 현재 게시 문맥이 없는 구 publisher는40001로 재계산을 요청한다. 큰 JSON을 다시 조립하는 fallback은 두지 않는다.

2026-09-12 PR #1579 / 17ce7812로 release/v0.18.0에 반영됐다. 관련 DB·재시작·독립 full 동치와 필수 precheck를 통과했지만 Production 전환이나 전체 G04 성능 통과를 뜻하지 않는다. private datum 크기·manifest 크기·전체 relation 크기·WAL은 각각 다른 지표로 기록한다. 동일 backend의 임시 표가 사라진 뒤 다른 backend/재시작에서 복구, 결과 누락/변조·다른 owner·세대·source/lease·부분 실패·정리와 full/incremental 동치를 확인한 근거를 R04 결과에 연결한다. 후퇴가 필요하면 위의 기존 full 설정과 forward migration 절차를 따른다.