Rails 5.2.4 で autosave: true な関連が invalid なときに元レコードの valid? の振る舞いが変わっている件

rack の脆弱性対応があり1、それに対応した Rails を使おうとすると 5.2.4.1 を使う必要がある。のでアプデを試みた。5.2.3 -> 5.2.4.1

マイナーバージョンだし、大丈夫やろw と思いながらとりあえずCIを回してみたところテストが落ちた。はい。

原因を気合で確認したところ、この変更によるものであることがわかった。

github.com

この変更自体について説明するとこんな感じ。例えば、既存レコードについて何らかの理由で Foo.find(1).valid? すると false を返すようなレコードがあるとする。後からバリデーションが追加されたんでも update 文を手でかいたらぶっ壊れたんでもよいけど、とりあえずそういう invalid なレコードがあるとする2。そのときに、それへの autosave: true な関連をもつモデルについてバリデーションの結果が不正なレコードがロード済みであるかによって結果が変わるという話。ロード済みならつられて invalid 扱いになるし、未ロードの場合は valid 扱いになる。これが 5.2.3 までの挙動。この incosistency がいやなので、変更がなさそうなときはバリデーションかけるのやめようや、という変更が 5.2.4 に bug fix としてはいった。その結果、ロード済でも変更してないと valid になってしまうように振る舞いが変わっている。

元 issue の再現コードをちょっと手直ししたのをここにおいておく。

https://github.com/rails/rails/pull/36671 · GitHub

たしかに 5.2.3 -> 5.2.4 でテスト結果が変わっていることが確認できる。しかし実は他のバージョンも試すと(ログも上記の gist に含まれている) 5.2.3 だけではなく 5.1.7, 5.0.7.2, 4.2.11.1 でも落ちていることがわかる。つまり、かなり長い間そうであった挙動がマイナーバージョン変更時に変わったということになる気がしますね。うーん、ちょっと困った。しかも bug fix 扱いなので CHANGELOG にのってないし。

とりあえずはテストのコード変更で対応予定です。


2019-12-20 14:45 追記 && タイトル変えた

github.com

さすがに 4.2 からのコードが動かないのツラいっしょっていう話になったので issue 報告してみました。

github.com

これは似てるけどたぶん元PRの後追い fix のほうの条件でひっかかってるやつだと思う。

regression というタグはついたけどコード変更したほうがいいのかなー、と迷い中。

2019-12-20 15:18

36671 番の変更の内容をもうちょい詳しく書いてる。


  1. https://github.com/advisories/GHSA-hrqr-hxpp-chr3

  2. 再現コードでは save!(validate: false) を使っている。

平成Ruby会議01 に参加してきました #heiseirubykaigi

めちゃくちゃ楽しかったです。

トーク感想

What is expected?

かねこさんのキーノート。パーザーの話。トーク前に expectってかいときゃ rspec だと思われるでしょ、などと邪悪な発言をしており治安が悪いなと思いました。

僕はスライドのチェックを依頼されたので事前に資料だけは見ていたのですが、なかなか骨太な内容でしたね。state の遷移図が結構くせもので、あれがあると state 自体が変化していくように思っちゃうのですが、実際には state の stack になっているのがミソです。あの図がないと遷移のイメージがわかない一方で、あれがあると誤解しやすいという。

まあたぶん誰も質問しない(できない)だろうと思って質問したいなーと思っていたのですが、なにせスライドを事前に読んでわからないところを聞いてるから質問が思い浮かばない。これはマズいと思っていたところ、資料チェック時には読み飛ばしていた(すみません)実装の話で、default reduce を使わないという方針はないのかという質問をしました。

そもそも、default reduce を使うというのは action table のサイズが小さくなるのが嬉しい一方で、エラー発生が遅延する(とりあえず reduce してから行きづまるため)ことと stack への依存が強くなることが一般的な問題点として挙げられていました。action table のサイズは、ruby バイナリ自体のサイズ及び実行時のメモリフットプリントに影響する気がします。まあ昨今のストレージおよびメモリは潤沢にあるとされているので、ある程度の増加なら特に問題ないような気もします1。というわけで一般的な問題は解決できるものとすると、問題は parse.y が default reduce 中に LEX_STATE を書き換えているところだけになるので、そっちの選択肢はないのかなあ、というのが質問の背景でした。まあ結論からするとほぼ全面書き直しになるのでは?という話で無理だったわけですが。

