Rubyのパターンマッチを使ってMarkdownからコードブロックを抜き出す

Rubyのパターンマッチを使って、(GitHub Flavored) Markdownのテキストからコードブロックを抜き出す。次のようなテキストを考える。

# Ruby
## hello world
Rubyでは次のようにhello worldします。
```ruby
puts 'hello world'
```
```
これはRubyではありません。
```
## error
Rubyでは次のようにエラーを起こします。
```ruby
raise 'error'
```

Markdownのパース

まず、このMarkdownをパースするために、kramdown (2.4.0)とkramdown-parser-gfm (1.1.0)を使う。Markdownをパースして、ASTのハッシュを変換する。

require 'kramdown'
require 'kramdown-parser-gfm'

doc = <<~EOS
    # Ruby
    ## hello world
    Rubyでは次のようにhello worldします。
    ```ruby
    puts 'hello world'
    ```
    ```
    これはRubyではありません。
    ```
    ## error
    Rubyでは次のようにエラーを起こします。
    ```ruby
    raise 'error'
    ```
EOS

ast = Kramdown::Document.new(doc, input: 'GFM').to_hash_ast

これで次のようなASTが取得できる。childrenにMarkdownテキスト上の各要素を持ち、それぞれ同じレベルに並んでいる。

{:type=>:root,
 :options=>
  {:encoding=>#<Encoding:UTF-8>,
   :location=>1,
   :options=>{},
   :abbrev_defs=>{},
   :abbrev_attr=>{},
   :footnote_count=>0},
 :children=>
  [{:type=>:header,
    :attr=>{"id"=>"ruby"},
    :options=>
     {:level=>1, :raw_text=>"Ruby", :location=>1},
    :children=>
     [{:type=>:text,
       :value=>"Ruby",
       :options=>{:location=>1}}]},
   {:type=>:header,
    :attr=>{"id"=>"hello-world"},
    :options=>
     {:level=>2,
      :raw_text=>"hello world",
      :location=>2},
    :children=>
     [{:type=>:text,
       :value=>"hello world",
       :options=>{:location=>2}}]},
   {:type=>:p,
    :options=>{:location=>3},
    :children=>
     [{:type=>:text,
       :value=>"Rubyでは次のようにhello worldします。",
       :options=>{:location=>3}}]},
   {:type=>:codeblock,
    :attr=>{"class"=>"language-ruby"},
    :value=>"puts 'hello world'\n",
    :options=>
     {:location=>4, :fenced=>true, :lang=>"ruby"}},
   {:type=>:codeblock,
    :value=>"これはRubyではありません。\n",
    :options=>{:location=>7, :fenced=>true}},
   {:type=>:header,
    :attr=>{"id"=>"error"},
    :options=>
     {:level=>2, :raw_text=>"error", :location=>10},
    :children=>
     [{:type=>:text,
       :value=>"error",
       :options=>{:location=>10}}]},
   {:type=>:p,
    :options=>{:location=>11},
    :children=>
     [{:type=>:text,
       :value=>"Rubyでは次のようにエラーを起こします。",
       :options=>{:location=>11}}]},
   {:type=>:codeblock,
    :attr=>{"class"=>"language-ruby"},
    :value=>"raise 'error'\n",
    :options=>
     {:location=>12, :fenced=>true, :lang=>"ruby"}}]}

配列とハッシュの構造に対するパターンマッチでコードブロックを抽出

このASTに対して、パターンマッチで特定の構造にマッチさせて値を取得できる。rubyinfo stringが付与されたコードブロックを抜き出すときは、次のようにchildrenの各ノードに対してパターンマッチを適用する。

# rubyのinfo stringが付与されたコードブロックを抜き出す
def extract_ruby_codeblocks(nodes, code_blocks)
  return if nodes.empty?

  # Markdownテキストの各要素のハッシュの配列に対してパターンマッチを実行する
  case nodes

  # この構造のハッシュにマッチするとき、キー:valueの値を変数valueに入れる。
  # マッチするハッシュの前後についてもパターンの適用が必要。
  # 最初にマッチするハッシュが出てくるまでの分は*でマッチさせて捨てる。
  # マッチしたハッシュより後ろの配列は*restという記法で変数restに入れる
  in *, { type: :codeblock, options: { lang: 'ruby' }, value: value }, *rest 
    code_blocks << value

    # restに対して繰り返しパターンマッチを実行する
    extract_ruby_codeblocks(rest, code_blocks)
  else
    # pass
  end
