무슨 일이 있었나
어제 한 채널의 대화를 처리하는 자동화와 매시간 저장소를 도는 자동화가 같은 이슈를 몇 분 간격으로 각자 집어 들고 수정 작업을 시작했다. 어느 쪽도 상대가 이미 시작했는지 확인하지 않았고, PR이 세 개 열렸다. 하나만 남기고 두 개를 닫았다. 당연해 보이는 해결책은 '시작 전에 열린 PR이나 진행 중인 작업이 있는지 먼저 확인하기'다. 그것만으로 충분했을지 직접 시험해 보았다. 충분하지 않았다.
확인 후 시작은 왜 둘 다 통과시키나
'이미 누가 하고 있는지 확인하고, 아무도 없으면 시작한다'는 두 단계다. 작업 A가 확인하고 시작 표시를 남기기 전에 작업 B가 확인하면, 둘 다 '아무도 없음'을 본다. 스크래치 디렉터리에서 두 작업을 동시에 띄워, 각자 파일이 없는지 확인한 뒤 0.2초 후에 그 파일에 자기 이름을 쓰게 했다. 다섯 번 돌렸는데 다섯 번 모두 파일에 두 줄이 남았다. 둘 다 시작한 것이다. 실제 자동화에서는 확인과 시작 사이가 0.2초가 아니라 작업을 띄우는 데 걸리는 몇 분이다.
확인과 차지를 한 번에 하는 방법
필요한 것은 '없으면 내가 차지한다'를 한 번의 동작으로 하는 것이다. 같은 기계에서 도는 작업이라면 디렉터리를 만드는 것으로 충분하다. mkdir은 이미 있으면 실패한다.
mkdir "claims/issue-$N" 2>/dev/null || exit 0
같은 스크래치 환경에서 두 작업이 동시에 이 줄을 실행하면, 한쪽은 진행하고 다른 쪽은 조용히 끝났다. 이 디렉터리는 작업이 끝나도 남아 있으므로, 작업을 마치면 직접 지워야 한다. 지우지 않으면 그 이슈는 영영 '처리 중'으로 남는다.
실행 중에만 유지되는 잠금
작업이 실행되는 동안만 막으면 되는 경우에는 flock이 편하다.
exec 9>"locks/issue-$N.lock"; flock -n 9 || exit 0
util-linux 2.37.2에서, 작업 A가 잠금을 쥔 채 실행되는 동안 시작한 작업 B는 바로 끝났다. A가 끝난 뒤에 시작한 작업 C는 잠금을 얻었다. 잠금 파일은 남아 있지만 잠금 자체는 프로세스가 끝나면 풀린다. 그래서 작업이 죽어도 남은 잠금을 따로 치울 필요는 없지만, 반대로 '몇 시간짜리 작업이 진행 중'이라는 표시로는 쓸 수 없다.
여러 기계에 걸친 차지
어제 두 자동화는 같은 기계에서 돌았지만, 서로 다른 기계라면 로컬 디렉터리나 잠금은 서로 보이지 않는다. 둘 다 접근하는 git 원격 저장소가 있다면, 그곳의 ref 하나를 차지 표시로 쓸 수 있다.
git commit --allow-empty -m "claim issue $N by $WHO" && git push origin HEAD:refs/claims/issue-$N
두 클론에서 각자 빈 커밋을 만들어 같은 ref로 동시에 push하자 한쪽만 성공했다. 다른 쪽은 ! [rejected] ... (fetch first)로 거부되었고 종료 코드는 1이었다. 두 커밋이 서로 다르니, 나중에 온 push는 빨리 감기가 될 수 없기 때문이다. 단 --force를 붙이면 남의 차지를 그대로 덮어쓴다. 실제로 덮어써졌다. 이 방식을 쓰는 스크립트에는 --force를 넣지 않는다.
차지 확인과 해제
이런 ref는 기본 fetch 대상이 아니라서 git fetch 뒤에도 로컬에 보이지 않았다. 원격에 직접 물어본다.
git ls-remote origin 'refs/claims/*'
git push origin :refs/claims/issue-$N
작업이 끝나면 마지막 줄로 차지를 지운다. mkdir 방식과 마찬가지로, 작업이 도중에 죽으면 차지가 남는다. 차지 커밋 메시지에 누가 언제 가져갔는지 적어 두면, 오래된 차지를 사람이 판단해서 지울 수 있다.
정리
먼저 확인하는 단계는 중복을 줄여 주지만 막아 주지는 않는다. 두 작업이 같은 일을 시작할 수 있는 구조라면, 확인과 차지를 한 번에 하는 동작을 시작 조건으로 둔다. 그리고 차지가 작업 실행 중에만 필요한지, 작업이 끝날 때까지 남아야 하는지에 따라 잠금과 남는 표시 중 하나를 고른다.
What happened
Yesterday, an automation that handles one channel's conversations and an automation that sweeps the repository every hour picked up the same issue minutes apart, and each started fix work. Neither checked whether the other had already started, and three pull requests were opened. We kept one and closed the other two. The obvious fix is "check for an open pull request or running work before starting". I tested whether that alone would have been enough. It would not.
Why check-then-start lets both through
"Check whether someone is already on it, and start if nobody is" is two steps. If job B checks after job A has checked but before A has left any sign that it started, both see "nobody". In a scratch directory I launched two jobs at once; each checked that a file did not exist and, 0.2 seconds later, wrote its name into that file. I ran it five times, and every time the file ended up with two lines. Both had started. In a real automation, the gap between the check and the start is not 0.2 seconds but the minutes it takes to launch the work.
Check and claim in one step
What you need is "if it is free, it is mine" as a single operation. For jobs on the same machine, creating a directory is enough, because mkdir fails if it already exists.
mkdir "claims/issue-$N" 2>/dev/null || exit 0
With two jobs running this line at the same moment in the same scratch setup, one went ahead and the other quietly exited. The directory stays after the job ends, so remove it yourself when the work is done. Otherwise the issue stays "in progress" forever.
A lock that lasts only while the job runs
If you only need to block others while the job is running, flock is convenient.
exec 9>"locks/issue-$N.lock"; flock -n 9 || exit 0
With util-linux 2.37.2, job B, started while job A held the lock, exited at once. Job C, started after A had finished, got the lock. The lock file remains, but the lock itself is released when the process exits. So you never have to clean up after a job that died, but for the same reason it cannot mark "a multi-hour task is in progress".
Claims across machines
Yesterday's two automations ran on the same machine, but on different machines a local directory or lock is invisible to the other side. If both can reach a git remote, a ref there can serve as the claim.
git commit --allow-empty -m "claim issue $N by $WHO" && git push origin HEAD:refs/claims/issue-$N
Two clones each made an empty commit and pushed it to the same ref at the same time, and only one succeeded. The other was refused with ! [rejected] ... (fetch first) and exit code 1: the two commits differ, so the later push cannot be a fast-forward. Adding --force, however, simply overwrites someone else's claim, and it did. Do not put --force in scripts that use this.
Seeing and releasing claims
These refs are not part of the default fetch, so even after git fetch they did not show up locally. Ask the remote directly.
git ls-remote origin 'refs/claims/*'
git push origin :refs/claims/issue-$N
Remove the claim with the last line when the work is done. As with mkdir, a job that dies halfway leaves its claim behind. If the claim's commit message says who took it and when, a person can judge and remove a stale one.
Summary
A check-first step reduces duplicates but does not prevent them. Where two jobs can start the same work, make an operation that checks and claims in one step the condition for starting. Then choose between a lock and a lasting mark depending on whether the claim is needed only while the job runs or until the work is finished.
发生了什么
昨天,一个负责处理某频道对话的自动化任务,和一个每小时巡检仓库的自动化任务,相隔几分钟先后接手了同一个问题,各自开始了修复工作。双方都没有检查对方是否已经开工,结果开了三个拉取请求。我们留下一个,关掉了另外两个。看上去理所当然的修法是“开工前先查一下有没有打开的拉取请求或正在进行的工作”。我测试了光靠这一步够不够。不够。
为什么先检查后开工会让两个都通过
“先看有没有人在做,没有就开工”是两步。如果任务 A 检查完、还没留下任何开工标记时,任务 B 也做了检查,两个看到的都是“没人”。我在一个临时目录里同时启动两个任务,每个先确认某个文件不存在,0.2 秒后再把自己的名字写进这个文件。跑了五次,每次文件里都是两行,两个都开工了。在真实的自动化里,检查和开工之间隔的不是 0.2 秒,而是启动工作所需的好几分钟。
一步完成检查和占位
需要的是把“空着就归我”做成一个操作。对于同一台机器上的任务,创建一个目录就够了,因为 mkdir 在目录已存在时会失败。
mkdir "claims/issue-$N" 2>/dev/null || exit 0
在同一个临时环境里让两个任务同时执行这一行,一个继续往下走,另一个安静地退出了。这个目录在任务结束后仍然存在,所以工作完成后要自己删掉,否则这个问题会永远显示为“处理中”。
只在任务运行期间有效的锁
如果只需要在任务运行期间挡住别人,用 flock 很方便。
exec 9>"locks/issue-$N.lock"; flock -n 9 || exit 0
在 util-linux 2.37.2 上,在任务 A 持有锁期间启动的任务 B 立即退出了;A 结束之后启动的任务 C 拿到了锁。锁文件会留下,但锁本身在进程退出时就释放了。所以任务崩溃后不需要清理残留的锁,但也正因如此,它不能用来表示“一个要跑好几个小时的任务正在进行”。
跨机器的占位
昨天的两个自动化任务跑在同一台机器上,但如果在不同机器上,本地目录和锁对另一边是不可见的。如果双方都能访问同一个 git 远程仓库,就可以用那里的一个 ref 作为占位标记。
git commit --allow-empty -m "claim issue $N by $WHO" && git push origin HEAD:refs/claims/issue-$N
两个克隆各自创建一个空提交,同时推送到同一个 ref,只有一个成功。另一个被 ! [rejected] ... (fetch first) 拒绝,退出码为 1:两个提交不同,后到的推送不可能是快进。不过加上 --force 就会直接覆盖别人的占位,实际也确实被覆盖了。用这种方式的脚本里不要写 --force。
查看和释放占位
这些 ref 不在默认的 fetch 范围里,所以即使执行了 git fetch,本地也看不到它们。要直接问远程仓库。
git ls-remote origin 'refs/claims/*'
git push origin :refs/claims/issue-$N
工作完成后用最后一行删除占位。和 mkdir 方式一样,任务中途崩溃会留下占位。如果在占位提交的说明里写上是谁、在什么时候占的,人就可以判断并删除过期的占位。
总结
先检查的步骤能减少重复,但挡不住重复。只要存在两个任务可能开始同一件工作的结构,就把“一步完成检查和占位”的操作作为开工条件。然后根据占位只需要在任务运行期间有效,还是要一直保留到工作完成,在锁和持久标记之间选一个。
何が起きたか
昨日、あるチャンネルの会話を処理する自動化と、毎時リポジトリを巡回する自動化が、数分違いで同じ課題を拾い、それぞれ修正作業を始めた。どちらも相手がすでに始めていないかを確かめず、プルリクエストが三つ開かれた。一つを残し、二つを閉じた。当然に見える対策は「始める前に、開いているプルリクエストや進行中の作業がないか確かめる」ことだ。それだけで足りたのかを試してみた。足りなかった。
確認してから始めると、なぜ両方通るのか
「誰かがもうやっているか確かめ、誰もいなければ始める」は二つの手順だ。ジョブ A が確認を終え、まだ開始の印を残していないうちにジョブ B が確認すると、どちらも「誰もいない」と見る。スクラッチのディレクトリで二つのジョブを同時に動かし、それぞれがあるファイルが存在しないことを確かめてから、0.2 秒後にそのファイルへ自分の名前を書くようにした。五回動かして、五回ともファイルには二行が残った。両方が始めたということだ。実際の自動化では、確認と開始のあいだは 0.2 秒ではなく、作業を立ち上げるのにかかる数分になる。
確認と確保を一度に行う
必要なのは「空いていれば自分のものにする」を一回の操作で行うことだ。同じマシンで動くジョブなら、ディレクトリを作るだけで足りる。mkdir はすでにあれば失敗する。
mkdir "claims/issue-$N" 2>/dev/null || exit 0
同じスクラッチ環境で二つのジョブがこの行を同時に実行すると、一方は先へ進み、もう一方は静かに終了した。このディレクトリはジョブが終わっても残るので、作業が終わったら自分で消す。消さなければ、その課題はいつまでも「対応中」のままになる。
実行中だけ有効なロック
ジョブが動いているあいだだけほかを止めればよいなら、flock が便利だ。
exec 9>"locks/issue-$N.lock"; flock -n 9 || exit 0
util-linux 2.37.2 で、ジョブ A がロックを持って動いているあいだに始めたジョブ B はすぐに終了した。A が終わったあとに始めたジョブ C はロックを取れた。ロックファイルは残るが、ロックそのものはプロセスが終わると解放される。だからジョブが落ちても残ったロックを片付ける必要はないが、同じ理由で「何時間もかかる作業が進行中」という印には使えない。
複数のマシンにまたがる確保
昨日の二つの自動化は同じマシンで動いていたが、別々のマシンなら、ローカルのディレクトリやロックは相手から見えない。両方がアクセスできる git のリモートがあれば、そこの ref を一つ確保の印に使える。
git commit --allow-empty -m "claim issue $N by $WHO" && git push origin HEAD:refs/claims/issue-$N
二つのクローンでそれぞれ空のコミットを作り、同じ ref へ同時に push すると、一方だけが成功した。もう一方は ! [rejected] ... (fetch first) で拒否され、終了コードは 1 だった。二つのコミットは違うので、あとから来た push は早送りになれないからだ。ただし --force を付けると、他人の確保をそのまま上書きする。実際に上書きされた。この方法を使うスクリプトには --force を入れない。
確保の確認と解放
こうした ref は既定の fetch の対象ではないため、git fetch のあとでもローカルには現れなかった。リモートに直接たずねる。
git ls-remote origin 'refs/claims/*'
git push origin :refs/claims/issue-$N
作業が終わったら最後の行で確保を消す。mkdir の方法と同じく、ジョブが途中で落ちると確保が残る。確保のコミットメッセージに誰がいつ取ったかを書いておけば、古い確保を人が判断して消せる。
まとめ
先に確認する手順は重複を減らすが、防ぎはしない。二つのジョブが同じ作業を始めうる構造なら、確認と確保を一度に行う操作を開始の条件にする。そして、確保が必要なのがジョブの実行中だけか、作業が終わるまで残す必要があるかによって、ロックと残る印のどちらかを選ぶ。