そういえば compress するときに default 挙動をまとめたからって、最終的に本当にエラーになるかってあんまり自明じゃなくないですか?どうなってるんでしょうね。行き詰まらない場合には default 挙動ではなくちゃんとエラーにしてるのかな。

Ruby2.7以降のiseqのバイナリ表現の改善について、Rubyアソシエーション開発助成金2019採択プロジェクトの途中経過について

ISeq のバイナリ表現を改善したよっていう話。たぶん最初はバイトサイズも小さく、読み込むまでの時間もはやくなるようにしようとしたんだけど、実行時間まではそんなに早くならなかったのではないかと邪推している。

資料的には具体例が3つあったけど話せたのは2つまで。不要な情報の削除、数値表現の圧縮。圧縮のほうは msgpack か?というツッコミがあったけど utf8 とかを参考にしたと言ってたみたい。戦略的には短く表現できるものをより短く表現するために余分な情報を先に付与することで、長くなるのを防ぐって感じっぽい。

バイナリサイズが小さくなると何が嬉しいのか、というと開発環境的にはまあ当然嬉しいものとして、Railsアプリを本番で動かすシーンで嬉しいのか、という話はある。立ち上げ時の時間を気にするようなサービスがあったときに Docker イメージ内に bootsnap cache もいれておく、みたいなことすることあるんですかね?あるならそこの転送量が減ってうれしそう。bootsnap を真面目につかったことないのでこのへんどうなのかなーと気になるところ。

SimpleDelegator活用のご提案

draper とかの decorator ってこれ由来なんだろうなーと思わせるなにか。局所的に拡張したいみたいなことはあるので、ありな気がする。例えば特定のコントローラー内の inner class としてしまうとかね。似たようなのフルスクで書いてたけど SimpleDelegator 使えばよかったな。

Ruby on Jeeeeeeeeets!!✨🚀✨

ジェット機Ruby が組み込まれてるんですか!?っておもったら違った(思ってません)。サーバーレスフレームワーク jets の紹介。サーバーレスなんもわかんねーって思いながら話を聞いてました。アクション単位でラムダ分割するのは面白いなと思った。既存 Rails アプリを分割するの、絶対ムリだろ感があるのにチャレンジしてるのは胸がアツい。

アセット関連のところで「アイツですよアイツ」って言われて Sprockets かなって思ったら Webpacker で、そうだねって思いました。

休憩

疲れたんで広いスペースでぼーっと

既存RailsApplicationの高速化

Rails アプリを高速化するようなやつつくりてー、って話(つくれたとはいってない)。

マジで実行して評価する系のアプローチは、評価するパスを通す必要があるのでめんどいってのと、本人も言っていたようにそのメソッドの返り値をうんたらかんたら、みたいなことをすると解析のスコープがでかくなってめんどい(実質無理なのでは?)という問題があります。コード解析はそういう意味で返り値がどう使われているのかなどはレキシカルなスコープでは非常にわかりやすいので今回のケースだとそちらのほうがよさそうに見えました。

一応評価するときのアプローチでも、例えば count で Integer を返すのではなく IntegerWrapper を返して #zero? を呼ばれたら警告を出すみたいな、ほぼ Integer と同じなんだけどときどき警告を出すみたいなナニカを返す方法はありそうです。質問でいうにはデカい話になるので省略しましたが。これは後に pocke さんと話したときに「それだと if を騙せない」という話になり学びがありました。Ruby の if は nil と false をチェックするCの実装があり( RB_TEST でしたっけ)それを騙すことが Ruby のレベルでは(たぶん)できません。たぶんこれを騙すには maccro とか使ってやる必要がありそう。 ifif _if とかに書き換えるやつです。あと if じゃなくてパターンマッチなら deconstruct とかで挙動を上書きできるから悪いことできそう。