end

code_blocks = []
extract_ruby_codeblocks(ast[:children], code_blocks)

結果のcode_blocksの中身は次のとおり。rubyというinfo stringが付与されたコードブロックだけ抜き出せている。

["puts 'hello world'\n", "raise 'error'\n"]

Railsアプリのテスト実行時に特定の警告だけを例外に変換する

今後役立つのかわからないがRuby 2.7→3.xアップデートのときに使った方法のメモ。

Warning[:deprecated] = true すると表示される非推奨警告を発生させるコードがRailsアプリに含まれないように、テスト実行時だけこのような警告をあらかじめエラーに変換できると検出しやすくて便利。また、特定の警告(たとえばキーワード引数分離など)だけエラーにしたいという状況がありうる。jeremyevans/ruby-warningを使うことで、このような警告のハンドリングを実現できる。

RSpecのテストがある場合、spec/rails_helper.rbの最初のほうに次のようなコードを書いておく。

# spec/rails_helper.rb
require 'spec_helper'
ENV['RAILS_ENV'] ||= 'test'

require 'warning'

# 1. 非推奨警告を出力する
Warning[:deprecated] = true

# 2. Railsアプリにキーワード引数分離に関する警告が含まれるとき例外を上げる
Warning.process(File.expand_path("..", __dir__), keyword_separation: :raise)

# 3. Warningの設定後にアプリと依存しているgemをロードする
require_relative '../config/environment'

まず、1のように非推奨警告を表示するようにRubyのレベルで設定しておく。その後、ruby-warning gemによって追加されるWarning#processを通じて、2のように親ディレクトリ(アプリのルートディレクトリ)配下のコードでキーワード引数分離(:keyword_separation)の警告が発生したら例外を発生(:raise)させる。ここで指定できる警告とハンドリングの種類についてはruby-warning gemのREADMEに説明がある。

https://github.com/jeremyevans/ruby-warning#label-Usage+

以上の設定は、3のようにアプリ自体のコードをロードする前に実行しておく必要がある。

参考

約8年開発されている Rails 製プロダクトを Ruby 3 にバージョンアップするために keyword parameters is deprecated を「網羅的に」検知する方法 - Money Forward Developers Blog

Gemfile.lockをもとに特定のバージョンのRubyで利用できないgemの一覧を出す

Railsアプリなど、Bundlerでgemの依存管理をしているプロジェクトでRubyのアップデートをしようとするとき、不幸なことにgemが全体的に古いことがある。

また、gemspecでrequired_ruby_versionというメタデータでRubyのバージョンの範囲を設定していると、その範囲のRubyの環境下でしかそのgemはインストールできない。よくある例として、次のようにRuby 2系でしか使えないようにしているものがある。

Gem::Specification.new do |spec|
  spec.required_ruby_version = "~> 2.0"
end

このような状況下でRubyだけアップデートするとgemをインストールできないことがある。

こうなると、まずgemをアップデートしていく必要がある。この作業の範囲がどれぐらいか把握するために、Gemfile.lockに載っているgemのうち、特定のバージョンのRubyだとrequired_ruby_versionを満たせないgemの一覧を出せるようなスクリプトを書いた。

# unusable_gems.rb

require 'bundler'

