Skip to content

통계 worker 가 한 번에 여러 사람 것을 묶어 처리하고, 죽었다 돌아온 작업자가 결과를 덮어쓸 수 있고, 운영 예산이 손 편집 cron 명령에 있던 것에서 — 한 트랜잭션 = 한 유저·임대 토큰 울타리·설정 표 한 곳·계산 중 저장 수용까지 — v0.18.0 D08 (2026-09-10)

  • 기간: 2026-09-10 (세션 3개 — 192d2212-8bf2-489e-bf5f-1809237c28da 분석·Phase 계획 게시·Phase 1 착수(03:31~03:55 KST, 사용량 한도로 중단), 68e2ca6c-cb65-48f6-aed7-d1a7ca3c8876 초안 검토 후 Phase 1~4 완주·회귀 테스트 결정성 수정·release 병합(~12:44 KST), c92eb2fe-0f1f-4372-aa43-864d1bf62271 구현 검토·결함 수정·release 재병합·재번호·Phase 5). 오너 지시 "1412 이어서 진행해줘 · opus 가 뭐 잘못 구현한 거 없는지 봐주고 · 작업 잘 끝났는지 검토도 해줘". 계획 ID D08 / Phase 3 스텝 3-2. 직접 선행 D04(#1403)는 착수 시점에 release/v0.18.0(b074cbb1)에 병합돼 있었다. v0.17.5 격리 worker 핫픽스(#1450)는 release 에 반영된 상태에서 시작했다.
  • 랜딩: 앱 PR #1510(base release/v0.18.0; Phase 1~4 925623cd · 검토·수정 21972340·0fe58a3c·8613bfc7 · 최종 head b2cb788d), 마이그레이션 1건 20260914053000_d08_stats_worker_lease.sql(level=high: 표 1개 생성·설정 행 1개·user_stats_projection_runs 열 2개·울타리 트리거·cron 명령 3개 재등록·함수 재발행 11개·옛 cron 래퍼 삭제; 유저 원본 등급 열은 만지지 않는다), Edge 함수 stats-process-refresh-jobs 재작성(배포 버전 20260910-d08-isolated-worker-v1). release 통합·staging 확인·Production 배포는 서로 다른 상태다 — 이 문서의 최종 갱신 시점은 release/v0.18.0 통합 직후(a1d462a8)이며 staging·Production 은 §5 상태 표가 갱신한다.
  • 설계서: 없음 — 착수 분석(P1~P5)·해결 방안·Phase 계획·"예상 효과·개선사항" 표는 #1412 착수 댓글(2026-09-10 03:31 KST). HQ 긴급 인계(v0.17.5 운영 복구 뒤 우선 작업 4개)는 이슈 본문 머리.
  • 정본: 설정 표 supabase/definitions/stats/tables/stats_projection_worker_settings.sql · 울타리 트리거 stats_projection_run_lease_fence_v1 · worker 3단계 claim_stats_projection_job_v1(text)·compute_stats_projection_job_v1·publish_stats_projection_job_v1 · 동기 문 process_user_exercise_stats_refresh_jobs(호출당 유저 1명)·process_user_exercise_stats_refresh_jobs_for_user_now(격리 임대 중 미룸) · 관측 stats_projection_worker_status_v1 · Edge supabase/functions/stats-process-refresh-jobs/index.ts · 문서 Stats Refresh Jobs D08 절 · 정책 §6 G-5/G-6 · 운영 runbook.
  • 도구: 실제 DB 연결 4개 회귀 tests/db/statsProjectionWorkerConcurrency.test.mjs(ci:local DB 단계·policy-contract.yml 에 등록) · 수렴 probe scripts/performance/probe/stats-isolated-convergence.mjs(D08 동작으로 단언 갱신, 삭제·약화 없음) · 관측 함수는 Edge GET /stats-process-refresh-jobs 에 bearer 토큰으로 노출.
  • 게이트: 새 pgTAP stats_projection_lease_fence_v1(48 assert) · 기존 pgTAP 3파일 갱신(stats_isolated_publication_v1 45→44 — 게시 거절 단언 2개가 "자기 세대 게시" 단언으로, stats_projection_cadence_v1 cron 명령이 설정 함수를 읽는지, stats_worker_cancellation_progress 13→14 옛 래퍼 → batch 문 has_more) · 단위 resourceContract(G04 예산 = 설정 표 기본값·저장 RPC 상한·cron 명령) · opsObservabilityContract 옛 래퍼 부재 · 명부 등재 수정 1건(wodupStartImportFunction 배포 버전 도장, pending-changes.json 신고). 로컬 실측은 §5.
  • 버그리포트: 없음(구조 개선). 관련 사고 = BUG-084(#1432, v0.17.5 게시 3초 초과) — 이 트랙이 그 손 편집 override 를 설정 표로 흡수한다.
  • 계약: Stats Refresh Jobs D08 절 · 통계 투영 정책 §6 G-5/G-6 · 계산 DAG 진입점 절 · 자원 계약 appResourceContract.workers.statsWorker* 7개(설정 표 기본값) · 총괄 D08 카드 · HQ 운영 장부.

Phase 현황

Phase내용상태
Phase 1재현·기준선 — 실제 DB 연결 4개로 P1(같은 batch 의 A 저장 대기)·P2(만료 뒤 옛 worker 늦은 쓰기)·P4(계산 중 저장 → 결과 폐기)·P5(인입 인라인 경로가 시도 소모)를 기준 SHA 에서 실패하는 단언으로925623cd(기준 b074cbb1 에서 4건 모두 실패 확인)
Phase 2임대·울타리·예산 원천 — lock_owner·retry_not_before 열, 55P03 울타리 트리거, 단계 경계 임대 갱신, backoff, 문맥 초기화, 설정 표 + 읽기 함수, cron 명령 3개가 설정을 읽음925623cd
Phase 3단일 경로 이식·계산 중 저장 수용 — Edge 가 격리 3단계 호출, batch 문 호출당 유저 1명(has_more), 인입 엔진 enqueue 만, 옛 cron 래퍼 삭제, compute/publish 가 "요청 세대가 앞섰다"로 버리지 않음925623cd
Phase 4관측·예산 연결·마이그레이션 — stats_projection_worker_status_v1, Edge GET, 자원 계약 7개, 회귀를 ci:local·CI 에 등록, sql:candidate --ddl → 스냅샷·registry·장부·위험 헤더·populated upgrade 증거925623cd·8b46ac32
Phase 5검토·문서·PR·큐 — 이전 세션 구현 검토(결함 1건 수정), release 재병합 3회·재번호 3회, 문서 5개, ci:precheck-local, PR, merge:request✅ 이 문서 · 앱 PR §5

1. 배경

Production 의 통계는 1초마다 도는 예약 작업 셋이 처리한다. ① claim(잡을 집고 5분 임대·시도 횟수 기록, 활성 임대 2개 상한) → ② compute(임시 표에서 계산해 결과를 JSON 으로 보관, 60초 상한) → ③ publish(세대·날짜를 다시 확인한 뒤 공개 표를 짧게 교체). 이 격리 worker 는 v0.17.5 핫픽스(#1450)로 들어왔고, D04(#1403)가 "가장 낮은 세대부터 집고 실패 범위를 흡수한다"를 붙였다.

그러나 v0.17.5 운영 복구(#1432) 때 287종목 계정의 게시가 3초를 넘겨 실패했고, 운영자가 Production 의 cron 명령 문자열을 손으로 6초로 고쳐 복구했다. HQ 는 D08 에 (1) 종목·기간 출력량까지 대표하는 workload (3) 실제 수정·삭제와 지연 FK 를 포함한 게시 예산 (4) 운영 override 와 양환경/코드/게이트 예산 통합, 그리고 기존 privileged/import/Edge 진입점의 단일 경로 이식·실제 backend 종료·lease 회수를 넘겼다. (2) 변경행 게시(delta publish)는 publication 계약을 맡은 D11(#1422)의 몫으로 두었다.

2. 문제 제기

유저 A 와 B 로 읽으면 다섯 가지가 남아 있었다(착수 댓글 P1~P5).

느린 B 의 계산이 끝날 때까지 A 의 저장이 기다렸다 (P1)

옛 전역 batch 문 process_user_exercise_stats_refresh_jobs 가 살아 있었다. Edge 함수·관리자·G04 측정 도구·D01 oracle 이 이걸 불렀고, 한 호출이 최대 100명의 유저 잠금을 한 트랜잭션에 쥐고 공개 표에 직접 썼다. 같은 batch 에서 먼저 끝난 A 의 잠금도 B 가 끝날 때까지 안 풀려, 그 사이 A 가 운동을 저장하면 저장이 A 잠금에서 기다렸다. 실제 연결 4개로 재현: C 가 B 의 통계 행을 잠가 B 의 계산을 멈춰 세운 뒤 A 가 새 운동을 저장하면 1.5초 안에 끝나지 않았다(기준 커밋 회귀 ① 실패).

죽었다 돌아온 옛 작업자가 새 임대 위에 결과를 덮어쓸 수 있었다 (P2)

임대 토큰을 확인하는 울타리가 없었다. worker X 가 A 의 잡을 집고 죽음 → 5분 뒤 worker Y 가 다시 집음 → X 가 늦게 돌아와 "계산 끝"을 쓰면 Y 의 임대 위에 X 의 결과가 그대로 들어갔다(기준 커밋 회귀 ② 실패: 토큰 없는 UPDATE·옛 토큰 UPDATE 모두 성공).

예산이 세 곳에 흩어져 있고 Production 만 달랐다 (P3)

3초/60초/3초는 cron 명령 문자열에, 5분 임대·활성 2개·시도 3회는 함수 본문 상수에 박혀 있었다. Production 의 6초는 손으로 고친 cron 명령이라, 다음 마이그레이션이 cron 명령을 다시 쓰면 3초로 되돌아가 287종목 계정의 통계가 다시 실패한다(#1432 재발 경로).

실패 뒤 곧바로 다시 집고, 계산 중 저장이 오면 결과를 버렸다 (P4)

60초 취소로 실패한 잡은 1초 뒤 바로 다시 60초를 썼다(3회 = 3분 낭비). 계산하는 동안 새 저장이 오면 계산 결과를 55000 으로 버리고 후속 잡이 처음부터 다시 계산했다. 운동 중 30초마다 세트를 저장하는 이력 긴 유저는 계산(25초)이 매번 버려져 통계가 끝내 안 나올 수 있었다(D04 가 "starvation 은 D08 몫"으로 넘긴 것; 기준 커밋 회귀 ③ 실패).

인입·유지보수 경로가 격리 worker 를 안 탔다 (P5)

Wodup 인입 엔진은 저장 직후 동기 유저 문을 같은 트랜잭션에서 불러 공개 표에 직접 썼다. 격리 compute 가 그 유저 것을 계산 중이면 순서 방어(55000)로 실패해 시도 횟수만 소모했다(기준 커밋 회귀 ④ 실패). 옛 cron 래퍼 run_user_exercise_stats_refresh_cron 은 v0.17.5 부터 예약에서 빠졌는데 함수·예외 목록·계약 테스트에 남아 있었다.

3. 해결 방안

원칙 (오너 결정)

새 오너 결정 없음(착수 댓글 "오너 결정 필요: 없음"). 전제: ① PR base release/v0.18.0 ② 활성 임대 2·compute 1·publish 1·시도 3회 상한 유지(HQ 지시) ③ 게시 예산 기본 6초(Production 실측 4.4~5.9초, 저장 RPC 8초보다 작음; 3초 복귀는 D11 변경행 게시 뒤 재실측) ④ delta publish·row provenance/publication generation 분리는 D11 ⑤ "끝까지 완주" 지시 범위에 merge:request 포함.

접근

내용채택
A. 땜질전역 batch 의 limit 을 1로 낮추고 Production cron 명령을 문서에만 적어 둔다기각 — 다중 유저 트랜잭션·손 편집 override 가 그대로 남는다
B. 구조① 한 트랜잭션은 한 유저의 통계 작업만 담는다 ② 임대 토큰 울타리(55P03)·단계 경계 임대 갱신·backoff·worker 표식 ③ 예산 한 곳(stats_projection_worker_settings)에서 cron 명령·함수·게이트가 같은 값을 읽고 Production 6초는 그 표의 값 ④ 계산 중 저장이 와도 결과를 자기 세대로 게시하고 새 저장은 후속 세대로 ⑤ 대기·실패 관측 함수채택 — 이슈 상세 작업 1~6·HQ 우선 (1)(3)(4) 와 일치, 같은 종류 문제가 다른 경로에서 재발하지 않는다
C. 별도 worker 프로세스(Edge 상주 루프)DB 밖 실행기기각 — 운영 비용·추가 실행기. cron 예약 작업이 이미 실행기다

4. 적용한 내용

Phase 1 — 재현·기준선 (925623cd 에 포함)

  • tests/db/statsProjectionWorkerConcurrency.test.mjs: 실제 DB 연결 4개(A·B·C·D)로 ①~④ 를 단언. 업무 날짜 고정(2026-08-11·12·09-01), 세션·mutation UUID 는 격리 identity 일 뿐. 기준 b074cbb1 에서 4건 모두 실패 → 수정 뒤 4건 통과. ci:local DB 단계와 policy-contract.yml(승격 full CI)에 등록.
  • probe stats-isolated-convergence.mjs: 계산 중 저장 뒤 "결과 폐기·공개 변경 없음" 단언을 D08 동작("자기 세대 게시 완료 또는 원본 참조 깨짐 23503, 후속 세대 dirty 보존, 전체 재생 동치")으로 갱신. 게시 예산 호출을 설정 함수로. 실패 뒤 backoff 가 걸려 곧바로 재집기되지 않는 단언 추가. 논리 키 동률 행의 물리 순서 흔들림은 내용 비교로.

Phase 2 — 임대·울타리·예산 원천 (925623cd)

  • user_stats_projection_runslock_owner(worker 표식, 진단용)·retry_not_before(backoff 시각). before update 트리거 stats_projection_run_lease_fence_v1: 살아 있는 임대(claimed/computed 이고 lease_until 이 앞)의 phase·payload·토큰·임대 시각·오류·앵커를 바꾸려면 트랜잭션이 lift_guild.stats_lease_token 에 현재 토큰을 선언해야 한다. 아니면 55P03. 만료·completed·failed 행은 자유(claim 의 만료 회수가 그 경로).
  • claim_stats_projection_job_v1(text)(worker 표식 인자) + 인자 없는 cron 호환 오버로드. 만료 회수는 backoff 없음, 실패 되살리기는 retry_not_before 를 기다리고 max_attempts 를 설정에서 읽음, 활성 임대 상한도 설정. 반환에 user_id·target_version·attempts·lease_until·lock_owner.
  • compute/publish: 시작 시 stats_projection_reset_context_v1() 로 이전 잡의 트랜잭션 문맥 5개·임시 표를 지운다(pooled connection 누수 방지). 긴 SQL 중 heartbeat 가 불가능하므로 단계 경계(compute 완료·publish 지연)에서 임대를 갱신한다. 실패는 시도 횟수 × retry_backoff_seconds 뒤 재시도(55000·40001·23503 은 즉시 — 후속 흡수 또는 같은 세대 재계산).
  • 설정 표 stats_projection_worker_settings(행 1개, CHECK: 게시 < 8000ms, 임대 > 계산+게시) + stats_projection_worker_settings_v1()·stats_projection_statement_budget_v1(stage). cron 명령 3개가 select set_config('statement_timeout', 이 함수, false) 뒤 worker 를 부른다. 마이그레이션은 이미 설치된 게시 cron 명령이 6초보다 크면 그 값을 보존한다(작게 되돌리지 않는다). e2e 드레인·probe 도 같은 함수로 예산을 건다.

Phase 3 — 단일 경로 이식·계산 중 저장 수용 (925623cd)

  • Edge stats-process-refresh-jobs: 옛 batch 문 대신 claim → compute → publish 를 호출당 limit 게시까지 반복(publish deferred 는 250ms 뒤 최대 3회 재시도, 진전 없는 라운드 2회면 종료). 응답에 단계별 건수·stop_reason·global_worker_busy. GET 헬스에 bearer 토큰이 있으면 관측 함수 결과를 싣는다. compute 의 임시 표 UPDATE 에 WHERE(PostgREST safeupdate 호환).
  • process_user_exercise_stats_refresh_jobs: 가장 오래된 pending 유저부터 보되 다른 worker 가 잠근 유저·격리 임대가 살아 있는 유저는 건너뛰고 호출당 유저 1명만 처리하고 끝낸다. has_more·remaining_user_count 로 호출자가 반복. _for_user_now: 격리 임대가 살아 있으면 deferred (isolated_lease_active) — 잡은 pending·시도 0 그대로.
  • Wodup 인입 엔진: 저장 뒤 동기 통계 처리 호출 2곳 제거, enqueue 만 하고 결과에 stats_requested_version 을 싣는다. 옛 cron 래퍼 run_user_exercise_stats_refresh_cron 삭제(service_role 전용, 앱 호출 없음; 예외 목록 2건 정리).
  • compute/publish 의 세대 검사: "요청 세대가 앞섰다"는 더 이상 폐기 사유가 아니다. 잡이 processing 이고 target 이 같고 게시된 base 가 그대로면 자기 세대로 게시한다(D04 정착 prefix 규칙 그대로 — 후속 세대는 자기 dirty 범위를 들고 pending). 게시는 지연 FK 를 즉시 평가(set constraints all immediate)해 계산 중 삭제된 원본을 참조하는 행이 있으면 23503 으로 실패하고 후속 claim 이 범위를 흡수한다.

Phase 4 — 관측·예산 연결·마이그레이션 (925623cd·8b46ac32)

  • stats_projection_worker_status_v1()(privileged·admin): 설정·대기(유저·잡·가장 오래된 대기 초)·시도 소진 실패·stale 유저·임대(활성·만료 미회수·backoff 대기·활성 목록 10)·최근 1시간 완료/실패/실패 코드/계산·게시 p95·max.
  • 자원 계약 workers.statsWorker* 7개(설정 표 기본값) + resourceContract 테스트가 표 DEFAULT·저장 RPC 상한·cron 명령을 대조. db-objects-policy·reference-data-tables 에 설정 표 등재(참조 데이터로 스냅샷에 실림).
  • 마이그레이션 sql:candidate --ddl(표·열·행·트리거·cron·함수 삭제는 명시 DDL 입력) → level=high 위험 헤더 · populated upgrade 증거(from=55e9fae8 to=925623cd, fact-rows 81,403 · owner-sessions 260 · 1,086ms · facts=preserved · verdict=passed).

Phase 5 — 검토·재병합·문서 (이 세션)

  • 이전 세션(Opus) 구현 검토 — 정의 원천·마이그레이션·Edge·테스트 diff 전체를 읽고 계획 P1~P5·HQ 우선 (1)(3)(4) 와 대조했다. 결함 1건: batch 문의 remaining_user_count 가 방금 처리한 유저를 제외해, 그 유저에게 후속 세대 pending 이 남아도 has_more=false 가 나올 수 있었다(has_more 로 배수하는 호출자가 일찍 멈춤). pending 유저 전부를 세도록 고치고(21972340, 정의·마이그레이션·스냅샷 같은 문장) 회귀 ① 에 remaining_user_count = 2(A 의 후속 세대 + B) 단언을 추가했다. 나머지(울타리 조건·만료 회수와 행 잠금의 관계·backoff·문맥 초기화·설정 CHECK·마이그레이션의 override 보존·Edge 반복 종료 조건·인입 경로·pgTAP 갱신)는 계획과 일치했고 설계상 문제를 찾지 못했다.
  • release 재병합 3회: 2518e156(#1501 A10·#1502 S09) 뒤 번호 충돌(20260914023000) → 033000; 이어 8b796c1f(#1506 D05) 가 같은 033000 으로 들어와 → 043000, 다시 209bbffd(#1503 A14) 가 같은 043000 으로 들어와 → 053000(한 트랙에서 재번호 3회). D05 가 refresh_user_exercise_stats_fromp_scope 를 추가해 compute 의 호출을 D04 범위(from_date·exercise_ids) + D05 scope 로 합쳤고, D05 가 바꾼 compute·batch 문·_for_user_now 본문을 우리 마이그레이션의 재발행 문장에도 그대로 옮겼다(안 옮기면 우리 마이그레이션이 D05 를 되돌린다; 동기화 스크립트로 3개 교체·9개 동일 확인). 스냅샷 지문·registry·장부·위험 헤더 재생성.
  • 문서 5개(이 기록·잡 큐 문서 D08 절·정책 G-5/G-6·DAG 진입점·runbook)와 총괄 D08 카드·HQ 장부·D10/D11/R04/R05 인계.

주요 결정과 그 근거

  • 계산 중 저장은 결과를 버리지 않는다(D04 "거절" → "자기 세대 게시"). 폐기 방식은 반복 저장하는 유저의 통계가 영영 안 나오는 starvation 을 만든다. 자기 세대로 게시해도 후속 세대가 자기 dirty 범위를 다시 계산하므로 최종 상태는 전체 재생과 같다(probe 동치 단언). 유일한 예외는 계산 중 원본이 삭제돼 참조가 깨진 경우(23503) — 그때만 폐기·흡수. 이 규칙은 정책 §6 G-5/G-6 에 적고 D11(publication 계약)에 인계한다.
  • 울타리는 토큰 선언으로, 만료 회수는 행 잠금으로. 살아 있는 worker 는 compute 내내 run 행 잠금을 쥐므로 claim 의 만료 회수(for update skip locked)가 그 잡을 뺏지 못한다. 그래서 "긴 SQL 중 heartbeat 불가" 문제는 단계 경계 갱신만으로 충분하다(임대 5분 > 계산 60초 + 게시 6초, CHECK 로 고정).
  • 게시 예산 기본값 = 6초. Production 실측(287종목 4.4~5.9초)이 근거이고 저장 RPC 8초보다 작다. staging·로컬 게이트에도 같은 값이 적용된다. 3초 복귀는 D11 변경행 게시 뒤 재실측.
  • 인입은 enqueue 만. 인입 직후 화면은 잠시 "반영 중"(저장과 같은 방식). 격리 worker 와 시도 횟수를 다투지 않는다.
  • 동기 batch 문은 남기되 호출당 유저 1명. 관리자·G04 측정·D01 oracle 이 아직 부르므로 삭제하지 않고, 트랜잭션이 두 유저를 담을 수 없게만 바꿨다. G04 측정 도구의 라운드 수가 늘어난다(R04 에 기록).

작업 중 드러난 것

  • 이전 세션(Opus)의 구현은 계획대로였고 결함은 has_more 1건(위). 회귀 테스트 결정성은 그 세션이 이미 두 번 고쳤다: 활성 임대 상한(2)이 전역이라 같은 DB 단계에서 앞선 테스트가 남긴 computed 실행이 슬롯을 차지하면 claim 이 busy 가 됐다 → test.before 에서 stale 실행 회수; publish 가 유저 쓰기 잠금 경합으로 deferred 를 내면 100ms 뒤 재시도(예약 작업이 하는 것과 같음).
  • 마이그레이션 번호 충돌이 한 트랙에서 세 번 났다(#1501/#1502 → D05 → A14). 큐 Merge Check 가 잡지만 매번 재번호·지문·registry·장부·위험 헤더를 다시 만들어야 한다. migrations:renumber 가 "이 브랜치가 만진 파일의 모든 버전 토큰"을 바꾸므로, 다른 트랙(D05)의 증거 문자열에 적힌 번호까지 바꿔 놓았다 — 되돌렸다. 재번호 뒤 마이그레이션 내용을 또 바꾸면(위험 헤더) 지문을 다시 계산해야 한다(순서: 본문 확정 → 위험 헤더 → 지문).
  • release 병합에서 생성 파일(schema.sql·registry·db-objects)이 충돌할 때 git checkout --theirs 는 파일 전체를 release 판으로 바꿔 우리 객체가 스냅샷에서 사라진다(→ sql:extract 가 정의 파일을 지운다). 충돌 묶음(지문 줄)만 골라 해결하고 도구로 재생성해야 한다.
  • 우리 마이그레이션이 재발행하는 함수를 다른 트랙(D05)이 같은 release 에서 바꾸면, 정의 파일은 git 이 합쳐 주지만 마이그레이션 본문은 옛 것이 남아 replay 결과가 스냅샷과 달라진다. 정의 → 마이그레이션 본문 동기화를 스크립트로 했고, DB 단계의 스냅샷 대조가 이를 검증한다.
  • ci:precheck-local 은 병합 충돌이 있으면 그 자리에서 멈춘다 — 해결·커밋 뒤 재실행.
  • 사전 검증 누락(이전 세션): 첫 Precheck 에서 단위 테스트 2건이 실패했다. ① 작성자 장부 stats-projection-writers.jsonuser_stats_projection_runs current 에 새 열(lock_owner·retry_not_before)과 임대 갱신이 반영되지 않았다(드리프트 검사) ② db-objects 장부의 작성자 검출이 문자열을 먼저 지우는데, 새 SQL 주석의 아포스트로피(job's)가 문자열 시작으로 읽혀 compute 의 UPDATE·claim 의 INSERT 가 검출에서 빠졌다. 둘 다 npm run check 로 재현 가능했는데 이전 세션이 pgTAP·DB 회귀만 돌리고 단위 스위트 전체를 돌리지 않았다. 장부 갱신·주석 재작성으로 해결(SQL 주석에 아포스트로피를 쓰지 않는다).
  • 이 세션이 회귀 ①에 넣은 remaining_user_count = 2 단언은 첫 Precheck 의 DB 단계에서 실패했다 — A 의 저장이 첫 batch 호출 종료 뒤에 올 수 있어 타이밍에 걸렸다. 두 번째 호출이 B 의 계산 안에서 C 의 행 잠금을 기다리는 동안 B 가 저장(저장은 유저 잠금을 잡지 않으므로 막히지 않고 후속 세대 3)하게 바꿔 결정적으로 단언했다.
  • DB 레인에서만 나는 간헐 실패(3회 중 2회): 회귀 ①이 "A 의 첫 세대가 정착했다"에서 applied 0·dirty_from = A 의 첫 날짜 로 어긋났는데, 단독 실행은 매번 통과했다. 처리 결과와 잡 목록을 함께 찍어 보니 batch 문이 완료한 잡의 유저가 A 가 아니었다 — 같은 샌드박스에서 앞선 테스트 파일(statsRefreshScopeConcurrency)이 남긴 다른 유저의 pending 잡이 더 오래돼 전역 batch 문이 그 유저를 먼저 집었고, A 의 잡은 pending 으로 남아 뒤의 저장과 병합됐다. 전역 batch 문·격리 claim·has_more 는 모두 "가장 오래된 pending 유저"를 전역으로 보므로, 회귀는 시작할 때 stale 임대와 함께 잔재 잡(pending·failed)을 회수한다(시스템 표, cron 정지 샌드박스). 진단 중 실패한 테스트 프로세스가 끝나지 않고 남아 레인을 막아 강제 종료했다.
  • populated upgrade 증거는 925623cd 기준이다. 그 뒤 바뀐 것은 함수 본문·번호뿐이고 DDL(표·열·행·트리거·cron)은 그대로다.

5. 적용 결과

항목전 → 후
같은 batch 의 느린 B 가 있을 때 A 의 새 저장B 계산 종료까지 대기(회귀 ① 1.5초 초과) → 즉시(A 의 첫 세대 정착·요청 세대 2 수신, batch 문 processed 1·has_more true)
만료 뒤 옛 worker 의 늦은 쓰기성공(새 임대 덮어씀) → 55P03 거부(토큰 없음·옛 토큰·옛 토큰으로 연장 3경로 모두), 산 worker 는 임대 갱신 뒤 게시
게시 예산의 위치cron 명령 리터럴 3곳 + Production 손 편집 6초 → 설정 표 행 1개(기본 6초, 마이그레이션이 더 큰 운영값 보존)
계산 중 저장계산 결과 55000 폐기·재계산 → 자기 세대(2) 게시 + 후속 세대(3) pending·dirty 보존, 이어 수렴
실패 뒤 재집기1초 뒤 즉시 → 시도 × 30초 backoff(만료 회수는 즉시)
격리 임대 중 인입 인라인 경로55000 실패·시도 소모 → deferred, 후속 잡 pending·시도 0
Wodup 인입의 통계 갱신인입 트랜잭션 안 동기 계산 → enqueue 만(stats_requested_version 반환)
옛 cron 래퍼함수·예외 2건·계약 테스트 잔존 → 삭제
대기·실패 관측없음 → stats_projection_worker_status_v1 + Edge GET
실제 DB 연결 회귀(4건)기준 4건 실패 → 4건 통과 (ci:local DB 단계·승격 CI 등록)
pgTAP이전 세션 실측 137파일 2,673 assert 통과(새 파일 48 포함) · Precheck 실측은 아래
로컬 게이트sql:check·db:model:check·migrations:check·migrations:risk --check·check:migrations 통과 · ci:precheck-local: 검증: ci:precheck-local precheck · static 통과 · unit-1 통과 1858 passed / 0 failed / 34 conditional skip · unit-2 통과 1788 passed / 0 failed / 16 conditional skip · database 통과 db reset(마이그레이션 전체 적용): pass · schema.sql 스냅샷 --check: pass · pgTAP: pass 140파일/2753 assert · 동시 저장 세대·영수증: pass · dirty 범위·세대 병합: pass · worker 임대·울타리·한 유저 트랜잭션: pass · 큰 이력 계산 격리·동시 CRUD·통계 수렴: pass · 5분 14초 (head b2cb788d · 기준 209bbffd · 5분 14초)
release 통합 / staging / Productionrelease/v0.18.0 병합 a1d462a8(2026-09-10 05:00 UTC · 14:00 KST, 큐 run 34439276020, PR #1510) · staging 미반영 · Production 미반영 — 이슈 말머리 [v0.18.0 병합완료](§28)

6. 이번 개선으로 향상된 것

  • 느린 유저의 계산이 다른 유저의 저장을 막지 않는다 — "한 트랜잭션 = 한 유저" 가 동기 문·Edge·인입 모두에 걸린다.
  • 장애 복구 때 옛 작업자의 늦은 쓰기가 새 임대를 덮지 못한다(울타리 계약이 pgTAP 48 + 실제 연결 회귀로 고정).
  • 운영 예산이 코드에 있다 — 마이그레이션 재적용·새 환경에서도 Production 6초가 3초로 되돌아가지 않는다(#1432 재발 경로 차단).
  • 운동 중 반복 저장해도 통계가 나온다(starvation 제거).
  • 실패한 잡이 즉시 재시도로 예산을 태우지 않고, 대기·실패·임대 상태를 한 번에 읽을 수 있다.

남은 것

  • D11(#1422) 인계: 변경행 게시(delta publish)·row provenance 와 publication generation 분리·3초 복귀 재실측. HQ 격리 DB 검토 자료(work/incident1432/publish-delta-*)가 입력. "계산 중 저장 → 자기 세대 게시" 규칙(정책 G-5/G-6)을 publication 계약에 반영.
  • D10(#1418) 인계: claim/compute/publish 가 lease/fence/문맥 계약을 갖췄으므로 repair·rollover 탐색은 lock_owner·retry_not_before·상태 함수를 그대로 쓴다. 동기 batch 문은 호출당 유저 1명(has_more)이다.
  • R04/R05 인계: 설정 표 기본값(claim 3s·compute 60s·publish 6s·lease 300s·active 2·attempts 3·backoff 30s)이 G04 자원 계약 statsWorker* 에 있다. 대표 workload(종목·기간 출력량 포함)로 60초/6초 예산 실측과 혼합 부하에서의 활성 임대 수 판단은 R04 몫, rollback/재개 절차는 runbook. G04 측정 도구는 batch 문이 유저 1명씩 돌아 라운드 수가 늘어난다.
  • 대기·실패 경보 소비자(관리자 화면·알림)는 관측 함수만 준비됐다 — 어디에 붙일지는 운영 결정.
  • Edge 함수 stats-process-refresh-jobs 는 배포 매니페스트 버전만 올렸다 — staging Deploy 가 Edge 를 배포할 때 실제 호출 1회로 확인한다(staging 몫).