あとまあ個人的な感覚では、世にある Rails アプリケーションは DB と非常に密接に関わっているので DB レイヤへの言及なしに速度の話をするのはあまり本質的でないのでは、と思っています。

RubyJVMを実装してみる

JVM の話。step by step で進んでいきよかった。JVM実装流行してますね。

time ではかったときに system が増えているのは IO#read のせいではないんじゃないかなーと思っています。IO#read は buffered なはずなのでカーネルレベルでのオーバーヘッドがかかるわけではない、と思う。単純に ruby バイナリを起動してからスクリプトを読むような時間が必要なのでそことかじゃないですかね。勘ですが。まあそもそも ruby バイナリの起動にどんくらいかかってるのかも知らないけど。

System.out.println の実装は結局どうしたのかが気になってます。中で Rubyputs してるのか、そのへんのライブラリも読めるのか、読む必要がないのかなど。懇親会で聞こうかなと思ってたんだけど行けなかったので残念。

Good to know YAML

この問題設定みたことあるーって思いながら聞いてました。妥当なアプローチがすすんでおりよさそうでした。

@enable_validator 的なのがあるのはたぶん今後の展望に関係してるんだろうけど今のところなんで存在してるのか謎だね、という感じがあり、そのへんを質問したかった。時間がなかったので終わった後お話させてもらいました。

rustで拡張モジュールを作成してgemにする

おもしろかった。コンパイルどうすんのかな、fat gem なのかなと思っていたらthermite なんてあるんですね。GCCとかと違って普通にあるわけじゃないから大変そう。

今回は Symbol#start_with? の実装の話だったんだけど、これオブジェクトのアロケーションとかをするとなんか大変そうだなーと思った。cruby からもらったメモリはまあ触らなければよくて、Rustでアロケーションしたやつをどうするのかとかが気になる。でもC拡張でやってるのと同じようにすればなんとかなりそうな気もする。

階層的クラスタリングRubyで表現する

階層的クラスタリングってなんや〜と思って聞いてた。解析してみても特に面白い結果がでないというのはまあよくある話で(俺もやったことある)つらいですね。前処理のレベルでどうなるのかわからんけど難しそうだった。自然言語処理なにもわからない……。

Play with Ruby

parse.y を操作するライブコーディング。アツい。ujm さんのトーク を思い出しますね。右代入の実装でした。

最初コンフリクトしないような気がしていたけど p { a => b} が Hash を引数にとるのか、右代入のブロック引数なのかで解釈がわかれますね。あのコードでどっちが勝つのかは謎。

openssl のエラーは configure でフラグ渡せば解決します。あるいは miniruby をビルドするだけでも動きそうなのでそこで止めてもよさそう。具体的にはこうしておくとよい、というのを昔教えてもらったのでおいておきます。brew --prefix とか使ったほうがたぶんきれい。

gist.github.com

休憩

夜の街を散歩してました

LT

knu さんの。自身の経験から若者を導く感じがあってよかった。

tadsan の。まさにLTっぽい勢いでよかった。

すみません忘れちゃった&HPにないからよくわからない。成果発表って感じだった。俺もなんかやらんとなーという気持ちになりました……。

Breaking Change

koic さんのキーノート。これも自分がやっていることをもとに話しており、koic さんにしかできない話でしたね。内容的にも非常に教育的で僕も聞いててうなずくシーンが多かったです。某 issue はほんとになんにもよくなくてワロタ

高機能書き換えツールとしての rubocop ってのは考えたことなかったけどいい手法だなと思いました。俺もちょっと素振りしとこ。

運営とか

あと運営の人宛の思ったことをちょっと書いときます。

  • 2トラックやるときにスマホで2トラックがひと目で見れないと判断に迷うので、そういうデザインになってると助かりました
  • トーク内容の説明がないと判断ができないので詳細ページがあるとよかったなと思いました

全体的に非常に楽しかったです。骨太なトークが多く満足感がありました。
スピーカー、スタッフのみなさんありがとうございました。


  1. 雑試算したら30MBとかあったのでちょっとウッとなりましたが

読んだ作品の備忘録

