🧱

パーサを書いて Parquet の Repetition Level と Definition Level を理解する

に公開

ogp

Parquet、使っていますか?
速くて、ファイルサイズが小さくて、複雑なデータ構造でもいい感じに扱ってくれてすごいですよね。
すごいなーで終わらせてしまうのはもったいないので、どういった仕組みでそれを実現しているのか理解したくなりました。

Parquet のファイルフォーマットの全体像については Parquetフォーマット概観 の記事が非常に分かりやすいのでぜひ参照いただきたいのですが、 Repetition Level と Definition Level については概念が複雑で、記事を読むだけでは直感的な理解まで至りませんでした。

Repetition Level と Definition Level の出典元である Dremel: Interactive Analysis of Web-Scale Datasets に目を通してもなお十分に理解できなかったので、改めて自分自身で整理しなおした上で、概念レベルでの Writer / Reader の処理を書いてパーサの気持ちを理解してみようと思います。

モデルの構造

Parquet には階層や繰り返しを含む複雑な構造のデータを以下のようなスキーマの形式[1]で保存することができます。

message Root {
  repeated group repeated1 {
    repeated string repeated2;
    required string normalField2;
  }
  optional string normalField1;
}

上記スキーマのデータ例を JSON 形式で以下に例示します。(実際の Parquet はバイナリです)

[
  {
    "repeated1": [
      {
        "repeated2": [
          "value1-1-1",
          "value1-1-2"
        ],
        "normalField2": "v1"
      },
      {
        "repeated2": [
          "value1-2-1"
        ],
        "normalField2": "v2"
      }
    ],
    "normalField1": "v3"
  },
  {
    "repeated1": [
      {
        "repeated2": [
          "value2-1-1"
        ],
        "normalField2": "v3"
      }
    ]
  }
]

データの構造については以下のような種類があります。

  • required : フィールドは常に1つ(1..1)
  • optional : フィールドは0または1つ(0..1)
  • repeated : フィールドは0以上の複数(0..n)

Repetition Level

Parquet は列指向のデータフォーマットのため、カラムごとにデータを保持します。
また、ネストされたデータ構造を平坦化して保持するため、カラムは repeated1.repeated2 のようなフィールドのパスで表現されます。

前述の例について考えてみると、Parquet としては repeated1.repeated2 の値は以下のように連続して格納されています。

  • "value1-1-1"
  • "value1-1-2"
  • "value1-2-1"
  • "value2-1-1"

これらの値を取り出して元のスキーマに復元したいと考えた時、どこからどこまでの値が何番目のレコードの何番目の repeated1 の何番目の repeated2 の値なのかが分からないといけません。
Repetition Level はその値と一つ前の値とを比較したときに、パスに含まれる repeated なフィールドのどの階層で繰り返されているか を示します。

前述の例では以下のようになります。

  • "value1-1-1" は、前の値が無い => Repetition Level = 0
  • "value1-1-2" は、前の値 "value1-1-1" と、 repeated2 の階層で繰り返されている => RL = 2
  • "value1-2-1" は、前の値 "value1-1-2" と、 repeated1 の階層で繰り返されている => RL = 1
  • "value2-1-1" は、前の値 "value1-2-1" と、 同じ階層で繰り返されていない(新しいレコードである) => RL = 0

repeated ではないフィールドの Repetition Level の導出についても確認するために、 repeated1.normalField2 の Repetition Level の値を導出してみます。
normalField2 自身は repeated ではないですが、パスのフィールド repeated1repeated であるため、 Repetition Level は 0 または 1 となります。

  • "v1" は、前の値が無い => RL = 0
  • "v2" は、前の値 "v1" と、 repeated1 の階層で繰り返されている => RL = 1
  • "v3" は、前の値 "v2" と、 同じ階層で繰り返されていない(新しいレコードである) => RL = 0

Definition Level

optional なフィールドと repeated なフィールドは、required なフィールドと異なり値が 存在しない(以下 null で表現)となる場合があります。

以下のようなスキーマについて考えてみます。

message Root {
  optional group optGroup {
    required group requiredGroup {
      optional string optField
    }
  }
}

