Skip to content

통계 갱신 잡(Stats Refresh Jobs)

한국어 번역본

이 문서는 원문(영어)의 한국어 번역이다. 정본은 원문이며, 계약·게이트 판단이 갈리면 원문을 따른다. 원문: docs/data/stats-refresh-jobs.md

종목별 배치 — #1590 Production 반영 완료 (2026-09-14)

#1590은 큰 계정의 계산 안에도 트랜잭션 경계를 둔다. 앱 커밋 a8e5871f·migration 20260915083000은 로컬 precheck와 범위를 명시한 populated upgrade를 통과했다. staging 배포 34771151454와 main 1711ae2eProduction 배포 34771543350가 성공했다. a8e5871f와 main은 AGENTS.md 외 모든 파일이 byte-identical이며, main에서 정적·빌드·산출물 검사를 추가로 통과했다.

종전에는 사용자 한 명의 모든 종목에서 임시 표를 만들고 지우는 작업이 하나의 트랜잭션에 묶였다. 표를 DROP해도 관계 잠금은 커밋 전까지 남았다. 운영에서 658세션·296종목 계정 한 명의 계산이 공유 잠금 메모리 부족(53200)으로 실패했고, 요청 세대 637·게시 세대 633으로 정체했다. 사용자 한 명이라는 경계가 계산량의 상한은 아니었다.

후보 종목이 100개를 넘으면 준비 → 최대 100종목씩 순차 계산 → 최종 집계를 각각 다른 트랜잭션으로 실행한다. 준비 단계가 정확한 종목 목록을 고정한다. 비공개 출력은 기존 stats_output_* 표의 같은 job·lease 아래 저장하고, payload._batch에 단계·다음 위치·source/policy·기준 날짜·출력 revision·재시도 횟수를 남긴다. 최종 집계가 끝날 때까지 run은 claimed다. 100개 이하는 기존 단일 computed 응답을 유지한다.

compute 응답호출자의 처리
computed완성된 세대를 기존 publisher가 검증하고 원자적으로 공개한다.
batch_pending + stage, next_offset, total_exercises커밋한 뒤 새 RPC/트랜잭션에서 compute를 호출한다. 미완성 출력은 게시하지 않는다.
batch_pending, reason=transaction_boundary_required현재 호출을 끝낸다. 같은 트랜잭션 안에서 반복해도 진행 위치가 전진하지 않는다.
retry_pending + retry_not_before완료 배치를 보존하고 서버가 정한 재시도 시각을 기다린다. 즉시 재시도·게시하지 않는다.
failed기존 실패·후속 scope 흡수 절차를 따른다. 부분 게시하지 않는다.

재개한 배치의 57014/55P03은 해당 배치만 되돌리고 기존 시도 상한·대기 설정을 적용한다. 원본·정책·기준 날짜·게시 기준 세대·비공개 revision이 바뀌면 그 시도는 무효다. 만료된 배치 임대는 실행 중인 행 잠금이 없고 입력·revision이 맞으며 재시도 예산이 남을 때만 같은 token·진행 위치로 이어간다. 새로운 lease가 이전 lease의 출력을 이어 쓰지는 않는다.

Edge는 RPC를 나누고 재시도 또는 기존 반복 상한에서 제어를 돌려준다. batch_count, pending_count, retry_not_before를 보고한다. 동기 어댑터도 pending을 deferred로 반환하며 완료 집계·미완성 무결성 검증을 하지 않는다. cron이 커밋된 진행을 재개한다. 공개 투영과 applied_version은 마지막 게시에서 함께 바뀐다.

배치 안에서는 순차 계산기가 임시 표를 재사용하고 종목마다 내용을 초기화한다. 배치 사이 커밋이 관계 잠금을 해제한다. 100은 검증할 분할 기준이며 무제한 안전 보장이 아니다. 최초 정규화·최종 집계와 게시·전체 바이트·한 종목에 집중된 이력 비용은 여전히 커질 수 있다. 설정 표의 compute/publish SQL 예산 60초/6초와 기존 Edge 역할 예산은 유지한다. 전환 계약검증 기록에서 규모·환경 차이·미검증을 구분한다.

