Edited at

バッチ実行で現在時間を使って絞り込みをしてはいけない理由


はじめに

コードレビューをしていて、年に一回ぐらいは、バッチのコードに対して実行時間の絞り込みに現在時間を使ってはいけないといという指摘をするので、

なぜそれがいけないのかを説明する資料を作っておくことにした。

うまくテストコードを書かないと見つけにくく、本番環境でしばらく運用してからそのミスに気づいたり、ずっと気づかなかったりすることになる。

定期的な課金処理であるユーザの課金処理をずっとやり損なうなんて事故もありうるのだ。

Railsのコードを用いて説明するが、どんなプログラミング言語でも応用は可能である。


前提条件

Ruby: 2.5.1

Rails: 5.2

テーブル: usersテーブルに更新日付が入ったupdated_atというカラムがあるものとする。


バッチのコードに時間の絞り込みに実行時間を使う問題点

1日前までに更新されたデータを取得するコード

User.where(updated_at: (Time.zone.now - 1.day)...Time.zone.now)

# 発行されるSQL
User.where(updated_at: (Time.zone.now - 1.day)...Time.zone.now).to_sql
=> "SELECT \"users\".* FROM \"users\" WHERE \"users\".\"updated_at\" >= '2019-02-11 03:26:21.992358' AND \"users\".\"updated_at\" < '2019-02-12 03:26:21.992952'"

バッチ実行する場合(Cron)などで同じように

User.where(updated_at: (Time.zone.now - 1.day)...Time.zone.now)

とやってはいけない。

0:00:00にCronを実行を設定したとしても、対象のコードが実行されるのが

0:00:00に実行される保証がない。バッチが起動してライブラリがロードされ実際にバッチの処理が実行される時間が0:00:01になる可能性が大いにある

境界値テストで考えてみると

updated_atの時間が

現在時間が2/13 0:00:00として、2/12に更新されたユーザのデータ

つまりupdated_atが2/12 00:00:00から2/13 0:00:00未満のデータを取得したい。

下記のようなデータがある場合、取得したいデータは2, 3, 4だ


  1. 2/11 23:59:59

  2. 2/12 00:00:00

  3. 2/12 00:00:01

  4. 2/12 23:59:59

  5. 2/13 00:00:00

User.where(updated_at: (Time.zone.now - 1.day)...Time.zone.now)

これをバッチの起動に時間がかかり2/13 00:00:01に実行された場合、2のデータは取得できず、不要な5を取得してしまう。

バッチ実行時してユーザを取得するタイミングが00:00:00(厳密に言えばミリ秒単位で)でない限りは現在時間で絞り込んではいけないのだ


この問題を解決するには

バッチの実行される時間にある程度ずれるのを考慮したコードを書けば良い。

つまりバッチの実行が00:00:00に厳密に実行されなくても00:00:01や02:12:42に実行されても大丈夫なようにしておけば良い(もちろんバッチが失敗した際のリトライ処理なども必要だがこれはまた別のお話)

絞り込みする期間を明示的にその日の00:00:00にしてあげれば良い。

具体的にはTime.zone.now.beginning_of_dayを利用すると良い。

これを利用して書き直したコードはこちら

User.where(updated_at: (Time.zone.now.beginning_of_day - 1.day)...Time.zone.now.beginning_of_day)

# 発行されるSQL
User.where(updated_at: (Time.zone.now.beginning_of_day - 1.day)...Time.zone.now.beginning_of_day).to_sql
=> "SELECT \"users\".* FROM \"users\" WHERE \"users\".\"updated_at\" >= '2019-02-11 00:00:00' AND \"users\".\"updated_at\" < '2019-02-12 00:00:00'"

# 少し冗長なので時間を変数にするとこうなる
midnight = Time.zone.now.beginning_of_day
User.where(updated_at: (midnight - 1.day)...midnight)