具体的なデータ例を JSON 形式で表現してみると以下のようなものが考えられます。

[
  {
    "optGroup": {
      "requiredGroup": {
        "optField": "v1"
      }
    }
  },
  {
    "optGroup": {
      "requiredGroup": {
        "optField": null
      }
    }
  },
  {
    "optGroup": null
  }
]

上記について、optGroup.requiredGroup.optField カラムについて考えてみると、値は以下のようになります。

  • "v1"
  • nulloptFieldnull
  • nulloptGroupnull

これらの値だけを見ると、 null の場合にどのフィールドまでが存在しないのかが分かりません。
Definition Level は、その値のパスに含まれるフィールドのうち、 null になり得るが null ではないフィールドの数 を表現した値です。

前述の例では、

  • "v1" は、optGroupoptFieldnull になり得るが null ではない => Definition Level = 2
  • nulloptFieldnull)は、optGroupnull になり得るが null ではない => DL = 1
  • nulloptGroupnull)は、null になり得るが null ではないフィールドがない => DL = 0

となります。
null になり得るフィールドだけが対象であるため、前述の optGroup.requiredGroup.optField の例の場合は requiredGroup は対象外となり、Definition Level の最大値は 2 となります。

RL と DL を導出する Writer

ここまでで理屈はわかったので、実際に自分で RL と DL を用いて構造の保存と復元を行う簡易的な Writer と Reader を書いてみます。
(言語は何でも良いのですが、 TypeScript で書いてみます。)

Writer は、スキーマとそのスキーマのデータを入力として、値ごとの RL と DL を出力する仕様とします。
Reader は、スキーマと Writer の出力である値ごとの RL と DL の一覧を入力として、それらをもとに復元したデータを出力する仕様とします。

型定義
型定義
type Repetition = "required" | "optional" | "repeated";

interface Field {
  name: string;
  repetition: Repetition;
  defLevel: number; // 累積 definition level
  repLevel: number; // 累積 repetition level
}

interface ColumnEntry {
  value: unknown;
  rl: number;
  dl: number;
}
Writer
function write(records: unknown[], fields: Field[]): ColumnEntry[] {
  // 各レコードを処理。新レコードは常にRL=0から開始
  return records.flatMap((record) => writeRecord(record, fields, 0, 0));
}

function writeRecord(
  data: unknown,
  fields: Field[],
  fieldIndex: number,
  currentRl: number // 親から引き継いだRL。新レコードなら0、繰り返し中ならその階層
): ColumnEntry[] {
  if (fieldIndex >= fields.length) return [];

  const field = fields[fieldIndex];
  const isLeaf = fieldIndex === fields.length - 1;
  const maxDl = fields[fields.length - 1].defLevel;

  const value =
    data !== null && typeof data === "object"
      ? (data as Record<string, unknown>)[field.name]
      : undefined;

  // --- null/undefined の場合 ---
  // このフィールドで構造が途切れた。DLは「一つ前まで定義されていた」ことを示す
  // 例: repeated1=null → DL=0(repeated1より前=ルートまで定義)
  if (value === null || value === undefined) {
    const dl = fieldIndex === 0 ? 0 : fields[fieldIndex - 1].defLevel;
    return [{ value: null, rl: currentRl, dl }];
  }

  // --- repeated の場合 ---
  if (field.repetition === "repeated") {
    const arr = value as unknown[];

    // 空配列の場合、「一つ前まで定義されていた」ことを示す
    // 例: repeated2=[] → DL=1(repeated1までは存在)
    if (arr.length === 0) {
      return [{ value: null, rl: currentRl, dl: field.defLevel - 1 }];
    }

    return arr.flatMap((item, i) => {
      // 最初の要素: 親から引き継いだRLをそのまま使う
      // 2番目以降: このフィールドのrepLevelを使う(ここで繰り返しが発生)
      // 例: ["a","b"] → "a"はRL継続、"b"はRL=2(repeated2で繰り返し)
      const rl = i === 0 ? currentRl : field.repLevel;

      return isLeaf
        ? [{ value: item, rl, dl: maxDl }]
        : writeRecord(item, fields, fieldIndex + 1, rl);
    });
  }

  // --- required/optional の場合 ---
  // RLには影響しない(repeatedではないので繰り返しは発生しない)
  return isLeaf
    ? [{ value, rl: currentRl, dl: maxDl }]
    : writeRecord(value, fields, fieldIndex + 1, currentRl);
}