로컬 precheck는 1회 9분 24초로 통과했다. 정적/unused/build/artifact, 단위 전체 3,947개 중 성공 3,799·조건부 skip 148, pgTAP 122파일·2,444단언, 실제 DB Node 107/107(skip 0), 큰 이력 격리·동시 CRUD 수렴 probe를 검증했다. Populated upgrade B는 현행 222 migration 테이블 상태에서 구 runtime 10개를 복원하고 신규 helper 2개를 제거한 조건으로 53200을 재현한 뒤 전체 수리 migration을 적용해 원본 hash 보존과 세대 수렴을 확인했다. 구 221 schema 전체 재생과는 구분한다.

2026-09-14 02:28:52 KST 운영 확인에서 stale 사용자 1→0, 미해결 53200 작업 1→0, open job 0이었고 해당 계정은 applied 633에서 requested=applied=639로 수렴했다. 복구 job은 오류 없이 완료됐다(compute 합계 43,853.488ms, publish 1,984.670ms). 이후 화면 RPC 3개도 DB 읽기 전용 트랜잭션에서 contract 4·stale=false였다. HTTP·실기기 브라우저 검증과는 구분한다. 승인된 관리자 직행으로 PR/Full CI는 없었고, Production browser journeys는 기존 if: false 정책에 따라 skip됐다. 자세한 완료 범위와 미측정 상한은 위 작업 기록을 따른다.