def resolve_gemspec_path(spec)
  # nokogiriはarchとosをgemspecのファイル名に持つ場合がある
  if spec.name == 'nokogiri'
    native_gem_path = Bundler.specs_path.join("#{spec.name}-#{spec.version}-#{Gem::Platform.local.cpu}-#{Gem::Platform.local.os}.gemspec")
    return native_gem_path if native_gem_path.exist?
  end

  case spec.source
  when Bundler::Source::Rubygems
    # default gem
    default_gem_path = Pathname.new(Gem.default_specifications_dir).join("#{spec.name}-#{spec.version}.gemspec")
    return default_gem_path if default_gem_path.exist?

    # ふつうのgem
    Bundler.specs_path.join("#{spec.name}-#{spec.version}.gemspec")
  when Bundler::Source::Git
    # git sourceからインストールしたgem
    Bundler.bundle_path.join("#{spec.source.install_path}", "#{spec.name}.gemspec")
  else
    warn "failed to resolve: #{spec.inspect}"
    nil
  end
end

gemspec_paths = Bundler::LockfileParser.new(Bundler.default_lockfile.read).specs.map { |spec|
  path = resolve_gemspec_path(spec)

  unless path&.exist?
    warn "gemspec not found: #{path}"
    next nil
  end

  path
}.compact

gemspecs = gemspec_paths.map { |path| Gem::Specification.load(path.to_s) }

checked_ruby_version = Gem::Version.create(ARGV[0])
puts gemspecs.reject { |gemspec| gemspec.required_ruby_version.satisfied_by?(checked_ruby_version) }.map { |gemspec| "#{gemspec.name} (#{gemspec.version})" }

例として、まず次のGemfileでbundle installしておく。

source "https://rubygems.org"

gem "activerecord", "7.0.4.2" # required_ruby_version = ">= 2.7.0"

Gemfile.lockが存在するディレクトリで次のように実行する。かなり古いバージョン2.6.0、2.7.0を例として使う。

$ ruby unusable_gems.rb 2.6.0
activemodel (7.0.4.2)
activerecord (7.0.4.2)
activesupport (7.0.4.2)
nokogiri (1.14.1)
oauth (1.1.0)
oauth-tty (1.0.5)

$ ruby unusable_gems.rb 2.7.0
# 2.7.0だと使えないgemがないので結果は空

たとえば間接依存のoauth 1.1.0も https://github.com/oauth-xx/oauth-ruby/blob/v1.1.0/oauth.gemspec#L36 を見るとわかるように2.7.0以上の制限がある。

注意点としては、今回必要だったユースケースではgemのソースの種類としてパス (Bundler::Source::Path) などはなかったので入れていない。また、nokogiriのような特殊なパターンもまだあるかもしれない。

既存のgemにRBSで型定義を書く

RBSの練習としてhatenablogというgemの型定義をRBSで書いた。

https://github.com/kymmt90/hatenablog/blob/v0.8.0/sig/hatenablog.rbs

まだ該当gemのsigディレクトリに置いているだけだが、やったことを書いておく。

作業の流れ

Ruby 3.0をインストールするなどしてrbstypeprofは使える状態になっているとする。

  • TypeProfで型定義ファイルの雛形を生成する
  • Steepを設定する
    • rbs collectionでサードパーティgemの型定義を導入する
  • steep checkを実行してエラーを確認する
  • 型定義やコード本体を修正し、エラーを解消する
  • CIでSteepを実行する

ディレクトリ構造

次のようなディレクトリ構造とした。

.
├── Steepfile
├── lib
│   └── (gemのコード)
├── rbs_collection.lock.yaml
├── rbs_collection.yaml
└── sig
    └── hatenablog.rbs
  • sigディレクトリにRBSで書いた型定義を置く
  • ルートディレクトリに型チェックを実行するSteepの設定ファイルであるSteepfileを置く
  • ルートディレクトリにサードパーティgemを管理するrbs collectionコマンドで使うrbs_collection.yamlとrbs_collection.lock.yamlを置く

型定義ファイルの雛形を作る

今回はTypeProfで雛形となるRBSの型定義ファイルを生成した(ce61db1)。

