docs(anchoring): 최근 강화분(dry-run·정합 감시·캐시 방어) 문서 동기화

코드에는 있었지만 규범 문서에 누락돼 있던 서술을 채움:

- 개발용 §7: 캐시 값 범위검증([10,200] 밖 오염 → 미스 취급)·미스 백필 SET NX·
  127.0.0.1 바인딩(무인증 Redis 비노출 MUST NOT) 규약 추가
- 개발용 §8: --once --dry-run 예행 모드·박제 정합 감시(판정 절차 내)·
  company_ids 스코프·--once 종료코드 1 규칙 서술 + 로그 규약에 정합 WARN
- 개발용 §11.5: dry-run 무변경·박제 정합 WARN 검증 벡터 2행 추가
- 워크플로우: 안전장치에 "예행 연습"·"잘못 찍힌 기준가 자동 감지" 항목(비개발자용)
- 운영및유지보수: 테스트 개수 18→20, 첫 운영 실행 전 dry-run 권장 절차,
  트러블슈팅에 "범위 밖 캐시 값 무시(오염 의심)" WARN 행
- README 런북: 박제 정합 불일치 WARN 대응 항목

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
민헌 2026-07-02 21:22:56 +09:00
parent b4d6cf817b
commit 6fda59970a
4 changed files with 25 additions and 4 deletions

View File

@ -73,4 +73,5 @@ docker logs anchoring | grep -E "WARNING|ERROR" # 이상 신호만
- **Redis 유실/재기동**: 캐시는 파생값 — 매 실행(매주, 게이트 무관) 시작 시 조정 보유 칸 전체를 re-SET 하고
TTL 7일이 보조하므로 자가 회복된다. 수동 복구가 필요하면 `--once`.
- **가격 제시율 0% WARN**: backend 의 `last_offered_price` 기록 배선 유실 신호(학습 무증상 동결) — 즉시 점검.
- **박제 정합 불일치 WARN**: negodata 의 앵커 산출 이식 오류 의심(정수식 ≠ 박제 anchor) — `docs/인수인계.md` §1.3 점검 요청.
- 조정 이력은 append-only — UPDATE/DELETE 금지. 배치가 sessions 에 쓰는 컬럼은 `anchoring_adjustment_id` 하나뿐.

View File

