Replicated State Machinesでのストレージ故障からのリカバリー

FOLIOアドベントカレンダー2018 17日目です。 昨日はyasuharu519さんのDocker stop 時に別のコマンドを実行して Graceful shutdown を実現するでした。

NewSQLを調べつつ最近はストレージ周りにも手を出してみたいバックエンドエンジニアの @matsu_chara です。

今回はFAST '18のBest PaperであるProtocol-Aware Recovery for Consensus-Based Storage を読んだので紹介や感想を書きたいと思います。 自分が面白いと思ったところを中心にしたり、個人的に調べた内容が入っていたりするので正確かつ完全な情報は上記論文や関連文献等を直接読んでいただければと思います。

ざっくり

プロトコル特有の知識を利用した分散システムにおけるストレージ障害からの回復方法(PAR=protocol-aware recovery)である、CTRL (Corruption-Tolerant RepLication)の提案です。

多くのモデルでは、 クラッシュ, ネットワーク断 を想定することが多いですが、今回はこれらの障害に加え、ストレージ故障も含めます。 ここでのストレージ故障は、何らかのストレージレイヤーでの故障によりデータ破損(数byteが読み取れないetc)・アクセス不可能(read error)などが発生するケースを指しています。

この ストレージ故障 が発生したときに、合意アルゴリズム特有の知識を利用することで データが一つ以上コピーされている場合は安全に復旧する, すべてのデータが破損している場合は(間違ったデータを返すのではなく)利用不可 といった保証を実現するのがCTRLです。 ただし、特定のconsensus algorithm(今回の場合はRSM=Replicated state machines)での知識に依存しているので全てのシステムに適用できるわけではありません。 適用範囲こそ限定されてはいますが、RSMはpaxos, raftやzab(zookeeper)など幅広く使われているので、これについてより安全性が高まるのは重要な成果と言えそうです。

モチベーション

前述しましたが、クラッシュとネットワーク断以外にもストレージ故障によってデータ破損やアクセス不可能などの障害が発生する可能性があります。 ext2,3,4などではデータ破損時に破損したデータが読み取られることがありますし、btrfsではデータ破損時にアクセス不可能になります。(どちらのfilesystemでもアクセス不可能になるような障害はありうる) またデータだけでなくinodeなどのメタデータももちろん破損することがあります。

このような状況下で、分散システムの1ノードについてデータ破損が発生した場合、あるあるなパターンではデータを完全削除して再起動(=まっさらなノードとして立てる)という方法があります。*1

さて、これで安全に復旧可能かと言われるとそうではありません。むしろこのリカバリー中にコミット済みのデータが失われることすらあります。

5台のクラスタについて下記のシナリオを考えます。

f:id:matsu_chara:20181130203110p:plain
https://www.usenix.org/system/files/conference/fast18/fast18-alagappan.pdf より

  1. データ破損が発生したノードS1のデータを削除して再起動
  2. 何らかの理由で最新commitedなデータを持ったノードS2,S3がcrash。(このときS1,S4,S5は最新データを持ってないとする)
  3. 残ったS4,S5+S1でmajorityを構成できるのでS1,S4,S5のクラスタが誕生

このときS1,S4,S5は前述の通り最新commitedなデータを持ってないので、同時にcrashしたのは2/5台なのにcommit済みデータが失われました。(= globally data loss)*2

このような問題は当然困るので、これらを防ぐための方法が必要です。 ここで、protocolに依存しない( protocol-oblivious な)方法ではなくprotocolに依存した( protocol-aware な)方法を採用しているようです。*3

今回の論文ではRSMに対応したprotocol-awareなrecovery手法(PAR)を考えます。 冒頭でRSMはpaxos, raftやzab(zookeeper)で使われていると述べましたが、これらの合意KafkaやHDFS、GFSやBigTableなど様々な分散システムを構成する際に用いられる基礎的なパーツの一つになっており、これの信頼性を上げるというのは一つの大きなチャレンジと言えそうです。

CTRL (Corruption-Tolerant RepLication)

ストレージ故障はメディアエラー以外にもファームウェアデバイスドライバー・ファイルシステムのバグなどいろいろな事象から発生します。 今回の故障モデルでは通常のエラーに加えストレージ故障として2つのエラー(block errors, corruption)を追加します。

すでに知られている対処方法

No Detection

checksumを見ない方法。間違ったデータをクライアントに返すことがある。(多くのシステムでは基本的にはchecksum見ている。がLogCabinはsnapshotを読み取るときにchecksumを見ていないらしい)

Crash

checksum等を見ておかしかったら、そのノードをクラッシュさせる。(LogCabin, Zookeeper, etcd, ...etc。例えばzookeeperはsnapshotが壊れていた場合はcrashする。LogCabinはsnapshotではチェックしていないがlog entryではチェックしているらしい) 間違ったデータを返すことはないがどこか一箇所でも壊れていることを検知したらcrashするのでクラスター全体がunavailableになるリスクが増している。 ディスク故障の場合、crashしたあとそのまま再起動しても治らないケースもあるのでマニュアルで治すまで障害が続くのも特徴。

