イベントスキーマをどう変更する?古いAIエージェントを壊さない互換性設計
AIエージェントが出した TaskCompleted イベントへ summary を追加しただけなのに、古い通知ワーカーが未知の項目を理由に停止することがある。反対に、従来の result を削除すると、新しい生産側は正常でも、更新が遅れた集計や監査の処理は値を読めない。イベントは送った瞬間に消える引数ではなく、複数の利用者と保存済みデータが読む契約である。
イベントスキーマの変更では、新しいコードが古いイベントを読めるか、古いコードが新しいイベントを読めるかを分けて考える。すべての生産側と受信側を同時に更新できるという前提を置かず、互換性を確認してから形式を切り替える必要がある。
イベントには時間差のある読み手がいる
HTTP APIなら要求と応答の相手が同じ時間に動いていることが多い。イベントでは、生産側が送ったあと数分後にワーカーが読む場合もあれば、障害から復旧した翌日に再送される場合もある。監査用のイベントを数か月後に再生することもある。
そのため、現在動いているコードだけを見ても足りない。次の組み合わせを扱う。
- 新しい受信側が古い保存イベントを読む
- 古い受信側が新しいイベントを読む
- 更新途中で新旧の生産側が同時に送る
- 隔離したイベントを修正後のコードで再処理する
「最新環境では通った」という確認だけでは、遅れて読む経路を守れない。
backwardとforwardを主語付きで考える
互換性の用語は、何が何を読めるのかを省略すると混乱しやすい。この記事では次のように扱う。
- backward compatibility: 新しい受信側が、古い形式で作られたイベントを読める
- forward compatibility: 古い受信側が、新しい形式で作られたイベントを読める
- full compatibility: 両方を満たす
Confluent Schema Registryの公式資料も、backwardを新しいschemaが以前のschemaで作られたデータを読めること、forwardを以前のschemaが新しいschemaで作られたデータを読めることとしている。直前の一版だけでなく、保持中の全版を対象にする場合はtransitiveな互換性が必要になる。
追加は省略時の意味を決めてから行う
任意項目の追加は比較的安全だが、単にnullableにすればよいわけではない。古いイベントには新項目が存在しないため、新しい受信側は欠落時の意味を決める必要がある。
例えば risk_level を追加するとき、欠落を low とみなすと、評価前の古いイベントを低リスクだと誤認する。unknown として扱い、人の確認や再計算へ回す方が安全な場合がある。型として読めることと、業務上正しく扱えることは別である。
古い受信側は未知の項目を安全に無視できるようにする。JSONを厳密なオブジェクトへ変換するとき、未知項目をすべてエラーにする設定は変更を難しくする。一方で、必須項目の欠落や型違いまで黙って無視してはいけない。
標準Pythonだけで、未知の項目を無視しながら必要な項目を検査する最小例は次のようになる。
def read_task_completed(event): required = {"event_id", "task_id", "status"} missing = required - event.keys() if missing: raise ValueError(f"missing fields: {sorted(missing)}")
return { "event_id": str(event["event_id"]), "task_id": str(event["task_id"]), "status": str(event["status"]), }
new_event = { "event_id": "evt-101", "task_id": "task-7", "status": "completed", "summary": "問い合わせへの回答を作成",}
print(read_task_completed(new_event))出力は次のとおりである。
{'event_id': 'evt-101', 'task_id': 'task-7', 'status': 'completed'}古い読み手が知らない summary は無視するが、契約上必要な三項目は検査する例である。実際にはschema validatorや生成コードを使う場合も、未知項目、既定値、必須項目の方針を明示する。
削除は「送らない期間」を挟む
項目をすぐ削除すると、まだ参照している受信側が壊れる。段階的に進める。
- 利用状況を調べ、受信側を新旧両方へ対応させる
- 生産側が旧項目と新項目を併記する
- 受信側を新項目へ移行する
- 旧項目をdeprecatedとして一定期間維持する
- 保持中イベントと再送経路を確認してから送信を止める
- 互換性を壊すなら新しいイベント型へ分ける
名前の変更も、実際には新項目の追加と旧項目の削除である。同じ名前の意味だけを変えると、schema上は同じ文字列でも受信側の解釈が分かれる。例えば duration をミリ秒から秒へ変えるなら、duration_ms と duration_seconds のように単位を名前へ含め、移行期間を設ける。
数字と名前を再利用しない
Protocol Buffersではfield numberがwire format上の項目識別子になる。公式ガイドは、利用中のfield numberを変更せず、削除した番号を再利用しないよう求めている。削除した番号と名前を reserved にすれば、将来別の意味で使う事故をコンパイラが防げる。
JSONでも考え方は同じである。過去に result が回答本文を意味していたなら、削除後に同じ名前を判定結果のbooleanへ使わない。保存済みイベント、遅延したイベント、古いコードが新旧の意味を取り違える。互換性試験でparseできても、意味が変われば安全ではない。
enumへの値追加にも注意がいる。形式上は追加可能でも、古い受信側が全候補を列挙した switch で未知値を異常終了させることがある。未知値を隔離するのか、unknown として処理を続けるのかを決める。権限や決済に関わる判定では、安全側へ停止する必要がある。
envelopeの版とdataの版を混ぜない
イベントには、配送に共通するenvelopeと業務内容のdataがある。event_id、source、type、発生時刻、対象ID、追跡IDはenvelopeへ置き、回答内容や状態変更はdataへ置く。
CloudEventsでは specversion はCloudEvents自体の仕様版を示す。業務dataのschema版ではない。dataが従うschemaは dataschema で示せる。CloudEvents Primerは、互換性を壊す変更では通常 type と dataschema を変え、移行期間に新旧イベントを生産する方法を案内している。
つまり specversion: 1.0 のままでも、業務イベントのdataは変わりうる。自社の schema_version を持つ場合も、envelopeの版、イベント型の版、payloadのschema版を一つの数字へ押し込まない方がよい。
互換性を壊す変更は新しい型として並走させる
項目の意味、粒度、発生条件が変わるなら、同じイベントを改造するより新しい型を作る。例えば一つの TaskCompleted を、道具実行単位の完了へ意味変更してはいけない。ToolExecutionCompleted のように別の型へ分ける。
移行中は、生産側が新旧両方を送るか、変換ワーカーで旧形式も供給する。ただし二重配信によって同じ外部作用が二度起きないよう、両形式の関係を示す元イベントIDや操作IDを持たせる。受信側ごとの移行状況を記録し、全利用者が切り替わるまで旧形式を止めない。
schemaだけでなく意味の契約を試す
CIでは新旧schemaの互換性検査に加え、実際のevent fixtureを保存して読む。
- 新しい受信側が最古の保持対象イベントを読める
- 古い受信側が新しいイベントの任意項目を無視できる
- 必須項目の欠落と型違いを検出する
- 未知のenum値で危険な操作を続けない
- 新旧イベントを並走させても副作用が重複しない
- 隔離済みイベントを新コードで再処理できる
生産側と受信側の所有者が異なる場合は、schema registryだけでなく、利用中の版、廃止予定日、連絡先を管理する。互換性検査を通ったから即座に削除可能とは限らない。長期間保存したイベントや一時停止中の受信側が残っていることがある。
イベントの形式は、AIモデルの出力に合わせて気軽に変える内部JSONではない。新旧どちらがどちらを読むのかを決め、任意項目は欠落時の意味を持たせ、削除は段階的に進める。意味を壊す変更は新しいイベント型として並走させる。この手順が、モデルや道具を更新しても周辺処理を止めない土台になる。