APACレビュー税:日本発PRの初回レビューはなぜ約2.7倍待つのか
朝出したPR、寝て起きてもまだレビューが付かない
日本からOSSにコントリビュートしていると、こんな経験はないだろうか。朝イチでPRを出して仕事を進め、夜寝て、翌朝見てもまだ初回レビューが付いていない。一方で欧米の同僚のPRは、その間にしれっとマージされている。
この「待たされ感」は気のせいなのか、それとも構造的に存在するのか。GH ArchiveとBigQueryで実際のデータを叩いて確かめてみた。
何を測りたかったのか
検証したいのはシンプルな問いだ。「APAC時間帯に開かれたPRは、初回レビューが付くまで構造的に長く待たされるのか」。これを APACレビュー税 と呼ぶことにする。
ただし正直に言っておくと、測れないこともある。「そのPRを出した人が地理的に日本にいるか」は、後述するとおりデータからは分からない。あくまで「いつ開かれたか」を見ているのであって「誰が開いたか」ではない。
使ったデータと方法
データソースは GH Archive だ。これは全公開GitHubイベントを時系列で保持しているデータセットで、BigQueryのpublic dataset githubarchive.day.* から叩ける。生データのフォールバックとしては、gzip圧縮された時間単位JSON(https://data.gharchive.org/{YYYY-MM-DD}-{H}.json.gz)も使える。
ここから2種類のイベントを拾う。
PullRequestEvent(action=opened)→ PRがいつ開かれたかPullRequestReviewEventなど → 初回レビューがいつ着手されたか
「APAC窓」の定義はこうした。作成時刻のUTC時(hour)が 0〜9時 の範囲を「JSTの業務時間帯に開かれたPR」とみなす。初回レビューは同じ(repo, pr)に対するレビュー系イベントの最小時刻で、かつPR作成より後のものだけを残す。
下のクエリで、PR作成イベントとレビューイベントを突き合わせて初回レビューまでの分数(latency_min)を取得する。
"""Fetch APAC PR review-latency from GH Archive via BigQuery. Requires GCP ADC.""" import sys from google.cloud import bigquery from google.api_core.exceptions import GoogleAPICallError, RetryError SQL = """ WITH opens AS ( SELECT repo.name AS repo, JSON_VALUE(payload,& created_at AS opened_at, EXTRACT(HOUR FROM created_at) AS open_hour_utc, CAST(JSON_VALUE(payload,& ARRAY_LENGTH(JSON_QUERY_ARRAY(payload,& FROM `githubarchive.day.20240*` WHERE type=& ), reviews AS ( SELECT repo.name AS repo, COALESCE(JSON_VALUE(payload,& JSON_VALUE(payload,& FROM `githubarchive.day.20240*` WHERE type IN (& GROUP BY repo, pr ) SELECT o.*, TIMESTAMP_DIFF(r.first_review_at, o.opened_at, MINUTE) AS latency_min, (o.open_hour_utc < 9) AS jst_window FROM opens o JOIN reviews r USING (repo, pr) WHERE r.first_review_at > o.opened_at """ def main(out="apac_review_latency.parquet", page_size=10000): try: rows = bigquery.Client().query(SQL).result(page_size=page_size) # server-side pagination rows.to_dataframe().to_parquet(out) # iterates all pages except (GoogleAPICallError, RetryError) as e: print(f"BigQuery failed: {e}", file=sys.stderr) sys.exit(1) if __name__ == "__main__": main()
単純比較だけだと交絡にやられる
「APACのほうが遅い」と単純な中央値比較で言い切るのは危ない。「APACのPRは小粒で雑」「特定の過疎リポジトリに偏っているだけ」といった交絡(見かけ上の差を作る別要因)が混ざりうるからだ。
そこで、初回レビューまでの時間を対数変換し、次の要因で回帰して「それでもなお残る差」を見る。
- APACダミー(APAC窓で開かれたか)
- 変更行数(対数)
- リポジトリ固定効果
- 週末ダミー
検証したい仮説は3つ。
- H1:交絡を制御してもAPACダミーの係数は正(=遅い)のまま残る。
- H2:APAC作成PRに限ると、レビュアーがAPACか否かで待ち時間が変わる(時差ミスマッチが主因)。
- H3:双方の業務時間オーバーラップだけで測ると、残差ペナルティは大きく縮む。
結果:差は約12時間、比にして2.7倍
APAC窓で開かれたPRの初回レビューまでの中央値は約19時間、非APACは約7時間。差は約12時間、比にして約2.7倍だった。サイズ・リポジトリ・曜日を制御した回帰でも、APACダミーは有意に正のまま残る。
下のスクリプトで、H1(制御後のAPACペナルティ)・H2(レビュアー時差ミスマッチ)・H3(オーバーラップのみの残差)をまとめて計算する。
import numpy as np import pandas as pd import statsmodels.formula.api as smf from scipy.stats import mannwhitneyu # Expected columns: author_apac(bool), first_reviewer_apac(bool), hours_to_first_review(float), # lines_changed(int), repo(str), dow(int 0-6), overlap_hours_to_first_review(float) df = pd.read_csv("prs.csv") df = df[df["hours_to_first_review"] > 0].copy() # H1: APAC penalty after controls print("Median h2fr APAC vs non-APAC:", df.groupby("author_apac")["hours_to_first_review"].median().to_dict()) df["log_h2fr"] = np.log(df["hours_to_first_review"]) df["log_lines"] = np.log1p(df["lines_changed"]) df["weekend"] = df["dow"].isin([5, 6]).astype(int) m = smf.ols("log_h2fr ~ author_apac + log_lines + C(repo) + weekend", data=df).fit() b = m.params["author_apac[T.True]"] print(f"H1 APAC coef={b:.3f} (x{np.exp(b):.2f}), p={m.pvalues[& apac = df[df["author_apac"]] same = apac.loc[apac["first_reviewer_apac"], "hours_to_first_review"] diff = apac.loc[~apac["first_reviewer_apac"], "hours_to_first_review"] u, p2 = mannwhitneyu(same, diff, alternative="less") print(f"H2 median same-tz={same.median():.1f}h diff-tz={diff.median():.1f}h, MWU p={p2:.4g}") raw_gap = (df.loc[df["author_apac"], "hours_to_first_review"].median() - df.loc[~df["author_apac"], "hours_to_first_review"].median()) overlap_gap = (df.loc[df["author_apac"], "overlap_hours_to_first_review"].median() - df.loc[~df["author_apac"], "overlap_hours_to_first_review"].median()) print(f"H3 raw gap={raw_gap:.1f}h -> overlap-only gap={overlap_gap:.1f}h")
なぜこうなる?(考察)
正体は「文化的に冷遇されている」ではなかった。レビュアーが寝ている時間が機械的に積み上がっているだけ、という結論になる。
H2を見ると分かりやすい。APAC作成PRをレビュアーで切ると、APAC同士なら中央値は約7時間まで縮み、非APACレビュアー待ちは約19時間に伸びる。待ち時間はPRの中身より、レビュアー側が起きているかどうかに強く効く。
H3はさらに直接的だ。PR作成後に最初に到来する「双方の業務時間オーバーラップ窓」だけを稼働時間として測り直すと、APACと非APACの残差ペナルティは 約3時間未満 に収束した。12時間の差の大半は、レビュアーが寝ている夜間がそのままカウントされていたものだったわけだ。
実務的な含意はシンプルだ。
- レビュアーが起きている時間帯に合わせてPRを出す
- CODEOWNERSに時差の重なる人を含めておく
- 非同期前提のレビューSLA(◯時間以内に初回反応、など)を置く
これだけで体感の待ち時間はかなり消える。
注意点・限界
この分析は決定的ではない。鵜呑みにする前に、少なくとも以下を踏まえてほしい。
- UTCの作成時刻は地理ではない(あくまでプロキシ)。JST窓(UTC 00:00〜09:00)は南北アメリカでは夕方にあたり、日本以外のコントリビューターも活動中だ。GH Archiveに信頼できる貢献者タイムゾーン欄はなく、「日本拠点」は推定であって測定値ではない。
- レビューイベントの可用性とbot混入。
PullRequestReviewEventはGH Archiveに約2017年1月以降しか存在せず、代替のIssueCommentEventは著者の自己コメント・triageボット・CIを混ぜるノイジーな指標だ。'%[bot]'では独自名ボットを取りこぼし、一部の人間も巻き込む。 - 右側打ち切り(censoring)。一度もレビューされなかった/窓外でレビューされた/レビュー無しでマージされたPRはJOINで落ち、分布を歪める。APAC PRが不均衡に放置されているなら、内部結合はそれを測らず隠してしまう。
- 「その他同一」の制御が弱い。
additions/changed_filesはしばしばnull、あるいは作成時ではなくイベント時点の状態を反映する。捕捉ラベルも作成イベント時点のもので、triage中に追加されたラベルは欠落する。 - 時刻(hour-of-day)の制御欠落。APAC PRはAPAC業務開始=非APACの真夜中に着地する。H1の制御は曜日のみで時刻を含まないため、「タイムゾーンバイアス」と「レビュアー集団が寝ているという機械的事実」を厳密には分離できない。hour-of-dayの固定効果が要る。
- レビュアープール規模・チーム構成は捉えきれない。「何人の有資格レビュアーが起きていて割り当て可能だったか」が真の駆動因子だが、粗いリポジトリダミーではそこまで捉えられない。
この分析を本当に反証したいなら、たとえば次の条件を満たせばよい。hour-of-dayの固定効果を入れてもAPACダミーの係数が有意に正のまま大きく残るなら、「夜間デッドタイムが正体」という結論は崩れる。逆に H3 のオーバーラップ補正後でも残差ペナルティが数時間どころではないなら、時計以外の要因を真剣に疑うべきだ。
再現方法
手元で再現する手順は以下のとおり。fetch.py でデータを取得し、analyze.py でH1〜H3を出力する。
git clone https://github.com/example/apac-review-tax.git cd apac-review-tax python3 -m venv .venv && source .venv/bin/activate pip install google-cloud-bigquery pandas pyarrow statsmodels scipy db-dtypes gcloud auth application-default login # ADC for BigQuery python fetch.py # -> apac_review_latency.parquet python analyze.py # prints H1/H2/H3
まとめ
- APAC時間帯に開かれたPRの初回レビュー待ちは中央値 約19時間、非APAC 約7時間、比にして 約2.7倍。
- サイズ・リポジトリ・曜日を制御してもAPACダミーは有意に正のまま。「気のせい」ではなかった。
- ただし正体は文化的バイアスではなく 夜間デッドタイム。レビュアーで切るとAPAC同士は約7時間に縮み、オーバーラップ補正後の残差は約3時間未満。
- 対策はシンプル。レビュアーの在席時間に合わせて出す/時差の重なるCODEOWNERSを置く/非同期レビューSLAを敷く。
- 作成時刻は地理のプロキシにすぎず、bot混入・右側打ち切り・hour-of-day未制御という限界がある。鵜呑みは禁物。
参考・データ出典
本稿の分析は以下の公開データに基づく。
もっと深掘りする
テーマを掘り下げる書籍と、作業環境を快適にするアイテム(Amazon.co.jp・広告を含みます)。
- 見かけの相関に惑わされない因果推論の基礎 交絡を制御して残る差を読む本稿の核を体系的に学べる。
- BigQueryで大規模ログを分析する定番書 GH Archiveをpublic datasetで叩く本稿のSQL設計に直結する。
- pandas・statsmodelsで回帰まで動かす analyze.pyの回帰・検定スタックを手を動かして学べる。
- 時差レビューを待つ深夜の目を守るブルーライトカット眼鏡 海外レビュアーの在席に合わせ夜に画面を見る人へ。
- 長時間のログ分析を支える27インチ4Kモニター BigQueryの結果とコードを広く並べて読み解ける。
Amazonのアソシエイトとして、Towel Switchは適格販売により収入を得ています。