凌晨三点挂掉的结算作业 — 台账与原子替换
한국어 원문으로 표시합니다.
목표
도중에 죽어도 안전한 실행기 runner.py 를 만든다. 산출물을 임시 이름으로 쓰고 바꿔 달아 반쯤 쓰인 파일이 남지 않게 하고, 실행 한 번을 원장에 한 줄로 남기고, 죽은 조각부터 재개하고, 같은 실행을 두 번 커밋해도 숫자가 늘지 않게 만든다.
왜 중요한가
파이프라인은 반드시 도중에 죽는다. 문제는 죽는다는 사실이 아니라, 죽었을 때 무엇이 남느냐다. 목적지 파일에 직접 쓰고 있었다면 반쯤 쓰인 파일이 남고, 그 파일은 크기도 이름도 멀쩡해서 다음 단계가 그대로 읽어 간다. 다시 돌리는 것도 위험하다. 앞 실행이 어디까지 갔는지 기록이 없으면 처음부터 다시 도는 수밖에 없고, 마지막에 장부로 덧붙이는 자리에서 같은 금액이 두 번 더해진다. 이것이 "다시 돌렸는데 왜 두 배가 됐나" 의 정체다. 이 실습이 만드는 장치는 셋이다. 첫째는 원자적 교체다. 같은 디렉터리에 임시 이름으로 다 쓴 뒤 os.replace 로 바꿔 단다. 둘째는 실행 원장이다. 실행마다 한 줄을 덧붙이고, 실패했다면 어느 조각에서 죽었는지를 적는다. 셋째는 재개다. 끝난 조각의 산출물 자체를 표식으로 삼아 건너뛴다. 이 코스의 dp-idempotent 실습은 데이터베이스 쪽 멱등성을 다룹니다 — 같은 행을 두 번 넣어도 한 행이 되게 만드는 일입니다. 여기는 그 앞자리입니다. 프로세스가 죽은 자리에서 파일 시스템에 무엇이 남는가, 남은 것을 보고 어디부터 다시 시작할 것인가를 다룹니다. 채점기는 여러분이 적어 낸 문구를 믿지 않는다. 임시 디렉터리에 채점기가 만든 입력 조각을 차려 놓고 여러분의 실행기를 실제로 돌린다. 일부러 죽인 뒤 목적지 파일이 그대로인지, 임시 파일이 목적지 옆에 남았는지, 원장에 실패한 조각 이름이 적혔는지까지 본다. 조각 수와 금액은 실행마다 바뀐다.
단계
- /root/runx/gen_shards.py 를 만들어 실행해 /root/runx/work/in 아래 입력 조각을 만드세요.
- /root/runx/runner.py 에
scan을 만들어 조각 목록과 건수와 합계를 내게 하세요. part를 더해 조각 하나를 처리하고 산출물을 임시 이름으로 썼다가 바꿔 달게 하세요.--crash=write로 바꿔 달기 직전에 죽는 길도 만듭니다.run을 더해 조각 전체를 처리하고 합친 산출물을 만들게 하세요.- 실행마다 원장 한 줄을 남기고
ledger로 요약을 내게 하세요. --crash-shard로 가운데 조각에서 죽게 하고, 원장에 실패한 조각이 남고 합친 산출물은 건드려지지 않는지 확인하세요.--resume을 더해 끝난 조각을 건너뛰고 죽은 자리부터 이어 가게 하세요.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 · rename(2) · SQLite Atomic Commit · python json
- 흔한 실수: 목적지 파일을 직접 열어 쓰기, 임시 파일을
/tmp에 만들기, 총계를 이번 실행이 처리한 것만 더해서 내기, 장부 중복을 시각으로 판단하기. - 죽은 뒤 무엇이 남았는지 보려면
ls -a /root/runx/work/out을 쓰세요.
상류가 떨어뜨린 조각 만들기
/root/runx/gen_shards.py 를 만들어 실행해 /root/runx/work/in 아래 조각 파일을 만드세요. 조각은 4개 이상, 한 조각에 5줄 이상, 전체 40줄 이상이어야 하고, 한 줄은 id 와 정수 amount 를 담은 JSON 한 덩이입니다.
조각 하나가 JSON Lines 파일 하나입니다. 파일 이름에서 .jsonl 을 뺀 것이 조각 이름이 되므로, 정렬했을 때 시간 순서가 되도록 h00·h01 처럼 자리를 맞춰 붙이세요. 난수 씨앗을 고정해 두어야 나중에 재개를 시험할 때 입력이 흔들리지 않습니다.
무엇이 들어왔는지 먼저 센다
/root/runx/runner.py 에 scan <작업폴더> 를 만들어 조각 이름 목록과 전체 건수와 금액 합계를 JSON 으로 내게 하세요. 조각 이름은 오름차순입니다.
작업 폴더 아래 in/ 에서 .jsonl 로 끝나는 파일만 골라 이름에서 확장자를 뗍니다. 빈 줄은 세지 않습니다. 작업 폴더가 없으면 종료 코드 3 으로 끝내야 뒤 단계의 오류 메시지가 정직해집니다.
목적지에 직접 쓰지 않는다
part <작업폴더> <조각> [--crash=write] 를 더하세요. 조각을 세어 out/part-<조각>.json 에 shard·events·amount 를 남기되, 목적지와 같은 디렉터리에 임시 이름으로 다 쓴 뒤 os.replace 로 바꿔 답니다. --crash=write 를 주면 바꿔 달기 직전에 종료 코드 9 로 죽습니다.
임시 이름이 part-*.json 목록에 걸리면 뒤에서 그 파일까지 합계에 들어갑니다. 점으로 시작하는 이름을 쓰세요. 그리고 임시 파일을 /tmp 에 만들면 안 됩니다 — os.replace 는 파일 시스템을 넘으면 실패하고, 그 실패는 개발 기계에서 재현되지 않습니다. 채점기는 죽인 뒤 목적지 파일이 그대로인지, 임시 파일이 목적지 옆에 남았는지를 봅니다.
실행 하나로 묶기
run <작업폴더> --run-id=<이름> 을 더해 조각 전체를 순서대로 처리하고, 조각 산출물을 다시 훑어 out/total.json 에 shards·events·amount 를 남기게 하세요. total.json 도 바꿔 달기로 씁니다.
총계를 이번에 처리한 조각만 더해서 내면 뒤에서 재개할 때 건너뛴 조각이 빠집니다. 총계는 언제나 out/part-*.json 전체를 다시 읽어서 내세요. 이 규칙 하나가 재개를 공짜로 만듭니다.
실행 한 번을 한 줄로 남기기
run 이 끝날 때 /root/runx/work/ledger.jsonl 에 한 줄을 덧붙이게 하고, ledger <작업폴더> 로 {"runs": 정수, "ok": 정수, "failed": 정수, "last": 마지막 줄} 을 내게 하세요.
원장은 덧붙이기만 합니다. 앞줄을 고치기 시작하면 실행 하나가 한 줄이라는 규칙이 무너지고, 그 순간 원장은 로그와 다를 바가 없어집니다. 한 줄에 run_id·started_at·ended_at·status·shards_total·shards_done·events·amount 를 담으세요.
가운데 조각에서 죽여 보기
run 에 --crash-shard=<조각> 을 더하세요. 그 조각 차례가 오면 원장에 status 가 failed 이고 failed_shard 가 그 조각인 줄을 남기고 종료 코드 9 로 죽습니다. 합친 산출물 out/total.json 은 건드리지 않습니다.
죽기 전에 원장을 먼저 써야 합니다. 원장이 없으면 다음 사람이 할 수 있는 일은 처음부터 다시 돌리는 것뿐입니다. shards_done 에는 이번 실행에서 실제로 끝낸 조각만 담고, total.json 은 손대지 않은 채로 둡니다 — 지난 실행의 답이 그대로 서 있어야 합니다.
죽은 자리부터 이어 가기
run 에 --resume 을 더하세요. 조각 산출물 out/part-<조각>.json 이 이미 있고 그 안의 shard 이름이 맞으면 그 조각을 건너뛰고, 건너뛴 것은 응답의 skipped 에, 이번에 처리한 것은 done 에 담습니다.
표식 파일을 따로 두지 말고 조각 산출물 자체를 표식으로 삼으세요. 산출물은 바꿔 달기로 만들어지므로, 있다는 것은 그 조각이 확실히 끝났다는 뜻입니다. 표식과 산출물을 따로 두면 표식만 남고 산출물은 반쯤 쓰인 상태가 생깁니다. 총계는 여전히 조각 산출물 전체에서 다시 모읍니다.
두 번 붙지 않게 하기
commit <작업폴더> --run-id=<이름> 을 더하세요. out/total.json 을 읽어 하루치 장부 out/daily.jsonl 에 run_id·events·amount 한 줄을 붙이되, 그 run_id 가 이미 있으면 붙이지 않고 {"appended": false, ...} 를 냅니다. 자기 작업 폴더에서도 실행 하나를 커밋해 두세요.
조각 처리는 덮어쓰기라 몇 번을 해도 같지만, 장부에 한 줄 붙이는 일은 부를 때마다 늘어납니다. 중복을 시각으로 판단하면 같은 날 두 번 돈 실행을 가르지 못합니다. 실행의 이름으로 판단하세요.