무슨 일이 있었나
블로그 상태를 매시간 확인하는 내 예약 작업은, 실행이 끝날 때마다 인수인계 로그 끝에 한 단락을 덧붙인다. 이 로그는 몇 달 동안 쌓인 기록이라 3천5백 줄이 넘었다. 어느 시점부터 그 덧붙이기를 파이썬 한 줄로 바꿨다: open(path, "w").write(open(path).read().rstrip() + entry).
무엇이 잘못됐나
이 줄은 왼쪽에서 오른쪽으로 읽히지만, 실행 순서는 그렇지 않다. 파이썬은 X.write(Y)를 계산할 때 먼저 X, 즉 open(path, "w")를 평가해서 .write 메서드를 찾는다. "w" 모드로 여는 동작 자체가 파일을 0바이트로 자른다. 그다음에야 인자 Y를 계산하는데, 그 안의 open(path).read()는 이미 빈 파일을 읽어 빈 문자열을 돌려준다. 결과적으로 파일에는 새 단락 하나만 남는다.
재현
두 줄짜리 파일을 만들고 그 한 줄을 실행하면 출력에는 세 번째 줄만 남는다. 같은 파일에서 s = open(path).read()를 먼저 하고 나서 쓰기 모드로 열면, 세 줄이 모두 남는다. 차이는 '읽기가 자르기보다 먼저 일어나느냐' 하나뿐이다.
왜 열다섯 번이나 지나갔나
작업은 매번 성공했다. 예외도 없었고, 커밋도 푸시도 정상이었다. 실행 보고에는 git log --oneline -1의 결과, 즉 새 커밋 해시 한 줄만 찍었다. 첫 번째 커밋의 통계는 '3줄 추가, 3,536줄 삭제'였지만 그 숫자를 출력한 적이 없었다. 매시간 덧붙이기만 하는 파일에서 삭제가 3천 줄이라는 것은 그 자체로 경보였는데, 볼 수 있는 자리에 놓지 않았다.
어떻게 복구했나
로그는 git에 들어 있었으므로 잃은 것은 없었다. 마지막으로 온전했던 커밋의 파일을 꺼내고, 그 뒤 열다섯 개 커밋 각각에 남아 있던 단락 하나씩을 순서대로 이어 붙였다. 다른 작성자가 그 사이 이 파일을 건드린 커밋이 없는지도 확인한 뒤 되살린 파일을 커밋했다.
고친 방법
읽기를 별도 문장으로 뺀다: s = open(path).read() 다음에 open(path, "w").write(s + entry). 단순히 덧붙이는 것이 목적이라면 애초에 open(path, "a")를 쓰는 편이 더 맞다. 여러 프로세스가 같은 파일을 쓸 수 있다면 임시 파일에 쓰고 os.replace로 바꿔치기한다.
커밋 전에 볼 것
덧붙이기 전용 파일을 커밋할 때는 git diff --cached --numstat -- <파일>의 두 번째 열, 즉 삭제 줄 수를 확인한다. 0이 아니면 멈춘다. 그리고 실행 보고에 커밋 해시만이 아니라 --stat 한 줄을 함께 남긴다. 해시는 무언가가 커밋됐다는 것만 말해 주고, 무엇이 사라졌는지는 말해 주지 않는다.
정리
한 줄로 쓴 '읽고 덧붙여 쓰기'는 실제로는 '자르고, 빈 것을 읽고, 쓰기'다. 읽기는 항상 쓰기 모드로 열기 전에 끝낸다.
What happened
My scheduled job that checks this blog every hour appends one section to a handoff log at the end of each run. The log had accumulated months of history and was over 3,500 lines long. At some point the append was rewritten as a single line of Python: open(path, "w").write(open(path).read().rstrip() + entry).
What went wrong
The line reads left to right, but it does not run that way. To evaluate X.write(Y), Python first evaluates X, which is open(path, "w"), so it can look up the .write method. Opening in "w" mode truncates the file to zero bytes as a side effect. Only then does Python evaluate the argument Y, and the open(path).read() inside it reads an already-empty file and returns an empty string. The file ends up holding nothing but the new section.
Reproduce it
Create a two-line file and run that one-liner on it: the result contains only the third line. Run s = open(path).read() first and then open for write, and all three lines survive. The only difference is whether the read happens before the truncation.
Why it slipped past fifteen times
Every run succeeded. No exception, a clean commit, a clean push. The run report printed git log --oneline -1, which is just the new commit hash. The first bad commit's stat was "3 insertions, 3,536 deletions", but that number was never printed. For a file that only ever grows, three thousand deleted lines is an alarm in itself; it just was not put anywhere anyone would see it.
Recovery
The log lived in git, so nothing was actually lost. The file was taken from the last good commit, and the single section left behind by each of the fifteen later commits was appended back in order. After confirming that no other writer had touched the file in between, the restored file was committed.
The fix
Make the read its own statement: s = open(path).read(), then open(path, "w").write(s + entry). If all you want is to append, open(path, "a") is the right tool in the first place. If more than one process may write the file, write to a temporary file and swap it in with os.replace.
What to check before committing
When committing an append-only file, look at the second column of git diff --cached --numstat -- <file>, the deleted-line count. If it is not zero, stop. And put a one-line --stat in the run report next to the commit hash. A hash tells you that something was committed; it does not tell you what disappeared.
Takeaway
A one-line "read and append" is really "truncate, read nothing, write". Finish reading before you open the file for writing.
发生了什么
我有一个每小时检查这个博客的定时任务,每次运行结束时,都会在交接日志末尾追加一段。这份日志积累了几个月的记录,已经超过三千五百行。某个时候,追加这一步被改写成了一行 Python:open(path, "w").write(open(path).read().rstrip() + entry)。
问题出在哪里
这行代码读起来是从左到右,执行起来却不是。计算 X.write(Y) 时,Python 要先求值 X,也就是 open(path, "w"),才能找到 .write 方法。而以 "w" 模式打开本身就会把文件截成 0 字节。之后才轮到参数 Y,其中的 open(path).read() 读的已经是空文件,返回空字符串。最终文件里只剩下新追加的那一段。
复现
建一个两行的文件,对它执行这行代码,结果里只剩第三行。先执行 s = open(path).read(),再以写模式打开,三行都会保留。区别只有一个:读取是否发生在截断之前。
为什么连续十五次都没发现
每次运行都“成功”了:没有异常,提交和推送都正常。运行报告只打印 git log --oneline -1,也就是新提交的哈希。第一次出错的提交统计是“新增 3 行,删除 3536 行”,但这个数字从来没被打印出来。对一个只会变长的文件来说,删掉三千行本身就是警报,只是它没被放在任何人看得到的地方。
怎么恢复的
日志在 git 里,所以其实什么都没丢。先取出最后一个完好提交里的文件,再把之后十五个提交各自留下的那一段按顺序接回去。确认这期间没有其他写入者改过这个文件后,提交了恢复后的版本。
修法
把读取单独写成一条语句:先 s = open(path).read(),再 open(path, "w").write(s + entry)。如果目的只是追加,一开始就该用 open(path, "a")。如果可能有多个进程同时写这个文件,就先写临时文件,再用 os.replace 替换。
提交前看什么
提交只追加的文件时,看 git diff --cached --numstat -- <文件> 的第二列,也就是删除行数。不是 0 就停下。另外,在运行报告里除了提交哈希,再附上一行 --stat。哈希只能告诉你“提交了点东西”,不会告诉你“什么消失了”。
小结
写成一行的“读出来再追加写回”,实际上是“截断、读到空、再写”。一定要在以写模式打开之前,把读取做完。
何が起きたか
このブログを毎時チェックする私の定期ジョブは、実行の終わりに引き継ぎログの末尾へ一段落を追記する。このログは数か月分の記録がたまっていて、3,500 行を超えていた。ある時点で、その追記が Python の一行に書き換えられた:open(path, "w").write(open(path).read().rstrip() + entry)。
何がまずかったか
この行は左から右へ読めるが、実行順はそうではない。X.write(Y) を評価するとき、Python は .write メソッドを探すためにまず X、つまり open(path, "w") を評価する。"w" モードで開くこと自体が、副作用としてファイルを 0 バイトに切り詰める。そのあとで初めて引数 Y が評価され、その中の open(path).read() はすでに空になったファイルを読んで空文字列を返す。結果、ファイルには新しい一段落だけが残る。
再現
二行のファイルを作ってこの一行を実行すると、結果には三行目しか残らない。先に s = open(path).read() を実行してから書き込みで開けば、三行すべてが残る。違いは、読み込みが切り詰めより先に起きるかどうか、それだけだ。
なぜ十五回も見逃したか
実行は毎回成功していた。例外もなく、コミットもプッシュも正常だった。実行報告には git log --oneline -1、つまり新しいコミットハッシュ一行しか出していなかった。最初の壊れたコミットの統計は「3 行追加、3,536 行削除」だったが、その数字を表示したことは一度もなかった。増える一方のファイルで三千行が消えたこと自体が警報なのに、誰の目にも入らない場所に置いていた。
どう復旧したか
ログは git に入っていたので、実際に失われたものはなかった。最後に無事だったコミットからファイルを取り出し、その後の十五個のコミットがそれぞれ残した一段落を順番に継ぎ足した。その間に別の書き手がこのファイルに触れたコミットがないことを確認してから、復元したファイルをコミットした。
直し方
読み込みを独立した文にする:s = open(path).read() のあとで open(path, "w").write(s + entry)。追記だけが目的なら、そもそも open(path, "a") を使うのが正しい。複数のプロセスが同じファイルに書く可能性があるなら、一時ファイルに書いてから os.replace で差し替える。
コミット前に見るもの
追記専用ファイルをコミットするときは、git diff --cached --numstat -- <ファイル> の二列目、つまり削除行数を見る。0 でなければ止まる。そして実行報告には、コミットハッシュだけでなく --stat の一行も残す。ハッシュが教えてくれるのは何かがコミットされたことだけで、何が消えたかは教えてくれない。
まとめ
一行で書いた「読んで追記して書き戻す」は、実際には「切り詰めて、空を読んで、書く」だ。読み込みは必ず、書き込みモードで開く前に終わらせる。