取得の対象が増えると、処理時間が伸びます。時間が伸びれば、途中で失敗する確率も上がります。ネットワークが切れる、相手が一時的に落ちる、こちらのプロセスが止められる。どれも珍しくありません。
最初からやり直せばいいと思うかもしれませんが、7時間走ったあとに落ちた処理を最初からやり直すのは、時間の面でも相手の負荷の面でも無理があります。どこまで終わったかを記録しておけば、続きから始められます。
この形にしておくと、運用の負担も軽くなります。失敗の通知を受けた人がやることは「もう一度実行する」だけになり、原因の調査を後回しにできます。再開できない作りだと、誰かが手作業で範囲を計算して部分的に流し直すことになり、その手順自体が属人化します。
のような定期実行と組み合わせるなら、なおさら再開できる形が要ります。失敗した回を人が拾わなくても、次の実行が残りを片付けてくれるからです。放っておいても収束するのが理想の形です。
どこまで進んだかを残す
必要なのは です。処理の単位ごとに「ここまで終わった」を書き出しておき、開始時にそれを読んで続きから始めます。単位は、日付・ページ・対象IDなど、その取得で自然に区切れるものにします。
単位の選び方には一つ条件があります。その単位だけを独立して取り直せることです。側が「10件ずつしか返さない」形なら、10件のまとまりが単位になります。逆に、前のページを取らないと次が取れない作りなら、途中から再開する余地がそもそもありません。その場合は取得の設計自体を見直すことになります。
記録の頻度が問題になります。1件ごとに書けば確実ですが、書き込みの回数が増えて遅くなります。まとめて書けば速いですが、落ちたときに戻る量が増えます。1回ぶんの取得にかかる時間を基準に、数十秒〜数分ぶんの進捗が失われる程度に収めるのが目安です。
取得そのものが1件あたり数秒かかるなら、1件ごとに記録しても書き込みの負担は相対的に小さく、確実さを取るべきです。逆に1件が数ミリ秒で終わる処理なら、100件ごと・1000件ごとにまとめて記録するほうが理にかなっています。記録の費用と、失う進捗の量を天秤にかけるという考え方です。
from pathlib import Path
class Progress: """処理済みの単位を1行ずつ追記する。単純だが落ちても壊れにくい。"""
def __init__(self, path: Path): self.path = path self.done: set[str] = set() if path.exists(): self.done = { ln.strip() for ln in path.read_text(encoding="utf-8").splitlines() if ln.strip() }
def remaining(self, all_units: list[str]) -> list[str]: return [u for u in all_units if u not in self.done]
def mark(self, unit: str) -> None: self.done.add(unit) with self.path.open("a", encoding="utf-8") as f: f.write(unit + "\n") f.flush() # 途中で落ちても、書いたぶんは残す進捗を JSON で丸ごと書き直す方式だと、書き込みの途中で落ちるとファイルが壊れます。1行ずつ追記していく形なら、最後の行が欠けるだけで、それまでの記録は読めます。
再開しても結果が変わらないようにする
続きから始める以上、同じ単位を2回処理する可能性は残ります。落ちる直前に取得は成功していて、進捗の記録だけができていない、という状況があるからです。
だから保存側にが要ります。同じ単位を2回書いても結果が同じになる形にしておけば、重複を気にせず再開できます。ファイルなら単位ごとに決まった名前で上書き、データベースなら鍵を決めて更新する形にします。
import jsonfrom pathlib import Path
def fetch_and_store(unit: str, out_dir: Path, fetch) -> None: """同じ unit を2回処理しても、できあがるファイルは同じ。""" out_dir.mkdir(parents=True, exist_ok=True) final = out_dir / f"{unit}.json"
payload = fetch(unit)
# いったん別名で書いてから差し替える。 # 直接書くと、途中で落ちたときに中身が欠けたファイルが残る。 tmp = final.with_suffix(".json.tmp") tmp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") tmp.replace(final)上のコードで別名に書いてから差し替えているのは、書き込みの途中で落ちたときに中途半端なファイルを残さないためです。差し替えの操作は分割されないので、ファイルは「古いまま」か「新しく完成した状態」のどちらかになります。
全体を組み合わせる
進捗の記録と、冪等な保存を組み合わせると、何度実行しても最終的に同じ結果へ収束する取得になります。失敗したらもう一度実行するだけで済むようになるのが、この形のいちばんの利点です。
過去にさかのぼって取り直すも、同じ仕組みで実現できます。進捗の記録を消して範囲を指定し直せば、同じコードがそのまま遡及取得になります。通常の取得と遡及取得で別のコードを書かないのが、保守の面では効きます。
def run(all_units: list[str], out_dir, progress_path, fetch) -> None: progress = Progress(progress_path) todo = progress.remaining(all_units) print(f"全体 {len(all_units)} / 残り {len(todo)}")
for i, unit in enumerate(todo, 1): try: fetch_and_store(unit, out_dir, fetch) except Exception as e: # 1件の失敗で全体を止めない。あとで再実行すればこの単位だけ拾える print(f" 失敗 {unit}: {type(e).__name__}: {e}") continue progress.mark(unit) if i % 100 == 0: print(f" {i}/{len(todo)}")上の実装は失敗を記録せず次へ進みます。再実行すれば拾えますが、誰も再実行しなければ欠けたままです。失敗した単位の一覧を残し、件数がゼロでないことを通知する仕組みまで含めて設計してください。
定期実行と失敗の通知については、この連載の後半で扱います。次回は、取得コードのテストです。
この記事の感想を教えてください
あなたの 1 クリックで、本当にこの記事は更新されます。「もっと詳しく」「続編希望」が一定数集まった記事は、 ふくふくが 実際に内容を拡充したり続編記事を公開 します。 送信したリアクションはお使いのブラウザに記録され、再カウントされません。