日記を投稿すると、AIがタイトルとあらすじを付けてくれる。そういうサービスを作った。公開して3週間、看板のはずのこの機能が、一度も成立していなかった。
気づいたきっかけは、1件目の投稿にあらすじが付いていないことだった。「AIが失敗したのかな」で済ませそうな症状だ。
何が起きていたか
あらすじの生成は非同期で、投稿の少しあとに走る。生成している間に本文が保存し直されるかもしれないので、古い本文から作ったあらすじで新しい本文を上書きしないよう、楽観ロックで守っていた。
UPDATE entries
SET title = $1, summary = $2, ai_state = $3
WHERE id = $4 AND updated_at = $5
$5 には、生成を始めたときに読んだ updated_at を渡す。本文が保存し直されていれば updated_at が進んでいるので、この UPDATE は0行になる。意図どおりだ。
問題は、本文が変わっていなくても0行になっていたことだった。毎回。
- Workers AIは呼ばれる。課金もされる
- 生成したタイトルとあらすじは、
UPDATEが0行なので毎回捨てられる ai_stateはキューから取り出したときの印のまま残り、後追いの対象からも外れる
公開した2026-08-14からずっとこの状態だった。 機能の看板を掲げて、3週間、裏で結果をゴミ箱に入れ続けていた。
原因: パラメータ経由のtimestamptzはミリ秒までしか送れない
PostgreSQLの timestamptz はマイクロ秒精度で持っている。一方、postgres.js(Node.jsのPostgreSQLクライアント)でパラメータとして渡すと、ミリ秒に丸められる。JavaScriptの Date がミリ秒精度だからだ。
実測するとこうなる。
PG の updated_at::text → 2026-09-04 20:13:52.972863+09
パラメータ経由 ($1::timestamptz) → 2026-09-04 20:13:52.972+09 ← μs が消える
リテラルで書いた場合 → 2026-09-04 20:13:52.972863+09 ← 落ちない
.972863 と .972 は違う値だ。だから WHERE updated_at = $5 は絶対に一致しない。
「Date で渡すからだろう、文字列で渡せば」と思って '2026-09-04 20:13:52.972863+09'::timestamptz の形で渡しても、同じだった。postgres.jsはパラメータの型に応じてシリアライズするので、文字列でも timestamptz として解釈された時点でmsに落ちる。 逃げ道は、パラメータを使わないリテラルか、比較の型を変えることしかない。
なぜ3週間気づけなかったか
ここが一番書き残したいところだ。
記事ページは、title が空でも本文から切り出したタイトルを表示時に作る。
r.title || fallbackTitle(r.body)
親切なフォールバックだ。AIが失敗しても、ページが「タイトルなし」にならない。
そしてこのフォールバックのせいで、画面上は完全に正常に見えた。タイトルはある。あらすじは無いが、無くても記事は読める。誰も「壊れている」とは思わない。
E2Eテストも画面を見ていた。編集して保存して、タイトルが表示されることを確認する。このテストは、この不具合のおかげで通っていた。 AIが上書きしないので、編集した本文から作られるフォールバックのタイトルが安定して出る。もしAIが正しく動いていたら、AIが付けたタイトルが表示されて、テストの期待とずれていたかもしれない。
「表示に落ちる仕組み(フォールバック)があるものは、画面を見ても壊れていることが分からない。」フォールバックは利用者を守る。同時に、開発者から不具合を隠す。
直し方: 文字列で突き合わせる
比較を ::text にした。
-- 読むとき
SELECT ..., updated_at::text AS updated_at FROM entries WHERE id = $1
-- 突き合わせるとき
UPDATE entries SET ...
WHERE id = $1 AND updated_at::text = $5
読むときにPostgreSQL側で文字列にしておけば、μsが落ちない。突き合わせも文字列どうしなので、パラメータのシリアライズを通らない。
「時刻を文字列で比較するのか」という気持ち悪さはある。でもこの値は時刻として計算したいのではなく、変更検知のトークンとして使いたいだけだ。トークンなら文字列で十分で、むしろ精度の落ちる型を経由しないほうが正しい。
DB接続のヘルパーの冒頭に注意書きを足した。
// timestamptz をパラメータで往復させない。postgres.js はミリ秒までしか送れず μs が落ちるため、
// WHERE ts = $N(Date でも文字列 + ::timestamptz でも)は必ず外れる。
// SELECT ts::text で読み、WHERE ts::text = $N と文字列どうしで突き合わせる
他のサービス(4つ)も走査したが、timestamptz の等値比較は他に無かった。今後書くときはこの規則に従う。
回帰テストは画面ではなくDBの行を見る
テストも直した。画面を見るのをやめて、保存されている値を見る。
- あらすじ生成のあと、
entriesの行を直接読んでtitleとsummaryが入っていることを確認する - μsが落ちること自体も固定した。 パラメータ経由で渡した値と
::textの値が一致しないことをテストにして、ライブラリの挙動が変わったら分かるようにした
フォールバックを持つ機能は、フォールバックの手前の値をテストしないと、テストもフォールバックに騙される。
副産物: 検閲が「飲酒」を拾っていた
同じ調査で、もう一つ見つかった。投稿の自動検閲にLlama Guardを使っていて、そのS2(Non-Violent Crimes)の定義に「規制物質の使用」が含まれる。減酒日記は飲酒の記録そのものなので、全投稿がS2に当たる。 放置すると全件が自動非表示になる(実際に1件目がそうなった)。
日記ごとに免除カテゴリを持たせることにした。免除したカテゴリだけが検出された場合は自動非表示にせず、ただし管理用のフラグとSlack通知は従来どおり出す。素通しにはしない。
題材が検閲カテゴリと構造的に重なる企画(ダイエット=摂食、懺悔=犯罪告白)は、公開前に1件テスト投稿して判定を見る。全件非表示になるものを気づかず公開しない。
まとめ
- postgres.jsはtimestamptzをパラメータで渡すとμsが落ちる。
Dateでも、文字列 +::timestamptzでも同じ。WHERE ts = $Nは必ず外れる - 変更検知に使うなら、
SELECT ts::textで読んでWHERE ts::text = $Nで文字列どうし突き合わせる。トークンとして使う値に時刻型は要らない - フォールバックがあるものは、画面を見ても壊れが分からない。 利用者を守る仕組みが、開発者から不具合を隠す
- 画面を見るテストは、フォールバックのおかげで通ってしまう。保存されている値そのものを見る
- ライブラリの癖(μsが落ちる)自体もテストに固定しておく。挙動が変わったときに気づける
- 検閲カテゴリと題材が重なる企画は、公開前に1件投稿して判定を見る
「AIが失敗したのかな」で済ませそうな症状の裏に、3週間ぶんの課金と、3週間ぶんの捨てられた生成結果があった。