@ -369,20 +369,22 @@ anchoring.current_rates -- 칸별 현재값(최신 조정 행). 여기 없는
| 키 | `anchor:{company_id}:{supplier_type}:{bracket_index}` — supplier_type 은 **SMALLINT 코드값**. 예: `anchor:0b0e…:1:10` |
| 값 | 정수 천분율 문자열. 예: `"30"` |
| TTL | **7일** (stale 잔존 방지 보조 — 주 1회 re-SET 가 주 방어선, §8) |
| 캐시 미스 | 조정 이력 최신 행 조회 → 없으면 정적 테이블 시작값 → SET 후 사용 |
| 캐시 미스 | 조정 이력 최신 행 조회 → 없으면 정적 테이블 시작값 → **SET NX**(키 없을 때만) 후 사용 — 배치가 방금 쓴 새 값을 읽기 경로가 구값으로 되덮는 write-after-read 경합 방지 |
| 갱신 | 배치가 평가한 칸 SET + **매주 토 잡 실행 시(격주 게이트 무관) 조정 이력 보유 칸 전체 re-SET** (§8 절차 0.5) |
| 장애 내성 | Redis 에러 시 GET→None 취급(DB 폴백), SET 은 로그만 남기고 무시 (MUST — 견적 생성·배치를 Redis 가 막으면 안 됨). socket timeout **0.2~0.5초** 설정 MUST(행 방지) |
| 값 검증 | GET 값이 정책 범위 **[10, 200] 밖이면 오염**(외부 SET 등)으로 간주 — WARN 후 미스 취급(DB 폴백 + 재적재로 자가 교정). 캐시 값을 검증 없이 제안가에 쓰지 않는다 (MUST) |
- 캐시는 파생값이다. Redis flush가 발생해도 조정 이력에서 완전 복구 가능해야 한다 (MUST).
- ⚠️ **stale 키는 "미스"가 나지 않는다**: 배치의 DB 커밋 후 SET 실패, 또는 Redis 가 옛 스냅샷(RDB/AOF)으로 재기동하면 옛 rate 가 계속 서빙된다. 그래서 TTL + 주간 re-SET 이중 방어가 MUST 다.
- 멀티 인스턴스 동시 미스 → 결과 동일(최신 조정 행은 하나)하므로 락 불필요.
- 클라이언트: `redis.asyncio` — 사용 주체는 **anchoring 서비스**(배치 SET/re-SET)와 **negodata**(reader GET, 인수인계). backend 는 Redis 를 쓰지 않는다. 설정은 모듈 `config.toml` + `REDIS_HOST/PORT/PASSWORD` env 오버라이드. Redis 인스턴스는 모듈 docker-compose 에 동봉(negodata 가 같은 인스턴스를 바라봄).
- 보안: 무인증 Redis 를 외부 네트워크에 노출 **MUST NOT** — 오염된 rate 는 실제 제안가를 왜곡한다. 모듈 compose 는 포트를 `127.0.0.1` 로만 바인딩한다. negodata 가 다른 호스트에서 접근해야 하는 배치라면 인증(requirepass)·네트워크 격리 적용 후 개방한다(TODO 백로그).
---
## 8. 배치 잡 명세
- **러너**: `schedules/anchoring` **자립 컨테이너**의 APScheduler(AsyncIOScheduler, `Asia/Seoul`) — 자체 Dockerfile·docker-compose·config.toml 보유, backend 코드 import 없음. 단일 컨테이너가 곧 스케줄러라 중복 실행이 원천 차단되며(`coalesce=True`, `max_instances=1`, `misfire_grace_time=3600`), 진입점은 `python -m anchoring.main`(상주) / `python -m anchoring.main --once`(수동 1회, 게이트 무시).
- **러너**: `schedules/anchoring` **자립 컨테이너**의 APScheduler(AsyncIOScheduler, `Asia/Seoul`) — 자체 Dockerfile·docker-compose·config.toml 보유, backend 코드 import 없음. 단일 컨테이너가 곧 스케줄러라 중복 실행이 원천 차단되며(`coalesce=True`, `max_instances=1`, `misfire_grace_time=3600`), 진입점은 `python -m anchoring.main`(상주) / `python -m anchoring.main --once`(수동 1회, 게이트 무시) / `--once --dry-run`(예행 — 아래 dry-run 모드). `--once` 는 종료 상태가 `done`/`skipped`/`dry_run` 이 아니면(부분 실패 포함) **종료코드 1** 로 끝난다(cron·수동 실행 실패 감지).
- **스케줄**: 매주 토 00:00 KST 트리거(`CronTrigger(day_of_week="sat", hour=0, minute=0)`) + 잡 내부에서 **ISO 주차 % 2 == EVAL_WEEK_PARITY** 격주 게이트 (기준 패리티는 상수 고정 MUST).
- **멱등성**: 소비 마킹이 담당 — 같은 배치가 2회 실행돼도 1회차가 마킹한 세션은 2회차 pending에서 빠져 n < 10 스킵. 마킹 UPDATE의 `AND anchoring_adjustment_id IS NULL` 조건 + **rowcount = n 검증(불일치 시 전체 롤백) MUST** 가 경합을 차단한다 — 유니크 가드가 없는 구조에서 이중 조정(+2δ)을 막는 유일한 방어선이므로 SHOULD 가 아니라 MUST 다.
- **원자성**: 조정 INSERT 와 세션 마킹은 **같은 DB 세션의 한 트랜잭션**에서 실행한다(MUST). 모듈은 자체 async 엔진(`session_scope`)을 쓰므로 자연 충족된다. (참고: backend 의 `DB_SESSION_MNG.execute_lambda_run`은 db_type 2개 이상을 거부하므로, 이 로직을 backend 로 옮길 경우 단일 DBType 세션으로 실행해야 한다.)
@ -401,6 +403,10 @@ anchoring.current_rates -- 칸별 현재값(최신 조정 행). 여기 없는
- EXCLUDED 또는 칸 구성 불가(q.supplier_type ∉ {1,2,3} / company 미해석)
→ anchoring_adjustment_id = 0 일괄 마킹 (재스캔 방지)
- 유효 표본 → 칸별 그룹 적재
- 박제 정합 감시: rate 가 박제된 세션에 대해 tp×(1000−anchor_rate_permille)//1000 과
박제 anchor 를 대조, 불일치 수를 세어 WARN("박제 정합 불일치 n건") + 요약 snapshot_mismatch
— negodata 이식 오류(float 잔재·칸 해석 오류)를 적용 첫 주에 자동 감지. rate 미박제(전환기)는
검사 대상 아님. 판정 자체는 계속 박제 anchor 기준(§4.3 — 감시는 경고만, 판정을 바꾸지 않는다)
3. 칸별 (유효 n ≥ 10 인 칸만, 칸 단위 독립 트랜잭션 — 한 칸 실패가 전파되지 않음):
anchor_rate_before = 최신 조정 anchor_rate_after (없으면 정적 테이블 시작값)
anchor_rate_after = evaluate_pending(...) # §4.4 / §10
@ -417,9 +423,11 @@ anchoring.current_rates -- 칸별 현재값(최신 조정 행). 여기 없는
- 출력 = stdout(컨테이너 json-file 드라이버, compose 에서 10MB×5 로테이션). 타임스탬프는 컨테이너 TZ 와 무관하게 **항상 KST(+0900)**.
- 모든 배치 라인에 `[batch {run_id}]` 태그(run_id = 시작 시각) → 회차 단위 grep. 칸·회사 라인은 `company= type= bracket=` key=value 형식 → **회사별 grep**(`grep company=<uuid>`).
- 라인 구성: 시작(ISO 주차·force) → 캐시 re-SET 칸 수 → 제외 마킹 건수 → **칸별 조정 상세**(`n= 성공= before‰→after‰ adj_id=` — DB 행과 교차 확인) → **회사요약**(회사당 1줄: 평가/상승/유지/하락/이월/실패/제외) → redis 실패 누계(WARN, 있을 때만) → 종료 요약.
- 레벨: 칸 실패 = ERROR(칸 키 포함, 격리됨) / `failed_cells > 0` 이면 종료 요약을 **WARNING 으로 승격**(“WARN 이상 알람” 정책 호환) / Redis 실패 WARN 은 연산별 처음 5건만 남기고 누계로 요약(폭주 억제) / 가격 제시율 0% = WARN.
- 레벨: 칸 실패 = ERROR(칸 키 포함, 격리됨) / `failed_cells > 0` 이면 종료 요약을 **WARNING 으로 승격**(“WARN 이상 알람” 정책 호환) / Redis 실패 WARN 은 연산별 처음 5건만 남기고 누계로 요약(폭주 억제) / 가격 제시율 0% = WARN / 박제 정합 불일치 = WARN.
- 상주 기동 시 다음 실행 예정 시각 로그, apscheduler 로거도 동일 핸들러에 연결(misfire 등 스케줄 이상 가시화).
- **dry-run 모드** (`--once --dry-run` / `run_evaluation_batch(dry_run=True)`): 절차 0.5 re-SET·제외 마킹·조정 INSERT·캐시 SET 을 전부 건너뛰고, 판정 결과·예상 조정(`조정예정` 라인, adj_id=None)·제외 예정 건수만 로그로 남긴다(종료 status `dry_run`). 상태를 소비하지 않으므로 직후 실제 실행 결과와 동일하다 — 첫 운영 실행(레거시 세션 전량 판정) 전에 규모를 확인하는 예행 용도.
- 수동·테스트 실행은 `run_evaluation_batch(company_ids=[...])` 로 대상 회사를 한정할 수 있다 — 공유 DB 에서 다른 회사의 미처리 세션을 소비하지 않는다(테스트 스위트가 사용).
- INSERT+마킹(3)과 Redis SET(4) 사이 장애 시: 캐시는 stale이지만 TTL(7일)·다음 주 re-SET(절차 0.5)이 회복한다. 트랜잭션은 DB까지만 보장하면 된다.
- n < 10 칸의 유효 표본은 **마킹하지 않는다** — 그것이 이월이다.
- 배치 실패·지연 시에도 견적 생성·협상은 캐시(또는 on-demand 조회)로 계속 동작한다.
@ -655,6 +663,8 @@ clamp·격리 케이스:
| 배치 2개 프로세스 동시 실행(오설정 시뮬레이션) | 한쪽만 조정 성공, 다른 쪽은 rowcount 불일치 롤백 → 칸당 조정 정확히 1건 |
| Redis 에 옛 rate 를 심고 주간 잡 실행(격주 게이트 OFF 주) | 절차 0.5 re-SET 으로 최신 rate 로 회복 |
| 가격 제시율 0% 상태에서 배치 실행 | 요약 로그에 WARN 출력 (backend 기록 배선 유실 감지) |
| dry-run 실행 (유효 10건 + 제외 1건 시드) | status=`dry_run`·`조정예정` 로그만 — 조정 0행·마킹 없음(제외 포함). 직후 실제 실행 시 그대로 반영(상태 미소비 증명) |
| rate=10‰ 박제인데 anchor 가 정수식과 다른 세션 | "박제 정합 불일치 1건" WARN (판정은 박제 anchor 기준 그대로) |
### 11.6 읽기 경로·가격 흔적 (E2E 스모크)

