Skip to content

통계 복구·날짜 전환의 제한된 탐색 — D10

대상은 #1418, release/v0.18.0이다. Production 적용 문서로 해석하지 않는다. D04 요청 범위D08 worker를 사용하며 계산기·발행기를 복제하지 않는다. 구현·검증·통합 상태는 작업 기록에 남긴다.

기록이 많은 A의 이력을 모두 집계해야 뒤의 B를 고칠 수 있었던 탐색을 행 페이지로 바꾼다. 접속 여부는 후보 조건이 아니다. 처리 대상이 없거나 한 사용자의 요청이 실패해도 확인한 페이지 다음으로 진행하며, 실패한 사용자는 별도 재시도 상태에 남는다.

1. 소유와 예산

실행 원천은 앱 supabase/definitions/stats/{functions,tables}/stats_maintenance_*, scan_stats_maintenance_v1이다. 기존 SQL 원천 분류에 맞추며 계획상의 stats/maintenance/**·stats/scheduling/**를 별도 사본으로 만들지 않는다. 기존 cron/RPC 이름 두 개는 호환 진입점으로 유지한다. 내부 함수와 제어표는 클라이언트에 열지 않는다.

탐색정렬·인덱스한 호출의 읽기 예산
완료 세션session(id) WHERE status='completed'원본 페이지 P행 + cursor 경계 최대 1행, 각 행의 일별 훈련 통계 존재 확인
세션 종목session_exercise_part(id)P행 + 경계 1행, 각 행의 부모 세션·해당 rollup PK 조회
롤업user_exercise_session_rollups(session_exercise_id)P행 + 경계 1행, 사용자별 가장 최근 훈련 통계 1행(user_id,updated_at DESC)
사용자·bootstrapauth.users(id)P명 + 경계 1행, 적용 세대의 날짜 헤더 3개·각 summary 최대 129행 확인
날짜 만료rollover state (next_refresh_at,user_id)기본 10명·최대 25명, next_refresh_at <= now의 정렬 페이지
종목 변경pending (changed_at,scope_id) + 사용자 PK잠기지 않은 scope 1개. 전역이면 P명, 개인이면 해당 사용자 최대 1명
재시도retry (next_attempt_at,user_id)만료한 실패 사용자 최대 min(P,25)명

Repair의 P는 기본 50, 최대 500이며 0은 작업하지 않는다. 한 호출은 네 소스 중 하나와 별도 retry 페이지를 처리한다. 정상·실패 탐색 행 수와 enqueue 수는 다르다. 헤더의 summary 계약은 최대 128개여서 129행까지만 읽어 초과·불일치를 발견할 수 있다. 사용자별 전체 이력을 COUNT/MAX로 집계하지 않는다.

이 예산은 발견 비용이다. 발견한 사용자에 대한 D04 full 요청은 D08에서 실제 이력을 계산한다. 날짜·종목 요청도 현재 full scope를 사용한다. 한 번의 탐색이 빠르다는 이유로 전체 계산·자정 backlog까지 빠르다고 보고하지 않는다. 계산·게시 시간 제한, 임대·활성 실행 상한은 stats_projection_worker_settings를 그대로 따른다.

2. 커서와 실패의 원자성

stats_maintenance_cursors에는 sessions/parts/rollups/owners 네 행만 둔다. migration에서 초기화하며 scanner 호출마다 seed INSERT를 반복하지 않는다. 누적 calls가 작은 소스부터 FOR UPDATE SKIP LOCKED로 하나를 집어 고정 시계에서도 네 소스가 돌아가게 한다.

  • 순회 시작 때 해당 소스의 최대 UUID를 watermark_id로 고정한다. cursor_id 뒤부터 상한 이하를 PK 순서로 읽는다. SQL의 범위 조건은 generic plan에서도 인덱스 조건으로 남는다.
  • 손상 확인 → D04 enqueue 영수증 또는 durable retry → cursor 저장은 같은 트랜잭션이다. statement 취소·연결 종료·rollback이면 모두 되돌아간다.
  • 사용자 쓰기 잠금과 D04 큐 잠금을 try-lock으로 확인한다. 바쁜/실패한 사용자를 기다리며 페이지를 붙잡지 않는다. 실패 사유·요청 키를 retry에 남긴 뒤 진행한다. backoff는 최대 15분이다.
  • 상한에 도착하거나 페이지가 비면 순회를 닫고 다음 호출에서 새 상한을 잡는다. cursor보다 작은 UUID의 늦은 INSERT/COMMIT은 다음 순회에서 다시 발견한다.
  • stats_maintenance_requests는 사용자별 마지막 요청 키·job·target version 1행이다. 같은 페이지 요청을 중복 발급하지 않으며, 아직 계산을 시작하지 않은 pending full repair는 다른 repair 발견도 덮는다. 순회 번호가 바뀌면 새 손상을 다시 요청할 수 있다.

유한 fixture에서 소스별 N행을 P씩 읽고 경합이 없으면 각 소스는 최대 max(1,ceil(N/P))번 방문으로 한 순회를 끝낸다. 네 소스를 한 분에 하나씩 처리하므로 해당 소스의 순회 상한은 4 × max(1,ceil(N/P))분이다. 시작 위치보다 뒤에 늦게 나타난 행은 현 순회, 이미 지난 위치의 행은 다음 순회에서 기회를 얻는다. 예를 들어 사용자 1,001명·P=50이면 한 owner 순회는 최대 84분이다. 반복 잠금·worker 장애·지속적 쓰기의 지연을 이 숫자에 포함해 보장하지 않는다. calls/sweep/last_finished_at과 retry를 함께 관측한다.

3. 종목 변경과 날짜 전환

exercise_catalog_changes의 AFTER INSERT trigger는 같은 트랜잭션에서 stats_maintenance_catalog의 token을 바꾼다. 0 UUID scope는 전역 종목, 그 외 UUID는 개인 종목의 사용자다. seq의 최대값을 완료 지점으로 쓰지 않는다. 따라서 낮은 seq가 나중에 커밋되거나 변경 로그가 보존 정리돼도 미처리 알림이 사라지지 않는다.

Fanout은 scan_token과 사용자 상한을 고정하고 페이지를 순회한다. 도중 새 변경은 latest_token만 바꾼다. 현재 유한 순회를 먼저 끝낸 뒤 새 token으로 후속 순회를 시작하여 앞의 사용자도 새 변경을 받는다. 최신 token까지 요청 또는 retry에 기록한 경우만 pending scope를 삭제한다. 잠긴 scope는 다른 scanner가 건너뛴다.

날짜 scheduler는 due 인덱스 페이지만 읽어 rollover:YYYY-MM-DD 요청을 만든다. UTC 날짜를 metadata의 maintenance_anchor_date로 기록하고 다음 발견 시각을 다음 UTC 자정으로 옮긴다. 이때 snapshot_generation·last_success_at은 게시 성공으로 바꾸지 않는다. 요청 실패는 due 행에 attempt/error와 다음 retry 시각을 남긴다.

D08의 기존 claim → compute → publish가 새 target version의 결과를 게시한다. rollover는 refresh_user_pr_overview_snapshot_window를 직접 호출하지 않는다. 직전 발행 세대는 기존 보존 규칙에 따라 유지되며 내용은 바꾸지 않는다. 신규 계정의 최초 generation 0 bootstrap은 기존 초기화 함수를 사용하고, 초기화 상태/창이 빠진 계정은 owner 순회가 복구한다.

고정 시각 회귀는 요청의 날짜 경계를 검증한다. 실제 D08 계산의 창은 기존 서버 current_date ± 1이다. metadata의 과거 날짜를 계산기가 소비한 것으로 해석하지 않는다.

4. 전환·복구·관측

Forward migration 20260914093000_d10_maintenance_cursors.sql은 기존 backfill/rollover 함수 본문을 교체하고 due 인덱스를 바꾼다. barbelic-stats-backfill-scan의 cadence만 시간당에서 매분으로 변경하며 job ID와 active 상태는 보존한다. lift-guild-pr-overview-snapshot-rollover는 기존 매분 job으로 새 함수를 부른다. 새 구독/중복 cron을 만들지 않는다. 빈 DB에서는 owner가 없으면 scanner가 제어행을 갱신하지 않는다.

관측의미·복구
source calls/examined/cursor_id/watermark_id호출과 페이지 진행. 손상이 없는 페이지도 cursor가 이동해야 한다
sweep/last_finished_at각 소스 순회 완료. 접속 없는 사용자를 제외하는 필터는 없다
stats_maintenance_retries아직 전달하지 못한 사용자 요청. 성공 시 삭제, 실패 시 capped backoff. cursor를 임의 초기화할 필요 없음
catalog latest_token/scan_token/cursor_id현재 pass와 후속 변경. 둘이 다르면 현 pass 완료 뒤 다시 순회
stats_maintenance_requests.job_id/target_version발견을 기존 큐·세대 상태로 추적. 요청 성공과 발행 완료를 구분
rollover next_refresh_at/last_error/last_success_at날짜 요청 재시도와 실제 게시 성공. backlog와 마지막 게시 시각을 함께 확인
응답 examined_count/enqueued_count/failed_count현재 source 또는 due 페이지의 수. retry/catalog 수는 각각 중첩 객체로 반환하며 합산해 처리량을 관측

한 scanner가 끊기면 같은 진입점을 다시 호출한다. 이전 cursor를 수동으로 올리거나 retry/pending token을 삭제하지 않는다. 장애로 예약 실행을 멈췄으면 기존 job을 pause/resume하고 진행 상태를 유지한다. 과거 전역 탐색 SQL을 병행 실행하지 않는다. ok=false 및 retry/catalog 실패 수를 보며, 전달 뒤의 실패는 D08 job/lease 상태에서 추적한다.

운영 rollback은 기존 발행 세대 재작성 SQL을 되살리는 방식으로 하지 않는다. 문제가 생기면 해당 예약 작업을 잠시 멈추고 제어 상태를 보존한 채 forward 수리한다. 이 문서는 Production에 즉시 pause·SQL 실행하라는 지시가 아니다.

5. 검증과 인계

  • pgTAP: stats_maintenance_cursor_v1 18단언, stats_maintenance_scheduling_v1 33단언. 고정 UTC 경계·손상·bootstrap·catalog 로그 정리·후속 순회·실패 전달·cron 재적용.
  • 실제 연결: statsMaintenanceConcurrency.test.mjs 7회귀. 중단/rollback, 잠긴 source/scope, 늦은 원본 COMMIT과 낮은 catalog seq, D08 새 세대 수렴·직전 세대 보존.
  • 실제 cron: prOverviewFixtureClock.test.mjs. 다섯 예약 작업 정지 중 고정 창 보존, 복원 후 새 세대 창 수렴, 원래 ID/schedule/active 복원.
  • 계측: scripts/performance/probe/stats-maintenance-discovery.mjs, stats-maintenance-processing.mjs, stats-maintenance-sweep.mjs. 전체 실행계획 JSON은 실행 시 지정한 별도 파일에 저장한다. 단일 warm 표본을 p95로 보고하지 않는다. 유한 순회 검사는 정상 사용자 1,000명 뒤의 손상 사용자를 21회 커밋된 페이지 호출·총 1,001명 검사 뒤 요청하는지 확인한다.
후속 담당확정 입력
D11maintenance 요청 키·anchor metadata·D04 job/target version, 발행 세대 직접 재작성 제거, D08 발행 경계 유지. 선택적 날짜 투영이 필요하면 이 full scope 경계에서 이어받는다
R04discovery와 enqueue/claim/compute/publish 비용을 나누어 재측정. P와 사용자·이력 규모, generic plan, 원본 checksum을 함께 기록
R05유한 순회·늦은 COMMIT·실패 retry·실제 예약 복원 fixture와 stage/Production 미검증 범위