キャッシュファイルはあるのに再生成が走った、判定条件の見落とし

キャッシュの有効判定がファイルの有無と検品台帳という2つの情報源でずれていたことを示すイメージ プログラミング

キャッシュディレクトリに音声ファイルが並んでいるのを確認してから実行したのに、フル生成のサイクルが動き出しました。原因は単純で、キャッシュが「使える状態」かどうかを判定する条件と、私が事前に確認していた条件が違っていたことです。ファイルの有無だけを見て「今回は再生成が走らないはず」と思い込んでいたのが、そもそもの見立て違いでした。

実行するコマンドを打つ前に、キャッシュディレクトリをのぞいて対象チャンク分のwavファイルが全部そろっていることを確認しました。数分で終わるはずの処理だと思って実行ボタンを押したのに、ログに流れてきたのは生成中の進捗表示でした。この時点ではまだ、自分の確認のどこが間違っていたのか見当がついていませんでした。

別名義のチャンネルで、音声をチャンク単位にキャッシュしている

私は別名義で解説動画チャンネルを運営していて、ナレーションは合成音声で作っています。台本はチャンクという発話単位に分割し、チャンクごとに音声ファイル(wav)を生成してキャッシュディレクトリに置く仕組みです。生成には時間がかかるため、一度作ったチャンクはできる限り使い回したい、という設計になっています。

生成した音声にはもう一つ、検品という工程があります。書き出した音声を実際に聞き、問題がなければ検品台帳に登録する流れです。台帳には「合格した」という事実だけでなく、話速などの調整パラメータも一緒に記録します。つまりこの仕組みには、「音声ファイルの実体」と「検品台帳」という2つの情報源が存在していました。

「ファイルがある」と「使える」は別の話でした

実行前に「今回はキャッシュが効くはずだ」と確認するとき、私が見ていたのはキャッシュディレクトリの中身、つまりwavファイルが存在するかどうかだけでした。ディレクトリを開いて、対象のチャンク数だけファイルが並んでいれば、それで安心していました。

ところが実際の解決処理は、それとは別の基準で動いていました。wavファイルの存在に加えて、検品台帳にそのチャンクのキーが登録されているか、さらに話速のフィールドが記録されているかまで見て、初めて「このキャッシュは使える」と判定する作りだったのです。

この2つの基準がズレていたせいで、wavファイルはキャッシュディレクトリにあるのに、検品台帳への登録が済んでいない、あるいは話速フィールドが欠けているチャンクが、実行時にフル生成サイクルへ入ってしまう、ということが起きました。ファイルの実体だけを見て安心していた自分の確認は、本番のロジックからすると何の保証にもなっていなかったわけです。

なぜ、目立つ条件だけを見てしまったのか

キャッシュの有効判定は、たいてい複数の条件のANDで組まれています。今回で言えば「ファイルが存在する」「台帳に登録されている」「必要なフィールドがそろっている」の3つです。人間が事前に確認するときは、このうち一番目に見えやすい条件——ファイルシステム上に実体があるかどうか——だけを見て済ませてしまいがちです。ディレクトリを開けば一覧できる情報は確認しやすく、台帳の中身やフィールドの有無まで見に行くのは一手間かかります。

事前確認のロジックと本番のロジックがズレていると、事前確認は気休めにしかなりません。「大丈夫だと思って実行したら全然大丈夫じゃなかった」という失敗の多くは、確認した基準と実行される基準が違うところから生まれます。今回の私がまさにそれでした。

事前確認を、本番のロジックに合わせて書き直した

対処として、実行前の確認(dry-scan)を、wavファイルの有無だけでなく、検品台帳にそのチャンクのキーが存在するか、話速フィールドが記録されているかまで見るように変えました。判定条件を、本番の解決処理と同じ粒度でなぞる形です。

理想を言えば、この判定は関数として1箇所に切り出し、事前確認も本番の処理も同じ関数を呼ぶべきです。実装がすぐにそうなっていない場合でも、確認の手順を本番のロジックに合わせるだけで、事前確認の精度は変わります。私が実際に整理した判定はこういう形でした。


def is_cache_valid(chunk_id: str, cache_dir: Path, ledger: dict) -> bool:
    wav_path = cache_dir / f"{chunk_id}.wav"
    if not wav_path.exists():
        return False
    entry = ledger.get(chunk_id)
    if entry is None:
        return False
    if "speech_rate" not in entry:
        return False
    return True