View File

@ -109,10 +109,17 @@ PYTHONPATH=src .venv/bin/python -m anchoring.main --once
끝부분에 `종료 {'run_id': ..., 'status': 'done', ...}` 와 `[main] 결과: {...}` 가 나오면 성공입니다(부분 실패면 종료코드 1).
(협상 데이터가 없으면 `scanned: 0` — 이것도 정상)
**첫 운영 실행 전에는 예행 연습을 먼저** 하세요 — 쌓여 있는 협상 전량이 첫 실행에서 한 번에 채점되므로, 무엇이 얼마나 바뀔지 미리 보는 게 안전합니다:
```bash
docker exec anchoring python -m anchoring.main --once --dry-run
# DB/Redis 를 전혀 바꾸지 않고 "조정예정 …" 라인과 제외 예정 건수만 로그로 보여줍니다 (status: dry_run)
```
테스트 스위트로 확인하려면:
```bash
PYTHONPATH=src .venv/bin/python -m pytest tests/ -q # 전부 passed 기대(현재 18개)
PYTHONPATH=src .venv/bin/python -m pytest tests/ -q # 전부 passed 기대(현재 20개)
```
## 5. 로그 읽는 법
@ -180,6 +187,7 @@ docker logs anchoring | tail -20 # 최근 상태
| 기동 실패 + DB 연결 예외 | config.toml/env 의 DB 접속 정보 오류 | 접속 정보 확인, `psql` 로 직접 접속 테스트 |
| `[redis] GET/SET 실패 … DB 폴백` WARN | Redis 다운/네트워크 | **서비스는 계속 정상 동작**(DB 폴백). `docker compose up -d anchoring-redis` 로 복구하면 다음 실행 때 캐시 자동 재적재 |
| `redis 실패 누계 get=… set=…` WARN | 위와 동일(회차 요약) | 위와 동일 |
| `[redis] 범위 밖 캐시 값 무시(오염 의심)` WARN | 누군가/다른 프로세스가 Redis 에 비정상 값을 씀 | 동작엔 문제 없음(자동 무시 + DB 폴백 + 재적재로 자가 교정). 반복되면 Redis 접근 경로 점검 — 포트가 외부에 열려 있지 않은지(`127.0.0.1` 바인딩) 확인 |
| `가격 제시 흔적 0%` WARN | backend 의 가격 기록 배선이 끊김(배포 사고 등) — 학습이 조용히 멈추는 신호 | backend 팀에 `chat_service` 의 `last_offered_price` 갱신 경로 점검 요청 |
| `칸 평가 실패 company=…` ERROR | 해당 칸 DB 오류/마킹 경합 | 스택 확인. 실패 칸은 마킹되지 않아 **다음 회차 자동 재시도** — 같은 칸이 연속 실패하면 개발 팀 문의 |
| `박제 정합 불일치 n건` WARN | negodata 의 앵커 산출 이식 오류 의심(정수식 ≠ 박제 anchor) | negodata 팀에 `docs/인수인계.md` §1.3 정수식 적용 여부 점검 요청 |