RL と DL の処理をまとめているため、少しわかりづらいですが、基本的には前述の説明をコードに起こしただけの内容です。
階層構造を持つ場合に対応するために再帰的な処理にしました。

実際に実行してみると、以下のような結果が得られます。

テストコード
テストコード
// スキーマ
const fields = [
  {
    name: "repeated1",
    repetition: "repeated",
    defLevel: 1,
    repLevel: 1,
  },
  {
    name: "repeated2",
    repetition: "repeated",
    defLevel: 2,
    repLevel: 2,
  },
];

// 入力データ
const records = [
  { repeated1: [{ repeated2: ["a", "b"] }, { repeated2: ["c"] }] },
  { repeated1: [{ repeated2: [] }, { repeated2: ["d", "e"] }] },
  { repeated1: [] },
];

console.log("=== スキーマ ===");
console.log(JSON.stringify(fields, null, 2));

console.log("\n=== 入力 ===");
console.log(JSON.stringify(records, null, 2));

console.log("\n=== 値ごとのRLとDLの導出結果 ===");
const entries = write(records, fields);
console.table(entries);
結果
=== スキーマ ===
[
  {
    "name": "repeated1",
    "repetition": "repeated",
    "defLevel": 1,
    "repLevel": 1
  },
  {
    "name": "repeated2",
    "repetition": "repeated",
    "defLevel": 2,
    "repLevel": 2
  }
]

=== 入力 ===
[
  {
    "repeated1": [
      {
        "repeated2": [
          "a",
          "b"
        ]
      },
      {
        "repeated2": [
          "c"
        ]
      }
    ]
  },
  {
    "repeated1": [
      {
        "repeated2": []
      },
      {
        "repeated2": [
          "d",
          "e"
        ]
      }
    ]
  },
  {
    "repeated1": []
  }
]

=== 値ごとのRLとDLの導出結果 ===
┌───────┬───────┬────┬────┐
│ (idx) │ value │ rl │ dl │
├───────┼───────┼────┼────┤
│     0 │ "a"   │ 0  │ 2  │
│     1 │ "b"   │ 2  │ 2  │
│     2 │ "c"   │ 1  │ 2  │
│     3 │ null  │ 0  │ 1  │
│     4 │ "d"   │ 1  │ 2  │
│     5 │ "e"   │ 2  │ 2  │
│     6 │ null  │ 0  │ 0  │
└───────┴───────┴────┴────┘

RLについて、新しいレコードの場合は 0、同じ repeated2 の配列で連続している場合は 2、repeated1 が同じで repeated2 が異なる場合は 1 となっていることが確認できます。
DLについても、repeated2 が空配列の場合は 1、repeated1 が空配列の場合は 0、それら以外は 2 となっていることが確認できます。

RL と DL から構造を復元する Reader

RL と DL を導出できたので、実際にそれらの値を使って構造を復元できることを確かめてみたいと思います。

