住所や地域ごとに災害リスクを集計するサービスを個人開発していて、月次でデータを更新するETLバッチを回している。このバッチの --dry-run オプションが、実は一度も正しく機能していなかったことに気づいた話を書く。
月次更新バッチの構造
不動産情報ライブラリAPIが提供する洪水・土砂・津波・高潮のデータは月次更新が入る。既存の公開済みエリアに対して、このデータだけを再取得してスコアを再計算するバッチが update_existing.py だ。
uv run python -m pipelines.update_existing --dry-run # 取得内容の確認
uv run python -m pipelines.update_existing # DB書き込み
--dry-run を付けると「対象エリア数」と「各エリアで取得できた値」がログに出て、実際の書き込みは行われない想定だった。全国約19,000地点を対象にする以上、本番実行の前に「ちゃんとAPIが応答しているか」「対象件数はおかしくないか」を確認したいのは当然の要求だ。
「更新対象: 0 areas」から抜け出せなかった
実際に --dry-run を実行すると、いつまで経っても「更新対象: 0 areas」としか表示されなかった。原因は run() 関数のこの部分にあった。
async def run(*, db_path: Path | None, dry_run: bool, include_unpublished: bool) -> int:
db: Database
if dry_run:
db = Database(memory=True)
apply_schema(db)
console.print("[yellow]dry-run: in-memory DB(実DBは変更しない)[/yellow]")
else:
db = open_db(db_path)
dry_run=True のとき、既存DBのファイルを開くのではなく、スキーマだけ当てた空のインメモリDBを新規に作っていた。当然、そこには areas テーブルの中身が1件もない。その後の
# 実際は単数/複数の出し分け(area / areas)を含むが、要点のみ抜粋
areas = _load_areas(db, include_unpublished=include_unpublished)
console.print(f"[bold]更新対象: {len(areas)} areas[/bold]")
は常に0件を返す。「本番と同じスキーマの空DBを用意すれば、実データに触れずに安全に動作確認できる」という発想自体は間違っていないが、それだと**「実行できるかどうか」しか検証できず、「今回の更新で何が起きるか」はまったく検証できていなかった**。件数もAPIレスポンスの中身も確認できないdry-runは、そもそも動作確認としての意味がなかった。
読み取り専用でファイルを開くという解決策
書き込みを防ぎたいなら、空のDBを別に用意するのではなく「本物のDBファイルを読み取り専用で開く」方が筋がいい。SQLiteにはURI接続で mode=ro を指定する方法がある。
def open_db_readonly(db_path: Path | str | None = None) -> Database:
"""既存DBを読み取り専用で開く。
dry-run など「実データを読むが絶対に書き込まない」用途向け。
``file:...?mode=ro`` URI を使うため、ファイルが存在しない場合は
新規作成せず FileNotFoundError を送出する(open_db は新規作成する点が違う)。
"""
import sqlite3
path = resolve_db_path(db_path)
if not path.exists():
raise FileNotFoundError(f"DB not found: {path}")
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
return Database(conn)
ポイントは2つ。
-
uri=Trueを指定した上で接続文字列をfile:スキームにすることで、?mode=roなどのクエリパラメータが有効になる。素のsqlite3.connect(path)はデフォルトで読み書きモードなので、この指定がないと意味がない - ファイルが存在しない場合は明示的に
FileNotFoundErrorを送出する。通常のopen_db()は「無ければ作る」実装なので、同じ関数で済ませると本番DBが存在しない環境でdry-runのつもりが空DBを新規作成してしまう、という前と同じ問題に逆戻りする
呼び出し側は1行の分岐で済む。
if dry_run:
db = open_db_readonly(db_path)
console.print("[yellow]dry-run: 実DBを読み取り専用で開きます(書き込みはしません)[/yellow]")
else:
db = open_db(db_path)
「読み取り専用」だけに頼らず、書き込みパスも二重にガードする
mode=ro で開いたSQLite接続は、INSERT や UPDATE を実行しようとした時点でエラーを返す。つまりコネクションレベルで書き込みをブロックできるが、それでも各処理の中に if dry_run: のガードは残してある。
# flood/landslide/tsunami/surge の各項目は実際は個別にフォーマットしているが、要点のみ抜粋
if dry_run:
console.print(
f" [DRY] {area_code} flood=... score={existing.composite_score} rank={existing.composite_rank}"
)
return True
upsert_risk_score(db, existing)
set_page_sources(db, area_code, existing.source_versions)
避難所データの更新箇所でも同様に if facilities and not dry_run: という条件を挟んでいる。読み取り専用接続に任せれば理論上は書き込みが弾かれるはずだが、「実行時エラーで気づく」のと「そもそもコードパスに入らない」のとでは安全性の質が違う。読み取り専用モードは最後の砦、実際に書き込み文を実行しない制御はアプリケーション側の責務として両方持たせている。
直してみてわかったこと
修正後に --dry-run を実行すると、「更新対象: 19170 areas」という正しい件数と、各エリアの実際の取得結果(洪水の深さ・土砂警戒区域の有無など)がログに流れるようになった。今まで「空のDBに対して空の結果を返していた」だけなので、エラーも出ず一見正常に動いているように見えていたのがこの不具合の厄介なところだった。
dry-runモードを実装するときは、「書き込みを防ぐ」ことと「実データに対する読み取りを保証する」ことは別の要件だと分けて考える必要がある。前者だけを満たそうとして安易に空のDB・モックデータを使うと、後者が壊れていても気づけないテストになりやすい。SQLiteであれば file:...?mode=ro のようなDB自体が持つ読み取り専用機構を使い、本物のデータに対して安全に読み取りだけを許可する形にするのが素直な解決策だった。
この記事は Zenn にも同じ内容を投稿しています。