D10 제한된 유지 작업 — v0.18.0 후보 (#1418)

복구·catalog·날짜 탐색은 영속 cursor/token과 due 인덱스를 사용한다. 발견은 새 D04 세대를 요청하고 기존 D08이 발행한다. 이전 applied 세대를 rollover가 직접 다시 쓰지 않는다. 시간당 전역 scan은 기존 job의 매분 페이지 처리로 전환한다. 예산·복구·전환 계약을 따르며 Production 배포 증거와 구분한다.

현재 분리 worker와 v0.17.7 예약 주기 (#1465)

v0.17.5의 통계 처리는 서로 다른 트랜잭션으로 실행한다. claim은 작업 인수와 임대를 먼저 커밋하고, compute는 비공개 파생 출력을 만든다. publish는 세대와 임대를 검사한 뒤 public 출력을 원자적으로 게시한다. 아래의 과거 bounded worker 설명은 이 게시 경로를 대체하지 않는다.

#1465의 v0.17.7 후보는 세 단계의 예약 주기를 각각 5초에서 1초로 바꾼다. 로컬 구현·검증 중이며 Production 적용 전이다. cron.alter_job으로 주기만 바꾸므로 기존 job 번호·명령·소유자· 일시정지 상태를 보존한다. 저장 요청 안에서 무거운 계산을 직접 실행하지 않는다.

예약변경 전 → 후보보존하는 제한시간
lift-guild-stats-refresh5초 → 1초claim 3초
barbelic-stats-projection-compute5초 → 1초compute 60초
barbelic-stats-projection-publish5초 → 1초기본/staging 3초, 승인된 Production override 6초

Production의 게시 6초 override는 v0.17.5 장애 대응에서 적용했다. 이번 예약 변경으로 이를 기본 3초로 되돌리지 않는다. 임대 만료·재시도 상한·세대 검사· 동시에 진행하는 두 작업의 한도는 유지한다.

예약 대기만 최대 약 15초에서 약 3초로 줄어든다. 계산·게시·잠금 경합·대기열은 별도이므로 모든 계정의 3초 완료를 보장하지 않는다. 2026-09-09 운영 조회에서는 큰 계정의 계산이 최대 26.26초, 게시가 최대 5.89초였다. 일반 저장·긴 이력·동시 사용자의 측정 결과를 구분한다.

세 cron은 하루 최대 259,200개의 실행 이력을 남긴다. 추가하는 barbelic-stats-cron-history-purge는 매시 7·17·27·37·47·57분에 해당 세 cron의 7일 지난 succeeded/failed 완료 이력만 최대 5,000행씩 삭제한다. 최근·실행 중· 다른 cron 이력과 운동 원본·통계 작업·projection run 장부는 보존한다. 7일치도 약 181만 행이 될 수 있어 부하와 보관 크기는 실측 결과와 함께 본다.

pg_cron 문서에 따라 같은 예약은 동시에 여러 번 실행되지 않으며 실행 이력은 자동 정리되지 않는다. 측정·릴리스 기록을 참고한다.

이 문서는 증분 통계 갱신(incremental stats refresh) 계획의 2-2 단계부터 2-6 단계까지를 정의한다. 즉 영속 큐 테이블, enqueue 함수, 상한이 걸린(bounded) 워커 프로세서, 그리고 완료 운동 쓰기에 대한 비동기 처리 정책이다.

테이블은 public.user_exercise_stats_refresh_jobs이다. 이 테이블은 의도적으로 anon이나 authenticated에 권한을 부여하지 않는다. 앱 쓰기 RPC, 임포트 함수, 그리고 앞으로 만들어질 워커 함수는 통제된 SQL 함수를 통해서만 이 테이블에 쓰고 소비해야 한다.

앱이 사용하는 진입점은 enqueue_user_exercise_stats_refresh(user_id, from_date, exercise_ids, reason)이다. 이 함수는 하위 계층의 merge 함수에 위임하며, authenticated에 부여된 유일한 통계 갱신 큐 API다.

테이블 계약

컬럼의미
id잡 id
user_id구체화된(materialized) 통계가 dirty 상태인 사용자
event_typepublic.stats_refresh_event_types(id)에 대한 FK
from_date재계산을 시작할 가장 이른 세션 날짜. null은 사용자 전체 이력을 의미한다
exercise_ids영향을 받은 종목 id. 빈 배열은 해당 사용자의 모든 종목을 의미한다
reason운영·디버깅에 유용한, 사람이 읽을 수 있는 사유
source_ref중복 제거(dedupe)를 위한 안정적인 소스 참조. 예를 들어 session:{id} 또는 wodup_batch:{id}
statuspending, processing, completed, 또는 failed
locked_at워커가 잡을 점유(claim)한 시각
lock_owner잡을 점유한 워커 id 또는 프로세스 라벨
attempts워커 시도 횟수
error_message마지막 실패 메시지
metadata고정 계약 밖에 두는 추가 구조화 컨텍스트
created_at, updated_at, completed_at생명주기 타임스탬프

범위 의미론

  • from_date는 보통 존재해야 한다. null은 의도적인 전체 이력 재구축에만 사용한다.
  • exercise_ids = '{}'::text[]는 해당 사용자의 모든 종목을 의미한다. 드물어야 하지만, 전체 재구축이나 범위를 알 수 없는 광범위한 변경에는 유용하다.
  • placeholder 해소가 canonical 행을 갱신하고 placeholder와 canonical 종목 범위를 모두 갱신하기 전까지는, exercise_ids 안의 placeholder id도 유효하다.
  • event_typepublic.stats_refresh_event_types에 정의된 이벤트 중 하나여야 한다.

과거 편집 dirty 범위

과거 세션 편집은 사용자의 전체 이력을 재구축하는 대신 안전한 최소 범위만 무효화해야 한다.

  • 세션 생성: from_date는 저장된 세션 날짜이고, exercise_ids는 새 세션에 포함된 종목의 집합이다.
  • 날짜 변경이 없는 세션 수정: from_date는 수정된 세션 날짜다.
  • 날짜 변경이 있는 세션 수정: from_date는 이전 날짜와 새 날짜 중 더 이른 쪽이다. 새 날짜만 사용하면 이전 날짜에 낡은 집계가 남을 수 있다.
  • 종목 변경이 있는 세션 수정: exercise_ids는 이전 종목 id와 새 종목 id의 합집합이다. 종목 id가 바뀌면 이전 id와 교체된 id를 모두 갱신해야 한다.
  • 세션 삭제: from_date는 삭제된 세션 날짜이고, exercise_ids는 삭제된 세션에 있던 종목의 집합이다.

이것이 저장 엔진(save_session_v5, 수정 경로)이 session_exercise_part를 다시 쓰기 전에 이전 세션 날짜와 이전 종목 id를 읽고, 쓰기 이후에 새 날짜와 새 종목 id를 읽은 다음에야 dirty 범위를 enqueue하는 이유다.

병합(merge) 의미론

enqueue_user_exercise_stats_refresh_job(...)은 정상적인 경우 사용자당 병합 가능한 pending 잡을 하나만 유지한다. 잠기지 않은 pending 잡이 이미 그 사용자에 대해 존재하는 상태에서 다른 dirty 이벤트가 도착하면, 이 함수는 잡을 하나 더 삽입하는 대신 새 dirty 범위를 기존 행에 병합한다.

병합 규칙은 다음과 같다.

  • from_date: 더 이른 날짜를 유지한다. 어느 한쪽이라도 null이면 null을 유지한다. null은 사용자 전체 이력을 의미하기 때문이다.
  • exercise_ids: 두 배열을 합집합한다. 어느 한쪽이라도 비어 있으면 빈 배열을 유지한다. 빈 배열은 해당 사용자의 모든 종목을 의미하기 때문이다.
  • reason: 누적된 사유 텍스트에 이미 존재하지 않는 한 새 사유를 덧붙인다.
  • metadata: 기존 metadata를 보존하고 가장 최근의 event_type, source_ref, enqueue 시각, 병합 횟수를 기록한다.

processing 상태의 잡은 enqueue가 절대 변경하지 않는다. 워커가 pending 행을 이미 점유했지만 아직 상태 변경을 커밋하지 않았다면, enqueue는 FOR UPDATE SKIP LOCKED를 사용해 긴 통계 트랜잭션을 기다리지 않고 다음 pending 행을 만든다. enqueue 주체들 자체는 사용자별 어드바이저리 락(advisory lock)으로 직렬화되므로, 이후의 쓰기는 그 새 행에 병합된다.

상태 의미론

  • pending: 큐에 들어가 워커가 가져갈 수 있는 상태다.
  • processing: 워커가 점유한 상태다. locked_atlock_owner가 설정되어 있어야 한다.
  • completed: 워커가 refresh_user_exercise_stats_from(user_id, from_date, exercise_ids)를 성공적으로 실행한 상태다.
  • failed: 워커가 포기했거나 재시도 불가능한 오류를 만난 상태다. error_message가 실패 원인을 설명해야 한다.

워커 프로세서

process_user_exercise_stats_refresh_jobs(limit, validate_integrity, lock_owner)는 pending 잡을 처리하는 상한이 걸린 프로세서다.

이 함수는 다음을 수행한다.

  • 권한 있는 서비스 컨텍스트(privileged service context) 또는 Barbelic 관리자를 요구한다.
  • 행을 점유하기 전에 논블로킹 전역 배치 어드바이저리 락을 하나 획득한다. 전역 호출이 겹치면, 활성 호출이 자신의 배치를 다 비울 때까지 처리 행 0건과 함께 global_worker_busy: true를 반환한다.
  • 여러 워커가 같은 잡을 처리하지 않도록 FOR UPDATE SKIP LOCKED로 pending 잡을 선택한다.
  • 선택된 각 잡을 processing으로 표시하고, attempts를 증가시키며, lock_owner를 기록한다.
  • refresh_user_exercise_stats_from(user_id, from_date, exercise_ids)를 호출한다.
  • 선택적으로 validate_user_exercise_stats_integrity(...)를 호출한다.
  • 성공한 잡을 completed로 표시한다.
  • refresh 실패나 무결성 검증 실패는 error_message와 진단 metadata와 함께 failed로 표시한다.

앱 범위 래퍼와 cron 래퍼는 attempts < 3인 동안 실패한 잡을 다시 큐에 넣는다. 세 번째 실패 이후에는 운영자가 검사할 수 있도록 잡이 failed 상태로 남는다. 재시도가 예약될 때 마지막 오류는 metadata에 보존된다.

limit0..100으로 제한(clamp)되므로, 한 번의 워커 호출이 실수로 무한정 밀린 작업을 처리할 수 없다.

전역 락은 사용자별 어드바이저리 락 네임스페이스와 의도적으로 분리되어 있다. 이는 두 다중 사용자 트랜잭션이 사용자 락을 서로 반대 순서로 붙잡는 상황을 막는다. 사용자 범위 워커는 서로 독립적인 사용자에 대해서는 여전히 동시에 실행될 수 있다.

process_user_exercise_stats_refresh_jobs_for_user(user_id, limit, validate_integrity, lock_owner)는 앱 쓰기·임포트 경로를 위한 범위 한정 프로세서다. 호출자가 Barbelic 관리자이거나 권한 있는 서비스 컨텍스트가 아닌 한, 인증된 사용자 본인의 잡만 처리할 수 있다.

응답에는 remaining_counthas_more가 포함된다. 이 필드들은 pending 행과 processing 행을 모두 센다. 따라서 범위 한정 요청이 전역 워커가 붙잡고 있는 행을 큐가 완전히 비워진 상태로 오인할 수 없다. 클라이언트는 상한이 걸린 단일 비행(single-flight) 루프로 추가 페이지를 드레인하고, 남는 작업은 Cron에 맡긴다.

PostgreSQL 구문 타임아웃(57014, 정확히 canceling statement due to statement timeout)은 재시도 가능한 워커 중단이다. 범위 한정 래퍼는 자신의 어드바이저리 락과 워커 호출을 중첩 서브트랜잭션 안에 둔다. 정확히 그 타임아웃이 발생하면 서브트랜잭션이 롤백되고, 영속 잡은 pending으로 남으며, RPC는 worker_timed_out: true, remaining_count, has_more와 함께 HTTP 200을 반환한다. 클라이언트는 그 결과를 한 번 재시도한 뒤 경고를 한 번만 내보내고 남은 작업은 Cron에 맡긴다. 클라이언트나 운영자의 취소를 포함한 다른 57014 취소는 다시 던져지며, 이 복구 경로가 절대 숨기지 않는다.

번호가 매겨진 풀스택 케이스는 자신의 정리(cleanup) 삭제가 만들어낸 최신 statsRequestedVersion을 드레인한 다음, requested_version = applied_version과 pending/processing 잡이 0건임을 증명한다. 이렇게 해서 한 케이스의 정리 프로젝션 작업이 다음 케이스의 사용자 여정으로 새어 나가지 않게 한다.

완료 운동 비동기 정책

앱에는 세션 쓰기 표면이 하나 있다(이슈 #1215, 2026-09-04). 생성·수정은 save_session_v5(...), 삭제는 delete_session_v5(...)다. 옛 save_workout_v4·update_completed_session_v4·delete_completed_session_v4는 LG426 스텁으로 폐기됐다. 각 RPC는 그 자체가 원자적 구현이며, 더 오래된 writer를 감싸거나 호출하지 않는다. canonical 행, 영속 변경 영수증(durable mutation receipt), dirty 범위 요청이 한 트랜잭션에서 커밋된다. RPC는 구체화된 볼륨, PR, 기간, 캘린더 통계가 재구축되기를 기다리지 않는다.

새 클라이언트는 save/update 페이로드에 expected_user_id를, delete에는 p_expected_user_id를 보낸다. 데이터베이스는 쓰기 전에 auth.uid()와 불일치하면 거부한다. 그 검사가 즉시 활성화되도록 클라이언트보다 데이터베이스 마이그레이션을 먼저 배포한다. 소유자 필드가 없는 경우도 거부된다. 이 소유자 필드들은 앱 계약이 요구하는 필수 항목이다. 리포지토리는 이 세 RPC만 호출한다. 함수가 없다면 그것은 배포 실패이며, 절대 다른 RPC나 테이블 폴백을 유발하지 않는다.

파생 읽기 모델(read model)은 데이터베이스가 소유한 dirty 범위 워커가 발행한다. 클라이언트 refresh는 영속 영수증 이후의 최선 노력(best-effort) 표현 갱신일 뿐이며, 절대 쓰기 성공의 일부가 아니다. 완료 운동 원본 테이블에 대한 변경 권한과 퇴역한 모든 writer/조정(reconciliation) RPC는 제거되었다. SELECT는 소유자 범위의 읽기·내보내기 경로에만 남아 있다.

임포트와 명시적 수동 refresh 경로는 별도의 관리·대량 워크플로이므로 당분간 인라인으로 남는다.

  • import_wodup_batch_to_canonical(...)

과거의 런타임 resolve_exercise_external_placeholder(...) 경계는 종목 정체성 하드 컷오버로 퇴역했다. 공급자(provider) 정체성은 임포트 시점에 정확한 외부 매핑을 통해 해소된다. 이미 사용된 placeholder를 통합하려면 오프라인 소스 재작성과 전체 프로젝션 리플레이가 필요하다.

PR 전광판 스냅샷 구체화

PR 전광판의 종목별 값은 get_pr_overview() 요청 안에서 종목 수만큼 다시 계산하지 않습니다. 내부 read model과 refresh control은 네 테이블로 나뉩니다.

  • pr_overview_projection_control: 전역 exercise catalog epoch
  • user_pr_overview_rollover_state: 사용자별 due 시각, snapshot generation, 적용 catalog epoch, retry 상태
  • user_pr_overview_snapshot_headers: 사용자·generation·as_of_date별 완성 snapshot 메타데이터
  • user_pr_exercise_summary_snapshots: 한 세대에 속한 typed 종목 요약 행

refresh_user_exercise_stats_from(...)의 최종 wrapper는 기존 exercise, training, calendar read model을 갱신한 뒤 refresh_user_pr_overview_snapshot_window(...)를 실행합니다. snapshot 행과 header가 모두 준비된 뒤 같은 transaction에서 user_stats_refresh_state.applied_version이 전진합니다. 별도 published/ready 상태를 두지 않으며 applied version 자체가 publication fence입니다.

as_of는 rolling 30-day와 전년도 비교의 정합성 경계입니다. 화면은 임의 historical 날짜를 요구하지 않고 브라우저의 local today를 전달합니다. worker는 timezone 경계를 위해 DB current_date - 1, current_date, current_date + 1의 3일 window를 항상 미리 만듭니다. reader는 요청한 as_of_date와 applied generation이 정확히 일치하지 않으면 SQLSTATE 55000으로 fail closed합니다. 다른 날짜 snapshot 재사용이나 read-time 동적 계산 fallback은 허용하지 않습니다. 동일 사용자의 concurrent worker는 사용자별 transaction advisory lock으로 build/publish 순서를 직렬화하고, 더 오래된 작업이 최신 generation을 덮어쓰지 못하게 합니다.

v0.18.0 D10에서는 lift-guild-pr-overview-snapshot-rollover가 매분 due 인덱스의 페이지를 FOR UPDATE SKIP LOCKED로 집습니다. 기본 10명·최대 25명에게 새 D04 generation을 요청하고, 실패 횟수와 다음 재시도 시각을 상태 행에 남깁니다. 현재 applied generation을 직접 보충하지 않으며 D08 worker가 새 세대의 3일 창을 발행합니다.

종목 변경 로그 INSERT와 같은 트랜잭션에서 scope별 미처리 token을 기록합니다. 전역 종목은 사용자 페이지를 순회하고, 개인 종목은 그 사용자만 요청합니다. 변경 중인 순회를 앞에서 다시 시작하지 않고 최신 token의 후속 순회를 남기므로, 늦은 낮은 seq의 커밋·로그 보존 정리도 요청을 잃지 않습니다. 쓰기 transaction에서 모든 사용자 snapshot을 동기 갱신하지 않습니다. 자세한 계약과 운영 전환은 D10을 따릅니다.

3일 window는 정상적으로 매분 보충되므로 rollover가 이틀 넘게 연속 실패하면 요청 날짜의 exact snapshot이 사라져 SQLSTATE 55000 fail-closed가 발생합니다. 운영 알림은 그 전에 last_success_at, attempt_count, last_error와 due backlog를 감시해야 합니다.

완료 운동 저장·수정·삭제는 기존처럼 canonical commit 후 detached worker를 사용합니다. 직접 1RM과 onboarding 초기 1RM처럼 user_manual_pr_records를 바꾸는 경로도 해당 종목의 PR snapshot을 무효화하거나 갱신해야 합니다. 그렇지 않으면 canonical manual PR은 저장됐지만 전광판만 이전 세대를 읽는 상태가 됩니다.

초기 migration은 기존 사용자 3일 window를 백필하고 기존 결과와 parity를 검증한 다음 thin reader를 snapshot으로 전환합니다. 운영 검증은 migration history와 remote schema 확인에 더해, applied generation의 3일 header 누락·고아 snapshot·동일 사용자/generation/as_of 중복이 없는지도 확인합니다. 프론트는 내부 snapshot 테이블을 직접 읽지 않고 계속 get_pr_overview(p_as_of)만 호출합니다.

직접 갱신 경계

세대(generation) 범위 클라이언트 정합화

같은 사용자·requested_version을 관찰한 전역 stale 복구와 workout sync는 서로 다른 worker/read 요청을 만들지 않는다. 클라이언트 generation coordinator가 stats drain → Home → 현재 PR/Volume overview → 선택 PR 상세 전체를 하나의 shared flight로 합류시키며, 완료된 fresh 결과와 terminal 결과는 같은 generation observer가 재사용한다. incomplete 또는 네트워크 실패 결과는 보관하지 않아 bounded retry가 새 flight를 만들 수 있다. 모든 reconciliation 요청은 receipt/read freshness가 제공한 양의 requested_version을 필수로 전달하며 누락되거나 0인 generation은 계약 오류다.

repository worker flight도 coalesced caller를 구분한다. 같은 requested generation의 read-only observer는 진행 중 promise만 공유하고 빈 trailing worker page를 요구하지 않는다. 반면 canonical write 또는 더 높은 requested generation은 마지막 pass 뒤 한 번 더 drain하도록 rerunRequested를 세워 late enqueue를 놓치지 않는다.

refresh_user_exercise_stats_from(...)은 공개 refresh 래퍼다. 이 함수는 종목별 기본 refresh 엔진을 호출한 뒤, 사용자 수준의 일/주/월/분기/연 합계를 위해 user_training_period_stats를 구체화한다. 앱 쓰기 경로와 임포트·관리자 매핑 경로는 이 함수를 직접 호출해서는 안 된다. 이들은 enqueue_user_exercise_stats_refresh(...)를 통해 dirty 범위를 enqueue한다.

무거운 refresh 함수들의 직접 실행 권한은 authenticated에서 회수되었다. 워커 함수가 pending 잡을 점유한 뒤에 refresh 엔진을 호출해야 한다.

워커 진입점

enqueue_stale_user_exercise_stats_refresh_jobs(limit)은 백필 스캐너다. 이 함수는 롤업이 없는 완료 세션, user_training_period_stats가 없는 롤업, 그리고 training period 통계보다 최신인 롤업을 찾는다. 해당하는 사용자들은 scheduled_stats_backfill과 함께 같은 dirty 범위 큐에 병합된다.

인증된 앱은 범위 한정 SQL 프로세서를 통해 자신의 잡을 드레인한다. lift-guild-stats-refresh라는 이름의 Supabase Cron 잡이 매분 권한 있는 전역 프로세서를 호출하는데, 이는 탭이 닫혔거나, 네트워크 요청이 중단되었거나, 상한이 걸린 드레인이 완료되지 않았거나, 오래된 클라이언트인 경우를 위한 영속 폴백이다. Cron은 비공개 run_user_exercise_stats_refresh_cron(...) security-definer 래퍼를 호출하며, 그 래퍼는 전역 프로세서가 기대하는 서비스 요청 컨텍스트를 제공하고 동일한 3회 시도 재시도 정책을 적용한다.

stats-process-refresh-jobs는 전역 배치와 낡은 데이터 스캔을 위한 관리자·서비스용 Edge Function 워커 진입점으로 남는다. 이 함수는 상한이 걸린 limit, validate_integrity, 선택적 lock_owner, 선택적 enqueue_stale/enqueue_limit을 받는다. enqueue_stale이 true이면 먼저 스캐너를 호출한 다음 아래를 호출한다.

text
process_user_exercise_stats_refresh_jobs(limit, validate_integrity, lock_owner)

이렇게 하면 명시적인 전역 백필·수리 경로를 유지하면서도 요청·쓰기 함수를 무거운 통계 처리와 분리할 수 있다.