酒を飲んでいたらシャーマンキング展の話になり、誘われたのでいくかーと思ったのですが。 飲み会のメンツ6人のうち3人しかマンキンに興味がなさそうだった。 僕は当時かなり好きで(と言っても読み始めたのが2002年くらいなのでそこそこ後ろのほうだが)思い入れがあるので、読んでないのが結構意外だった。参加者は年齢層的には僕と同じく30前後、そんなに離れているわけではない。

そこでじゃあシャーマンキングの面白さでも話すか、と思ったときに、あんまり言えない自分に気がついた。あんなに好きだったのに。中華斬舞とか叫んで傘を振り回してたのに……。

忘れてしまったシャーマンキングへの愛は再読で取り返すしかない。それは確定事項なのでシャーマンキング展までに読み返すつもりではあるのだけど、今好きなものもいつか忘れてしまうのかもしれない、と思ったら書き残したくなった。というわけで以下は漫画とかの話が続きます。

昔もこんな感じの記事を書いたなと思ったら2016年なのでかなり昔だった。

hkdnet.hatenablog.com

青春のアフター

上のエントリでも書いてあるのですが、その後ドハマリし、某ラジオでも宣伝し、4巻に関しては個別のエントリを書いたくらいに刺さっているのですが。 やっぱりある種の人の心にどちゃくそ刺さるんだろうなという確信があるので、話していてこの人には刺さりそうって思ったら今でも躊躇せずに(というか嬉々として)勧めています。 前もいったけどだいたい私立男子校出身の人には刺さりそう(偏見)。あとあらすじをはなすと「きみのぞっぽい」と言われることが多いのでこれで通じる人には刺さるんじゃないでしょうか。

そろそろ再読したいと思いつつ、読むとダメージを受けることが明らかなので再読する元気がないという状態です。

五等分の花嫁

あーはいはい感のあるチョイスですし、僕も読む前は「はいはいハーレム乙w」と思っていたのですがこれがなかなか面白い。 五人のヒロインの思惑がそれぞれ絡みつつ、割と想像を超える展開になることが多くて素直に面白いです。

これは余談ですが、比較として「いちご100%より面白い」と言ったら信者に殺されそうになりました。でも僕はいちご100%よりいいと思います(小声)。顔やスタイルがほぼ同じところからのスタートであることに意味があるのかなあ。心理描写や五つ子トリックも巧みだと思います。

戦争になるので安易には言えない、というお題目を除いても1-5が非常に甲乙つけ難く悩ましいです。2, 3が正妻って言われると「それはどうだろうか」っていうくらいの意見はあるけど(好き嫌いではなく立ち位置的に今のところ微妙では?)、誰かをディスられたら喧嘩を買うくらいには全員に思い入れがあります。

修学旅行のアレは実際誰にでも起こりうることだったんだろうし、やっぱり恋は攻めてこそなんだろうし、あのカードをもっと効果的に使えばどうだったのかもわからないし、今後明らかに一人大きな課題を抱えているのがどう描かれるのか楽しみだし、あのとき点数がよかったらどうなっていたのかもわからないし。 (ネタバレに配慮するために各個人に対する感想を乱数によって入れ替えてあります)

そろそろさすがに完結だと思うのですが先が楽しみですね。

異世界おじさん

「あのほとんさんが商業デビュー!?」と思って買ったやつ。相変わらず面白かったので満足。

確か高校生くらいのときだったと思いますが、人々はローゼンメイデンに夢中になっていました。僕はアニメは全く見ていませんでしたが、web小説・webコミを漁っているとだいたいそういう流行りには敏感になるもので、ローゼンメイデンの二次創作を漁っていたときに、ほとんさんに出会いました。

当時もかなり異端だった鉛筆感のある絵、抜群のギャグセンス、直接的ではないものの官能的な描写。学生であった僕は同人誌は買えなかったのですがHPでの更新を心待ちにしていました。

書いてるうちに思い出したけどもしかしたらハルヒのほうで検索して出会っていたような気もしてきた。ほとんさんのハルヒのファンアートもよかったですね。原作読んだときはそんなにハルヒ好きじゃなかったんですが、ほとんさんのと、あとハルヒかわいいっていう作品を投稿してたTTTっていうサークルのページがあってそこで宗旨変えをしたんですよね。金がない中、どうにか工面してこれは買った記憶がある。