View File

@ -100,6 +100,8 @@
- **"왜 이 칸이 7%야?"에 항상 답할 수 있음** — 조정 장부에 모든 변경이 근거(어떤 협상들, 성공률)와 함께 영구 보존됩니다. 장부는 수정·삭제가 금지돼 있습니다.
- **빠른 조회판이 날아가도 무사** — 어차피 사본이라 조정 장부에서 언제든 다시 만들 수 있습니다. 조회판(Redis)이 아예 꺼져 있어도 협상은 원본 장부를 직접 읽어 계속 동작하고, 조회판에 옛 값이 남아 있더라도 매주 정산 시각에 최신 값으로 전부 다시 붙입니다.
- **회사 간 칸막이** — A사의 협상 결과는 A사의 칸에만 반영됩니다. 다른 회사의 값과 기록은 완전히 분리됩니다.
- **예행 연습이 가능** — 실제로 아무것도 바꾸지 않고 "이번 정산에서 무엇이 어떻게 바뀔지"만 미리 보는 모드(dry-run)가 있어, 첫 가동처럼 조심스러운 순간에 결과를 눈으로 확인한 뒤 진행할 수 있습니다.
- **잘못 찍힌 기준가를 자동 감지** — 정산 때마다 각 협상에 도장 찍힌 기준가가 규칙대로 계산된 값인지 대조해서, 견적 시스템 쪽 계산 실수를 경고로 잡아냅니다.
---