$ typeprof lib/**/*.rb test/hatenablog/*.rb -I lib/hatenablog > sig/hatenablog.rbs

テストもlib配下のコードと一緒にTypeProfに入力して実際にメソッドが使われているコードを増やしたところ、libだけを入力したときよりもuntypedが減った。この方法を使う場合、テスト内のクラスが型定義に入るので削除しておく。

サードパーティgemの型定義を導入する

rbs collectionを使うとサードパーティgemの型定義が管理できる。今回はnokogiriのためにこの機能を使う。

rbs/collection.md at v1.7.1 · ruby/rbs

rbs collectionを使うにはrbs_collection.yamlを作成する。rbs collection initで同ファイルを生成したあと、必要なgemが入るように設定を書く。今回はrbs_railsの同ファイルを参考にして次のように書いた。

# Download sources
sources:
  - name: ruby/gem_rbs_collection
    remote: https://github.com/ruby/gem_rbs_collection.git
    revision: main
    repo_dir: gems

# A directory to install the downloaded RBSs
path: .gem_rbs_collection

gems:
  # stdlibs
  - name: erb
  - name: net-http
  - name: set
  - name: time
  - name: uri

  # gems
  - name: nokogiri

  # not used
  - name: activesupport
    ignore: true
  - name: ast
    ignore: true
  - name: listen
    ignore: true
  - name: parallel
    ignore: true
  - name: rainbow
    ignore: true
  - name: rbs
    ignore: true
  - name: steep
    ignore: true

設定の意図は次のとおり。

  • 型定義のインストール先をpathで設定する。ドキュメント通りにプロジェクトのルートディレクトリの.gem_rbs_collectionとする
  • 必要なgemのリストをgems配下のリストに追加する
  • 自動生成するとRBSとSteep(とその依存)自体の型定義もリストに入るが、これらをライブラリとして使っているわけではないのでignore: trueで無視する

このファイルが存在する状態でrbs collection installすると、bundle installのように、ruby/gem_rbs_collectionから必要な型定義を取得して.gem_rbs_collectionに保存する。このとき、使用する型定義のバージョンを記録するためのrbs_collection.lock.yamlが生成される。

---
sources:
- name: ruby/gem_rbs_collection
  remote: https://github.com/ruby/gem_rbs_collection.git
  revision: main
  repo_dir: gems
path: ".gem_rbs_collection"
gems:
- name: erb
  version: '0'
  source:
    type: stdlib
- name: net-http
  version: '0'
  source:
    type: stdlib
- name: set
  version: '0'
  source:
    type: stdlib
- name: time
  version: '0'
  source:
    type: stdlib
- name: uri
  version: '0'
  source:
    type: stdlib
- name: nokogiri
  version: '1.11'
  source:
    type: git
    name: ruby/gem_rbs_collection
    revision: 88e86e0b67262f9ab6244a356e81dd9ca8c55b37
    remote: https://github.com/ruby/gem_rbs_collection.git
    repo_dir: gems

今回はこのファイルもrbs_railsにならってリポジトリにコミットしたが、gemでGemfile.lockはコミットすべきでないという話と同様に、rbs_collection.lock.yamlもbin/setupとかで都度生成するようにしたほうがいいのかもしれない。

Steepを設定する

RBSによる型定義にしたがったコードになっているかはSteepでチェックする。BundlerでSteepをインストールする。

# Gemfile
group :development, :test do
  gem 'steep'
end

設定をSteepfileに書く。steep initで生成した雛形をもとにする。repo_pathで他のgemの型定義が入っているディレクトリのパスを指定し、libraryでruby/rbsに入っている標準ライブラリの型定義やサードパーティgemの型定義のうち、使いたいものの名前を指定することで、他のgemの型定義が利用できる。今回はconfigure_code_diagnosticsを使い、最も強いエラーレベルでlib配下のコードをチェックするように設定した。

# Steepfile
D = Steep::Diagnostic

target :lib do
  check "lib"
  signature "sig"

  repo_path ".gem_rbs_collection"
  library "erb", "net-http", "nokogiri", "set", "time", "uri"

  configure_code_diagnostics(D::Ruby.all_error)
end

型定義ファイルやコードを修正する

ここまでで型チェックの準備ができた。TypeProfで生成した型定義で

$ bundle exec steep check

を実行するとチェックがたくさん失敗するので、チェックがすべて通るまで型定義や場合によってはコードを直していく。該当PRだと3つ目のコミット以降。

ここでは、一般的に発生しそうなエラーとその解消方法をいくつか取り上げる。

サードパーティgemの型定義に起因するエラー

このgemではXMLを扱うためにNokogiriを使っていて、次のようなコードが存在する。

@categories.each do |category|
  prev_node.next = @document.create_element('category', term: category)
  prev_node = prev_node.next
end

@documentNokogiri::XML::Documentのインスタンスであり、create_elementはそのメソッド。このメソッドは省略可能なブロックを取ることができるのだが、gem_rbs_collectionでは次のように省略不可になっていて、結果としてSteepのチェックがエラーになった。

def create_element: (untyped name, *untyped args) { (*untyped) -> untyped } -> untyped

サードパーティgem側の型定義を修正すべき場合は修正する。今回はruby/gem_rbs_collectionのNokogiriの該当シグネチャを修正した。

Nokogiri: Make block arguments optional by kymmt90 · Pull Request #88 · ruby/gem_rbs_collection

サードパーティgemの型定義が存在しないエラー

このgemが依存するgemのうち、ostruct(OpenStructを提供する)とoauthとyamlの型定義は、2021年11月時点ではruby/rbsやruby/gem_rbs_collectionに存在しない。これらの型定義については、ひとまずSteepのチェックが通るようにpolyfillを追加した。

# polyfill for ostruct
class OpenStruct
  def initialize: (?Hash[untyped, untyped]? hash) -> OpenStruct
  def []: (String | Symbol) -> Object
  def to_h: -> Hash[Symbol, Object]
end

# polyfill for oauth
module OAuth
  class AccessToken
    def initialize: (untyped, untyped, ?untyped) -> void
  end

  class Consumer
    def initialize: (untyped, untyped, ?untyped) -> void
  end
end

# polyfill for yaml
module YAML
  def self.load: (String yaml, ?String? filename, ?fallback: bool, ?symbolize_names: bool) -> untyped
end

これらについてもruby/rbs、ruby/gem_rbs_collectionにコントリビュートするのが望ましい。

"Type (Foo | nil) does not have method bar"のエラー

次のようにnilになりうるクラスFooの変数

@foo: Foo?

に対してメソッドbarを呼び出していると、NilClassにそのようなメソッドがないので表題のエラーになる。いかにnilになるかどうかを意識せずにコードを書いているかがわかる。

現在は、状況に応じていくつかの方法で対応している。

nilにならない型に変更する

このエラーが起きている変数や引数の型からoptionalの?を外すことに問題がない場合、外す。すると(Foo | nil)という型からFooという型になるので、このエラーは起きなくなる。

&.を使う

本当にnilになる可能性もある変数なのであれば、&.を使う。&.であればnilに対してもメソッドを呼び出せるので、Steepのエラーは発生しない。

たとえば

> lib/hatenablog/entry.rb:167:61: [error] Type `(::Time | nil)` does not have method `iso8601`
> │ Diagnostic ID: Ruby::NoMethod
> │
> └         @document.at_css('entry updated').content = @updated.iso8601
                                                               ~~~~~~~

というエラーに対して

@document.at_css('entry updated').content = @updated&.iso8601

というコードに変える。

"Type (^(untyped) -> untyped | nil) does not have method call"のエラー

先ほどのエラーと似ているが、ここではブロック引数がエラーの対象。

たとえばEnumerator#eachの型は2021年11月現在次のようになっている

def each: () { (Elem arg0) -> untyped } -> Return
        | () -> self

ところで、Enumerableをincludeしてeachを実装しているクラスの場合、

  • ブロックが渡されないときはEnumeratorのインスタンスを返す
  • ブロックが渡されるときはブロックを実行する

という挙動を実現するために次のようなコードを書くことがある。

def each(&block)
  return enum_for unless block_given?

  @categories.each do |category|
    block.call(category)
  end
end

このコードではblock_given?を使ってガードすることで、ブロックがないときEnumeratorをすぐに返している。また、このメソッドの型定義は、次のようにブロックを取らずにEnumeratorを返すか、もしくはブロックを取ることを表現するものになる。

def each: () -> Enumerator[untyped, self]
        | () { (String) -> void } -> Array[String]

これでうまくいきそうだが、コード上は&blockはnilになりうる変数なので、Steepは引数&blockの型を^(untyped) -> untyped | nilと見なす。よって、そのままcallしようとすると、nilの可能性が考慮されて表題のエラーになる。

この問題は、Steepの機能であるアノテーションをコードに追加し、Steepにblockの型を明示することで解決できる。

def each(&block)
  return enum_for unless block_given?

  @categories.each do |category|
    # @type var block: ^(String) -> void
    block.call(category)
  end
end

@typeから始まるコメントが変数に対するアノテーション。blockの型が^(untyped) -> untyped | nilではなく^(String) -> voidであることを明示している。Steepはこのコメントを見て型チェックすることで表題のエラーを発生させなくなる。

動的に生成するメソッドに関するエラー

attr_accessorやOpenStructを使っていると動的にgetter/setterメソッドが生成される。これらのメソッド定義はコード中に存在しないので、そのままsteep checkすると次のエラーが発生する。

lib/hatenablog/feed.rb:7:8: [error] Cannot find implementation of method `::Hatenablog::Feed#uri`
│ Diagnostic ID: Ruby::MethodDefinitionMissing
│
└   class Feed
          ~~~~

動的に生成するメソッドについては、@dynamicというSteepのアノテーションをコードに追加することで、Steepがメソッド定義を見つけられなくてもエラーにしなくなる。

class Feed
  # @dynamic uri, next_uri, title, author_name, updated
  attr_reader :uri, :next_uri, :title, :author_name, :updated
end

モジュールのメソッドでModuleのインスタンスメソッドを使うとエラー

このgemではモジュールのメソッドでinstance_methodsしたりalias_methodしたりdefine_methodしたりとModuleのインスタンスメソッドを使っている。このモジュールは別のクラスでextendするという用途のために存在している。

あるモジュールはModuleクラスのインスタンスなので、モジュールのメソッドでinstance_methodsなどのメソッドをレシーバなしで呼び出すときSteepのチェックが通ることを期待していたが、次のエラーになった(ここでは::Hatenablog::AfterHookが該当のモジュール)。

> lib/hatenablog/entry.rb:13:11: [error] Type `(::Object & ::Hatenablog::AfterHook)` does not have method `instance_methods`
> │ Diagnostic ID: Ruby::NoMethod
> │
> └         if instance_methods.include? origin_method
>              ~~~~~~~~~~~~~~~~
>
> lib/hatenablog/entry.rb:17:8: [error] Type `(::Object & ::Hatenablog::AfterHook)` does not have method `alias_method`
> │ Diagnostic ID: Ruby::NoMethod
> │
> └         alias_method origin_method, method
>           ~~~~~~~~~~~~
>
> lib/hatenablog/entry.rb:19:8: [error] Type `(::Object & ::Hatenablog::AfterHook)` does not have method `define_method`
> │ Diagnostic ID: Ruby::NoMethod
> │
> └         define_method(method) do |*args, &block|
>           ~~~~~~~~~~~~~

実際はそれぞれちゃんと型定義が存在するが、"does not have method"と言われている。

とりあえず次のように型定義を素朴に追加するとチェックが通るのでそうしているが、もっといい方法がありそう。

module AfterHook
  # ...

  def alias_method: (::Symbol | ::String new_name, ::Symbol | ::String old_name) -> ::Symbol
  def define_method: (Symbol | String arg0, ?Proc | Method | UnboundMethod arg1) -> Symbol
                   | (Symbol | String arg0) { () -> untyped } -> Symbol
  def instance_methods: (?boolish include_super) -> ::Array[Symbol]
end

CIでSteepを実行する

GitHub ActionsでSteepも実行するようにしておく。ワークフローに次のようにジョブを定義すればよい。

jobs:
  steep:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.0'
      - run: bundle install -j4
      - run: rbs collection install
      - run: bundle exec steep check

steep checkの成功

Steepのチェックがすべて通ると標準出力に次のようなメッセージが表示される。これでgemの中のロジックについては型定義と矛盾しない状態になっていることが確認できた。

$ bundle exec steep check
# Type checking files:

...................................................................................

No type error detected. 🧉

参考にした資料

Rack::Timeoutによるリクエストタイムアウトの仕組み

はじめに

Rackミドルウェアの一つであるRack::Timeoutを使うと、Rackアプリがリクエストを受け取ってから一定時間が経過すると、アプリのどの場所でコードが実行されていても、その場所からRack::Timeoutの例外RequestTimeoutExceptionが発生する。

サンプルコードと、タイムアウト時のスタックトレースは次のようになる。

# config.ru
require 'rack-timeout'
require_relative './app'

use Rack::Timeout, service_timeout: 5
run App
# app.rb
require 'sinatra/base'

class App < Sinatra::Base
  get '/timeout' do
    sleep 6
    'Time out'
  end
end
[2021-04-12 20:20:17] INFO  WEBrick::HTTPServer#start: pid=1362 port=9292
source=rack-timeout id=287a1d6a-d9b2-47b1-8d03-27094d707e9d timeout=5000ms state=ready at=info
source=rack-timeout id=287a1d6a-d9b2-47b1-8d03-27094d707e9d timeout=5000ms service=5006ms state=timed_out at=error
2021-04-12 20:20:44 - Rack::Timeout::RequestTimeoutException - Request ran for longer than 5000ms :
    /Users/kymmt90/path/to/app/app.rb:5:in `sleep'
    /Users/kymmt90/path/to/app/app.rb:5:in `block in <class:App>'
    /Users/kymmt90/path/to/app/vendor/bundle/ruby/3.0.0/gems/sinatra-2.1.0/lib/sinatra/base.rb:1675:in `call'
(snip)
source=rack-timeout id=287a1d6a-d9b2-47b1-8d03-27094d707e9d timeout=5000ms service=5013ms state=completed at=info
::1 - - [12/Apr/2021:20:20:44 +0900] "GET /timeout HTTP/1.1" 500 30 5.0176

sleepしているapp.rbの13行目からRack::Attackの例外Rack::Timeout::RequestTimeoutExceptionが発生している。この仕組みが気になったので調べた。

先に結論

Rack::Timeoutでタイムアウトするとき、次のように動作する。

  • 渡したブロックを一定時間後に実行できるスケジューラを作る
  • Rack::Timeoutの#callメソッドを呼ぶとき
    • リクエストをハンドリングするスレッドでThread#raiseしてRack::Timeout::RequestTimeoutExceptionを発生させるブロックをスケジューラに渡す
    • 先のRackアプリに対して#callを実行し、タイムアウトの時間が経過すると渡したブロックを実行する

こうして、一定時間以内にRackアプリがレスポンスを返せばそのレスポンスを返し、そうでなければスケジューラに渡したブロックがアプリのスレッドの外から例外を発生させている。

Rack::Timeoutの概要

目的

Rack::TimeoutのREADMEには、その目的がこう書いてある:

Rack::Timeout is not a solution to the problem of long-running requests, it's a debug and remediation tool.

あくまでもタイムアウトをきっかけとしてアプリケーションを改善するために使っていこうというもの。

設定方法

Rack::RuntimeがRackミドルウェアとして入っているRackアプリなら、rack-timeoutをGemfileに追加するだけでRack::Timeoutの機能が追加される。そうでないときやRackミドルウェアスタックのどこにミドルウェアを入れたいかを制御したいなら自前で設定を書く必要がある。

設定項目の意味

Rack::Timeoutはさまざまな設定項目を持つ。それぞれの効果をあらためて確認した。

Service Timeout

Rackアプリの実行時間のタイムアウト。

Wait Timeout

リクエストがWebサーバのキューに入ってからRackアプリが処理し始めるまでの時間のタイムアウト。

たとえばHeroku Routerは30秒でタイムアウトするので、リクエストのキューに入って30秒たってからRackアプリが処理し始めるのは意味をなさない。そういうリクエストをRackアプリの手前で落としてRack::Timeout::RequestExpiryErrorをraiseする。

30秒のタイムアウトのとき、Rackアプリの前段で時間がかかって、Rackアプリの処理が始まるまでに20秒使ったら、service timeoutが実質10秒になってしまう。これを無効にするのが service_past_wait

X-Request-Startを使ってリクエスト開始時間をUNIXエポックで取得しているので、このヘッダがなかったらwait timeoutは発生しない。

Wait Overtime

wait timeoutでX-Request-Startを使っていることもあって、ボディが大きなPOSTリクエスト(ファイルアップロードとか)で受信しはじめから受信終わりまでに時間がかかると、受信完了前にwait timeout扱いになることがある。これを防ぐために、wait timeoutに下駄を履かせる*1

Term on timeout

たとえば1を設定すると、あるリクエストでタイムアウトしたらWebサーバにSIGTERMを送る。また、たとえば5を設定すると、5件のリクエストがタイムアウトするまでSIGTERMしない。

この機能はPumaなどWebサーバがマルチワーカーなときに使わないと、masterプロセスが終了してしまう。

コードの概要

この記事ではRack::Timeout v0.6.0を読む。コメントが豊富に書かれており、読みやすい。

lib/rack/timeout/core.rbにRackミドルウェアとしてのRack::Timeout#callが存在する。

module Rack
  class Timeout

  # ...

    RT = self # shorthand reference
    def call(env)
      # ...
    end

スケジューラRack::Timeout::Scheduler::Timeout(以下RT::Scheduler::Timeout)はタイムアウト処理を実現するにあたっての肝となるクラスである。Rack::Timeout#callでは、次のように次のミドルウェアまたはRackアプリに対しての#callRT::Scheduler::Timeout#timeoutに渡すブロックで包むことでタイムアウト処理を実現している。これにより、一定時間以上でタイムアウトするRackアプリが完成する。

      timeout = RT::Scheduler::Timeout.new do |app_thread|
        # タイムアウトしたときの処理を設定する
      end

      # 一定時間経過したとき上で渡したブロックを実行するために、#timeoutに@app.callを実行するブロックを渡す
      response = timeout.timeout(info.timeout) do
        begin
          @app.call(env)
        rescue RequestTimeoutException => e
          raise RequestTimeoutError.new(env), e.message, e.backtrace
        ensure
          register_state_change.call :completed
        end
      end

lib/rack/timeout/support/scheduler.rbにあるRT::Scheduler::Timeout#timeoutは次のとおり。本質は@scheduler.run_inの部分で#run_inは渡した秒数が経つとブロックを実行する。つまり、タイムアウト用のブロックを実行する。ここで得ているThread.currentはRackアプリへのリクエストハンドリングを行っているスレッドとなるので、スタックトレース上はRackアプリ内でRequestTimeoutExceptionが発生する形となる。

  def timeout(secs, &block)
    return block.call if secs.nil? || secs.zero?
    thr = Thread.current
    job = @scheduler.run_in(secs) { @on_timeout.call thr }
    return block.call
  ensure
    job.cancel! if job
  end

#run_inは次のとおり。fsecsはmonotonic clock*2から得られる時刻。スケジューラに渡せるイベントの一種としてRunEventがあり、これは渡された秒数経過後に1回処理を実行するというもの。

  def run_in(secs, &block)
    schedule RunEvent.new(fsecs + secs, block)
  end

#scheduleにイベントを渡すことで、Rack::Timeoutは新たなスレッドで無限ループしながら、実行が必要なイベントを随時実行する。

  def schedule(event)
    @mx_events.synchronize { @events << event }
    runner.run # 無限ループしながら実行が必要なイベントを実行するまで待つ#run_loop!を別スレッドで実行する
    return event
  end

#run_loop!は詳細は割愛するが、コメントが丁寧なのでやっていること自体は把握しやすい。現在実行すべきイベントを取り出して実行する、の繰り返しになっている。

*1:ベストな解決策はS3などオブジェクトストレージへのダイレクトアップロード

*2:現実の時刻は閏年などで補正されたり巻き戻ったりすることがあるが、monotonic clockは単調増加する