といま感慨にふけりながら検索してたら、TTTの人が商業デビューしてることを知って死ぬほど驚いたし、なんなら響の人らしくてハゲるかと思った。マジか。えー……。石田スイ横田卓馬もビビったけど。えー、知らなかった……。

マジでドンピシャなツイートがあったので引用。僕と同じあの時代に生きていた人がいることがわかって胸が熱くなりますね‥…。

そういえば、ハルヒに触れたルートは、web小説 → full flat 塩ワニさん → ひぐらしのなく頃に → ろくでなしの詩 → ハルヒ同人だった気がするなあと思って調べたらろくでな詩の俊さんも商業デビューしてたんすね……。こっちも買わないと。

他にもかこうと思ってたんだけどもうすべてが上書きされたのでタイトルと一言くらいさらっておいてここで終わります。

  • 空挺ドラゴンズ
    • 正統派
  • 幼女戦記
    • いまさら感あるけど。漫画だけで入門
    • すれ違いギャグが秀逸
    • たぶんコミカライズ自体がうまい気がする
  • Landreaall
    • ディア……
  • フラジャイル
    • 内科の先輩のエピソードがゲロ吐きそうな感じがあっていい
  • ミステリという勿れ
  • QEDシリーズ
    • CMBも含めて一気読みした。学生の頃からの悲願だった
  • 包帯少女期間
    • 愛が重い
  • 徒然日和
    • ほんわかいい
  • さっちゃん僕は
    • 2巻以降の出方次第っぽい
    • ポスト『クズの本懐』として注目を集めつつある(俺の中で)
  • 鬼滅の刃
    • よく聞くので読んだ。けっこーすき
    • バトル風夏目友人帳
  • 呪術廻戦
    • よいときとそんなでもないときの波が激しい気がする
    • マイブラザー東堂と禅院姉が好き
  • まくむすび
    • 前作『マヤさんの夜ふかし』よりも各回の当たり外れが大きいような
    • あたったときはすげえいいです
  • 正しいスカートの使い方
    • っていうか位置原光Z
    • マジで全部同じ感じなんだけど全部同じ感じにすげえいいので下ネタ耐性ある人たのむ

ダブルスの試合を2コートでまわすときにプレイヤーの重複のない組み合わせを探す

問題設定

ダブルスの試合をたくさんやることになりました。試合組はすでに決まっています。
2面を使って試合をし、2つとも終わった段階で次の2試合を始める方式とします。これを1ターンと呼びます。
全部で20試合あるので理論上10ターンで終わるはずです。しかし、当然1人が同一ターンの2試合に出ることはできません。
これを解消する必要がありますが、適当に組んでたらどうにもうまくいかなかったのでプログラムを書くことになりました。

制約

  • 試合ごとにかかる時間は一定ではないので、長い時間がかかる試合と短い時間がかかる試合が同じターンにあるとロスが大きいのですが、ここではかかる時間は一定であるとします
  • 連続した試合は当然避けたいものですが、この後のステップでターンごとに並び替えればよいのでここでは気にしないことにします

解法

gamesHash{Integer => Array<String>} で試合IDとプレイヤー名の配列が入っているものとします。
出力はなんでもいいのですが spreadsheet に食わせるので1ターンごとに1行で id_1,id_2 というCSV形式にしました。

方針自体は割と簡単で、とりあえず詰めてみて、制約違反が発覚したら1手戻してみるという形式です。
+ より push して pop すればよかった気はしなくもない。

全部で20ゲーム、ターン内の組み合わせは可換、10ターンの並び替えも可換なので組み合わせ的には 20!/(2^10)/10! ですかね。
手元で実行したら 0.17s でした。

def solve(games, table)
  if table.size == games.size
    return table
  end

  if table.size % 2 == 0
    games.each do |k, v|
      unless table.include?(k)
        ans = solve(games, table + [k])
        if ans
          return ans
        end
      end
    end
    nil
  else
    used_id = table[-1]
    used = games[used_id]

    games.each do |k, v|
      unless table.include?(k)
        if used.none? { |p| v.include?(p) }
          ans = solve(games, table + [k])
          if ans
            return ans
          end
        end
      end
    end

    return nil
  end
