既存のFailedExecution記録がある場合のクラッシュを修正
ジョブの失敗記録時にTypeError: can't cast ProcessMissingErrorが発生し、起動できなくなる問題が修正されました。この修正により、理論上は発生しないはずの状態でもシステムが復旧可能になります。
背景
Job#failed_withメソッドはcreate_or_find_by!を使用してFailedExecutionレコードを作成していましたが、このメソッドにexceptionオブジェクトを直接渡していたことが問題の原因でした。ユニーク制約違反が発生した場合、フォールバックのfind_by!がexceptionオブジェクトをSQLバインドパラメータとして使用しようとし、型エラーが発生していました。
#699で報告されたこの問題は、ジョブがClaimedExecutionとFailedExecutionの両方を持つという、本来ClaimedExecution#failed_withのトランザクション保証では起こりえない状態で発生していました。この状態になると、起動時のfail_orphaned_executions処理がクラッシュし、Solid Queueが全く起動できなくなります。
PR本文では、Mission Controlのジョブリトライ処理に競合状態やバグがある可能性が指摘されています。しかし、原因が何であれ、システムが復旧できる仕組みを持つことは重要です。
技術的な変更
Job#failed_withメソッドの実装が、create_or_find_by!から明示的な例外処理に変更されました。
変更前:
def failed_with(exception)
FailedExecution.create_or_find_by!(job_id: id, exception: exception)
end
変更後:
def failed_with(exception)
FailedExecution.transaction(requires_new: true) do
FailedExecution.create!(job_id: id, exception: exception)
end
rescue ActiveRecord::RecordNotUnique
if (failed_execution = FailedExecution.find_by(job_id: id))
failed_execution.exception = exception
failed_execution.save!
else
retry
end
end
新しい実装では、まずcreate!を試み、RecordNotUniqueエラーが発生した場合は既存レコードを検索して新しいエラー情報で更新します。create!失敗からfind_byの間にレコードが削除された場合(並行リトライとの競合)は、retryで再度create!を試みます。
さらに、FailedExecutionのexpand_error_details_from_exceptionコールバックが変更されました。
変更前:
before_create :expand_error_details_from_exception
変更後:
before_save :expand_error_details_from_exception, if: :exception
before_createからbefore_saveに変更し、if: :exceptionガードを追加することで、既存のFailedExecutionを新しい例外で更新する際にもエラー詳細が正しくシリアライズされるようになります。
設計判断
楽観的な作成と例外ハンドリングのパターンが採用されました。create_or_find_by!のような便利メソッドではなく、明示的なcreate!とrescueの組み合わせを選択しています。
このアプローチには2つの利点があります。第一に、create_or_find_by!は内部でfind_by!にフォールバックする際、渡された属性をすべてクエリ条件として使用しようとするため、RubyオブジェクトであるexceptionをSQLパラメータに変換できずエラーになっていました。明示的な例外処理では、このような予期しない型変換の問題を回避できます。
第二に、既存レコードの更新処理を明示的に記述することで、新しいエラー情報で上書きする意図が明確になります。create_or_find_by!では既存レコードをそのまま返すだけですが、新しい実装では最新のエラー情報を確実に記録します。
トランザクションのrequires_new: trueオプションは、外側のトランザクションと分離し、RecordNotUniqueを確実にキャッチできるようにしています。
まとめ
本PRは、理論上起こりえないデータ状態でもシステムが復旧できるよう、防御的なエラーハンドリングを追加した変更です。create!と明示的な例外処理の組み合わせにより、型変換エラーを回避しつつ、既存レコードの更新という意図を明確に表現しています。Mission Controlとの連携における潜在的な競合状態への対応としても機能します。