非同期の罠
最近こんなユニットテストを書いた。
◯spec/models/kommit_spec.rb
describe Kommit do it "return git message" do @user = create(:user) create_repository_for(@user) @repository.master.posts.create({title:"hoge",body:"fuga"}) @kommit = build(:kommit, message:"test") @kommit.branches << @repository.master @kommit.save @kommit.git_message.should == @kommit.message end end
少し説明すると、@repositoryや@branch,@kommitなどはすべて実際のGitレポジトリに対応していて、例えばBranchモデルを作成すると実際にGitレポジトリ上でブランチが作成される、というような対応関係を持っている。
で、ここでテストしたいことというのは、@kommit.messageはフォームから入力してもらった値だけど、@kommit.saveの段階でファイルシステムにも"git commit -m message"的なことをしている。で、@kommit.git_messageは@kommitに対応するGitのコミットのメッセージを取得するメソッド。
だから、このテストが通らないのは要するにデータベースとファイルシステムがうまく対応せずに作成されているという致命的なFailである。
ただ、これの面白いのはこのテストは実行するたびに結果が変わったのである。まぁ、10回に8回は失敗するけど2回ぐらい通っちゃう。で、何度か編集しながらコードを書きなおして、テストが通ったと思ってコミットして、そのあとで実行するとまだfailする。「ブラックジャック」に「人面瘡」という話があって、ブラックジャックが水ぶくれの患者の顔を直して包帯を巻いても、1か月後にとってみるとやっぱりその顔に戻っている。まるでそんな気分だった。
で、実はこの経験は前もある。それはNodeJS。NodeJSでも起動するたびに結果が変わるコードを書いてしまった経験があったので、たぶんこのfailも非同期が原因じゃないかと思ったら案の定そうだった。
さて、このkommitインスタンスが作成されると次のオブザーバーが呼び出される。
◯app/models/kommit_observer.rb
class KommitObserver < ActiveRecord::Observer def before_create(kommit) set_revision(kommit) unless kommit.bare end private def set_revision(kommit) repo = kommit.repository.repo repo.commit_index(kommit.message)・・・・(1) kommit.revision = repo.commits.first.id・・・・(2) end end
で、このアプリケーションでは実際のGitレポジトリ操作にはGritというgemを使っていて、このrepoやcommit_indexはGritが提供するオブジェクトとメソッドである。
いろいろ調べる内にどうやら(1)の処理が終わる前に(2)に進んでいるようだった。
そこでこのGritのソースを少し読んでいくと。。。
◯lib/grit/git.rb(超訳)
module Grit class Git include POSIX::Spawn def native(cmd) run(cmd) end def run(cmd) call = "#{prefix}#{....エスケープ処理など}#{cmd}" sh(call) end def sh(command) process = Child.new(...) end end end
まぁ、だいぶ端折ったというか、最低限の流れが追える以外はかなり削ったんだけど、ざっくりこんな感じ。コマンドを走らせる時にChildというオブジェクトを作っています。
で、Childなんだけど調べたらどうやらPOSIX::Spawnというライブラリのクラスなんですよね。
https://github.com/rtomayko/posix-spawn
はい、まぁNon-Blockingとか言ってますよね...
そもそもRubyにも組み込みでspawnというのがあって、これも子プロセスの終了を待たないタイプのsystem関数だそうです。
。。。。。
ちょっと僕の理解があってるかどうかわかんないけど、要するにこのspawnのようなものを駆使すればRubyでもノンブロッキングIOは当然実装できて、NodeJSで言ってるようなIO待ちを軽減させることはできて、その実装モデルがマルチプロセスがシングルスレッドのイベントループかっていう話ですよね・・・?? このへんちゃんと勉強しなきゃ。。。
で、ここで疑問なんですけどこういうコードに対して、次のどれを選択すべきなんでしょうか・・・?
1. 同期するようにsystemで書きなおす
2. 処理自体は非同期にまかせて、その結果作成されるファイルなどを前提とするようなコードはsleep(1) until(File.exists?("hoge")) のように待つ
なんとなく2がいいよって誰か言ってきそうな気はするけど、結局Gitのコミットが存在するかを同期で確認するにはsystemとか書かなきゃいけないんじゃ。。