end

ans = solve(games, [])

ans.each_slice(2) do |a, b|
  puts "#{a},#{b}"
end

以下テスト用のデータです。

{
  "2": [
    "a",
    "b",
    "c",
    "d"
  ],
  "3": [
    "b",
    "e",
    "c",
    "f"
  ],
  "4": [
    "a",
    "g",
    "f",
    "d"
  ],
  "5": [
    "a",
    "h",
    "c",
    "i"
  ],
  "6": [
    "g",
    "e",
    "f",
    "j"
  ],
  "7": [
    "a",
    "k",
    "f",
    "i"
  ],
  "8": [
    "b",
    "k",
    "c",
    "l"
  ],
  "9": [
    "b",
    "h",
    "d",
    "j"
  ],
  "10": [
    "a",
    "e",
    "f",
    "l"
  ],
  "11": [
    "b",
    "m",
    "c",
    "j"
  ],
  "12": [
    "h",
    "g",
    "d",
    "l"
  ],
  "13": [
    "h",
    "e",
    "i",
    "l"
  ],
  "14": [
    "g",
    "k",
    "d",
    "n"
  ],
  "15": [
    "e",
    "k",
    "c",
    "n"
  ],
  "16": [
    "a",
    "m",
    "i",
    "j"
  ],
  "17": [
    "h",
    "k",
    "l",
    "j"
  ],
  "18": [
    "e",
    "m",
    "f",
    "n"
  ],
  "19": [
    "g",
    "m",
    "i",
    "n"
  ],
  "20": [
    "h",
    "m",
    "l",
    "n"
  ],
  "21": [
    "k",
    "m",
    "j",
    "n"
  ]
}

Rack で transfer-encoding: chunked なレスポンスを返す

each を実装したオブジェクトを body として返してやるといい感じになる。 -> ならないかも。末尾参照(2019-10-28 17:25追記)

Streaming::Stream のように each を実装したオブジェクトを call の戻り値に詰めている。

実際のレスポンスをみると Transfer-Encoding: chunked であることがわかる。
またサーバ側のログからも wrapper end のあとに Stream#each が実行されていることがわかる。なので rack middleware 層を突き抜けた先で each が評価されている。
--raw をつけて実際 chunk ごとにきてることも確認している。chunk が来た瞬間に curl が stdout に書いてくれるわけではないようで、すべて終わってから書かれるのが気になるところではあるがきっとだいじょうぶだろう。

gist9adfaa2234729ad51913b3169064dbc0

2019-10-28 17:25追記

すべて終わってから書かれるのが気になるところではあるがきっとだいじょうぶだろう。

chrome タブで開いてTTFBを見ると 3sec かかっているのでぜんぜん大丈夫じゃなかったっぽい

f:id:hkdnet:20191028172652p:plain

Amazon SQSでFIFOだからってシステム全体が Exactly-Once になると思ったら大間違いだっていう話

TL; DR

  • Amazon SQS で Exactly-Once なキューを使おうとも冪等な処理を書くべき
    • キューが Exactly-Once であるという性質はシステム全体が Exactly-Once になることを保証できない
    • 結局マルチデータソースへの書き込みの問題が残る
  • Designing Data-Intensive Applications (邦訳: データ指向アプリケーションデザイン) が良書でした
    • 邦訳は未読1ですが原著の内容がいいのできっとだいじょうぶでしょう

Designing Data-Intensive Applications: The Big Ideas Behind Reliable, Scalable, and Maintainable Systems

Designing Data-Intensive Applications: The Big Ideas Behind Reliable, Scalable, and Maintainable Systems

データ指向アプリケーションデザイン ―信頼性、拡張性、保守性の高い分散システム設計の原理

データ指向アプリケーションデザイン ―信頼性、拡張性、保守性の高い分散システム設計の原理

経緯

先日 twitter を見ていたら以下のような発言がありました。

