Script
Cold Open
シーン1 他チームのマージ
六月五日。月曜日。
梅雨入り前の空は薄く曇っていて、窓の外のビルの輪郭が、少しだけ、にじんで見える朝だった。
桜子が出社してノートPCを開くと、開発Slackのいちばん上に、別チームの名前があった。販売管理チームの、河野さん。中堅のエンジニアで、桜子とは、廊下で会釈を交わす程度。話したことは、ほとんどない。
@kawano: 金曜の夜にマージした #481 日次集計機能、
ユニットテストもステージングも全緑だったんですが、
今朝、販売管理のユーザーから「上司のExcelと数字が微妙にズレる」と
問い合わせが来ています。原因が見えるまで、画面に注意書きを出しました。
うちのチーム、今日は手が足りていません。見てもらえる人、いませんか名前に、見覚えがあった。五月の終わり、開発Slackの端に増えた、翔太ではない AI-assisted のPR通知。あの日、桜子は、そのリンクを開かなかった。
今日は、開いた。
PR本文には、ガイドライン第2項のとおり、AI使用の注記があった。
Note: ChatPort で草案生成・実装補助。
対話履歴: wiki/ai/chatport-history/kawano-2023-05.md
レビュー: 販売管理チーム内で1名差分は、三百行弱。日次集計の関数がいくつかと、そのテストが、新規で追加されていた。ロールバック手順の欄は、なかった。
Part A
シーン2 朝のズレ報告
販売管理のユーザーからの問い合わせは、河野さんの投稿のスレッドに、そのまま、転記されていた。短い。
先週金曜 (6/2) の受注件数、
画面では 127件 / 上司のExcel集計では 131件。
4件、差があります(境界、だ)
桜子の頭の中で、去年の五月の記憶が、一瞬、走った。isvalidforsignup の不等号。十七と十八と十九を並べて見た、あの朝。バグはだいたい、仕様の角に咲く、と、遥は言った。
まだ、当たりとは限らない。桜子は、#481 のPR本文を、上から下まで、読んだ。設計セクションは、整っていた。テストも、parametrize で十数ケース、書いてあった。集計の核は、日付をひとつ受け取って、その一日分の受注を数える関数。画面は、それを日付ごとに呼んで、並べていた。
次に、ステージングで使われたテストデータの、作成日時を、確認した。
ぜんぶ、平日の昼に作られていた。日付の境目、二十三時五十九分と零時零分をまたぐレコードは、ひとつも、含まれていなかった。
(テスト、ぜんぶ、真ん中で走ってる)
真ん中しか走っていないなら、緑は、真ん中しか、保証しない。
シーン3 桜子が調査役
十時、鈴木が桜子と翔太を呼んだ。
「河野さんのところと話して、#481 の原因調査、うちで引き取ることにした。桜子さん、お願いできる。翔太くん、桜子さんのサポート」
「はい」
「注意書きは、河野さんの側で、もう出てる。数字が正しくなるまでは、画面より、Excelを正にしてもらう。レプリカの読み取り権限は、河野さんが、二人分、出してくれてる」
桜子と翔太は、空いていた小会議室に移って、#481 の中身を、一行ずつ、読んだ。
集計関数の核は、こうだった。
def daily_order_count(target_date: date) -> int:
today = datetime.today()
start = datetime.combine(target_date, time.min)
end = datetime.combine(target_date, time.max)
return Order.query.filter(
Order.created_at >= start,
Order.created_at <= end,
).count()翔太が、最初に、指を止めた。
「この today、どこにも使われてない」
「うん。消し忘れ、というより、誰も、ここを読んでない」
「罠じゃないけど、印ではある」
桜子は、次に、DBの orders テーブルの定義を開いた。
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()タイムゾーン付き。保存は、DB側の now()。受注が入った瞬間は、正しく、記録されている。画面に出すときは、アプリ側が、JSTに直して表示している。
一方、集計関数の start と end は、targetdate を、タイムゾーン情報なしのまま、datetime.combine していた。
「あ」
桜子は、声を、小さく上げた。
「これ、タイムゾーン付きの列と、タイムゾーンなしの値を、そのまま、比べてる」
「PostgreSQL は、タイムゾーンなしの値を、その接続のタイムゾーン設定で読む。このDB、設定、何」
翔太が、読み取り専用のレプリカに、一行、打った。
SHOW timezone;
-- UTC「UTC」
翔太は、続けて、アプリ側のDB接続設定も開いた。タイムゾーンの指定は、どこにも、無い。本番のアプリも、この設定のまま、UTCで繋いでいる。
「だから、targetdate が六月二日なら、DBから見た範囲は、六月二日の零時から二十三時五十九分、UTC。JSTに直すと、二日の朝九時から、三日の朝八時五十九分」
「九時間、ずれてる」
「二日の零時から九時までに入った受注は、こぼれる。三日の零時から九時までの受注は、二日に、混ざる」
翔太が、次に、#481 のテストを開いた。
「テストは、SQLite のインメモリで走ってる。timestamptz の型が、そもそも、ない。しかも、fixture の createdat が、ぜんぶ、正午」
「ステージングも、昼のデータだけ」
「昼のレコードは、JSTの一日にも、UTCで読んだ範囲にも、どっちにも入る。だから、全緑」
全緑は、嘘ではなかった。ただ、緑が保証していた範囲が、真ん中だけだった。
Part B
シーン4 境界条件、再び
桜子は、レプリカに、直接クエリを投げた。
SELECT count(*) FROM orders
WHERE created_at >= '2023-06-02 00:00:00+09:00'
AND created_at < '2023-06-03 00:00:00+09:00';結果は、131件。上司のExcelと、同じ数字。
画面の dailyordercount(date(2023, 6, 2)) は、127件。
差は、四件。ただ、四件がこぼれたのではなかった。続けて、こぼれた側と、混ざった側を、別々に数えた。二日の零時から九時までに入った受注が、六件。三日の零時から九時までが、二件。百三十一から六を引いて、二を足すと、百二十七。
問い合わせの数字と、一致した。
(境界が、タイムゾーンの角で、もう一度、咲いた)
去年の五月は、十七と十八の間だった。今日は、二十三時五十九分と零時零分の間。しかも、その境目は、JSTとUTCで、九時間、別の場所にある。
桜子は、二重の感触を持った。
ひとつは、原因が、きれいに切り分けられた満足感。もうひとつは、このバグが、レビューを通り、ユニットテストを通り、ステージングを通って、本番でユーザーの画面に出るまで、誰にも気づかれなかったという事実の、重さだった。
ChatPort は、この関数を、流暢に、書いた。テストも、正しそうな形で、書いた。列が timestamptz で、接続が UTC で、ユーザーが JST の一日で数字を見ている、ということは、聞かれていないので、知らない。知らないまま、整った形で、答えた。
翔太は、河野さんの対話履歴の wiki ページを、開いた。
「前提ブロック、ないね」
「うん」
「『日次集計の関数を書いて』から、始まってる。DBの型も、タイムゾーンも、一度も、出てこない」
翔太の声には、非難のトーンが、なかった。
「河野さん、たぶん、初めて ChatPort を業務で使って、まず、動くものを出した。四月の僕と、同じ形」
桜子は、自分の観察ノートの、五月の終わりのページを思い出した。翔太の前提ブロックに、境界条件のテストを含める、と一行あったのは、翔太が四月に、浮いた案を出し続けたあとだった。前提が入るには、一度、浮く必要が、あったのかもしれない。
ただ、今回は、浮いたコードが、本番まで、行った。
シーン5 遥の一言
昼前、鈴木が、調査の共有を設定した。会議室「桜」に、鈴木、遥、翔太、桜子。河野さんは、販売管理のフロアから、Meet で入った。
桜子は、タイムゾーン境界のズレを、画面共有で、説明した。原因、影響範囲、数字の内訳、ステージングとテストがなぜ緑だったか。
河野さんが、画面の向こうで、眉を下げた。
「申し訳ない。月曜の週次報告に間に合わせたくて、金曜の夜に通しました。自分で動かして、テストも緑で、チームの一人にも見てもらった。ただ、二人とも、三百行を、読み切れてはいなかったと思います」
鈴木が、
「責めてないよ、河野さん。ガイドラインの2項も3項も、守られてる。レビューも1名、通ってる。守った上で、通った。今日の話は、そこ」
遥が、短く、
「ひとつ、だけ、言わせてください」
と、口を開いた。
「ChatPort に限った話じゃない、と前置きした上で、言います」
全員が、遥の方を見た。
「読めない速度は、武器にならない」
会議室と、Meet の画面の向こうで、それぞれ、ひと呼吸が落ちた。
「速さは、人がその場で読み切れる範囲で、価値になる。人が読めないまま通った速さは、あとで、人が読む量を倍にする。今日、桜子さんと翔太くんが午前中まるごと使って読んだ三百行は、金曜の夜に、二人がかりでも、読み切れなかった三百行です」
河野さんは、うなずいた。
遥は、続けた。
「ステージングは、真ん中しか走らない。本番は、境目を、必ず踏む」
そこで、言葉を切った。
河野さんが、
「チェックリスト、うちのチームでも、要ります。自分ひとりの反省で、終わらせたくない」
遥が、桜子の方を向いた。
「桜子さん、今日の調査の手順を、そのまま、叩きにして。翔太くんとわたしが、横につく。河野さん、販売管理の側から、読んでもらえますか」
「はい。むしろ、お願いします」
Part C
シーン6 修正とpostmortem
午後。
桜子は、修正PR #489 を上げた。集計関数を、JSTの日付境界で明示的に作り直し、parametrize テストには、境界の前後のレコードを、意図的に並べた。
def daily_order_count(target_date: date) -> int:
tz = ZoneInfo("Asia/Tokyo")
start = datetime.combine(target_date, time.min, tzinfo=tz)
end = start + timedelta(days=1)
return Order.query.filter(
Order.created_at >= start,
Order.created_at < end,
).count()テストには、
@pytest.mark.parametrize("created_at, target_date, expected_in", [
("2023-06-01 23:59:59+09:00", date(2023, 6, 2), False),
("2023-06-02 00:00:00+09:00", date(2023, 6, 2), True),
("2023-06-02 08:30:00+09:00", date(2023, 6, 2), True), # 旧コードでは、こぼれる
("2023-06-02 23:59:59+09:00", date(2023, 6, 2), True),
("2023-06-03 00:00:00+09:00", date(2023, 6, 2), False),
("2023-06-03 08:30:00+09:00", date(2023, 6, 2), False), # 旧コードでは、混ざる
])
def test_daily_count_tz_boundary(pg_session, created_at, target_date, expected_in):
...テストDBは、SQLite ではなく、本番と同じ PostgreSQL を、接続のタイムゾーンも UTC に揃えて使う形に、変えた。SQLite には、timestamptz が、ない。本番と同じ赤は、本番と同じ型の上でしか、咲かない。
修正を入れる前に、桜子は、新しいテストを、#481 のままの関数に、一度、当てた。六行のうち、四行が、赤く咲いた。それから、関数を差し替えて、緑にした。
PR本文には、ロールバック手順を、書いた。#481 の関数に戻すだけ。戻しても、数字が金曜の形にずれるだけで、データは壊れない。ただし、戻すときは、画面の注意書きと、Excelを正にする運用も、一緒に戻す。その二行を書きながら、去年の秋、休日のカフェで見せてもらった、遥の私物ノートの星印を、思い出した。
遥と翔太がレビューし、河野さんが販売管理側で承認して、十六時にマージ、本番に反映された。河野さんは、本番の画面で六月二日を開き、百三十一件が上司のExcelと一致するのを確かめてから、注意書きを外した。
十七時、社内 wiki に、「AI生成コードの読み切りチェックリスト (v0.1)」の叩きが上がった。書いたのは桜子、横に遥と翔太、販売管理側の目で河野さん。
## AI生成コードの読み切りチェックリスト (v0.1)
PR作成時:
- [ ] プロンプトの「前提ブロック」が書かれている
(タイムゾーン、DBの型、認証、排他、集計窓の幅などを、外に出せる一般化した言葉で)
- [ ] 社内固有の名前・接続先・顧客情報はプロンプトに入れず(ガイドライン第1項)、
社内固有の条件は、社内のレビューとテストで確かめている
- [ ] AI生成コードと、手で書いた部分が、コミット単位で分かれている
- [ ] 境界条件テスト(日付/数値/NULL/空集合)が含まれている
- [ ] テストが、本番と同じDB・同じ型・同じタイムゾーン設定で走っている
- [ ] ロールバック手順がPR本文に明記されている
レビュー時:
- [ ] レビュアーが、関数ひとつを、自分の頭で一度、読み切れる分量か
- [ ] テストデータが、真ん中だけを走っていないか
- [ ] タイムゾーン・エンコーディング・NULL・境界の取り扱いが、明示されているか
- [ ] 使われていない変数や引数が残っていないか(誰も読んでいない印)
運用判断:
- [ ] 「流暢で、しかし、静かに間違える」可能性がある前提で、
ズレに気づく手段(突き合わせ先)と、戻し方を決めているか桜子は、その v0.1 を、観察ノートに、書き写した。
Ending
シーン7 観察ノート#019、警戒
十九時前。
駅までの坂道の途中のカフェで、桜子はノートを開いた。
ChatPort 観察ノート #019
2023/06/05
- 他チームのAI生成コードで、タイムゾーン境界のバグ
- 見かけ上の成功: ユニットテスト全緑、ステージング全緑
- 実態: SQLiteのテスト、正午だけのfixture、昼だけのステージングデータ。
境目は、どこも、未踏
- 山下さん: 「読めない速度は、武器にならない」
今日の観察:
- ChatPort は、流暢に書く
- 流暢なコードは、境界の罠を、隠すことがある
- 隠すつもりは、ChatPort にも、書き手にも、ない。ただ、現場の前提が、
出力の中に、入っていないだけ
- 数字は、正確そうに見える。今回、信じすぎなかったのは、上司のExcelだった
- 前提ブロックは、入口。緑を確かめるのは、出口。片方では、足りない
警戒の言葉:
- もっともらしく、しかし、静かに、間違える
- 流暢さと、正しさは、別
- 境界条件は、タイムゾーンにも、咲く書き終えて、桜子は、ペンを置いた。
遥の「読めない速度は、武器にならない」の響きが、まだ、胸のあたりに、薄く、残っていた。
鞄の持ち手で、桜色のキーホルダーが、揺れていた。三月の最後の日、坂道で、握らなかったキーホルダー。今日は、一度だけ、握った。全緑のまま本番に出た三百行の重さは、まだ、桜子の指の先に、あった。
去年の四月一日、遥に「ログを見よう」と言われてから、一年と二ヶ月。今日、桜子は、ログに書かれた赤い一行ではなく、ステージングで緑だった行を、疑って、前後を、読み直した。
(赤より、緑のほうが、怖い日が、ある)
観察ノートの新しい見開きに、桜子は、そのひと言だけを、書いた。