Reader
function read(entries: ColumnEntry[], fields: Field[]): unknown[] {
  const records: Record<string, unknown>[] = [];
  const maxDl = fields[fields.length - 1].defLevel;

  // contexts[i]: i番目のフィールドで「最後に追加したオブジェクト」を追跡
  // これにより「前の要素を再利用」するか「新しい要素を追加」するかを判断できる
  let contexts: (Record<string, unknown> | null)[] = [];

  for (const { value, rl, dl } of entries) {
    // --- RL=0: 新レコード開始 ---
    if (rl === 0) {
      records.push({});
      contexts = new Array(fields.length).fill(null);
    }

    // 現在のレコードのルートから開始
    let current = records[records.length - 1];

    // パスを辿って構造を構築
    for (let i = 0; i < fields.length; i++) {
      const field = fields[i];
      const isLeaf = i === fields.length - 1;

      // --- DLが足りない: このフィールドは存在しない ---
      // 例: DL=1 で repeated2(defLevel=2) を処理 → repeated2は空配列
      if (dl < field.defLevel) {
        if (field.repetition === "repeated") {
          current[field.name] ??= [];
        } else if (field.repetition === "optional") {
          current[field.name] = null;
        }
        break; // これ以上深く辿らない
      }

      // --- repeatedフィールドの処理 ---
      if (field.repetition === "repeated") {
        current[field.name] ??= [];
        const arr = current[field.name] as unknown[];

        // このフィールドが何番目のrepeatedか(=repLevel)を計算
        let thisRepLevel = 0;
        for (let j = 0; j <= i; j++) {
          if (fields[j].repetition === "repeated") thisRepLevel++;
        }

        // 新しい要素が必要か判断:
        // - contexts[i]がnull: まだ何も追加していない → 新規
        // - rl <= thisRepLevel: このフィールド以上の階層で繰り返し → 新規
        // - rl > thisRepLevel: より深い階層で繰り返し → 前の要素を再利用
        const needNew = contexts[i] === null || rl <= thisRepLevel;

        if (isLeaf) {
          // 末端の場合: 配列に値を追加
          if (dl === maxDl) arr.push(value);
          contexts[i] = current;
        } else if (needNew) {
          // 非末端で新規: 新しいオブジェクトを配列に追加
          const obj: Record<string, unknown> = {};
          arr.push(obj);
          contexts[i] = obj;
          current = obj;
        } else {
          // 非末端で再利用: 配列の最後の要素を使う
          current = arr[arr.length - 1] as Record<string, unknown>;
        }
      } else {
        // --- required/optionalフィールドの処理 ---
        if (isLeaf) {
          // 末端の場合: 値をセット
          current[field.name] = dl === maxDl ? value : null;
        } else {
          // 非末端の場合: オブジェクトを作成して次へ
          current[field.name] ??= {};
          current = current[field.name] as Record<string, unknown>;
        }
        contexts[i] = current;
      }
    }
  }

  return records;
}

Writer とは逆の変換を行っています。

  • RL = 0 の場合、新しいレコードの開始として解釈する
  • RL = n (n > 0) の場合、n 番目の repeated なフィールドに追加する
    • パス内の repeated なフィールドの配列に追加ができるようにするために、 contexts でパスのフィールドのオブジェクトを保持する
      • レコードが切り替わったら contexts をリセット
  • DL が処理対象の階層よりも小さい場合、空配列や null として階層の深掘りを中止
  • DL が最大値の場合、末端として値を設定

先の Writer のテストコードの出力を入力として Reader も実行してみると、以下のようになり、入力データを復元できていることがわかります。

テストコード
console.log("\n=== 復元結果 ===");
const restored = read(entries, fields);
console.log(JSON.stringify(restored, null, 2));

console.log("\n=== 入力と復元結果が同一か ===");
console.log(JSON.stringify(records) === JSON.stringify(restored));
結果
=== 復元結果 ===
[
  {
    "repeated1": [
      {
        "repeated2": [
          "a",
          "b"
        ]
      },
      {
        "repeated2": [
          "c"
        ]
      }
    ]
  },
  {
    "repeated1": [
      {
        "repeated2": []
      },
      {
        "repeated2": [
          "d",
          "e"
        ]
      }
    ]
  },
  {
    "repeated1": []
  }
]

=== 入力と復元結果が同一か ===
true

まとめ

ここまでの内容で、Repetition Level と Definition Level を用いることで複雑な構造を平坦化して保存・復元ができることを確認できました。

RL と DL の導出方法を概念的に理解するところまでで留めずに実際に処理を書いてみたことで、単にそれらの意義が腹落ちしただけでなく、 RL と DL の出典元である Dremel: Interactive Analysis of Web-Scale Datasets では空配列を扱っていないことにも気付くことができました。

今回は Parquet のバイナリにまでは触れませんでしたが、今度はバイナリのパーサを書いて Parquet を完全に理解したいと思います。

みなさまも良いデータエンジニアリング生活をお送りください。

脚注
  1. 例ではフィールド番号が省略されていますが Protocol Buffers というスキーマ言語です ↩︎

株式会社ログラス テックブログ

Discussion