데이터 파이프라인 · 백필과 이중 계산 · 실습
지난 구간을 다시 돌린다 — 두 배가 되지 않게
목표
구간을 받아 과거를 다시 돌리는 실행기 runner.py 를 만든다. 정기 실행과 같은 코드로 파티션 단위 치환을 하고, 구간 예약으로 정기 실행과의 충돌을 막고, 이미 내보낸 구간은 봉인해 정정으로 남기고, 누적 집계가 백필 때문에 두 배가 되는 것을 직접 재현한 뒤 고친다.
왜 중요한가
백필은 지난 구간을 다시 돌리는 일이지만, 어려운 부분은 계산이 아니라 조율이다. 백필이 도는 동안 정기 실행도 돈다. 둘이 같은 날짜를 동시에 쓰면 어느 쪽이 이겼는지 아무도 모른다.
같은 코스의 멱등 실습은 같은 행을 두 번 넣어도 결과가 같아지게 만드는 일이었다. 여기는 행이 아니라 구간이다. 파티션 표가 멱등해도 누적 표가 멱등하지 않으면 백필 한 번에 숫자가 부풀어 오른다. 더하는 누적은 백필을 만나면 반드시 틀리고, 파생시키는 누적은 몇 번을 돌려도 같은 값을 낸다.
그리고 되돌아가면 안 되는 자리가 있다. 이미 보낸 보고서와 이미 나간 알림은 자료가 아니라 사건이다. 그 구간은 봉인하고, 새 값이 나오면 덮어쓰는 대신 정정으로 따로 기록해야 "그때 우리가 무엇을 보냈는가" 와 "지금 무엇이 맞는가" 를 둘 다 말할 수 있다.
채점기는 여러분의 문구를 믿지 않는다. 임시 디렉터리에 채점기가 만든 파티션을 차려 놓고 환경 변수로 그 저장소를 가리킨 뒤 여러분의 실행기를 실제로 실행하고, sqlite 표를 직접 열어 대조한다. 날짜와 금액은 실행마다 바뀝니다.
단계
1. /root/backfill/gen_events.py 를 만들어 실행해 /root/backfill/events 아래 dt=2026-02-01.jsonl 부터 dt=2026-02-14.jsonl 까지 열나흘치 파티션을 만드세요.
2. /root/backfill/runner.py 에 init 와 run <시작일> <끝일> <주인> 을 만들어 구간의 파티션을 세어 daily 에 넣게 하세요.
3. run 이 파티션을 통째로 치환하게 고치고, 통째로 돌린 답과 하루씩 돌린 답과 재실행 결과를 /root/backfill/split.json 에 적으세요.
4. rollup-add <시작일> <끝일> 과 rollup 을 더하고, 백필 뒤에 두 방식이 얼마나 벌어지는지를 /root/backfill/double.json 에 적으세요.
5. claim <시작일> <끝일> <주인> 과 release <시작일> <끝일> <주인> 을 더해 구간을 예약하게 하세요.
6. run 이 남이 잡고 있는 파티션을 건너뛰고 skipped 로 보고하게 하세요. 건너뛴 것이 있으면 종료 코드는 5 입니다.
7. seal <시작일> <끝일> 과 amend <날짜> <사유> 를 더하세요. 봉인된 파티션은 run 이 건드리지 않고, 늦게 온 전표는 corrections 에 정정으로 남습니다.
8. /root/backfill/backfill_report.json 과 /root/backfill/backfill_report.md 로 한 장을 남기세요.
참고
- 실행 계약:
python3 /root/backfill/runner.py <명령> .... 답은 JSON 한 덩어리로 표준출력에 냅니다. 종료 코드는 0 성공, 2 모르는 명령이거나 인자 수가 다름, 3 상태 저장소 없음, 4 예약이 거절됨, 5 건너뛴 구간이 있음, 6 봉인되지 않은 구간에amend를 부름 입니다. - 상태 저장소 경로는 환경 변수
BACKFILL_DB로, 원본 파티션 폴더는BACKFILL_EVENTS로 바꿀 수 있어야 합니다. 기본값은 각각/root/backfill/state.db와/root/backfill/events입니다. 채점기가 이 두 변수로 자기 저장소를 가리킵니다. - 파티션 파일 이름은
dt=YYYY-MM-DD.jsonl이고, 줄 모양은{"order_id": 문자열, "dt": 날짜, "amount": 정수, "status": "paid" 또는 "refund"}입니다. 환불 줄의 amount 는 음수입니다. 한 파티션의orders는 줄 수,amount는 amount 의 합입니다. - 표는 다섯입니다.
daily(dt, orders, amount, owner)·rollup(metric, value)·claims(dt, owner)·seals(dt)·corrections(dt, orders_before, amount_before, orders_after, amount_after, reason).daily에는 기본 키를 걸지 않습니다 — 치환을 직접 구현해 보는 것이 이 실습의 요점입니다. run응답:{"owner": 문자열, "done": [날짜...], "skipped": [[날짜, 사유]...], "orders": 정수, "amount": 정수}.orders와amount는done에 든 파티션만 더한 값입니다. 사유는claimed_by:<주인>·sealed·no_data셋입니다. 원본 파일이 없는 날짜(no_data)는 종료 코드를 5 로 만들지 않습니다.claim응답:{"owner": 문자열, "claimed": [날짜...], "denied": [[날짜, 주인]...]}. 자기가 이미 잡은 날짜는claimed에 그대로 들어갑니다.release응답:{"owner": 문자열, "released": [날짜...]}이고 자기 예약만 풉니다.seal응답:{"sealed": [날짜...]}.amend응답:{"dt": 날짜, "orders_before": 정수, "amount_before": 정수, "orders_after": 정수, "amount_after": 정수, "reason": 문자열}.amend는daily를 고치지 않고corrections에 한 줄을 더합니다.rollup-add응답과rollup응답은{"mode": "add" 또는 "derive", "order_total": 정수, "amount_total": 정수}입니다.rollup-add는 구간 합계를 기존 값에 더하고,rollup은daily전체에서 다시 계산해 덮어씁니다.- 성능으로 판정하지 않습니다. 백필이 얼마나 걸렸는지는 재지 말고, 무엇을 어떤 순서로 건드렸는지만 남기세요.
- 공식 문서: [python sqlite3](https://docs.python.org/3/library/sqlite3.html) · [SQLite Transaction](https://www.sqlite.org/lang_transaction.html) · [SQLite UPSERT](https://www.sqlite.org/lang_upsert.html) · [Airflow Dag Runs](https://airflow.apache.org/docs/apache-airflow/stable/core-concepts/dag-run.html)
- 흔한 실수: 백필용 코드를 따로 만들기, 구간 전체를 한 트랜잭션에 넣기, 남의 구간을 조용히 덮어쓰기, 누적을 더하는 방식으로 두기, 이미 내보낸 구간을 덮어쓰고 그때 무엇을 보냈는지 잃기.
- 상태 저장소를 눈으로 보려면
sqlite3 /root/backfill/state.db 'SELECT * FROM daily ORDER BY dt'를 쓰세요.
단계 8개
- 열나흘치 파티션 만들기
- 구간을 받아 돌리는 실행기 만들기
- 파티션 단위로 치환하기
- 누적 집계가 두 배가 되는 것 재현하기
- 구간을 먼저 예약하기
- 남의 구간은 건너뛰고 보고하기
- 이미 내보낸 구간은 정정으로 남기기
- 재실행 한 판을 한 장으로 보고하기