事前確認のスクリプトも、本番の生成処理も、この同じ関数を呼ぶように統一しました。見るべき条件を1箇所にまとめてしまえば、「どっちの基準で見ていたか」というズレそのものが起きなくなります。

この確認手順に変えてから、あるエピソードの動画では66チャンク全件を事前に「生成不要」と確認でき、実際の実行でも音声生成の回数はゼロで完了しました。事前の見立てと本番の結果が、初めて一致した瞬間でした。

情報源が2つあるとき、片方だけを見た判断は破綻する

同じ対象について情報源が2つあるとき——今回で言えば実体ファイルとメタデータ台帳のように、それぞれ別の場所に別のタイミングで書き込まれる情報源が並んでいるとき——片方だけを見た判断は、遅かれ早かれどこかで破綻します。ファイルは生成した瞬間に存在しますが、台帳への登録は検品という別の工程を経てから初めて反映されます。この時間差そのものが、見落としの温床になります。

そうは言っても、判定を1箇所の関数に切り出す余裕が常にあるとは限りません。既存のコードに手を入れる時間が取れないなら、せめて「確認するときは本番の条件をそのまま読みに行く」ことだけは徹底したいところです。関数化できなくても、本番の判定コードを読んでから確認スクリプトを書けば、同じズレは防げます。

この取り違えは、時間のかかる処理ほどコストが大きくなります。今回のように音声生成に時間がかかる処理では、「事前確認は済んでいるからすぐ終わるはず」と思って実行を始めた作業が、フル生成サイクルに入った途端に数時間コースへ変わります。すぐ終わると思っていた作業ほど、始めてしまってから引き返しにくいものです。

同じ構造は、キャッシュ設計の他の場面でも起こり得ます

今回のような「実体ファイル」と「メタデータ台帳」の二重構造は、音声生成に限った話ではないはずです。ビルドキャッシュは成果物ファイルが残っていても、依存関係のハッシュが変わっていれば作り直しになりますし、CDNやブラウザのキャッシュもファイルの有無だけでなく、ETagや更新日時といった別の情報源との突き合わせで有効・無効が決まります。要は「実体があるかどうか」と「その実体をいま使ってよいかどうか」は、常に別の質問だということです。

この2つを同じ質問だと錯覚しやすいのは、普段の開発で「ファイルが存在する」ことを確認する操作のほうが圧倒的に手軽だからだと思います。ディレクトリを開く、ls を打つ、ファイラーで眺める。どれも一瞬で済みます。一方でメタデータ台帳やハッシュの突き合わせは、コードを読むか、専用のコマンドを叩かないと見えてきません。手軽な確認ほど、本番の判定条件を代表していない可能性を疑ったほうがよさそうです。

「確認しました」の粒度を見る

エンジニアリングマネージャーとして日々の仕事を見ていても、似た構図に出会うことがあります。「確認しました」という報告を受けたとき、大事なのは確認したという事実そのものより、何をどう確認したかが本番で起きることと一致しているかどうかです。チェックリストは項目を増やすより、1項目ごとの粒度を本番の条件にそろえるほうが効きます。

今回の件も、私が「wavがあるか」という1項目だけのチェックリストで満足していたのが原因でした。項目数は足りていても、その項目が本番の判定と同じ粒度でなければ、チェックは通っても結果はズレます。

まとめ: キャッシュの有効判定は、本番のコードを読んでから確認する

キャッシュにファイルがあるかどうかは、判定条件の一部でしかありませんでした。ファイルの存在という一番目立つ条件だけを見て安心していたことが、フル生成が走った直接の原因です。事前確認と本番の判定を同じ条件でそろえてからは、生成が要るかどうかを実行前にほぼ正確に言い当てられるようになりました。

情報源が複数あるキャッシュ設計を扱うときは、どの条件のANDで「使える」と判定しているかを、まず本番のコードで確認するところから始めることをおすすめします。見た目にわかりやすい条件だけで安心するのは、次に同じ失敗をする一番簡単な近道です。


私は現役EMとして、1on1やチーム運営の仕組み化について書いています。1on1管理のNotionテンプレート(無料Lite版)を公開中です → 試してみる。X: @anikuma_tech

コメント