데이터 파이프라인 · 실행 원장과 원자적 커밋 · 실습
새벽 3시에 죽은 정산 작업 — 원장과 원자적 교체
목표
도중에 죽어도 안전한 실행기 runner.py 를 만든다. 산출물을 임시 이름으로 쓰고 바꿔 달아 반쯤 쓰인 파일이 남지 않게 하고, 실행 한 번을 원장에 한 줄로 남기고, 죽은 조각부터 재개하고, 같은 실행을 두 번 커밋해도 숫자가 늘지 않게 만든다.
왜 중요한가
파이프라인은 반드시 도중에 죽는다. 문제는 죽는다는 사실이 아니라, 죽었을 때 무엇이 남느냐다. 목적지 파일에 직접 쓰고 있었다면 반쯤 쓰인 파일이 남고, 그 파일은 크기도 이름도 멀쩡해서 다음 단계가 그대로 읽어 간다.
다시 돌리는 것도 위험하다. 앞 실행이 어디까지 갔는지 기록이 없으면 처음부터 다시 도는 수밖에 없고, 마지막에 장부로 덧붙이는 자리에서 같은 금액이 두 번 더해진다. 이것이 "다시 돌렸는데 왜 두 배가 됐나" 의 정체다.
이 실습이 만드는 장치는 셋이다. 첫째는 원자적 교체다. 같은 디렉터리에 임시 이름으로 다 쓴 뒤 os.replace 로 바꿔 단다. 둘째는 실행 원장이다. 실행마다 한 줄을 덧붙이고, 실패했다면 어느 조각에서 죽었는지를 적는다. 셋째는 재개다. 끝난 조각의 산출물 자체를 표식으로 삼아 건너뛴다.
이 코스의 dp-idempotent 실습은 데이터베이스 쪽 멱등성을 다룹니다 — 같은 행을 두 번 넣어도 한 행이 되게 만드는 일입니다. 여기는 그 앞자리입니다. 프로세스가 죽은 자리에서 파일 시스템에 무엇이 남는가, 남은 것을 보고 어디부터 다시 시작할 것인가를 다룹니다.
채점기는 여러분이 적어 낸 문구를 믿지 않는다. 임시 디렉터리에 채점기가 만든 입력 조각을 차려 놓고 여러분의 실행기를 실제로 돌린다. 일부러 죽인 뒤 목적지 파일이 그대로인지, 임시 파일이 목적지 옆에 남았는지, 원장에 실패한 조각 이름이 적혔는지까지 본다. 조각 수와 금액은 실행마다 바뀐다.
단계
1. /root/runx/gen_shards.py 를 만들어 실행해 /root/runx/work/in 아래 입력 조각을 만드세요.
2. /root/runx/runner.py 에 scan 을 만들어 조각 목록과 건수와 합계를 내게 하세요.
3. part 를 더해 조각 하나를 처리하고 산출물을 임시 이름으로 썼다가 바꿔 달게 하세요. --crash=write 로 바꿔 달기 직전에 죽는 길도 만듭니다.
4. run 을 더해 조각 전체를 처리하고 합친 산출물을 만들게 하세요.
5. 실행마다 원장 한 줄을 남기고 ledger 로 요약을 내게 하세요.
6. --crash-shard 로 가운데 조각에서 죽게 하고, 원장에 실패한 조각이 남고 합친 산출물은 건드려지지 않는지 확인하세요.
7. --resume 을 더해 끝난 조각을 건너뛰고 죽은 자리부터 이어 가게 하세요.
8. commit 을 더해 하루치 장부 /root/runx/work/out/daily.jsonl 에 같은 실행이 두 번 붙지 않게 하세요.
참고
- 작업은 전부
/root/runx아래에서 합니다. 작업 폴더는/root/runx/work입니다. - 작업 폴더 구조: 입력은
in/<조각>.jsonl, 조각 산출물은out/part-<조각>.json, 합친 산출물은out/total.json, 하루치 장부는out/daily.jsonl, 원장은ledger.jsonl입니다. 조각 이름은 입력 파일 이름에서.jsonl을 뺀 것입니다. - 입력 한 줄은 JSON 한 덩이이고
amount칸에 정수가 들어 있습니다. 다른 칸이 더 있어도 됩니다. - 실행 계약:
python3 /root/runx/runner.py <명령> <작업폴더> [...]. 답은 JSON 한 덩어리로 표준출력에 냅니다. 성공하면 종료 코드 0, 작업 폴더나 필요한 파일이 없으면 3, 사용법이 틀리면 2, 일부러 죽는 길로 들어가면 9 입니다. scan <작업폴더>응답:{"shards": [이름 오름차순], "events": 정수, "amount": 정수}.part <작업폴더> <조각> [--crash=write]응답:{"shard": 이름, "events": 정수, "amount": 정수, "path": 산출물 경로}. 산출물 파일 안에는 shard·events·amount 를 담습니다.run <작업폴더> --run-id=<이름> [--resume] [--crash-shard=<조각>]응답:{"run_id": 이름, "status": "ok", "shards_total": 정수, "shards_done": [이름], "skipped": [이름], "done": [이름], "events": 정수, "amount": 정수, "started_at": 문자열, "ended_at": 문자열}.skipped는 재개로 건너뛴 조각,done은 이번에 처리한 조각입니다.- 원장 한 줄은 run_id·started_at·ended_at·status·shards_total·shards_done·events·amount 를 담고, 실패했다면 failed_shard 를 더합니다. status 는
ok아니면failed입니다. ledger <작업폴더>응답:{"runs": 정수, "ok": 정수, "failed": 정수, "last": 마지막 원장 줄}.commit <작업폴더> --run-id=<이름>응답:{"appended": 참거짓, "run_id": 이름, "lines": 장부 줄 수}. 장부 한 줄은 run_id·events·amount 입니다.- 임시 파일은 목적지와 같은 디렉터리에 만들고, 이름이
part-*.json목록에 걸리지 않게 하세요. os.replace 는 파일 시스템을 넘으면 실패합니다. - 공식 문서: [os.replace](https://docs.python.org/3/library/os.html) · [rename(2)](https://man7.org/linux/man-pages/man2/rename.2.html) · [SQLite Atomic Commit](https://www.sqlite.org/atomiccommit.html) · [python json](https://docs.python.org/3/library/json.html)
- 흔한 실수: 목적지 파일을 직접 열어 쓰기, 임시 파일을
/tmp에 만들기, 총계를 이번 실행이 처리한 것만 더해서 내기, 장부 중복을 시각으로 판단하기. - 죽은 뒤 무엇이 남았는지 보려면
ls -a /root/runx/work/out을 쓰세요.
단계 8개
- 상류가 떨어뜨린 조각 만들기
- 무엇이 들어왔는지 먼저 센다
- 목적지에 직접 쓰지 않는다
- 실행 하나로 묶기
- 실행 한 번을 한 줄로 남기기
- 가운데 조각에서 죽여 보기
- 죽은 자리부터 이어 가기
- 두 번 붙지 않게 하기