ある朝、壁紙アプリの画像分類バッチが「File ... is not found」を連発して止まっていました。前夜にアップロードしたはずの画像が、Gemini の Files API 側から消えていたのです。原因はすぐに分かりました。Files API のファイルは、アップロードから48時間で自動的に失効します。私はそれを「知っているつもり」でしたが、実際の運用ではその前提を設計に織り込めていませんでした。
Files API にアップロードしたファイルには、いくつか動かしようのない制約があります。第一に、保存期間は48時間で、これは延長できません。第二に、プロジェクトあたりの合計ストレージには上限があり、超えると新規アップロードが弾かれます。第三に、アップロード直後のファイルは PROCESSING 状態で、ACTIVE になるまで推論に使えません。動画など大きなファイルでは、この処理待ちが無視できない長さになります。
つまり Files API は「永続ストレージ」ではなく「48時間だけ有効な一時的な受け渡し場所」です。ここを取り違えると、アップロードしたファイルの uri を自前のデータベースに保存して使い回そうとして、翌々日に全滅する、という事故が起きます。私が最初にやったのが、まさにこれでした。
状態を確認するコードはこうなります。アップロード後に ACTIVE を待つ部分は、本番運用では必ず必要になります。
import timefrom google import genaiclient = genai.Client(api_key="YOUR_GEMINI_API_KEY")def upload_and_wait(path: str, timeout_s: int = 120): """アップロードして ACTIVE になるまで待つ。PROCESSING のまま使うと失敗する。""" f = client.files.upload(file=path) deadline = time.time() + timeout_s while f.state.name == "PROCESSING": if time.time() > deadline: # 処理が長引くファイルは握りつぶさず、呼び出し側に判断を委ねる raise TimeoutError(f"{f.name} stuck in PROCESSING") time.sleep(2) f = client.files.get(name=f.name) if f.state.name != "ACTIVE": raise RuntimeError(f"{f.name} ended in state {f.state.name}") return f
# Before: アップロードしっぱなしで状態を一切記録しないdef classify_wallpaper(path: str): f = client.files.upload(file=path) resp = client.models.generate_content( model="gemini-2.5-flash", contents=[f, "この画像を30カテゴリのいずれかに分類してください"], ) return resp.text # f は二度と参照されない。削除もしない。記録も残らない。
この書き方には三つの問題があります。ひとつ目は、アップロードしたファイルを削除していないので、48時間は無駄にストレージを占有し続けることです。ふたつ目は、どのファイルをいつアップロードしたかの記録がないので、後から「いま何件分のファイルが Files API 側に残っているのか」を把握できないことです。みっつ目は、分類処理が途中で失敗したとき、すでにアップロード済みのファイルが宙に浮いてしまうことです。これが孤児ファイルです。
破綻を防ぐ鍵は、Files API を「信頼できる唯一の状態」として扱わないことです。Files API 側の状態は48時間で勝手に変わってしまうので、それとは別に、自分が「何を、どのアプリのために、いつアップロードしたか」を自前で記録しておきます。そのうえで、両者を定期的に照合します。
私は SQLite の小さなテーブルひとつで管理しています。個人開発ではこれで十分です。
import sqlite3import datetimedef init_db(conn): conn.execute(""" CREATE TABLE IF NOT EXISTS uploads ( file_name TEXT PRIMARY KEY, -- Files API の name (files/xxxx) app_id TEXT NOT NULL, -- どのアプリ向けか local_path TEXT NOT NULL, uploaded_at TEXT NOT NULL, -- ISO8601 (UTC) purpose TEXT NOT NULL, -- classify / variation など done INTEGER DEFAULT 0 -- 推論まで完了したか ) """) conn.commit()def record_upload(conn, f, app_id: str, local_path: str, purpose: str): conn.execute( "INSERT OR REPLACE INTO uploads VALUES (?, ?, ?, ?, ?, 0)", (f.name, app_id, local_path, datetime.datetime.now(datetime.timezone.utc).isoformat(), purpose), ) conn.commit()
照合の考え方はシンプルです。Files API 側に存在するファイルの集合と、自前DBに「未完了(done=0)」として記録されているファイルの集合を突き合わせます。すると、三つのカテゴリに分かれます。
まず、両方に存在し、まだ使う予定のもの。これは正常な進行中のファイルです。次に、Files API 側にだけ存在し、自前DBに記録がないもの。これは記録を取り損ねたか、別経路でアップロードされた、素性の分からないファイルです。そして、自前DBには「未完了」とあるのに Files API 側から消えているもの。これは48時間で失効したか、途中で削除されたものなので、DB側を「失効」として畳む必要があります。
def reconcile(conn): remote = {f.name: f for f in client.files.list()} rows = conn.execute( "SELECT file_name, app_id, uploaded_at FROM uploads WHERE done = 0" ).fetchall() local_pending = {r[0]: r for r in rows} only_remote = set(remote) - set(local_pending) # 素性不明 only_local = set(local_pending) - set(remote) # 失効済み both = set(remote) & set(local_pending) # 進行中 # 失効していた未完了ファイルは、DB を畳んで再アップロード対象にする for name in only_local: conn.execute("UPDATE uploads SET done = -1 WHERE file_name = ?", (name,)) conn.commit() return {"unknown": only_remote, "expired": only_local, "active": both}
Files API のアップロードと保存そのものには課金されませんが、推論で消費する入力トークンには当然コストがかかります。画像1枚あたりのトークン量は解像度に依存するため、アップロード前にリサイズしておくとトークンとコストの両方が下がります。ここは照合とは別の最適化ですが、ライフサイクル管理と同じテーブルで件数を持っておくと、コストの按分が一気に楽になります。
Files API は、使い方を誤れば翌々日に静かにファイルを消していく仕様ですが、48時間という制約を設計に正面から織り込んでしまえば、むしろ後始末を強制してくれる気持ちのよい仕組みになります。次に画像や音声を Files API に投げる処理を書くときは、アップロードと同じ関数の中に削除と記録まで書き切ってみてください。それだけで、孤児ファイルに悩まされる日々から抜け出せるはずです。同じように多アプリを個人で運用している方の参考になれば嬉しいです。