Truncate

壊れていたデータの後続を捨てる。(RSMなので後続ログを捨てるイメージ)

自動で復旧してくれそうに見えて、comittedなデータを失うことがある。 truncateしたノードとlagがある複数台のノードでmajorityを構成できるようなケースではtruncate後のログが正になりうる。その場合一度commitしたはずのデータが(特にエラーなどを出さずに)上書きされる。*4

また、ログの先頭の方でストレージ故障が発生した場合、多くのデータをリーダーから貰う必要がありネットワーク帯域を消費したり、リカバリー時間の増加につながるといった問題がある。

safetyとfast recoveryに問題があるTruncateですが、一方でavailabilityは高いですしマニュアル作業などは不要。

DeleteRebuild

Truncateだが、異常を発見したら完全にまっさらにしてnodeを立て直す方法。*5 メリット・デメリットは基本的にTruncateと同じ。

MarkNonVoting

GoogleのPaxosベースのシステムで使われているらしい。*6

レプリケーションが追いつくまでvotingの権限を奪うことでTruncateで起きたようなsafety violationを回避することができます。 が、それでも安全でない場合がある様子。*7

またストレージ故障時にvoteできなくなるので可用性にも問題が出る可能性がある。

Reconfigure

壊れたノードを取り除いて新しいノードをjoinさせる方法。 新しいnodeをjoinさせるためにはマジョリティのコミットが必要なので、可用性に問題が出るケースがある。

BFT

究極的にはByzantine障害耐性のあるアルゴリズムを使えばストレージ故障があっても一貫性のある値をクライアントに返すことができる。 が、コストが高い(スループットが半分になる可能性もある)。また故障数fに対して3f+1ノードが必要なのもネック。

CTRL

CTRLが利用するRSM特有の知識

  • leader base: すべての更新はまずリーダーが受け取る
  • epoch: 1epochにつきリーダーは一人。ログは<epoch, index>の組でユニークに特定できる。(indexはログエントリーに振られる番号)
  • leader completeness: leaderはすべてのcommitedなログを持っている。

CTRLではlocal storage layerdistributed recovery protocol の2つがセットになって復旧を行う。 具体的にはstorage layerがストレージ故障を検出し、distributed recovery protocolが冗長化されたデータから復元する役割分担になっている。

CTRLが保証すること

  • コミット済みの正しいデータが最低一つあれば、それを使って修復を行う
  • コミット済みのデータは消失しない。
  • すべてのコピーが故障した場合、unavailable状態になる(暗黙に上書きなどを行わない)
  • さらなる保証:uncommittedなデータの故障についても可能な限り早期に決定を行う(可用性向上)

ストレージはログ・スナップショット・メタデータの3つを永続化する。

  • ログはRSMのエントリー単位で故障を検知する(protocol aware)
  • snapshotは大きくなるのでチャンク単位で故障を検知する
  • メタデータはノード固有の情報を持つので他ノードからのリカバリーは不可能である。そのためローカルに2つのコピーを持って復旧を行う。

storage faultの検知はreadのreturn codeとchecksumで行う。メタデータのエラーもopen時のエラーハンドリングで検知可能。*8

大抵のケースで、メタデータfault時はcrashさせる。 これはsafetyには影響しないがunavaiableになる時間が増える。 ただデータブロックのほうがメタデータブロックに比べて圧倒的に多いので、相対的には許容可能な停止時間になるだろうとの見込み。

CTRLで行われる故障の分離

単にchecksumミスマッチを起こした場合でも下の2つの場合を分離可能

  • 更新途中でノードがクラッシュした場合(crashed)
  • 古いログが壊れた場合(corrupted)

前者の場合はackを返してないはずなので安全にログをdiscardできる。 コミットされているかどうかはpersist record p_i をevent entry e_iの後のタイミングで書きこむことで確認できるようになる。

通常は write(ei), fsync(),write(pi), fsync() のようにするが、厳密な判断をしたいわけでなくcrashのケースを拾いたいだけなので、パフォーマンスのネックになるfsyncを除いてwrite(ei),write(pi),fsync() としている。

