pt-online-schema-changeで外部キーを削除する

サービスを停止せずにデータベースリファクタリングする - Pepabo Tech Portal の作業をやっていたときに知ったことについて書いておく。

結論

pt-online-schema-change (pt-osc)で外部キーを削除するときは

  • そのキー名の先頭に_を付与して--alterに渡す
  • すでに_が2つ付与されていれば、そのキー名先頭の_を2つ削って--alterに渡す
# 外部キー名がfoos_ibfk_1のとき
$ pt-online-schema-change --alter "DROP FOREIGN KEY _foos_ibfk_1" ...

# 外部キー名が_foos_ibfk_1のとき
$ pt-online-schema-change --alter "DROP FOREIGN KEY __foos_ibfk_1" ...

# 外部キー名が__foos_ibfk_1のとき
$ pt-online-schema-change --alter "DROP FOREIGN KEY foos_ibfk_1" ...

なぜか

外部キーを持つテーブルのスキーマを変更するためにpt-oscを使うと、自動で外部キーの名前が変更されるから。

pt-oscはスキーマに変更を加えるテーブル(旧テーブルと呼ぶ)に対して、スキーマを変更済みの新テーブルを新しく作り、トリガーを使って旧テーブルから新テーブルへレコードをコピーする。旧テーブルが外部キーを持つなら、新テーブルも同じ外部キーを持つことになる。一方、MySQLでは、外部キーなどの制約の名前はデータベース内で一意でなければならない1。pt-oscで新テーブルを作るときにそのまま外部キーをコピーしてしまうと、この決まりを守れない。そこで、外部キーを持つテーブルのスキーマをpt-oscで変更しようとすると、外部キーの変更有無に関わらず、外部キー名を変更するようになっている。

pt-oscは外部キー名の先頭にアンダースコアを付与したものを新テーブルの外部キー名とする。この方式で外部キーを持つテーブルのスキーマを繰り返し変更するとアンダースコアが増えていってしまう2。そこで、アンダースコアは2個を上限として、さらにスキーマを変更するとアンダースコアなしに戻るようになっている3

よって、pt-oscで外部キーを削除するときは、新テーブルを作成したあと--alterに渡されたオプションをもとに外部キーをDROPするので、新テーブルでの外部キー名を指定する必要がある。

参考


  1. https://dev.mysql.com/doc/refman/5.6/ja/create-table-foreign-keys.html の「CONSTRAINT symbol 句が指定されている場合、symbol 値 (使用されている場合) はデータベース内で一意である必要があります」

  2. 過去はそのような実装だったようだ

  3. https://github.com/percona/percona-toolkit/blob/v3.3.1/bin/pt-online-schema-change#L10759-L10777

AOPに基づいてconcernモジュールを作る

発端: concernモジュールの命名をどうするか

ここではActiveSupport::Concernをextendしたモジュールのことをconcernモジュールやconcernと呼ぶ。

「Concernに何を実装すべきかは…非常に曖昧」1である。さらに、Railsアプリのメインであるルーティングからモデルまでは命名規約がかっちり決まっているが、concernに明示的な規約はない。concernも基本的にはRubyのモジュールなので、EnumerableComparableのような組み込みモジュールと似たような命名にするべきだろうか。実際、concernの名前に動詞+ableを使うのはよくやると思う。

Rubyのプレーンなモジュールとの違いとして、ActiveSupport::Concernにはincludedclass_methodsが存在する。これらの機能があることで、concernはコールバックの定義やクラスマクロの定義に使われるようなフォースが働いているように見える。そこで、そのような特徴を踏まえたうえでconcernならではの命名があるか考えてみる。

横断的関心事

concernとはいったいなんだったのかというのをふりかえってみると、アスペクト指向プログラミング(AOP)における横断的関心事(cross-cutting concern)が起源の1つといえそう2

AOPにおける横断的関心事についてはこちらが詳しい。

横断的関心事 - アスペクト指向なWiki

横断的関心事の分類の1つとして静的か動的かというものがある。静的な横断的関心事はインタータイプ宣言の機能を担う。AspectJなどではクラスに新たなメソッドやプロパティを追加する仕組みのことをインタータイプ宣言と呼んでいるようだ。一方、動的な横断的関心事はポイントカット+アドバイスの機能を担う。こちらは、コードを実行すると発生する特定のイベント(メソッド呼び出しなど)にあわせてアドバイスと呼ばれるコードを実行する。

それぞれ、concernだと次のようなものにあたるといえそう。

  • 静的な横断的関心事は、スコープやクラスマクロを追加するconcern
  • 動的な横断的関心事は、コールバックを追加するconcern

concernの事例

Basecampの事例

concernといえば、DHHがかつて示していたBasecampのコード例が有名。

