「読み込んで上書き」を繰り返すCSVログは、実行中に落ちると壊れる
長時間動かすSeleniumバッチでは、その日にクリックした回数や登録できた件数などを日別に集計して、CSVに記録しておきたい場面がよくあります。素直に実装すると「既存のCSVを読み込み、対象の日付の行だけ値を書き換えて、同じファイルに上書き保存する」という処理になりますが、この「読み込んで上書き」を同一ファイルに対して繰り返すやり方には落とし穴があります。
書き込みの途中でプロセスが強制終了させられたり、実行環境ごとOSがクラッシュしたりすると、ファイルの中身が中途半端な状態のまま残ってしまうのです。次回起動時にそのCSVを読み込もうとすると、行の途中で切れた壊れたデータを相手にすることになり、パース時に例外が飛んだり、集計結果がおかしくなったりします。長時間バッチはネットワーク断やタスクスケジューラによる強制終了など、プロセスが不意に止まる場面が少なくないため、この問題はいつか必ず踏むことになります。
対策は「一時ファイルに書き切ってから、最後にrenameする」だけ
対策自体はシンプルです。本番のCSVファイルを直接上書きするのではなく、別名の一時ファイルにその日時点での完全な内容を書き切ってから、最後にFile.renameで本来のファイル名に置き換えます。多くのファイルシステムでは同一ドライブ内のrenameはほぼ一瞬で完了するため、「書き込み途中の状態」が外部から観測される時間はほとんどありません。プロセスが途中で落ちても、被害は書きかけの一時ファイルだけに留まり、本番のCSVは直前の正常な状態を保ち続けます。
require 'csv'
def record_daily_count(csv_path, activity_type, increment = 1)
today = Time.now.strftime('%Y-%m-%d')
columns = %w2026/07/18
rows = {}
if File.exist?(csv_path)
CSV.foreach(csv_path, headers: true) do |row|
next unless row['date']
rows[row['date']] = {
'like' => row['like'].to_i,
'follow' => row['follow'].to_i,
'post' => row['post'].to_i
}
end
end
rows[today] ||= { 'like' => 0, 'follow' => 0, 'post' => 0 }
key = activity_type.to_s
raise "unknown activity type: #{activity_type}" unless columns.include?(key)
rows[today][key] += increment
tmp_path = "#{csv_path}.tmp"
CSV.open(tmp_path, 'w') do |csv|
csv << columns
rows.keys.sort.each do |date|
counts = rows2026/07/18
csv << 2026/07/18, counts['follow'], counts['post']]
end
end
File.rename(tmp_path, csv_path)
ensure
File.delete(tmp_path) if tmp_path && File.exist?(tmp_path) && File.exist?(csv_path)
end
呼び出し側は、いいねやフォローなど何らかのアクションが成功するたびにこれを呼ぶだけです。
record_daily_count('./activity_log.csv', :like)
record_daily_count('./activity_log.csv', :follow, 1)
一度全件をハッシュに読み込んで日付ごとに集計し直しているのは、同じ日付の行が複数あってはいけないのと、日付の昇順でCSVを並べておきたいためです。既存の行を壊さずに1行だけ差し込む、という発想では書けないため、必然的に「全件読み込み→全件書き直し」の形になります。
例外時の後始末で、一時ファイルをゴミとして残さない
書き込みの途中で例外が起きると、一時ファイルが削除されずに残ったままになることがあります。これを放置すると、次回起動時にディスク容量を無駄に使うだけでなく、デバッグ中に「このファイルは何だっけ」と紛らわしいゴミとして残り続けます。呼び出し全体をbegin/rescueで包み、失敗時には一時ファイルの削除だけは必ず行うようにしておくと安心です。
def record_daily_count_safely(csv_path, activity_type, increment = 1)
record_daily_count(csv_path, activity_type, increment)
rescue => e
warn "CSV記録でエラーが発生: #{e.message}"
tmp_path = "#{csv_path}.tmp"
File.delete(tmp_path) if File.exist?(tmp_path)
end
record_daily_countのensureでも一時ファイルの削除を試みていますが、renameが成功した直後は元のtmp_pathがもう存在しないため、File.exist?(csv_path)もあわせて確認し、rename前に例外が起きたケースだけを掃除の対象にしています。呼び出し側のrescueはさらに外側の保険で、想定していない箇所で例外が飛んだ場合でも一時ファイルだけは片付けてから抜けられるようにしています。
なぜこの一手間に価値があるのか
CSVに一行だけ追記するのであればFile.open(path, 'a')で十分なことも多いです。ですが今回のように「日付ごとの累計を毎回更新する」タイプの記録は、構造上「全件読み込み→全件書き直し」を避けられません。この形式のまま同じファイルに直接書き込むと、書き込み中に何らかの割り込みが入った瞬間の中身が、そのまま壊れたCSVとして残ってしまいます。
一時ファイルに書いてからrenameで置き換える、という工程を挟むだけで、この「書き込み中に落ちたら壊れる」というリスク自体をほぼ消すことができます。実装コストはほとんど増えないのに、長時間バッチの安定運用という観点では効果が大きい、割に合う対策だと感じています。
まとめ
- 日別集計のような「全件読み直して書き直す」CSV更新は、直接上書きせず一時ファイル経由にする
- 一時ファイルへの書き込みが完了してから
File.renameで本番ファイルに置き換えると、途中状態が外部から観測されない - rename前に例外が起きた場合に備え、
ensureと呼び出し側のrescueの二重で一時ファイルの後始末を行い、ゴミファイルを残さないようにする - 単純な追記で済む処理には不要な工程だが、累計を毎回書き直すタイプのログでは実装コストに対して安定性の見返りが大きい


コメント