保存と実行の間にコードが変わる
オブジェクトのジョブでは、Queue::createObjectPayload() が serialize(clone $job) を実行し、結果をペイロードの data.command に入れます。暗号化する場合も、その前段には同じシリアライズがあります。
ワーカー側の CallQueuedHandler::getCommand() は保存済みデータを unserialize() し、復元したジョブをDispatcherへ渡します。通常の handle() は、その時点で動いているワーカーのコードとサービスコンテナで実行されます。
長寿命ワーカーを再起動していなければ、更新後も旧コードが動き続けます。ローリングデプロイでは「旧投入側と新ワーカー」だけでなく、「新投入側と旧ワーカー」の組み合わせも考えます。
クラスの改名や削除は、利用者が直接そのクラスを呼んでいるかだけで判断しません。待機中・遅延中・再試行待ちのジョブや、失敗ジョブの保存期間も互換性の対象です。
SerializesModelsが保存するもの
Illuminate\Foundation\Queue\Queueable は、内部で SerializesModels を利用しています。これはモデルの置換だけでなく、ジョブ自身のプロパティを列挙する __serialize() と、値を戻す __unserialize() も提供します。
宣言時の既定値と等しい値は保存されない
__serialize() は、static・未初期化などのプロパティを除外し、プロパティの宣言時既定値と現在値が厳密に等しい場合も保存を省略します。
'csv' のままなら、保存配列に format は入りません。後のリリースで宣言時既定値を 'json' に変えると、旧ジョブにも 'json' が適用され得ます。単なる見た目の既定値変更ではなく、旧データの読み方の変更です。
復元時にコンストラクタは呼び直されない
__unserialize() は、現在のクラスに存在し、保存配列にもキーがあるプロパティへ値を代入します。コンストラクタを通じた入力の補完や検証は実行しません。
'csv' はコンストラクタ引数の既定値です。プロパティ自体の宣言時既定値ではないため、format を持たない旧ペイロードを復元したときの補完にはなりません。追加した型付きプロパティが未初期化のままになれば、後でアクセスしたときに失敗します。
また、publicのキーは名前そのもの、protectedは可視性を表す接頭辞付き、privateはクラス名も含むキーになります。プロパティをpublicからprivateへ変える変更も、旧ペイロードで検証します。
識別子と実行サービスを分ける
実行に必要な識別子や選択肢をジョブへ保存し、APIクライアントやExporterなどの実行サービスはhandle() に注入します。コンストラクタにサービスを渡して保持すると、その内部状態まで保存対象になり得ます。
次は、旧版では reportId だけを持っていた同名ジョブへ、形式の選択とペイロードバージョンを追加する例です。ReportExporter はパッケージが提供してコンテナにbindする契約で、export(string $reportId, string $format): void を持つものとします。
- 旧ペイロードに
formatがなければ、宣言時既定値の'csv'を利用する。 - 旧ペイロードに
payloadVersionがなければ、宣言時既定値の1として扱う。 - 新規生成時はコンストラクタが
payloadVersionを2にする。宣言時既定値と異なるので、この値は保存される。 - 新規ジョブの
formatが'json'なら、その値も保存される。'csv'なら省略されるため、宣言時既定値の'csv'を今後も維持する。
モデルを渡す場合はスナップショットと区別する
モデルを直接プロパティに保存すると、SerializesAndRestoresModelIdentifiers はクラス・ID・接続名・読み込み済みリレーション名などを ModelIdentifier に置き換えます。単一モデルの復元では、DBから firstOrFail() で再取得し、対象のリレーションを読み込みます。
これは、投入時点の属性や関連データを固定する仕組みではありません。
- 実行前にレコードが更新されれば、復元後は更新された内容を参照する。
- 事前に適用したリレーションの絞り込み条件は保存されないため、必要な条件は実行時に付け直す。
- 不要な関連は
withoutRelations()や#[WithoutRelations]で保存対象から外す。 - モデルが削除された場合、ジョブの
handle()より前の復元段階で失敗し得る。
#[DeleteWhenMissingModels] を検討できます。ただし、パッケージが勝手に破棄を既定動作にせず、利用者へ失敗方針を公開します。IDだけを渡して handle() 内で検索する設計なら、その検索の失敗やスキップは自身で制御します。
投入時点の内容が必要なら、必要な値を明示して保存するか、不変のレポート要求などをDBへ記録し、そのIDを渡します。単にモデルを渡すことと、業務データのスナップショットを保存することを区別します。
コミット後の投入は別の問題
トランザクション中に作成したレコードを処理する場合は、コミット前にワーカーが読み始めないようにします。afterCommit() は、開いている親トランザクションのコミット後まで投入を待ち、ロールバック時はその投入を破棄します。クラス名・プロパティの互換性や、外部処理の重複実行を解決する機能ではありません。
fakeと旧ペイロードのテストを分ける
通常のQueue::fake() は投入を記録するためのものです。assertPushed() が通っただけでは、保存・復元や、新版による旧ペイロードの処理を確認したことにはなりません。
現在のクラスでの往復は、次のように検証できます。
serializeAndRestore() はfake内で serialize() と unserialize() を実行します。ただし、実バックエンドへの投入・ワーカーによる実行・旧版コードでの生成は検証しません。
旧版で生成したfixtureを残す
旧版のGenerateReport が reportId だけを持つ状態で、実データを含まない固定のジョブを生成します。以下は旧版のテスト環境で一度実行する準備用コードです。tests/fixtures/ は事前に用意します。
TestCase はパッケージのテスト環境で用意する基底クラスです。unserialize() へ渡すのは自身で管理するfixtureだけにし、外部入力を渡さないでください。
さらに、復元した旧ジョブの handle() をテスト用Exporterで実行し、出力や保存結果まで検証します。モデルを渡す場合は更新・削除・接続・関連の再取得を、実際に利用するキュー接続ではコミット後の投入や再試行も別途検証します。
新形式を投入する前に読み取り側を更新する
1
旧形式を読めるリリースを用意する
旧クラス名と旧値の解釈を維持し、新形式も読めるコードを先に配布します。fixtureで復元だけでなく処理結果も確認します。
2
旧ワーカーが残らないことを確認する
利用アプリのデプロイ手順にワーカーの入れ替えを含めます。通常の
queue:work では php artisan queue:restart で現在のジョブ処理後の終了を要求し、プロセスマネージャーが新版を起動することを確認します。再起動シグナルにはキャッシュが使われます。3
新形式の投入を有効にする
読み取り側が対応してから、機能フラグなどで新形式の投入へ切り替えます。同じコードの配布だけで新形式の生成も始まる場合は、投入を一時停止するか、新旧のキューと対応ワーカーを分けるなど、旧ワーカーへ渡さない手順が必要です。
4
再試行とロールバックの期間も保守する
失敗ジョブの再試行や、チェーン内で後から投入されるジョブも確認します。新形式を保存した後に旧コードへ戻すなら、旧コードでも読めるかを検証します。キューが一度空になっただけで旧クラスや旧形式の対応を削除しません。
queue:restart は保存済みペイロードを書き換えるコマンドではありません。パッケージの更新案内には、対応形式、必要なワーカーの入れ替え、旧形式を廃止する条件を明記します。バージョン互換性管理とChangelogの運用にも同じ変更を記録します。
参照した一次情報
- Laravel公式ドキュメント: ジョブの構造と依存注入
- Laravel公式ドキュメント: コミット後の投入
- Laravel公式ドキュメント: ワーカーとデプロイ
- Laravel公式ドキュメント: 削除されたモデルの扱い
- Queue: オブジェクトのペイロード作成
- Queueable: ジョブ用トレイトの構成
- SerializesModels: プロパティの保存と復元
- SerializesAndRestoresModelIdentifiers: モデルの置換と再取得
- CallQueuedHandler: 復元と実行
- QueueFake: fake内の保存・復元
- Laravel公式テスト: モデルのシリアライズ