e_i, p_iの順序が入れ替わるかもしれないことに注意。(今回のケースでは大丈夫ですが、fsyncを入れないと、どうなるか?といった議論はちょうど去年のアドベントカレンダーに書いた内容で議論されているのでぜひ https://matsu-chara.hatenablog.com/entry/2017/12/09/110000 という脱線)

つまり、persist recordが存在しなければcrashであることがわかる。存在する場合にeが最後のイベントだと判定が行えなくなるがその場合はcorruptと判定して他ノードからリカバリーする。*9 *10

なおsnapshot等はtemporary fileに書いたあとatomicにrenameを行うことで破損を避けています。

CTRLで行われる故障箇所のidentify

故障したデータが何だったのか?という特定(identify)を行う必要があるため、identifierを別途保存してどのログやデータが壊れたか分かるようにしている(ついでにpersist recordも兼ねる)。内容としては <epoch, index> のペアがあればログエントリーを一意に特定できる。 identifierは物理的にデータとは別の場所に保存されるため、基本的に同時に壊れる可能性は低い。もし同時に壊れていた場合はnodeをcrashさせる。(unavailableにはなるが、頻度が低いため許容可能)

CTRLでの具体的なリカバリー方法

ナイーブな方法としてLeader Restriction. リーダーになる際にfaultyでないことを保証する方法がある。 リーダーがすべてのイベントを持っているので、フォロワーのストレージ故障時は基本的にはリーダーからコピーすればOK。 ログを問い合わせ(<epoch, index>でユニーク)て、リーダーが知らない場合は正式にcommitされたものではないのでdiscardする。

ただし、この方法だとリーダーになる前にすべてを保証しないといけなくてunavailableになる可能性が高くなる。 一つでも無事なコピーが存在していればクラスタ全体は継続して利用可能にしたい。 そのため以下のようにクラスターから情報を収集して判断を行う。

リーダーが正常で、フォロワーが故障している場合

修正は容易。

  • リーダーからコピーする
  • リーダーが知らない場合は、uncomittedなものなのでdiscardする
リーダーが故障している場合

リーダーはフォロワーに<epoch, index>を問い合わせる。レスポンスは以下の3種が考えられる

  1. (have) フォロワーが壊れていないログを持っている
  2. (dontHave) フォロワーがログを持っていない
  3. (haveFaulty)フォロワーはログを持っているが、壊れている。

リーダーはこれらのレスポンスを集めて以下の判断をする

  1. 一つ以上のフォロワーから壊れていないログが帰ってきた(have)場合はログを修復する。
  2. フォロワーの過半数がログを持っていない場合(dontHave)、未コミットなので該当イベントとその後続を捨てる
  3. haveFaultyが帰ってきた場合は1, 2のどちらかになるのを待つ*11

Snapshotリカバリ

ログ同様snapshotも壊れる可能性がある。ZooKeeperとLogCabinはどちらもsnapshotが壊れていた場合に正しくハンドルできないケースがある(らしい)。 スナップショットは本質的にコミット済みの物のみを含むのでdiscardできない点がログと異なる。

また各ノードが独立してsnapshotを取るので、uniqueなidentifierで取得といったことができない(そのidで必ずsnapshotを取るわけではないので)し、その場合chunk単位で持ってくるのも難しいのがハードル。まるっとsnapshotを送ると帯域を消費するため避けたい。

これを解決するためにリーダーがsnapshotを取るindexについて合意を取ることで同一のsnapshotを生成する。 過半数がsnasphotを取り終えたらGC markerを挿入して再び合意を取りつつログをGCする。

リカバリー時は以下のように判断する。

  • ローカルログがGCされずに残っている場合はそこから復旧
  • GCされている場合は、他ノードでsnapshotがあることが保証されているので、そこからコピー
  • フォロワーがリーダーの知らないindexを問い合わせた場合(フォロワーが古いindexを送る場合など)は、リーダーが最新のsnapshot全体を送ります。*12

まとめ

今回の論文では以下のようなテクニックが使われていました。

  • a crash/corruption disentanglement
  • a global-commitment determination
  • leader-initiated snapshotting

この後、実装についてとパフォーマンス評価が続いていますが今回は割愛したいと思います。興味が出たらぜひ読んでみてください! Pythonベースのモデル検査やTLA+ specification of RaftにCTRLのログリカバリーを追加して検査もしているようです。*13

related worksにはいくつかのstorage fault対策をほどこした研究が挙げられています。基本的には故障台数の上限を決めて動かしているようです。(わりと自然な仮定なような気もします)CTRLは一つでもコピーが残っていればなんとか復旧してくれるのでそういう実用面では優位性がありそうです。 一方でPASCはメモリエラーにも対応できるようです。(CTRLでstorage faultに耐え、PASCでメモリーエラーに耐えるなどの併用による強化も可能らしいです。)

感想

FASTは普段はあまり読まないんですが、たまに読むと課題設定からして勉強になることが多いのでFileSystemマイスター以外の方にもおすすめです。

zookeeperがお手軽にinconsistentにされることが多い気がするんですが・・不憫。(それだけ使われているということでもあります)

分散システムのみならずアプリケーションの整合性を考えるという意味ではファイルシステムの整合性は切っても切り離せません。 うまく動くでしょ、と思っていたらファイルが破損して・・みたいなことは実運用にでかいシステムを乗せると 稀によくある ことだと思うので、 本番で論文に出てくるようなものを直近で使う予定がなくても、ここでおかしなことになるといった知見や、それに対する解決方法などから得た新しい概念はどこかで活かせるのではないかと思います₍₍ (ง´・_・`)ว ⁾⁾

*1:本番で愚直にやるとレプリケーション負荷で死ぬシステムもあるのでご利用は計画的になやつ

*2:リカバリして最新に追いつくまでhealthyなnodeとしてカウントしなければ、そもそも前提エラーになるような気もしなくもない?と思った人!(僕がそうなんですが・・)この後その方式が出てくるのでご期待ください

*3:protocol-obliviousだと何故だめなのかに関する議論はあまりなかったように読めました。要勉強。

*4:この辺は前述したまっさらにして再起動パターンで起こる現象と同じですね。

*5:検証中とかによくわかんなくなったらとりあえずこれやりますよね

*6:引用されてる文献はリアルワールドPaxosノウハウ集で有名なやつ(?)ですね https://www.cs.utexas.edu/users/lorenzo/corsi/cs380d/papers/paper2-1.pdf

*7:リーダー入れ替え後に古いリーダーからのエントリーを受け入れてしまうようです。詳しくは文献70を参照

*8:メタデータ破損時はそもそもファイルが開けなくなるという想定

*9:crashedにするのはあくまでも最適化なので、crashedなものをcorrupt判定してしまっても保証にとっては影響がないということですね。

*10:pもcorruptedになるのでは?と思うが、pもchecksumにより検査されるのと、p自体は小さいのでatomicに書かれるためcrashによってはcorruptedにならないだろうとのこと。なおeとpが同時にcorruptedになった場合は後者と判断し、慎重にリカバリーを行う。

*11:過半数がhaveFaultyで、残りがdontHaveを返してきた場合と、全体がhaveFaultyを返してきた場合について記載がないように思える(見落としかも)ものの、corruptedであるというエラーを返すのが目的だったと思うのでそのようにするのかなと思っています。

*12:すべてのノード上でsnasphotが壊れていた場合のケースがなさそうでしたが、その場合はもはやどうしようもなさそうですね・・。

*13:そこも気になったんですがさらっと書いてあるだけでした。

オンプレconfluenceのURLをクラウドconfluenceのURLに変換するスクリプト

on-premisesなconfluenceからcloudに移行するとき、マイグレーションツールによってリンク等がある程度自動で引き継がれます。 が、ソースコードのコメントなどconfluence以外の場所に書かれたコンフルURLについては(もちろん)変換対象外なので旧コンフルURLのままになります。

pageIdあるしドメイン名変えれば大丈夫だろうと最初は思ったのですが、どうもpageIdも変わるし、URLの構造も変わる!という仕様のようです。 幸いなことに「spaceKeyとtitleでユニークになる」という仕様自体は同じようなので、mappingするスクリプトを作りました。

リポジトリにPRまですると大変そうだったので変換スクリプトを生成するスクリプトになっています。(実行すると置換されるので、PRなどは各自おねがいします方式) ガッと作ったのでAPI呼び出しでエラーになったときのエラーメッセージが雑ですが、認証関係がちゃんとしていれば大丈夫なはず。ただしコンフルのバージョンによってAPIが違うかもしれないのでそのへんは要調節です。

後、確実に変換漏れがあるのでコンフルの {pageId, spaceKey, title} をダンプしてどこかに保存しておくと、後からこれなんだっけ?という場合に辿れるようになるのでそちらもあると良いです。(以下のスクリプトを少し変えれば、そのjsonファイルから変換スクリプトを作ることも可能)

#!/bin/bash

set -eu

## require
# - ag
# - gsed
# - jq

### usage
#
# 1. generate convert.sh
# ./confluence.sh $JSESSIONID $CONFLUENCE_EMAIL $CONFLUENCE_API_TOKEN $OLD_CONFLUENCE_HOST $NEW_CONFLUENCE_HOST > convert.sh
#
# 2. execute convert
# ./convert.sh

### env
JSESSIONID="$1" # confluence session via cookie
CONFLUENCE_EMAIL="$2" # for login
CONFLUENCE_API_TOKEN="$3" # https://confluence.atlassian.com/cloud/api-tokens-938839638.html
OLD_CONFLUENCE_HOST="$4" # example.old.confluence.com
NEW_CONFLUENCE_HOST="$5" # example.cloud.confluence.com

grep_page_id() {
  ag --nofilename "$OLD_CONFLUENCE_HOST" | grep pageId | gsed 's/.*pageId=\([[:digit:]]\+\).*/\1/' | sort -n | uniq
}

get_page_space_key_title() {
  local pageId

  while read -r pageId; do
    pageJson=$(curl -sS "https://$OLD_CONFLUENCE_HOST/rest/api/content/$pageId" -H "Cookie: JSESSIONID=$JSESSIONID")
    echo "$pageJson" | jq -c "{spaceKey: .space.key, title: .title, oldPageId: \"$pageId\"}"
  done

}

get_new_confluence_page_id() {
  local pageJson

  while read -r pageJson; do
    local spaceKey=$(echo "$pageJson" | jq -r '.spaceKey')
    local title=$(echo "$pageJson" | jq -r '.title')
    local oldPageId=$(echo "$pageJson" | jq -r '.oldPageId')
    local newPageId=$(curl -sS --header 'Accept: application/json' --user "$CONFLUENCE_EMAIL:$CONFLUENCE_API_TOKEN" -G "https://$NEW_CONFLUENCE_HOST/wiki/rest/api/content/search" --data-urlencode "cql=(type=page and space=$spaceKey) AND (title=\"$title\")" | jq -r '.results|map(.id)[]')
    echo "$pageJson" | jq -c \
      --arg newPageId "$newPageId" \
      '.+ {newPageId: $newPageId}'
  done
}

convert_new_page_id_to_new_url() {
  local pageJson

  while read -r pageJson; do
    local spaceKey=$(echo "$pageJson" | jq -r '.spaceKey')
    local title=$(echo "$pageJson" | jq -r '.title')
    local oldPageId=$(echo "$pageJson" | jq -r '.oldPageId')
    local newPageId=$(echo "$pageJson" | jq -r '.newPageId')
    echo "$pageJson" | jq -c \
      --arg newUrl "https://$NEW_CONFLUENCE_HOST/wiki/spaces/$spaceKey/pages/$newPageId" \
      --arg oldUrl "https://$OLD_CONFLUENCE_HOST/pages/viewpage.action?pageId=$oldPageId" \
      '.+ {newUrl: $newUrl, oldUrl: $oldUrl}'
  done
}

sed_old_to_new() {
  local pageJson

  while read -r pageJson; do
    local oldUrl=$(echo "$pageJson" | jq -r '.oldUrl')
    local newUrl=$(echo "$pageJson" | jq -r '.newUrl')
    local spaceKey=$(echo "$pageJson" | jq -r '.spaceKey')
    local newPageId=$(echo "$pageJson" | jq -r '.newPageId')
    if [ "$newPageId" != "null" ] && [ "$newPageId" != "" ] && [ "$spaceKey" != "null" ] && [ "$spaceKey" != "" ]; then
      echo "ag --files-with-matches -Q \"$oldUrl\" | xargs -I {} sed -i '' \"s@$oldUrl@$newUrl@g\" {}"
    fi
  done
}

check_remaining() {
  echo 'echo "remaining after sed"'
  echo "ag --ignore convert.sh $OLD_CONFLUENCE_HOST/"
}

grep_page_id | get_page_space_key_title | get_new_confluence_page_id | convert_new_page_id_to_new_url | sed_old_to_new
check_remaining

参考

ペアプロとフロー効率

世の中に大量にペアプロ記事があるのに新たに追加してしまうシリーズ。

自分の考えのまとめ置き場が欲しかったんだ(´;ω;`)

背景

ペアプロのメリットについて、詳しい人からの知識移転ができる。先輩から後輩へのスキル移転ができてチームが成長するといったメリットが挙がることが多いと思います。

これはこれで分かるし必要だと思うんですが、一方で二人で一つの作業をやったら効率は半分になるのでは?といったもやもやがあるときに上記のような長期的なメリット*1を挙げられると頭ではわかるけど腹は膨れないなというか、実践につなげるのが難しいことってないでしょうか?

特にペアプロは一人では完結しないので草の根的に行うのには大きな熱量を必要とします。 「長期的なメリットがあるからやろうぜ」といって人を巻き込んで、それを布教して、長期的なメリットが得られるまでやり続けられるって結構大変じゃないかなと思ってます。

この記事では、長期的なメリットについては脇に置いて、ペアプロをやることで短期的にもこんなメリットがありそうだ!といった近視眼的視野でペアプロについて考えます。 そのためガチ勢からすると重要なところが語られてない!と思うかもしれません。その辺はちゃんとした記事がたくさんあると思うので許して。 あといくつか仮定があるので、うちはこうじゃないとかうちには当てはまらないとかもあると思います。人によって答えは違って良いだろうということでこれも許して。

なお話の前提になるフロー効率については フロー効率性とリソース効率性について で学べます。

行ったり来たりのレビュー HELL

さて、機能開発においてはどのような言語でもそこそこの量のコードを書くことになると思います。 典型的な着手方法としてAさんは機能1を作ってBさんは機能2を作るといった形での作業分担方式がありそうです。

作るところまではいいんですが、レビューがなかなか大変ですよね。 正直、ドメインのコードが300行どーん!みたいなPRがきても一発でマージできることはなかなかないでしょう。

これは得てしてスキルの問題ではなく将来どんな追加が予想されるのか?といった未来に関する想定の違いや、こういう概念もあるんじゃない?といった捉え方の違いだったりします。 ドメインの捉え方、パッケージやクラスをどう切るか、一人だと考え方の癖がどうしても出るしレビュアーと合意に至るためには多少なり議論が必要になることが多いでしょう。

こういった議論が必要なケースで非同期のレビューを続けてレビューコメント数が100近くになったり、レビュアーが別業務で忙しかったりすると3時間に一回レビューコメントを返信し続けて早一週間みたいな状況になり得ます。

さらにレビュー対応によりドメインの構造が大きく変わったので先に開発しておいた5つのPRを順番にリベースしてまわるみたいな地獄に続くこともあるでしょう。

反対に、(なんかここまで書いてもらってるしAPI実装自体はできてるからそこまでモデリングこだわらなくてもよいか・・?自分の考えも一長一短あるからこれの方が良いのかも・・)みたいな気持ちになってLGTM!!!みたいなことも人生に一度くらいは覚えがあるのではないでしょうか。 こんなことになったら、もうレビューは単なる作業チェックです。*2

こうして長い長いレビュー時間が必要になったり妥協したソフトウェアが完成するわけです。 後者については防ぐべき事柄だと思います。前者についてはどうでしょうか?

フロー効率と手戻りの最小化

レビュー待ち時間が長くても、レビュアー・レビュイーは待っている間に何かの仕事を行ってはいるのでリソース効率については問題ないと言えます。 ですが、「一つの機能がマージされるまでが長い」「リベースといった本来不必要な作業が発生する」といった点はまさにフロー効率が悪い場合の特徴と言えます。

フロー効率を改善しようというのはペアプロを始める一つの動機になるでしょう。 具体的なメリットとして、ドメインのコードを書く段階で合意が取れたコードを作成できます。 また書いてしまったし今更言うのもな・・といった遠慮の問題も大きく軽減できます。

書いたコードが即レビューされる小さいフィードバックサイクルを持つ開発プロセスでは、レビューによって後から大量の修正が必要となるような手戻りのコストを削減できます。

また、ペアプロでは非常に細かい単位でこれはどうしてこうするのか説明を求められます。 口頭での説明を通して、二人が本当に対象を深く理解しているのか?を細かく確認し合う中で、惰性で書いたコードとそうでないコードを区別できるようになります。

そこで説明できないコードはおそらく対象となる問題への理解が不足しているでしょう。 そしてそれは、何かを説明しているようで結局聞かないとわからないあいまいなコメントや、何となくここに入れておけば怒られないゴミためパッケージの誕生につながります。

説明と議論を通してコードやモデリングの穴を探すのは非常に有効な手段です。(それこそが単なる作業チェックではないレビューのあるべき姿のはず。) ペアプロではこうした妥協による品質の低下も削減できますし、むしろ通常のレビューよりも濃いレビューを促進できます。*3

ペアプロではたしかに二人でコードを書くことになりますが、レビューのやりとりとリベース地獄によって消費される時間を思えばそこまでスループットは低下しません。むしろ普段集中するために費やしてる時間もコード書けるので向上を感じられることもあるんじゃないかなと思います。 レビューが長くなりそうだなと感じたときにペアプロを始めると成功してる実感を得やすいはずです。(ペアレビューから始めるのもおすすめです)

さらに、細かいフィードバックサイクル(レビュー・説明)によって作業分担方式では検出できなかった潜在的な問題を早期に検出できるような効果も期待できます。 細かい品質の積み重ねが後に大きな違いになるのは想像に難くありませんし、議論によってリファクタを繰り返すスタイルは細かい成功してる実感も感じやすいんじゃないかなと思います。

あと単純に楽しいです。集中できない日もペアでとりあえず始めると意外と集中できます。

ペアプロしたい時

ぶっちゃけ手戻りとか難しいこと考えずに、これレビューコメントが多くなりそう!みたいな予感がしたらペアプロするというのが一番簡単な判断基準です。

ただしペアプロは魔法の薬ではないので、いつ何時もうまく行くわけではありません。 会話のスピードで色々決まってしまうので一人で考えたほうが細部まで詰められるということもあるし、モデリングの自由度が高すぎる状態で0から初めると議論が発散する可能性もあります。便利な仕組みを1から作るときや0からモデルの土台を作るところまでは一人でやったりして、適宜切り替えられると便利です。

まとめ

昨今ではペアプロやってるところも特に珍しくなくなってきてモブプロが推進されているチームも多くなってきているので(要出典)、いまさらペアプロの良さを書くのはやや時代遅れな感じもしますが、一方でどういう効果があるんだっけ?というのは常に捉えておかないと、「いいよねペアプロ教」みたいになって(理由の説明が十分じゃないのに)誰かに押し付けてしまったり、逆に「ペアプロはいつも有効なわけじゃない」となって十分に議論されずに不要とされてしまうことはまだまだあるのではないかなと思います。

楽しくできてれば良いかなと言う気持ちもありますが、トレードオフ関係を明らかにした上で良い方法を選択したいので考えをまとめました。(でも全体的にふわっとしてるし、書いてから読み直しただけでちょっと恥ずかしくなる・・・)

ということで、フロー効率が下がってきたな(PRのマージがなかなか終わらないな、議論ちゃんとできてないな)なんてときにペアプロすると短期的な効果が実感しやすくて、長期的な目的を根気強く目指さなくても気軽にペアプロやりやすいんじゃないかなという話でした。

ref

*1:ここでの長期は割と感覚的なものなので人によって違うはず。自分の中では”いつかメリットを感じられるその時まで”やり続ける必要があるといったイメージ

*2:ここまでで、こんなこと一度もない。と思った人は成熟したチームの一員として一つの目標として働けていると思うのでめっちゃ羨ましい限り。

*3:ここはオンラインのレビューでも元からできてるよ!っていうチームもあるかもしれません。

見積もりについて勉強したことを色々スライドにしてみた

見積もりについて思ってることとかをまとめてみました。

マネージメントの実戦経験というよりソフトウェア開発手法について学んだ結果のアウトプットみたいなノリでみていただければ幸いです。

社内勉強会で話したので会社のスライド使ってますが、会社が今こうというわけではなく一般的な話にとどめています。(あとstudy_lean_agileっていう名前の勉強会なんですが、このスライドはリーンもアジャイルもあまり意識していません)

前半は自分なりの考え方、後半は具体的にはこうしたらいいんじゃないかと思っていることという風に分けています。

www.slideshare.net

www.slideshare.net

descriptionにもちょっと書いてあるんですが、「見積もりと計画は違う」みたいな話とか「じゃあ見積もりについてはこれでいいとして計画はどうすればいいの?」みたいな話とかいろいよ残っているので続きを書く予定です。

こういう価値観をどう広めるのか?も難しい問題だと思うのでその辺も入れないと、なんかもにゃっとするかなとは思うので、また何か考えたらそこについても書いていきたいなと思います。

あと割とベーシックな話で終わってしまってるので、もうちょい突っ込んだ話もどこかでまとめたい(とはいってもそんなに深掘りできてはいないんですが)

本については色々載せたけど個人的には以下の二冊が好きです。

他の名著情報があったらぜひ教えてください。

magnoliaで素振り

magnoliaは型クラスのインスタンス自動導出のためのscalaライブラリです。 GitHub - propensive/magnolia: A better generic macro for Scala

既にscalaz-magnoliascalacheck-magnolia などがリリースされています。

公式チュートリアルもしっかりとあり、それに加えて特に何かというわけではないんですが最近本ばっかり読んでてあんまり色々いじれてないので個人の素振り備忘録です。 (つまりこの記事よりも Magnolia: Home を見たほうが良いです)

magnolia特長

型クラスの自動導出自体はshapelessでもできますが、Genに移した後HListとCoproduct用のインスタンスを定義して〜とやると意外とコード量が増えたりコンパイルエラーが起きてデバッグする時間が多くなったりします。

magnoliaを使うと以下のようなメリットが得られます。

  • 短く書ける
  • 導出に失敗した際のデバッグメッセージが詳しく出る
  • shapelessに比べて4~15倍速いcompile時間

ただフリーランチというわけではなく以下のような弱点もあります。

  • まだexperimentalなので色々変わるかもしれない
  • 一部型チェック諦めてるのでClassCastExceptionが実行時に出る可能性がある
  • 現時点ではhigher-kindな型クラス(Functor)の導出はできない

やってみた

チュートリアルとほぼ内容同じなので解説はそっち読んで貰えればと思います。 チュートリアルの延長線上でStringじゃなくて型名とパラメータ名を構造化してほしいなって思ったのでMapDumpみたいな物を作ってみました。 https://github.com/matsu-chara/magnolia-example

package example.domain

case class UserId(value: Long) extends AnyVal
case class UserName(value: String)

sealed trait UserType
object UserType {
  case object Normal extends UserType
  case object Premium extends UserType
}

case class User(
  id: UserId,
  name: UserName,
  tpe: UserType
)
package example

import example.domain.{User, UserId, UserName, UserType}
import example.dump.MapDump

object Main extends App {
  val u1 = User(UserId(1L), UserName("sato"), UserType.Normal)

//  if derivation failed, then output
//  Main.scala:11:22: magnolia: could not find MapDump.Typeclass for type Long
//       in parameter 'value' of product type example.domain.UserId
//       in parameter 'id' of product type example.domain.User
  println(MapDump.gen[User].dumpAsMap(u1))

//  same as above. but no debug output
//  (User,Map(id -> (UserId,UserId(1)), name -> (UserName,Map(value -> (string,sato))), tpe -> (Normal,Normal)))
  println(implicitly[MapDump[User]].dumpAsMap(u1))
}
package example.dump

import magnolia._

import scala.language.experimental.macros

trait MapDump[A] {
  /** return
    * (className, Map(param1 -> value1, param2 -> value2))
    * or
    * (className, value1)
    * when value class or object
    */
  def dumpAsMap(value: A): (String, Any)
}

object MapDump extends GenericMapDump {
  def apply[A](f: A => (String, Any)): MapDump[A] = new MapDump[A] {
    override def dumpAsMap(value: A): (String, Any) = f(value)
  }

  implicit val stringDump: MapDump[String] = MapDump[String] { value =>
    ("string", value)
  }

  implicit val longDump: MapDump[Long] = MapDump[Long] { value =>
    ("long", value)
  }
}

trait GenericMapDump {
  type Typeclass[T] = MapDump[T]

  def combine[T](ctx: CaseClass[Typeclass, T]): Typeclass[T] = new Typeclass[T] {
    override def dumpAsMap(value: T): (String, Any) = {
      val valueOrMap = if (ctx.isValueClass || ctx.isObject) {
        value
      } else {
        ctx.parameters.map { p =>
          p.label -> p.typeclass.dumpAsMap(p.dereference(value))
        }.toMap
      }
      (ctx.typeName.short, valueOrMap)
    }
  }

  def dispatch[T](ctx: SealedTrait[Typeclass, T]): Typeclass[T] = new Typeclass[T] {
    override def dumpAsMap(value: T): (String, Any) = ctx.dispatch(value) { sub =>
      sub.typeclass.dumpAsMap(sub.cast(value))
    }
  }

  implicit def gen[T]: Typeclass[T] = macro Magnolia.gen[T]
}

この型クラス何の役に立つんだろう・・・?というツッコミとか色々有る気がするけどとりあえず動いてるのでセーフ。

せっかくなのでdecodeも作ってみました

package example

import example.domain.{User, UserId, UserName, UserType}
import example.dump.{MapDump, FromDumpedMap}

object Main extends App {
  val u1 = User(UserId(1L), UserName("sato"), UserType.Normal)

  // User(UserId(1),UserName(sato),Normal)
  val dumped = MapDump.gen[User].dumpAsMap(u1)
  println(FromDumpedMap.gen[User].constructFrom(dumped))
}
package example.dump

import magnolia._

import scala.language.experimental.macros
import scala.util.control.NonFatal

trait FromDumpedMap[A] {
  def constructFrom(value: (String, Any)): A
}

object FromDumpedMap extends GenericFromDumpedMap {
  def apply[A](f: (String, Any) => A): FromDumpedMap[A] =
    new FromDumpedMap[A] {
      override def constructFrom(value: (String, Any)): A = f(value._1, value._2)
    }

  implicit val stringDump: FromDumpedMap[String] = FromDumpedMap[String] {
    case (clazz, param: String) if clazz == "string" =>
      param
    case arg =>
      throw new IllegalArgumentException(s"failed to decode. $arg")
  }

  implicit val longDump: FromDumpedMap[Long] = FromDumpedMap[Long] {
    case (clazz, param: Long) if clazz == "long" =>
      param
    case arg =>
      throw new IllegalArgumentException(s"failed to decode. $arg")
  }
}

trait GenericFromDumpedMap {
  type Typeclass[T] = FromDumpedMap[T]

  def combine[T](ctx: CaseClass[Typeclass, T]): Typeclass[T] = new Typeclass[T] {
    override def constructFrom(value: (String, Any)): T = {
      if (ctx.isValueClass || ctx.isObject) {
        try {
          value._2.asInstanceOf[T]
        } catch {
          case NonFatal(e) => throw new IllegalArgumentException(s"failed to decode. $ctx $value", e)
        }
      } else {
        ctx.construct { p =>
          val paramMap = try {
            value._2.asInstanceOf[Map[String, (String, Any)]]
          } catch {
            case NonFatal(e) => throw new IllegalArgumentException(s"failed to decode. $ctx $value", e)
          }

          val param = if (paramMap.contains(p.label)) {
            paramMap(p.label)
          } else {
            throw new IllegalArgumentException(s"failed to decode. $ctx $p $value")
          }
          p.typeclass.constructFrom(param)
        }
      }
    }
  }

  def dispatch[T](ctx: SealedTrait[Typeclass, T]): Typeclass[T] = new Typeclass[T] {
    override def constructFrom(value: (String, Any)): T = {
      val (subtype, subTypeValue) = ctx.subtypes.find(_.typeName.short == value._1) match {
        case Some(sub) =>
          try {
            (sub, value._2.asInstanceOf[sub.SType])
          } catch {
            case NonFatal(e) => throw new IllegalArgumentException(s"failed to decode. $ctx $value", e)
          }
        case _ => throw new IllegalArgumentException(s"failed to decode. $ctx $value")
      }
      subtype.typeclass.constructFrom((value._1, subTypeValue))
    }
  }

  implicit def gen[T]: Typeclass[T] = macro Magnolia.gen[T]
}

この形式をデシリアライズしたい人間は居るのか?というところは置いといて出来てそうです。 (そしてちゃんとやるならStringじゃなくてTypeNameとかParamNameにしたほうが良い)

consturctメソッドや中で呼んでるrawConstructメソッド的にdecode失敗したらEitherで返すみたいなことはできないっぽいので、そういうことがやりたい場合はまだ難しそうですね。

ある程度パターン化できてるので、さっくりderivingしたいだけの時はかなり便利そうです。(decodeとか書くとdecode自体が面倒なのでさくっとという範疇ではない気がしますがそれはdecode自体の問題)