Amazon SQS は同一メッセージが複数回配信される可能性があるので冪等性を保証する必要がある」という言明がある。しかしこれは FIFO キューを使えば Exactly-Once な配信を保証できるので偽である

しかしこれは正しくありません。FIFOキュー使ってもシステムを堅牢にするためには冪等性を保証しておいたほうがよい、ということを以下で主張します。

複数データソースにおけるトランザクション

本記事では、なんらかの処理をして副作用として永続化を行うような一連のステップのことをトランザクションと呼びます。たぶん一般的な定義ともそんなにずれてないと思います。トランザクションは開始したあとに、コミットあるいはロールバックをして終了します。トランザクションを張りっぱなしで掴んだままのケースもありますが、適当にタイムアウトしてロールバックされるものとします。

複数のデータソースに対してトランザクションを行うとき、正常系ではコミットも複数回行う必要があります。このときに問題になる典型的なパターンがあります。それは、あるデータソースではコミットに成功したが違うデータソースではコミットに失敗するケースです。コミットはいろいろな事情により失敗する可能性があります。RDBのなんらかの制約であったり、ネットワークの不通であったり、あるいはプロセスが殺されたり、もしくはデータセンターに隕石が降ってくるかもしれません。ここで重要なのは、どんなトランザクションでもコミットに失敗しうる、ということです。

例えばよくあるトランザクションの例では、1000円の口座残高をもつAさんと、同じく1000円の口座残高をもつBさんがいたときに、AさんがBさんに100円送金する例が挙げられます。これはトランザクションを張っておきましょうねで済みます。しかし、このときAさんのレコードは mysql に、Bさんのレコードは postgres にいたりすると片方だけコミットに成功し、片方がロールバックされるような状況も起きえます。

この例では「いやいや mysql と postgres って。設計がヤバすぎでしょう」という気持ちにもなりますが、上述のSQSの問題についてもまさに同様のことが起きます。このケースでは我々はRDB2とSQSという2つのデータソースに対して一貫性を保ちたいということを言っているのですから。

SQSというデータソース

SQSにおけるトランザクション

Amazon SQS についてはトランザクションという言葉が(たぶん)出てこないので、本エントリでは Amazon SQS のどういうところを指してトランザクションと言っているのか、またそれがどういった性質をもっているのかを説明します。詳細な仕様は公式ドキュメントを参照してください。

SQSはメッセージキューです。キューの両端には producer と consumer がいます。producer がメッセージを送信して consumer がメッセージを受信します。

メッセージの受信方法が特徴的で3、 consumer は以下のような処理を行います。

  1. タイムアウトの時間を指定しつつ」メッセージを受信する
  2. メッセージの内容を解釈して実際の処理を行う
  3. メッセージの削除をおこなう。ただし、タイムアウト時間内にメッセージが削除されなかった場合は、そのメッセージは再度キューから受信できるようになり、1に戻る。

これは上述のトランザクションでいうと、メッセージの取得がトランザクションの開始に、メッセージの削除がコミットに対応すると言えます。ロールバックに関してはタイムアウト時間の経過により引き起こされると捉えることができます4

対応表を作ると以下のようになります。

意味合い\ 実際の処理 RDBの処理 SQS の処理
トランザクション開始 BEGIN + 書き込みロック メッセージの受信
コミット COMMIT メッセージの削除
ロールバック ROLLBACK タイムアウト

Exactly-Once なキューとは

SQSにはキューの種類が2種類あります。通常のキューとFIFOキューです。FIFOキューは名前の通り、(同一メッセージグループにおいて)先入れ先出しであることが保証されます。また Exactly-Once という性質があります。Exactly-Once については、あるメッセージが同時に複数の consumer に見えることがない、という意味です(以下抜粋)。

1 回だけの処理は、単一のコンシューマーおよび複数のコンシューマーシナリオの両方に適用されます。FIFO キューを複数のコンシューマー環境で使用する場合、現在のメッセージが削除されるか、可視性タイムアウトの有効期限が切れた後でのみ、メッセージを他のコンシューマーに表示するようキューを設定できます。
https://aws.amazon.com/jp/blogs/news/new-for-amazon-simple-queue-service-fifo-queues-with-exactly-once-delivery-deduplication/ より。

