통계 투영 계산 DAG·단일 작성자 계약 (이슈 #1286, D01)
한 문장 — 유저가 세트 하나를 고치면 서버가 통계를 다시 계산하는데, 그 계산이 어떤 순서로(DAG), 어느 표를 누가 쓰고(작성자 장부), 무엇이 "같은 결과" 인지(논리 키·비교 제외 열) 를 적은 계약이다. 초기 D01 조사와 목표 구조를 보존하고, #1432의 계산·발행 분리는 §0에 기록한다. 수치 정책의 정본은 정책·세대 계약이다.
- 이슈: #1286 (계획 ID D01). 선행: G02 테스트 기준선(oracle 코퍼스·DB barrier), G03 도메인 계약 §9(
StatsGeneration·isGenerationPublished), G01 ADR §3-4(단일 작성자·generation 발행). - 기계 판독 장부:
supabase/contracts/stats-projection-writers.json(계약 버전 1). 검사:tests/db/statsProjectionWriters.test.mjs(schema.sql 정적 분석 — CI 단위 잡) ·tests/db/statsProjectionDeterminism.test.mjs(샌드박스 — 같은 입력을 두 번 계산해 같은 결과인지). - 유지되는 현행 문서: dirty event 계약(11개 사건과 영향 범위), 잡 큐·worker 계약(잡 상태·합치기·PR 스냅샷 발행 게이트), 원본 불변(투영은 시스템이 다시 계산해도 되는 등급). 이 문서는 그 위에 "누가 무엇을 쓰는가" 만 더한다.
- 문서 버전 v1 (2026-09-07). 읽은 코드 = main
beff760b.
0. D11 현행 실행 경계 — claim / compute / publish
아래 §1~§4의 함수 수·호출 순서는 최초 D01 조사 시점의 기록이다. 현재 작성자와 열은 stats-projection-writers.json의 current, 실행 책임은 executionContract가 정본이다. D11 #1422, release/v0.18.0 대상 장부는 23개 표이며 게시 출력은 stats_projection_tables_v1()의 18개다. 전환 계약과 검증 기록을 함께 읽는다. Production 전환은 별도다.
| 경계 | 책임과 쓰기 대상 | 확정하는 상태 |
|---|---|---|
claim_stats_projection_job_v1 | 큐 시도 횟수와 user_stats_projection_runs의 lease를 짧은 별도 트랜잭션으로 저장 | 계산 실패나 연결 종료가 이전 claim까지 지우지 않는다 |
compute_stats_projection_job_engine_v1(job) | run_stats_projection_v1에서 D05 관측·세션 → D07 순차 파생 → D06 기간·달력 → snapshot을 pg_temp 출력으로 계산한다 | owner·generation·source/정책·anchor·단계 완료·18개 행 수의 manifest를 미발행 payload에 저장한다. D05 순수 관측 캐시는 기존 public writer를 유지한다 |
publish_stats_projection_job_engine_v1(job) | lease/base와 전체 manifest를 확인하고 user·queue 잠금 아래 18개 출력의 삭제·삽입·수정을 적용한다 | 모든 출력과 completed/applied가 한 트랜잭션이다. 변경 없는 행은 보존한다. 불완전·stale 결과는 롤백한다 |
| 동기·cron·Edge 호환 경로 | 기존 공개 서명은 같은 engine으로 전달하는 adapter다. 동기 경로도 공통 계산·게시를 거친다 | authenticated 호출은 큐 상태만 관찰한다. v5 영수증·A06 조회·A10/A11 freshness 의미를 유지한다 |
current는 물리적으로 쓰는 함수를 모두 적고 target은 논리 계산 소유권을 적는다. publisher가 18개 표에 등재돼도 표별 계산 책임은 D05/D06/D07에 있다. 하위 projector의 privileged 서명과 테스트 호출은 남아 있지만 활성 worker와 유지보수 adapter는 공통 orchestration을 사용한다.
2026-09-10 D11 추가 승인 계약(구현·검사·병합 진행 중): full 계산과 pg_temp 전체 복사는 유지한다. 15개 통계표의 계산값·정책·실제 source 및 시각이 같으면 개별 행의 이전 applied_version·계산 시각을 보존하고, 의미가 바뀐 행만 새 provenance로 게시한다. 새 owner 게시 세대는 완성된 결과의 세대이며 모든 물리 행을 그 번호로 다시 쓰라는 뜻이 아니다. 달력 source_updated_at의 실제 변화는 변경 판정에 포함한다.
PR summary도 재사용 대상이다. header의 row_generations JSONB가 운동별 실제 summary generation을 참조하며 최신·직전 publication은 각 header의 membership을 읽는다. {}인 기존 header는 exact-generation 경로로 읽는다. 현재·직전 publication이 참조하는 더 오래된 summary 행과 필요한 header를 보존하고, 참조가 사라진 오래된 행만 정리한다. 따라서 18개 출력 중 동일 원본 새 세대에서 물리 쓰기 0을 검사할 대상은 15개 통계표+summary 1개이며 header·rollover의 게시 쓰기는 별도다. 세부 계약과 검증 범위는 전환 계약에 둔다.
동적 SQL은 정적 표 이름 검색만으로 찾을 수 없다. scripts/sql/projectionDynamicWriters.mjs가 닫힌 표 목록과 public.%I DML을 연결한다. dynamicWriters는 publisher의 delete/insert/update와 전체 비생성 열을 기록하고 writer·DB model 검사에서 드리프트를 거부한다. 실제 정확성·동시성·원자성은 별도 DB 검사로 검증한다.
user_stats_projection_runs는 재계산 가능한 결과가 아니라 실행 제어 표(compare:false)다. 미발행 payload는 발행·실패 시 비우고, lease/진단 행은 통계 작업 또는 계정 삭제 cascade까지 남는다. 별도의 시간 기반 purge를 구현했다고 주장하지 않는다. 이전 세대의 결과와 현재 요청 세대를 섞어 발행하거나, 실패한 payload를 새 lease에서 재사용하지 않는다.
0-1. D06 기간·달력 계산 작성자
2026-09-10 #1416, release/v0.18.0 반영 완료(앱 PR #1518, 5b16a162). 필드·영향 계약과 작업 기록이 현행 변경의 근거다. 아래 최초 D01의 다중 writer 수치는 역사 기록으로 유지한다.
| 최종 표 | 계산 작성자 | 입력·완성 행 |
|---|---|---|
user_exercise_period_stats | refresh_user_exercise_period_buckets_v1 | session rollup·strength observation/daily·score → assemble_user_exercise_period_rows_v1 |
user_training_period_stats | refresh_user_training_period_buckets_v1 | completed session·shared-set·rollup·timing·출석 → assemble_user_training_period_rows_v1 |
user_calendar_day_summaries | refresh_user_calendar_day_buckets_v1 | calendar source·strength observation·score → assemble_user_calendar_day_rows_v1 |
계산 writer는 각 1개이며 publisher의 물리 DELETE/INSERT는 별도 경계로 계속 장부에 남는다. refresh_user_period_calendar_projection_v1가 D04 old/new와 완료된 D07 영향의 합집합을 계산한다. missing/version/owner/generation/complete 불일치는 발행 가능한 0으로 바꾸지 않고 중단한다. full과 증분은 같은 assembler를 사용하며 외부 DTO·수치 정책은 바꾸지 않았다.
0-2. D07 순차 계산·체크포인트 경계
2026-09-10 #1417을 앱 PR #1522 / e2833dee로 release/v0.18.0에 반영했다. 운영 미배포다. 상태·영향 계약과 검증·비용 기록을 따른다. §0의 17개 출력은 당시 집계이며 D07에서 user_stats_sequential_days를 포함한 출력은 18개, 전체 작성자 장부는 23개 표다.
PR·근력·최대반복수·점수의 일자 완료 상태를 각 계산 종류별 정책·원본 경계·확정 세대로 검증한다. seed가 없거나 손상되면 동일 계산 엔진의 full 경로를 쓴다. 전역 applied는 체크포인트 함수가 갱신하지 않으며 기존 atomic publisher가 출력과 함께 확정한다. 캐시는 일반 숫자 snapshot 비교에서 제외하고 별도의 seed·세대·발행·취소 불변식으로 검증한다.
refresh_user_sequential_projections_v1이 D05 관측 이후 네 계산을 마치고 전후 파생값을 비교한다. 실제 바뀐 날짜와 공유 점수의 모든 구성 종목을 D06에 전달하며, 불확실하게 넓어진 재생 경계는 full로 전달한다. 기간·달력 표의 계산 작성자는 §0-1의 세 함수로 유지된다. 고정된 과거 full SQL oracle와 실제 D06 소비 경로를 DB 레인에서 함께 검사한다.
1. 읽는 법 — 유저 A 의 세트 무게 수정 한 건
유저 A 가 8월 12일 스쿼트 세션의 3세트 무게를 100kg 에서 105kg 으로 고쳐 저장한다.
| 순서 | 일어나는 일 | 어디에 적히나 |
|---|---|---|
| ① | save_session_v5 가 원본(세트 행)을 바꾸고 dirty event exercise_set_updated 로 잡 하나를 큐에 넣는다. 영수증에 요청 세대(statsRequestedVersion, 예: 41)가 실린다 | 큐 user_exercise_stats_refresh_jobs · 세대 user_stats_refresh_state.requested_version = 41 |
| ② | 매분 도는 cron worker 가 잡을 집어 A 의 8월 12일 이후를 다시 계산한다. §3 의 10단계가 한 트랜잭션 안에서 순서대로 돈다 | 투영 표 16개 |
| ③ | 마지막에 PR 개요 스냅샷(3일 창)을 세대 41 로 쓰고, 발행 게이트가 3행·개수를 확인한 뒤 잡이 completed 가 된다 | user_pr_overview_snapshot_headers.generation = 41 |
| ④ | 잡 완료 트리거가 applied_version 을 41 로 올린다. 앱의 isGenerationPublished(41 ≥ 41) 이 참이 되어 홈·달력·기록 화면이 새 숫자를 믿는다 | user_stats_refresh_state.applied_version = 41 |
②에서 10개 함수가 같은 표에 겹쳐 쓰는 것이 이 문서가 고정하려는 문제다(§4). 목표(§5)에서는 A 의 수정 한 건이 관측 → 세션 합계 → 순차 파생 → 기간·달력 → 스냅샷 → 발행 을 한 방향으로만 지나고, 표마다 한 작성자가 모든 열을 한 번에 쓴다.
2. 용어
| 용어 | 뜻 |
|---|---|
| 원본(fact) | 유저가 기록한 것. 세션·세션 종목·세트 행. 시스템은 바꾸지 않는다(원본 불변) |
| 투영(projection) | 원본을 정책으로 계산한 결과 표. 언제든 다시 계산해도 된다. 이 문서의 표 16개 |
| 관측(observation) | 세트 하나를 정책으로 읽은 결과 한 행(추정 1RM 범위·최대 반복수 범위·적격 여부) |
| 순차 파생 | 시간 순으로 "그 시점까지의 값" 에 의존하는 상태(일별 대표 e1RM·근력 상태·측정 PR 이벤트·세트 스코어 기준값). 과거 한 건을 고치면 그 뒤 전부가 바뀔 수 있다 |
| 세대(generation) | 통계 재계산 요청 번호. 요청(requested) → 적용(applied) 두 열. 화면은 적용 ≥ 요청일 때만 숫자를 믿는다(G03 §9) |
| 작성자(writer) | 어떤 투영 표의 행을 넣고·고치고·지우는 함수(모듈). 목표는 표마다 하나 |
| 논리 키 | 같은 입력에서 항상 같은 값으로 정해지는 행 식별자(무작위 uuid 가 아닌 것). 동치 비교는 이 키로 맞춘다 |
| 비교 제외 열 | 쓰는 시각·세대 번호·무작위 id 처럼 같은 입력이라도 실행마다 달라지는 운영 열. 장부에 사유와 함께 적는다 |
3. 현행 계산 순서 — worker 한 라운드의 10단계
진입점 process_user_exercise_stats_refresh_jobs(p_limit, p_validate_integrity, p_lock_owner) (전역 자문 잠금 lift-guild:stats-refresh-global-worker — 사용자 간 직렬화, G04 실측 §4 참조) → 잡마다 refresh_user_exercise_stats_from(user, from_date, exercise_ids) (사용자 잠금 → 큐 잠금 순, 세대 울타리: 잡 문맥 없이 부르면 requested ≠ applied 일 때 55000 거부) → 아래 10단계 → validate_user_exercise_stats_integrity_engine_v1 → 잡 completed.
D08(#1412, 2026-09-10): 위 진입점은 이제 동기 유지보수 문이며 호출당 유저 1명만 처리하고
has_more를 돌려준다(전역 자문 잠금은 "동시에 한 호출"만 막고 여러 유저를 한 트랜잭션에 묶지 않는다). 1초마다 도는 격리 worker(claim_stats_projection_job_v1→compute_stats_projection_job_v1→publish_stats_projection_job_v1)가 정규 경로이고 Edgestats-process-refresh-jobs도 이 세 단계를 부른다. 계산 범위는 D04 typed scope(stats_refresh_scope_range_v1) + D05 scope 인자, 예산·임대·시도 상한은 설정 표stats_projection_worker_settings, 살아 있는 임대의 변경은 토큰 울타리stats_projection_run_lease_fence_v1(55P03)가 지킨다 — Stats Refresh Jobs D08 절.
| # | 함수 | 입력(읽는 것) | 출력(쓰는 표 · 방식) | 잠금 | 비고 |
|---|---|---|---|---|---|
| 1 | refresh_user_exercise_stats_from_base | 세트 원본(completed_session_v1·session_exercise_part·exercise_set_part), stats_effective_load_kg·stats_reps | rollups 삭제·삽입(18열, 추정 1RM 열은 NULL) · records 삭제·삽입 · exercise_stats 삭제·삽입 · period_stats 삭제·삽입(11열) | 사용자 자문 잠금 | 임시 표 exercise_stats_affected 로 범위 결정 |
| 2 | refresh_user_measured_pr_projection | 완료 main/top 세트의 실측 + user_manual_pr_records | pr_events 삭제·삽입 · pr_states 삭제·삽입 · 달력 dirty 표에 날짜 삽입 | 사용자 잠금 | 1~20RM 정확 반복수, 동률은 이벤트 없음 |
| 3 | refresh_user_exercise_best_reps_from | 세트 원본 + 기록 프로필 적격성 | exercise_stats UPDATE(best_reps) | 없음 | |
| 4 | refresh_user_exercise_main_reps_from | 세트 원본(stats_reps) | rollups UPDATE(main_reps) · period_stats 다시 삭제·삽입(11열) | 없음 | 주석에 "legacy helper" — 1단계가 넣은 기간 행을 통째로 다시 만든다 |
| 5 | refresh_user_strength_estimation_projection → _core → refresh_user_set_purpose_projection → refresh_user_max_rep_projection → refresh_user_set_score_period_stats_v1 | 세트 원본 + 정책 표(strength_estimation_policy_versions·curve·max_rep_estimation_policy_versions·set_purpose_policy_versions) + 세션 체중 | strength_observations 삭제·삽입 + UPDATE 10열 · strength_daily 삭제·삽입 · strength_states 삭제·삽입 · max_rep_observations 삭제·삽입 + UPDATE · set_score_states upsert(열 묶음 둘) · rollups UPDATE(추정 1RM·강도·RPE·목적·최대 반복수 열) · records UPDATE · exercise_stats UPDATE · period_stats UPDATE 3회(강도·목적·세트 스코어) | 사용자 잠금(core·max_rep 각각) | 909줄. 관측 → 일별 → 상태 → 세션 합계 보정 → 기간 보정을 한 함수가 한다 |
| 6 | refresh_user_training_period_stats_from → apply_training_attendance_policy_v1 | 세션 원본(날짜·시각) + session_set_totals_v1 + 출석 정책 | training_period_stats 삭제·삽입(9열) → UPDATE(출석 3열) | 없음 | |
| 7 | refresh_user_training_main_reps_from | 세트 원본 | training_period_stats UPDATE(main_reps) | 없음 | |
| 8 | refresh_user_session_timing_stats_from | 세션 시작·종료 시각 | training_period_stats UPDATE(시간·시간대 3열) | 없음 | |
| 9 | refresh_user_calendar_day_summaries → _v1_core | calendar_day_summary_source(rollups·세션·pr_events) + dirty 표 + set_score_observations_v1 | calendar_day_summaries 삭제·삽입(12열) → UPDATE 3회(강도·RPE·세트 스코어 열을 0 으로 지우고 다시 채움) · dirty 표 삭제 | 없음 | 범위 = least(from_date, 가장 오래된 dirty 날짜) 이후 전부 |
| 10 | refresh_user_pr_overview_snapshot_window(user, target_version, current_date) | build_user_pr_exercise_summary_rows(exercise_stats·period_stats·pr_events) + exercise_catalog_latest_seq_v1 | snapshot_headers 삭제·삽입(세대 = target_version, as_of = 어제·오늘·내일) · summary_snapshots 삽입 + UPDATE(1RM 근거 6열) · rollover_state upsert | 사용자 잠금 | 발행 게이트 guard_user_stats_refresh_projection_publication(3행·개수 일치, 55000) |
세대 이동: 잡 completed 로 바뀌는 순간 sync_user_stats_refresh_state_from_job 트리거가 applied_version = least(requested, greatest(applied, target)) 로 올린다 — 같은 세대의 형제 잡이 남아 있으면 올리지 않는다. 요청 세대는 enqueue_user_exercise_stats_refresh_job 만 올린다(+1, 이미 더러우면 범위를 과거로 넓히고 종목 범위를 전체로).
worker 밖에서 투영을 쓰는 길 (장부 current 에 함께 적혀 있다): 세트 삭제 트리거 clear_rollup_max_rep_on_source_set_delete_v1(rollups 의 최대 반복수 12열을 NULL 로) · 원본 트리거 mark_calendar_summary_dirty_date(dirty 표) · initialize_user_pr_overview_snapshot(신규 계정 최초 세대) · 관리자 cron refresh_exercise_usage_stats_v1(카탈로그 전역). D10 v0.18.0 후보의 매일 rollover는 새 세대 요청만 남기며 snapshot을 직접 쓰지 않는다.
4. 표별 현재 작성자 — 장부 current 요약
장부 tables.<표>.current 가 정본이고 검사 ①이 schema.sql 과 대조한다. 아래는 사람이 읽는 요약(2026-09-07, main beff760b).
| 투영 표 | 단계(목표) | 논리 키 | 현재 작성자 수 | 열을 나눠 쓰는 모양 |
|---|---|---|---|---|
user_exercise_session_rollups | 세션 합계 | session_exercise_id | 6 | base 가 18열 삽입(추정 1RM NULL) → main_reps 1열 → set_purpose 3열 → max_rep 11열 → strength_core 20열 → 트리거가 12열 NULL |
user_exercise_period_stats | 기간·달력 | user_id, exercise_id, period_type, period_start | 5 | base 삽입 → main_reps 재삽입 → set_purpose 3열 → set_score 8열 → strength_core 17열 |
user_training_period_stats | 기간·달력 | user_id, period_type, period_start | 4 | 삽입 9열 → attendance 3열 → main_reps 1열 → timing 3열 |
user_exercise_stats | 기간·달력 | user_id, exercise_id | 3 | 삽입 18열 → best_reps 1열 → strength_core 6열 |
user_exercise_records | 세션 합계 | session_exercise_id, record_type | 2 | 삽입 → strength_core 가 value·estimated_1rm·is_pr 등 7열. 읽는 화면 함수가 없다(§6) — 1단계가 종목 통계를 만들 때 중간 표로만 읽는다 |
user_exercise_strength_observations | 관측 | source_set_id | 2 | strength_core 삽입 43열 + 자기 UPDATE 10열(순차 파생 결과를 관측 행에 되적음) → set_purpose 가 stimulus_class |
user_exercise_set_score_states | 순차 파생 | user_id, exercise_id | 2 | strength_core(1RM 열 묶음) · max_rep(반복수 열 묶음) 각자 upsert |
user_calendar_day_summaries | 기간·달력 | user_id, date | 2 | core 삽입 12열 → 래퍼 UPDATE 24열 3회 |
user_calendar_summary_dirty_dates(control) | — | user_id, date | 3 | 트리거·measured_pr 삽입, calendar_core 삭제 |
user_pr_overview_snapshot_headers | 스냅샷 | user_id, as_of_date | 2 | snapshot_window 삭제·삽입 · prune 트리거 삭제 |
user_pr_overview_rollover_state(control) | 스냅샷 | user_id | 5 | window·initialize·D08 publish·D10 bootstrap·due 재시도/다음 시각. D10은 발행 세대를 쓰지 않음 |
user_stats_refresh_state(control) | 발행 | user_id | 2 | enqueue(요청 열) · 잡 트리거(적용 열) — 열 소유가 갈린다(장부 columnOwners) |
user_exercise_stats_refresh_jobs(control) | 발행 | id | 4 | enqueue 삽입·합치기 · worker 둘 · requeue |
| strength_daily · strength_states · max_rep_observations · pr_events · pr_states · summary_snapshots · exercise_usage_stats | — | 장부 참조 | 1 | 이미 단일 작성자 |
5. 목표 DAG — 6단계·작성자 14
flowchart LR
facts[원본: session · session_exercise_part · exercise_set_part · manual PR · 정책 표]
facts --> obs
subgraph obs[1 관측]
so[strength_projector<br/>strength_observations]
mo[max_rep_projector<br/>max_rep_observations]
end
obs --> roll
subgraph roll[2 세션 합계]
sr[session_rollup_projector<br/>session_rollups · records]
end
obs --> seq
roll --> seq
subgraph seq[3 순차 파생]
sd[strength_projector<br/>strength_daily · strength_states]
ss[set_score_state_projector<br/>set_score_states]
pr[measured_pr_projector<br/>pr_events · pr_states]
end
roll --> per
seq --> per
subgraph per[4 기간·달력]
ea[exercise_aggregate_projector<br/>exercise_stats]
ep[exercise_period_projector<br/>period_stats]
tp[training_period_projector<br/>training_period_stats]
cd[calendar_day_projector<br/>calendar_day_summaries]
end
per --> snap
seq --> snap
subgraph snap[5 스냅샷]
ps[pr_overview_snapshot_publisher<br/>summary_snapshots · headers · rollover_state]
end
snap --> pub
subgraph pub[6 발행]
gp[generation_publisher<br/>stats_refresh_state.applied_version]
end규칙
- 표마다 작성자 하나. 장부
targetWriters.<이름>.owns에 적힌 표만 그 작성자가 쓴다(검사 ②). 다른 모듈이 같은 표에insert/update/delete를 쓰면 검사 ①(현재 작성자 드리프트)이 막고, 장부를 고치려면 목표 열 중복(검사 ②)에 걸린다. - 한 작성자는 자기 표의 모든 열을 한 번에 넣는다. "삽입 뒤 다른 함수가 열을 채우는" 보정 UPDATE 를 없앤다. 뒤 단계가 필요로 하는 값은 앞 단계가 DTO(§5-1)로 넘긴다 — 표를 읽어 되적지 않는다.
- 화살표는 한 방향. 뒤 단계의 결과를 앞 단계 표에 되적지 않는다. 지금 관측 표의
reference_e1rm_kg·set_intensity_percent·recent_performed_1rm_kg처럼 상태(3단계)에서 온 값이 관측(1단계) 행에 적히는 열은, 목표에서 관측 표의 열이 아니라 세션 합계·순차 파생의 열로 옮기거나 같은 작성자(strength_projector)가 한 트랜잭션에서 쓴다. 어느 쪽인지는 D02 가 정한다 — 이 문서는 "되적지 않는다" 만 고정한다. - 부수 효과 없음. 측정 PR 작성자가 달력 dirty 표에 날짜를 넣는 것, 세트 삭제 트리거가 세션 합계를 NULL 로 만드는 것은 dirty event(D04)가 대신한다. 작성자는 자기 표만 쓴다.
- 한 세대 = 한 판. 같은 세대 번호로 같은 표를 두 번 쓰지 않는다(정책·세대 계약 §6). 롤오버는 새 세대를 요청한다.
- 발행은 한 곳.
applied_version은generation_publisher만 올린다. 모든 작성자가 그 세대를 끝냈을 때만(D11).
5-1. 단계 사이에 넘기는 DTO
이름은 D02 가 코드로 확정한다. 여기서는 "무엇이 들어 있어야 하는가" 만 적는다.
| DTO | 만드는 작성자 | 받는 작성자 | 내용 |
|---|---|---|---|
StrengthObservationBatch | strength_projector | session_rollup · set_score_state · exercise_aggregate · exercise_period · calendar_day | 세트별: 적격 여부·제외 사유·e1RM 범위(lower/representative/upper)·confidence·kind·강도 %·RPE 열·세트 목적(stimulus_class) |
MaxRepObservationBatch | max_rep_projector | session_rollup · set_score_state · exercise_aggregate | 세트별: 관측 바닥 반복수·추정 최대 반복수 범위·confidence·kind·세션 합계 대표 세트 여부 |
SessionRollupBatch | session_rollup_projector | exercise_aggregate · exercise_period · training_period · calendar_day · pr_overview_snapshot | 세션×종목별: 세트 수·볼륨·최고 무게·최고 추정 1RM 근거 세트·메인 반복수·목적/강도/RPE 분포·최대 반복수 대표값 |
StrengthStateBatch | strength_projector | exercise_aggregate · calendar_day(참조 기준) · pr_overview_snapshot | 종목별 현재 e1RM·신뢰 기준·stale 날짜, 일별 대표 e1RM |
MeasuredPrBatch | measured_pr_projector | calendar_day(pr_count) · pr_overview_snapshot | 종목×반복수별 이벤트(값·이전 값·달성일·근거) 와 현재 상태 |
SetScoreReferenceBatch | set_score_state_projector | exercise_period · calendar_day | 종목별 최근 수행 1RM·최근 수행 최대 반복수(세트 스코어의 분모) |
6. 화면이 읽는 것 — 투영 출력 경계 (A06 입력)
schema.sql 정적 분석(2026-09-07). "원본" 열은 투영이 있어도 화면 함수가 세션·세트 표를 직접 읽는 곳이다 — A06 이 "투영으로 옮길 것 / 원본을 읽어야 하는 것(계획·컨디션·표시용 세트 목록)" 을 가른다.
| 화면 RPC | 읽는 투영 표 | 함께 읽는 원본 |
|---|---|---|
홈 build_home_dashboard_core_v2 | session_rollups · period_stats · training_period_stats · calendar_day_summaries · pr_events | completed_session_v1 · session_set_totals_v1 · daily_conditions · planned_session_v1 |
달력 월 get_calendar_month_summary | session_rollups · calendar_day_summaries | completed_session_v1 · session_set_totals_v1 · daily_conditions · planned_session_v1 |
달력 일 get_calendar_day_summary | calendar_day_summaries | session_exercise · session_exercise_part · exercise_set_part · completed_session_v1 · daily_conditions · planned_session_v1 |
달력 범위 get_calendar_range_summary · 연간 get_home_year_activity | calendar_day_summaries (+ user_stats_refresh_state) | planned_session_v1 |
PR 개요 get_pr_overview_engine_v1 · 요약 get_user_pr_exercise_summaries_json | pr_events · snapshot_headers · summary_snapshots · user_stats_refresh_state | — |
볼륨 리포트 get_volume_overview_v3_core · _annual_v3 | period_stats · training_period_stats · pr_events · max_rep_observations | record_metric_measured_sets_v1 경유 세트 원본 |
종목 상세 build_exercise_pr_detail_core_v3 · get_exercise_pr_detail_year | exercise_stats · period_stats · pr_events · pr_states · strength_daily · strength_states | 유산소·버티기 기록은 세트 원본 |
종목 이력 get_exercise_pr_history_v3_core | session_rollups · pr_events | session_exercise_part · exercise_set_part · completed_session_v1 |
기록표 get_log_table_month_v2_core | period_stats | — |
세션 검색 get_session_search_v2_engine · 피드 get_profile_feed_page_engine_v1 | session_rollups | 세션 원본 |
운동 중 세트 스코어 미리보기 workout_set_score_preview_v1 | set_score_states · max_rep_observations · pr_events | — |
읽는 화면 함수가 없는 투영: user_exercise_records(세션×종목 session_best 행 3,970개 — Production). 계산 1단계가 종목 통계(user_exercise_stats)를 만들 때 중간 표로 읽고 5단계가 UPDATE 로 보정까지 하지만, 아무 화면 함수도 읽지 않는다 — 세션 합계 표가 같은 값을 이미 갖고 있다. D02 가 폐기(종목 통계를 세션 합계에서 직접) 여부를 정한다(작업 기록 §4 에 기록).
7. 세대·스냅샷 — 이 문서가 고정하는 것 (자세한 문장은 정책 계약 §6)
- 세대 번호는
user_stats_refresh_state의requested_version(요청) 과applied_version(적용) 두 열이다. 화면은applied ≥ requested이고 stale 이 아닐 때만 숫자를 믿는다(G03 §9isGenerationPublished). - 세대 번호를 행에 새기는 표: 관측·일별·상태(
applied_version), 최대 반복수 관측, pr_events·pr_states, set_score_states(열 둘), 스냅샷 헤더·행(generation). 세션 합계·기록·종목 통계·기간 통계·훈련 기간·달력에는 세대 열이 없다 — 이 표들은 "마지막 쓰기" 만 남는다. 목표에서 세대 열을 어디까지 두는지는 D11 이 정한다. - D10 v0.18.0 후보: 매일 롤오버의 이미 적용된
applied_version재작성 경로를 제거했다. 날짜·catalog·repair 발견은 D04의 새 target version을 요청하고 기존 D08이 계산·발행한다. 유지되는 직전 세대 내용은 변경하지 않는다. 커서·token·재시도·퇴역 계약을 D11 입력으로 사용하며, D11의 전체 단일 발행기 이식 완료를 뜻하지 않는다.
8. 확장·이전 절차
- 새 투영 표 / 새 작성 함수: 같은 PR 에서 장부에 표(논리 키·제외 열·목표 작성자)와
current를 추가한다. 검사 ①⑤ 가 누락을 잡는다.current는tests/db/support/projectionWriters.mjs의collectWriters출력을 그대로 붙인다(손으로 고치지 않는다). - D02~D11 이 한 표를 목표 작성자로 옮길 때: 옛 함수의 쓰기를 지우고 새 작성자 하나만 남긴다 →
current가 함수 하나가 된다 → 정책·세대 문서의 해당 문장을 갱신한다 → 결정성 검사(§9)와 기준 스냅샷 비교를 PR 에 붙인다. 정상 상태에서 구/신 작성자가 같은 표에 동시에 쓰는 기간을 두지 않는다(ADR §3-4). - 정적 분석의 한계: 함수 본문의
insert/update/delete/truncate문만 본다. 동적 SQL(execute)로 투영 표를 쓰면 검사 ④ 가 실패하므로 장부에 수동 등재하고 검사를 갱신한다. 뷰·트리거를 통한 간접 쓰기는 트리거 함수 본문에서 잡힌다.
9. 동치 판정 — "같은 결과" 의 정의
두 계산 결과(예: 옛 full 재계산과 새 작성자, 또는 같은 입력의 두 번 실행)가 같다는 것은 장부의 compare: true 표마다 아래가 성립하는 것이다.
- 행을
logicalKey로 정렬해 짝지었을 때 양쪽 행 수가 같고 짝이 빠지지 않는다. - 짝지은 행에서
excluded에 없는 모든 열이 같다. 숫자는 열의 정밀도(numeric(8,2) 등) 그대로 비교하고, 허용 오차는 정책 문서 §3 이 정한 곳(곡선 보간 6자리)만 둔다. random_ref열(예:pr_states.current_event_id)은 가리키는 행의 논리 키로 바꿔 비교한다.- 스냅샷은 anchor 날짜를 고정해(
refresh_user_pr_overview_snapshot_window(…, p_anchor_date)) 같은as_of_date창끼리 비교하고, 세대 번호는 비교하지 않는다. D01 corpus 의 고정 입력은2026-09-07이며 기준 요약에도anchorDate로 기록한다. 검증 샌드박스에서는 현행 worker 큐 소진 뒤 같은 applied 세대의 창을 이 날짜로 다시 발행하고, 수집 시 실제 header 가 전날·당일·다음날 3행인지 확인한다. 날짜 열을 제외하거나 창 밖 행을 숨겨 동치로 만들지 않는다. 이는 현행 RPC 를 활용한 검증 입력 제어이며 §7의 Production 세대 발행 방식 개선을 대신하지 않는다. compare: false표(큐·세대 상태·dirty·롤오버 상태)는 내용이 아니라 불변식으로 검사한다(정책 §6).
도구 = tests/db/support/projectionSnapshot.mjs(스냅샷 생성·diff) · tests/db/statsProjectionDeterminism.test.mjs(같은 입력 두 번 → 동일). 기준 스냅샷 = tests/fixtures/stats-projection-baseline/.
적재 간 기준 비교의 입력 고정: history-* workload 는 저장 엔진이 발급하는 원본 UUID·생성시각까지 고정해야 한다. session_created_at·source_created_at·대표 세트 참조는 동률 정책의 입력이므로 비교에서 제거하지 않는다. D01 전용 tests/db/support/deterministicWorkload.mjs는 명시한 로컬 Supabase sandbox에서 아직 생성되지 않은 workload owner만 등록하고, 적재 동안 고유한 임시 함수/trigger로 해당 owner의 UUID 상대 순서와 생성시각을 고정한 뒤 finally에서 제거한다. 다른 owner에는 적용하지 않으며 실제 저장·계산 엔진은 그대로 실행한다. snapshot은 UUID를 논리 키로 바꾼 뒤 다시 정렬한다. 기준 파일을 갱신한 다음에는 별도의 새 owner 적재로 같은 SHA가 나오는지 검증한다. 이는 같은 적재의 재계산 전후 일치만으로 대체할 수 없다.
입력 lifecycle 격리: D01은 worker를 직접 소진하므로 동일한 전용 sandbox의 barbelic-stats-backfill-scan·lift-guild-stats-refresh·lift-guild-pr-overview-snapshot-rollover·barbelic-stats-projection-compute·barbelic-stats-projection-publish를 suite 시작 전에 일시정지한다. #1465의 1초 예약 검증에서 기존 목록이 계산·게시 worker를 놓치는 것을 확인해 추가했다. tests/db/support/statsCronIsolation.mjs가 기존 job별 active 값을 캡처하고 연결·전송 중을 포함한 미종료 실행이 끝난 뒤 oracle 적재·재계산·snapshot 발행을 시작한다. 초기화 실패와 suite 종료 모두 원래 값으로 복구하며, 다른 D01 실행은 advisory lock으로 중복 제어를 거부한다. 이 격리는 운영 동시성 문제를 수정한 것이 아니다. 정각 backfill enqueue가 저장의 prior_requested_version + 1 확인과 경합하는 현행 문제는 D02 동시성 인계에 남긴다.
10. 후속 인계
| 받는 트랙 | 이 문서에서 가져가는 것 |
|---|---|
| D02(저장 세대 전환) | §5 작성자 14·DTO 표 · §4 records 폐기 판단 · 논리 키 unique 제약 검토(records) |
| D04(dirty event 정확 범위) | §3 의 단계별 입력 표(어느 사건이 어느 단계부터 다시 돌게 하는지) · §5 규칙 4(부수 효과 제거) |
| D08(사용자별 worker·lease) | §3 잠금 열 · 전역 잠금 제거 시 표별 작성자 잠금 단위 = 논리 키의 user_id |
| D11(완전 세대 발행) | §7 · 정책 §6 · 세대 열이 없는 표 목록 |
| A06(통계 조회) | §6 표 — 투영으로 옮길 원본 읽기 |