日記を投稿すると、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週間ぶんの捨てられた生成結果があった。