SQSを使ったシステム

SQSについての説明はここまでとし、次はSQSを使ったシステムを考えます。今回は consumer 側だけを考えるので十分です。consumer はメッセージを受け取りRDBに何らかの処理をするものとします。このとき、RDBとSQSの2つのデータソースがあるのでコミット順序についても選択肢が2つあります。

  1. RDBへのコミットをしてからメッセージの削除を行う
  2. メッセージの削除が成功してから RDB へのコミットを行う

それぞれのケースについて、先にしたコミットだけが成功するケースを考えます。

「1. RDBへのコミットをしてからメッセージの削除を行う」について

RDBへのコミットが成功しメッセージの削除が失敗したケースです。この場合、メッセージの削除に失敗したメッセージは複数回受信されることになります。

重複受信であることを何らかの手段で判断して捨てるなど、処理自体を冪等にしておかないと本当に重複実行されてしまいます。

「2. メッセージの削除が成功してから RDB へのコミットを行う」について

メッセージの削除が成功しRDBへのコミットが失敗したケースです。この場合、メッセージの内容は処理されておらず、キューからも消えているのでメッセージがロストすることになります。

どちらを選択するか

上述の失敗ケースはどちらもSQS上で Exactly-Once が保証されているかに関係ありません。

通常のシステムではメッセージのロストは許されない5と思うので、メッセージの削除に失敗した時に重複処理が起きることを受け容れることになるでしょう。

重複処理が起きるということは当然 Exactly-Once ではありません。これがタイトルでいう「Amazon SQSでFIFOだからってシステム全体が Exactly-Once になると思ったら大間違いだ」という主張でした。具体的には、consumer の処理を冪等にするなどの対策がよいと思います。やっぱり冪等性が大事なんですねえ。

終わりに

本記事は業務での経験と、書籍 Designing Data-Intensive Applications で得た知識によって構成されています。

もうちょい俯瞰した話を Designing Data-Intensive Applications Chapter 11. Stream Processing の Atomic commit revisited から抜粋します。

In order to give the appearance of exactly-once processing in the presence of faults, we need to ensure that all outputs and side effects of processing an event take effect if and only if the processing is successful.
...
If this approach sounds familiar, it is because we discussed it in "Exactly-once message processing" in the context of distributed transactions and two-phase commit.

ここでの exactly-once はSQS本体ではなくて処理全体としての話です。出力と副作用、つまりRDBへのコミットとSQSからの削除は、全部成功したときにしか現れてほしくないと言っています。そしてそれは分散トランザクションや2相コミットに似てると。先程はSQSとRDBだったので分散トランザクションとかは考えなかったのですが、そういうアプローチで解決することも可能なんでしょうね。本では Google Cloud Dataflow や VoldDB、Kafka などにそういった取り組みがあると書いてあります。

同Chapterの Idempotence セクションでは、分散トランザクションではなく冪等性で解決できるよ、ということも書いてあります。

Our goal is to discard the partial output of any failed tasks so that they can be safely retried without taking effect twice. Distributed transactions are one way of achieving that goal, but another way is to rely on idempotence.

この辺りのセクションが特に参考になるかと思いますので気になった方は読んでみてください。全編通して面白いのでおすすめです。日本語版の発売も楽しみですね。

余談

実装上の tips ですがSQSに DeleteMessage を送ってみたらタイムアウトしていた、というのに対応するために、RDBへのコミットまでのタイムアウトを5秒にして、SQSに対するタイムアウト時間を10秒にするなどのバッファをもっておくことで、送ってみたらタイムアウトしていたケースをほぼなくすことができるはずです。


  1. というか執筆当時未発売。2019年7月18日発売予定だそうです(公式ページより)

  2. なんらかのシステムにおけるプライマリなデータソースの代表例として挙げています。

  3. 他のメッセージキューに詳しいわけじゃないんで嘘かもですが

  4. 実際、あるメッセージの処理を即座に中断したいときにはタイムアウト時間を0に設定する処理によって実現することになります

  5. 許される要件があったら気になるので教えてください。