これ自体の賛否は置いておくとして、includeしているモジュールがすべてcocnernモジュールだと仮定すると、その名前には次のようなバリエーションがある。

  • 名詞
    • 単数形、複数形
  • 動詞
    • 接尾辞がable
      • スコープやメソッドを追加するために使われているように見える
    • 現在形(原型と三単現両方ある?)
    • 過去分詞

想定しうるバリエーションがあらかた存在する状況で、これだけだと明確なルールが見出しにくい。

api.rubyonrails.orgの事例

ActiveSupport::ConcernFooモジュールのような単純な例だけだったので、concerningのほうを見ると、EventTrackingという名詞のconcernが紹介されている。このconcernはメソッドを追加しつつコールバックも定義している。

AOPの観点でconcernを作る

実事例を見つつ、さきほど書いたAOPの静的・動的横断的関心事によるconcernの分類に基づくと、ある程度納得感のある名前を持つconcernが作れそうに見える。

モジュールやクラスが静的な横断的関心事にあたるconcernをincludeすると、その時点でconcernが定義しているクラスマクロ、スコープ、またインスタンスメソッドが使えるようになる。つまり、それらを利用するか否かにかかわらず使えるようにはなる。とすると、Rubyのプレーンなモジュールと同じく動詞+ableにするのがよさそうに見える。

たとえば、パーフェクトRuby on Railsに載っているようなTaggableなら付与したタグのためにtagged_withスコープやtagsなどの関連が追加されるだろうし、UserSessionIssueableならログインセッションをセットアップするメソッドlogin_as_userが追加されるだろう。

module Taggable
  extend ActiveSupport::Concern

  included do
    has_many :tags #, ...
  end

  class_methods do
    def tagged_with
      # ...
    end
  end
end

module UserSessionIssueable
  extend ActiveSupport::Concern

  def login_as_user
    # ...
  end
end

一方、モジュールやクラスが動的な横断的関心事にあたるconcernをincludeすると、その時点でconcernが持つコールバックを必要に応じて自動で実行するようになる。「できるようになる」よりは「する」なので、concernのモジュール名は動詞の現在形にしておくのがよさそう。また、アトリビュートも追加されて、なんらかの状態を持たされるなら過去分詞にするといいかもしれない。

たとえば、NotifyImportantOperationなら重要な操作をどこかに自動で通知するコールバックを定義するだろうし、Obfuscatedなら特定のアトリビュートを自動で難読化したものを新たにアトリビュートとして持つようにできるだろう。

module NotifyImportantOperation
  extend ActiveSupport::Concern

  included do
    before_action :notify, only: [:create, :destroy]
  end

  def notify
    # ...
  end
end

module Obfuscated
  extend ActiveSupport::Concern

  included do
    attr_reader :obfuscated
    after_validation :set_obfuscated
  end

  def set_obfuscated
    # @obfuscated = ...
  end
end

うまく命名できるconcernになっているかどうか考えることは、大きすぎたりモジュールとしての意味のまとまりが希薄なconcernを作らないようにするのにも役立ちそうだ。

今回のAOPの考えかただと、concernに名詞で命名するとしっくりくる状況があまり思いつかなかった。一方で、EventTrackingの例のように関連とコールバック両方を定義するようなときは、利用可能なメソッドの定義と自動で実行されるコールバックの両方が手に入るので、上記の命名方法よりは適切に名詞で命名したほうがわかりやすいというのはあるかもしれない。


  1. 『パーフェクトRuby on Rails【増補改訂版】』13-1-3

  2. https://texta.pixta.jp/entry/2022/01/12/150000 など参照

既存の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. 🧉

参考にした資料

OKRについて調べていた

今年から会社で導入されているOKRについて、結局どうやっていけばうまく運用できるのかわかっていなかったので、入門書を読んで調べていた。

OKRとは

OKR - Wikipedia

a goal-setting framework for defining and tracking objectives and their outcomes.

特徴的なのは、それなりに背伸びした成果*1(ストレッチ・ゴール)を目標の達成度を測る尺度として利用するところ。

読んだ本

1冊目の『OKR』はそれなりにレビューで評価が高く最初に読むにはよさそうだったので選んだ。2冊目の『Measure What Matters』は元IntelでシリコンバレーのGoogle含む各企業にOKRを広めたJohn Doerrの著作。原典に近そうだったので選んだ。

読書メモ

『OKR』

第1部は、とあるスタートアップがOKRを導入してみて1回失敗し、そのあともう一度トライしてうまくいくみたいな話。海外ドラマを見ている気分になる。

第2部でOKRというフレームワークについて説明。OKR設定については主に次のような注意点がある。

  • objectives (O)
    • 定性的かつ時間的に短距離走になる目標
      • 四半期がよさそう
    • チーム内で完結する目標
      • 達成できなかったときに他のチームのせいにできない
  • key results (KR)
    • Oの抽象的な表現の具体的な意味を定義する
    • KRは一般的に三つ
    • 自信度は10分の5 (50%?)
    • まず測定対象を決めて、そのあとムーンショットといえる値かどうかを話し合う
    • ビジネス指標と品質指標を組み合わせて相補的なものとする
    • ポジティブな尺度(加算方式)にする

