We Pull Everything Daily and Still Lose a Few Rows
한국어 원문으로 표시합니다.
목표
계속 바뀌는 원본을 페이지로 나눠 받는 동기화를 만든다. 오프셋 방식이 왜 조용히 행을 빠뜨리는지 직접 재현하고, (updated_at, id) 커서로 옮기고, 워터마크로 중단 지점부터 재개하고, 마지막에 원본과 사본을 대조한다.
왜 중요한가
60만 건을 1000건씩 600번에 나눠 받는 동안 원본도 계속 바뀐다. 이미 지나온 페이지의 행이 갱신되어 정렬 순서에서 맨 뒤로 가면, 뒤에 있던 행들이 앞으로 당겨지고 그 사이의 행은 아무도 읽지 않은 채 지나간다.
이 사고는 오류가 나지 않고, 건수는 대충 맞고, 매번 다른 행이 빠진다. 그래서 재현이 안 되고 몇 달 뒤 신고로 돌아온다.
고치는 방법은 자리를 번호가 아니라 값으로 잡는 것이다. 다만 정렬 키가 유일하지 않으면 또 다른 함정이 기다린다 — updated_at 하나로 잡으면 같은 시각의 행을 잃거나 같은 자리를 무한히 돈다. 그래서 커서는 유일해질 때까지 칸을 늘린다.
채점기는 여러분의 문장을 믿지 않는다. 원본 서버를 채점기가 고른 포트에 직접 띄우고, 채점기가 원본을 바꿔 가며 여러분의 동기화를 실제로 돌려 무엇이 빠지고 무엇이 겹쳤는지 센다.
단계
- /root/sync/source.py 를 만들어 포트 8019 에 띄우고, 전체를 /root/sync/snapshot.json 에 뜨세요.
- /root/sync/offset_sync.py 를 만들어 오프셋으로 순회하되 도중에 원본을 바꾸어, 누락과 중복이 함께 생기는 것을 숫자로 보이세요.
- /root/sync/cursor_sync.py 를 만들어
(updated_at, id)커서로 순회해 같은 상황에서 누락이 0 이 되게 하세요. - cursor_sync.py 에
--state를 붙여 워터마크를 파일에 남기고, 두 번째 실행이 바뀐 것만 받아 오게 하세요. - 페이지가 같은
updated_at을 가진 행들의 한가운데를 가르도록 만들어도 누락도 중복도 없음을 /root/sync/tie_report.json 에 적으세요. - 중단된 동기화를 이어받아 /root/sync/sink.jsonl 에 전체를 모으세요.
- /root/sync/reconcile.py 로 원본과 사본을 대조해 /root/sync/sync_result.json 을 만드세요.
- /root/sync/sync_report.md 에 네 절로 보고하세요.
참고
- 원본 서버 실행 계약:
python3 /root/sync/source.py --port <포트>./health는{"ok": true, "rows": 60},/all은 전체를(updated_at, id)순으로,/rows?offset=&limit=은 오프셋 방식,/rows?since=&since_id=&limit=은 커서 방식으로 냅니다./mutate?ids=R-0001,R-0002는 그 행들의updated_at을 지금 가장 큰 값 뒤로 밀고value를 1 올립니다. - 행은 60개이고
id·updated_at·value세 칸입니다. 21번부터 25번까지 다섯 행은updated_at이 같습니다 — 5번 단계의 경계가 여기서 생깁니다. - 순회기 실행 계약(둘 공통):
--base <URL> --limit <n> [--snapshot <파일>] [--mutate-after <페이지>] [--mutate-ids <a,b,c>] [--out <jsonl>]. 출력은{"pages": ..., "fetched": ..., "unique": ..., "missing": [...], "duplicated": [...]}이고, cursor_sync.py 는 여기에watermark가 더 붙으며--state <파일>과--max-pages <k>를 더 받습니다. pages는 한 행 이상을 받아 온 요청의 수입니다.missing은--snapshot을 주었을 때만 채우고, 안 주면 빈 리스트입니다.--out은 받은 행을 한 줄에 하나씩 JSON 으로 덧붙여 씁니다.--mutate-after <페이지>는 그 페이지를 받은 직후에--mutate-ids의 행들을 원본에서 갱신합니다. 도는 동안 원본이 바뀌는 상황을 우리가 흉내 내는 장치입니다.- 대조기 실행 계약:
python3 reconcile.py --snapshot <원본 JSON> --sink <사본 JSONL> --out <결과 JSON>은{"source_rows": ..., "sink_rows": ..., "unique": ..., "missing": [...], "extra": [...], "value_mismatch": [...], "match": ...}를 냅니다.sink_rows는 사본 파일의 줄 수,unique는 서로 다른 id 의 수입니다. 같은 id 가 여러 번 있으면updated_at이 가장 큰 것을 씁니다. - 경계 보고 형식:
{"limit": ..., "pages": ..., "fetched": ..., "missing": [...], "duplicated": [...], "tie_updated_at": ..., "tie_ids": [...]}. - 흔한 실수: 커서를
updated_at하나로만 잡기, 워터마크를 처리 전에 저장하기, 중복을 버그로 보고 없애려 하기(적어도 한 번이 정상입니다), 건수만 맞춰 보고 값은 안 맞춰 보기. - 서버는 백그라운드로 띄우고
/health가 200 이 될 때까지 기다린 뒤 다음으로 갑니다. 채점기는 여러분이 띄워 둔 프로세스를 보지 않고 스크립트를 직접 다시 띄웁니다.
계속 바뀌는 원본 띄우기
/root/sync/source.py 를 만들어 포트 8019 에 띄우고, /all 을 /root/sync/snapshot.json 에 저장하세요. 행은 60개이고 21번부터 25번까지 다섯 행의 updated_at 이 같아야 합니다.
정렬은 언제나 (updated_at, id) 두 칸으로 합니다. /rows 는 since 가 오면 커서 방식, 아니면 오프셋 방식으로 답하면 됩니다. /mutate 는 지정한 행들의 updated_at 을 지금 가장 큰 값보다 뒤로 밀어야 정렬에서 맨 끝으로 갑니다.
오프셋 순회가 삼킨 세 행
/root/sync/offset_sync.py 를 만들어 --limit 10 으로 순회하되 첫 페이지 직후에 이미 지나온 행 3개(R-0001, R-0002, R-0003)를 갱신하세요. 결과의 missing 과 duplicated 가 각각 3건이어야 합니다.
이미 읽은 행이 갱신되어 정렬 맨 뒤로 가면 그 뒤의 행들이 앞으로 당겨집니다. 오프셋은 그 사실을 모르므로 당겨진 만큼을 건너뜁니다. 그리고 맨 뒤로 간 행들은 마지막 페이지에서 한 번 더 잡힙니다. 두 현상이 동시에 일어나는 것이 핵심입니다.
자리를 번호가 아니라 값으로
/root/sync/cursor_sync.py 를 만들어 (updated_at, id) 커서로 순회하게 하세요. 2번과 똑같이 첫 페이지 직후에 세 행을 갱신해도 missing 이 0 이어야 합니다. 갱신된 행이 다시 잡히는 것(duplicated)은 정상입니다.
요청마다 마지막으로 본 행의 (updated_at, id) 를 넘기고, 원본은 그 값보다 큰 것부터 돌려줍니다. 비교는 두 칸을 함께 해야 합니다 — 시각 하나만 비교하면 같은 시각의 행을 잃거나 같은 자리를 무한히 돕니다. 중복을 없애려 하지 마세요. 그 중복은 '그 사이에 바뀌었다' 는 사실 그대로입니다.
다음 실행이 어디서부터인지 기억하기
cursor_sync.py 에 --state <파일> 을 붙이세요. 파일이 있으면 그 자리부터 시작하고, 페이지를 받을 때마다 워터마크를 갱신해 저장해야 합니다. 같은 명령을 두 번 돌리면 두 번째는 fetched 가 0 이어야 합니다.
워터마크는 디스크에 있어야 중단돼도 살아남습니다. 저장 시점이 중요합니다 — 받은 행을 처리하기 전에 저장하면 중단 시 그 페이지를 잃고, 처리한 뒤에 저장하면 다시 받습니다. 잃는 것보다 다시 받는 것이 낫습니다. 파일을 덮어쓸 때는 임시 파일에 쓰고 바꿔치기해야 중간에 죽어도 깨지지 않습니다.
같은 시각을 가진 다섯 행
--limit 3 으로 전체를 한 번 훑어 같은 updated_at 을 가진 다섯 행의 한가운데를 페이지가 가르게 만드세요. 그래도 누락도 중복도 없어야 합니다. 결과를 /root/sync/tie_report.json 에 limit·pages·fetched·missing·duplicated·tie_updated_at·tie_ids 로 적으세요.
커서를 시각 하나로만 잡으면 여기서 두 가지 중 하나가 일어납니다. 큰 값만 받으면 같은 시각의 나머지가 통째로 사라지고, 같거나 큰 값을 받으면 이미 읽은 행을 다시 받아 같은 자리를 돕니다. 두 칸을 함께 비교하면 둘 다 일어나지 않습니다. tie 행들은 스냅샷에서 updated_at 으로 묶어 세면 찾을 수 있습니다.
중단된 동기화를 이어받기
--max-pages 2 --limit 10 --out sink.jsonl --state resume.json 으로 중간에 멈춘 뒤, 같은 상태 파일로 다시 돌려 나머지를 받아 /root/sync/sink.jsonl 에 전체 60행이 모이게 하세요.
이어받기가 되는지 보는 방법은 간단합니다. 두 번째 실행이 처음부터 다시 받지 않고 남은 것만 받아 오면 됩니다. 사본 파일은 덧붙여 쓰므로 두 실행의 결과가 한 파일에 쌓입니다. 시작하기 전에 옛 파일을 지워야 셈이 맞습니다.
다 받았는지 세어 본다
/root/sync/reconcile.py 를 만들어 /root/sync/snapshot.json 과 /root/sync/sink.jsonl 을 대조하고 결과를 /root/sync/sync_result.json 에 적으세요. 건수뿐 아니라 같은 id 의 value 까지 맞는지 보아야 합니다.
건수만 맞는 것은 맞는 것이 아닙니다. 한 건이 빠지고 한 건이 중복되면 건수는 그대로입니다. 사본에 같은 id 가 여러 줄 있으면 updated_at 이 가장 큰 것이 지금 값입니다. missing 과 extra 와 값 불일치가 모두 비어 있을 때만 match 를 true 로 하세요.
동기화 점검 보고서
/root/sync/sync_report.md 에 ## 오프셋 순회가 무엇을 빠뜨렸나 ## 커서로 옮긴 뒤 무엇이 달라졌나 ## 같은 시각의 경계 ## 중단과 재개, 그리고 대사 네 절로 적으세요. tie_report.json 과 sync_result.json 의 숫자가 본문에 들어가야 합니다.
읽는 사람은 '매일 다 받아 오는데 왜 몇 건씩 비느냐' 고 묻는 사람입니다. 원인을 '타이밍 문제' 로 뭉뚱그리지 말고, 어떤 행이 왜 지나갔는지를 순서대로 적으세요. 중복이 버그가 아니라는 점도 함께 적어야 다음 사람이 그것을 없애려 들지 않습니다.