版を付けて変更し、両側を生かしておく
目標
バージョン番号が付いたスキーマでJSON Linesを扱う、契約ツールcontract.pyを作ります。バージョン間の変化を種類ごとに分類し、後方互換と前方互換をAvroのスキーマ解決ルールどおりに判定し、複数のバージョンが混ざって流れる移行期間を、読み取りスキーマ1つで乗り越えます。
なぜ重要なのか
書き込む側と読み取る側は、同じ瞬間には変わりません。その間には、必ず片方だけが新しいコードである期間があり、その期間にもデータは流れ続けます。そのため、スキーマ変更を設計するときに問うべきことは、「この変更は正しいか」ではなく、「どの順序でデプロイしても生き残るか」です。答えは3つに分かれます。デフォルト値のあるフィールドを加えることは両方向に安全で、デフォルト値のない必須フィールドを加えることは、新しい読み取りコードが古いデータを読めなくなり、型の変更は、広げる方向と狭める方向で、ちょうど反対の結果になります。名前の変更は、スキーマだけを見れば追加1つと削除1つで、エイリアスによってのみ1つの出来事に戻ります。ところが、エイリアスは読み取る側のスキーマのものだけが使われるため、名前の変更は片方向にだけ生き残ります。移行期間を越える方法は、1つしかありません。読み取りスキーマにデフォルト値を十分に付けて、古いバージョンまで読めるようにすることです。このラボは、そのデフォルト値1つが何件を生かすかを、数字で示します。採点ツールは、提出された文言を信じません。一時ディレクトリに、採点ツールが作ったスキーマと行を用意して、自分で作ったツールを実際に実行し、分類と判定を、採点ツールが別に実装した値と突き合わせます。フィールド名と型と金額は、実行ごとに変わります。
ステップ
- /root/evolve/gen_stream.pyを作成して実行し、/root/evolve/schemasに
v1.jsonからv5.jsonまで、/root/evolve/streamにバージョンごとに12行のv1.jsonlからv5.jsonlまでと、5つのバージョンが混ざったmixed.jsonlを作ってください。 - /root/evolve/contract.pyに
fields <스키마>(プレースホルダーはスキーマです)を作り、バージョン番号とフィールド名、必須・オプション、デフォルト値を出力させてください。 diff <옛 스키마> <새 스키마>(プレースホルダーは、古いスキーマと新しいスキーマです)を追加して、追加と削除を分類させてください。追加は、デフォルト値があるものとないものに分けて出力します。diffが、新しいバージョンのaliasesを見て、名前の変更を1つの出来事にまとめるようにしてください。まとめられた名前は、追加と削除の一覧から外れます。diffが、型の変更を、拡大と縮小に分けて出力するようにしてください。プロモーション表にあれば拡大、なければ縮小です。compat <옛 스키마> <새 스키마>(プレースホルダーは、古いスキーマと新しいスキーマです)を追加して、後方互換と前方互換を判定し、理由を固定されたコードで出力させてください。read <읽기 스키마> <스키마폴더> <파일>(プレースホルダーは、読み取りスキーマ、スキーマフォルダ、ファイルです)を追加して、複数のバージョンが混ざった行を1つのバージョンとして読ませ、寛容な読み取りスキーマ/root/evolve/reader.jsonを作って、2つの読み取りの違いを/root/evolve/window.jsonに書いてください。- 5つのバージョンの履歴を、/root/evolve/evolve_report.jsonと/root/evolve/evolve_report.mdとして残してください。
参考
- 実行の契約:
python3 /root/evolve/contract.py <명령> ...(プレースホルダーはコマンドです)。成功すれば終了コード0、渡したパスがなければ3、知らないコマンドか、引数の数が違えば2です。答えは、JSONの1つの塊として標準出力に出します。 - スキーマファイルの形:
{"version": 정수, "name": 문자열, "fields": [{"name": 이름, "type": 타입, "default": 기본값, "aliases": [옛이름...]}, ...]}(プレースホルダーは、整数、文字列、名前、型、デフォルト値、古い名前です)。defaultとaliasesは、あってもなくてもかまいません。 - デフォルト値のあるフィールドがオプションで、ないフィールドが必須です。このラボで必須とオプションを分けるのは、そのキー1つだけです。
- 型は、
int・long・float・double・string・booleanの6つです。プロモーション表は、Avro仕様そのままです。intはlong・float・doubleへ、longはfloat・doubleへ、floatはdoubleへ、プロモーションされます。同じ型もプロモーションと見なします。それ以外の組み合わせは、プロモーションではありません。 fieldsの応答:{"version": 정수, "names": [선언 순서대로], "required": [정렬], "optional": [정렬], "defaults": {이름: 기본값}}(プレースホルダーは、整数、宣言順、ソート済み、名前、デフォルト値です)。diffの応答:{"added_with_default": [정렬], "added_required": [정렬], "removed": [정렬], "renamed": [[옛이름, 새이름]...], "widened": [[새이름, 옛타입, 새타입]...], "narrowed": [[새이름, 옛타입, 새타입]...]}(プレースホルダーは、ソート済み、古い名前、新しい名前、古い型、新しい型です)。一覧は、すべてソートして出力します。ステップ3では最初の3つのキーだけ、ステップ4でrenamedが、ステップ5でwidened・narrowedが加わります。- 名前の変更の判定: 新しいバージョンにだけあるフィールドの
aliasesの中に、古いバージョンにだけある名前が入っていれば、その2つは同じフィールドです。値のサンプルで推測しません。ここでは、私たちがエイリアスを書く側です。 compatの応答:{"backward": 참거짓, "forward": 참거짓, "reasons": [정렬된 코드...]}(プレースホルダーは、真偽値、ソート済みのコードです)。理由のコードは、4つだけです。backward:missing_default:<필드>(プレースホルダーはフィールドです): 新しいバージョンにだけある必須フィールドなので、古いデータを読めませんbackward:no_promotion:<필드>(プレースホルダーはフィールドです): 古い型が新しい型にプロモーションされませんforward:missing_default:<필드>(プレースホルダーはフィールドです): 古いバージョンにだけある必須フィールドなので、新しいデータから値を見つけられませんforward:no_promotion:<필드>(プレースホルダーはフィールドです): 新しい型が古い型にプロモーションされません
- 判定は、「読み取る側が、書き込む側のデータを読めるか」という1つの関数で行い、引数を入れ替えて2つの方向を作るほうが短いです。後方互換は、読み取る側が新しいバージョン、前方互換は、読み取る側が古いバージョンです。
- エイリアスは、読み取る側のスキーマのものだけを使います。書き込む側のスキーマを、読み取る側の名前に直して読む方式だからです。そのため、名前の変更は、片方向にだけ生き残ります。
readの応答:{"records": 정수, "by_version": {"판번호": 정수}, "resolved": 정수, "failed": 정수, "amount_total": 정수}(プレースホルダーは、整数、バージョン番号です)。バージョン番号のキーは文字列です。行ごとに_vでバージョン番号を読み、スキーマフォルダからv<판번호>.json(プレースホルダーはバージョン番号です)を探します。読み取りスキーマがそのバージョンを読めなければ、failedとして数え、金額に入れません。amount_totalは、読み取りスキーマのamountフィールドを解決したあとで足した値です。- イメージにAvroライブラリはありません。ルールだけを仕様から持ってきて、JSON Linesとバージョン番号が付いたスキーマファイルで、自分で実装します。標準ライブラリだけを使います。
- 公式ドキュメント: Avro仕様のスキーマ解決・Confluentの互換性タイプ・Protocol Buffers言語ガイド・python json
- よくある間違い: デフォルト値のないフィールドを加えておいて、後方互換が成り立つと信じる、名前の変更が両方向に安全だと信じる、型を広げれば常に安全だと信じる、移行期間に最新バージョンだけを読んで、古いバージョンを静かに捨てる。
5つのバージョンを作って出力する
/root/evolve/gen_stream.pyを作成して実行し、/root/evolve/schemasにv1.jsonからv5.jsonまで、/root/evolve/streamにv1.jsonlからv5.jsonlまで、そして5つのバージョンが混ざったmixed.jsonlを作ってください。
バージョンごとに変わるものを1つずつにしておくと、あとで判定が何を捉えているかが見えます。v2はデフォルト値のあるフィールドを加え、v3はデフォルト値のないフィールドを加え、v4は名前を変えながら(エイリアスを付けます)型を広げ、v5はその型をまた狭めます。行ごとに_vで、どのバージョンかを書いてください。
必須とオプションをデフォルト値で分ける
/root/evolve/contract.pyにfields <스키마>(プレースホルダーはスキーマです)を作り、version・names・required・optional・defaultsをJSONで出力させてください。デフォルト値のあるフィールドがオプションで、ないフィールドが必須です。
namesは宣言順のまま、requiredとoptionalはソートして出力します。defaultキーがあるかないかだけを見ればよく、デフォルト値が空文字列や0でもオプションです。値ではなく、キーの有無で分けます。
加わったものと、なくなったものを分ける
diff <옛 스키마> <새 스키마>(プレースホルダーは、古いスキーマと新しいスキーマです)を追加して、added_with_default・added_required・removedの3つの一覧を出力させてください。3つの一覧とも、ソートして出力します。
名前の集合の差を求めるだけです。加わったフィールドは、新しいバージョンの宣言でdefaultキーがあるかどうかで、2つに分けます。このステップでは、エイリアスと型の変更は、まだ見なくてもかまいません。
名前の変更を1つの出来事にまとめる
diffの応答にrenamedを加えてください。新しいバージョンにだけあるフィールドのaliasesの中に、古いバージョンにだけある名前が入っていれば、その2つは同じフィールドです。まとめられた名前は、added_*とremovedから外れます。
名前の変更は、スキーマだけを見れば、追加1つと削除1つです。エイリアスが、その2つを1つの出来事に戻す唯一の仕組みです。値のサンプルで推測しないでください。ここでは、私たちがエイリアスを書く側なので、推測する理由がありません。
拡大と縮小を分ける
diffの応答にwidenedとnarrowedを加えてください。項目は[새이름, 옛타입, 새타입](プレースホルダーは、新しい名前、古い型、新しい型です)で、古い型が新しい型にプロモーションされれば拡大、それ以外は縮小です。名前が変わりながら型も変わったフィールドまで見ます。
プロモーション表を辞書1つで書いておくと、判定が1行になります。intはlong・float・doubleへ、longはfloat・doubleへ、floatはdoubleへ進みます。同じ型もプロモーションと見なすと、あとで互換性の判定が簡単になります。
後方と前方を別々に判定する
compat <옛 스키마> <새 스키마>(プレースホルダーは、古いスキーマと新しいスキーマです)を追加して、backward・forward・reasonsを出力させてください。理由は、参考の節の4つのコードだけを使い、ソートして出力します。
読み取る側が、書き込む側のデータを読めるかを見る関数を1つ作り、引数を入れ替えて2つの方向を作ってください。後方互換は、読み取る側が新しいバージョンで、前方互換は、読み取る側が古いバージョンです。エイリアスは、読み取る側のスキーマのものだけを使うという点が、ここで結果を分けます。
移行期間を読み取りスキーマ1つで乗り越える
read <읽기 스키마> <스키마폴더> <파일>(プレースホルダーは、読み取りスキーマ、スキーマフォルダ、ファイルです)を追加し、5つのバージョンをすべて読める寛容な読み取りスキーマ/root/evolve/reader.jsonを作ってください。schemas/v5.jsonで読んだ結果と、reader.jsonで読んだ結果を、/root/evolve/window.jsonにstrict・tolerant・amount_totalとして書いてください。
厳格な読み取りスキーマは、デフォルト値のないフィールドのために、古いバージョンを丸ごと捨てます。そのフィールドにデフォルト値を付けると、何件が生き返るかが、このステップの答えです。エイリアスも、読み取る側にあって初めて、古い名前を追いかけます。amount_totalは、寛容なほうの値を書いてください。
バージョンの履歴を1枚に残す
隣り合うバージョンごとにcompatを実行して、/root/evolve/evolve_report.jsonにversions・steps・full・brokenを書き、/root/evolve/evolve_report.mdに、## 어떤 판이 있나、## 어느 방향이 깨지나、## 전환 기간을 어떻게 넘기나、## 다음 판에 지킬 것の4つの節で書いてください(韓国語の見出しで、順に「どんなバージョンがあるか」「どの方向が壊れるか」「移行期間をどう乗り越えるか」「次のバージョンで守ること」を意味します)。
stepsは、{"from": 정수, "to": 정수, "backward": 참거짓, "forward": 참거짓, "reasons": [...]}(プレースホルダーは、整数、真偽値です)の一覧です。fullは両方向が成り立つペア、brokenは片方向でも壊れるペアを、[옛판, 새판](プレースホルダーは、古いバージョンと新しいバージョンです)で入れます。レポートには、移行期間に生き返った件数を、数字で書いてください。