機能別部署(技術部など特定の専門職だけが属する部署)の目標は必ずしもプロダクト・チームのOKRにつながらないので、プロダクト・チームのメンバーが機能別部署から仕事を命じれられた場合に混乱が発生する。なので、そういうタスクはプロダクト・チームの目標に組み込むのがいい、という記述がある。

このあたりは自分の所属しているチームがそもそもプロダクト横断で開発者体験やセキュリティを向上させるという機能別部署に近いチームというのもあり、そういうチームでOKRを考えるのは、一工夫いる感じがした。

OKRを日常の一部にするために、週始めにチェックイン、週終わりにウィンセッションを実施するとよいという話があった。それぞれ次のようなもの。

  • チェックイン
    • 目標に向けてやるべき優先事項(P1)3件を決める
      • OKRに繋がるか熟考する
      • 複数の手順を要する濃い仕事を選ぶ
    • 今後1か月のプロジェクトの予定を共有する
    • OKR自信度の状況を更新する
      • 下がっている場合、対策を考える
        • 自信度が下がるのは、自力で解決できない問題、技術的、スケジュール的、外部との折衝などにおける問題が出てきた時
      • 時間が進むにつれて上がる方がいいっぽい?
    • 健康・健全性指標を確認する
      • チーム自身の健康、コードベースの健全性、顧客との関係など守りたい要素の状況を確認する
  • ウィンセッション
    • 作業中のコード、デモを見せあう
    • チーム横断でやると盛り上がってよい
      • 軽食を用意して実施するとよい、会社はそういうところに投資すべきとか書いてあってよい

「よくあるOKRの失敗例」はゴールが多すぎる、ストレッチ・ゴールになっていない、などアンチパターン集になっていて参考になる。

そのほか、毎週のチーム状況報告メールの書きかたにも言及されているが、これはそのままやりたくない感じがする。エッセンスだけ抜き出してNotionなどのプラットフォームの上でうまくできればいいのかもしれない。

『Measure What Matters』

こちらの本はOKRを用いた会社のケーススタディがたくさん載っている。フレームワークのコツについて簡潔に説明しているような本ではなかった。むしろおもしろかったのは、著者がIntel時代にOKRを生み出したAndy Groveと出会ってからの体験についてなどで、そのあたりは興味深く読んだ。

実践

そもそも改めてOKRについて調べたのは、2021年上期に所属チームで雰囲気でOKRをやっていて、惨憺たる結果に終わったからだった。OKRは1回は失敗するものらしいが、そもそも方法論もよくわかっておらず、チェックインの頻度も推奨されるものよりずっと少なかったので、失敗の仕方も悪い感じがした。

前回は第一四半期と第二四半期をまとめて一つの期間とし、OKRの目標を決めていた。しかし、状況は色々と変わるので、今回は第三四半期に絞って目標を定めてみることにした。目標を決めるときは『OKR』の「OKR設定ミーティングを開催する」を参考にしてチームで時間を取らせてもらった*2。Oはなんとか決まったが、それを測る自信度10分の5のストレッチが効いたKRを決めるために、まず現状(例えば、どれぐらい最新でないバージョンのパッケージが残っているか、とか)を把握したうえで目標を実現するKRの値を決める必要がある。このあたりを決めないとOKRが始まらないが、現状把握もそれなりに大変なので、ここが踏ん張りどころだと思う。

これまではスクラムっぽいなにかで2週間に1回ふりかえりとKPTを実施し、計画と称してタスクの優先順位を決めて、カンバンを運用していた。しかし、そもそも顧客への機能の提供に主眼を置いたプロダクト・チームではないこともあって、チームに渡された期限の決まっているタスク以外の開発者体験の改善のようなタスクをどのように進めるかが曖昧になりがちだった。この点はOKRのチェックインでかなり解決できるのではないかと考えている。目標を慎重に決めておけば、毎週始めのチェックインで現状のKRの値やKR自体の自信度を更新し、それらをもとに目標を達成するために必要な優先事項を3件挙げて、それに必要なタスクの優先順位を上げればよいので、フォーカスが絞られる。これらが従来のタスクの優先順位決めに代わるものになる。

週末はウィンセッションとして各自の作業の成果*3を見せたりできればいいのかなと考えている。

というような感じでやっていくと、やるべきことにフォーカスして物事を進められるような気がする。2021年第三四半期は、ためしに上に述べたような方法を使ってチームでOKRを運用してみようと勝手に考えている。

*1:『OKR』では「10分の5の自信度」という表現が出てくる

*2:その節は稚拙な進行で失礼しました

*3:CLIでなにかが動く、とか調べ物をしたドキュメントを見せる、とかでいい

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は単調増加する