Chapter Table Formats memperkenalkan kategori table format sebagai lapisan metadata di atas Parquet. Apache Iceberg adalah salah satu table format yang paling banyak diadopsi: tabel Iceberg dapat dibaca Spark, Trino, Flink, dan DuckDB tanpa mengubah satu berkas data pun. Kunci desainnya adalah pohon metadata berlapis yang menggantikan trik Hive — partisi berbasis direktori dan nama berkas. Pada chapter ini kita bedah lapisan metadata tersebut sampai ke berkas di disk, lalu mengujinya dengan pyiceberg: membuat tabel, menulis, membaca, memindai metadata, dan mengevolusikan schema.
Arsitektur Metadata Iceberg #
Tabel Hive klasik menyimpan partisi sebagai direktori (tahun=2025/bulan=03/), sehingga query engine menebak isi tabel dari struktur direktori. Pendekatan ini rapuh: mengubah strategi partisi berarti memindahkan seluruh berkas, dan dua writer yang menulis bersamaan dapat membuat daftar berkas tidak konsisten. Iceberg memindahkan seluruh kebenaran ke dalam metadata yang diperbarui secara atomik, sementara berkas data tetap diam di tempatnya. Diagram berikut memperlihatkan rantai rujukan dari catalog sampai berkas Parquet:
Empat lapisan pada diagram di atas punya tugas yang terpisah tegas:
| Lapisan | Berkas | Isi utama |
|---|---|---|
| Metadata tabel | *.metadata.json | schema, partition spec, daftar snapshot, lokasi snapshot aktif |
| Manifest list | snap-*.avro | per snapshot: daftar manifest file yang ditambah/dihapus |
| Manifest file | *-m0.avro | per data file: path, tuple partisi, jumlah baris, statistik min/max |
| Data file | *.parquet | data aktual yang dibaca engine |
Karena setiap perubahan hanya menulis snapshot metadata baru, penggantian strategi partisi (partition evolution) tidak menyentuh berkas data lama — manifest lama tetap memakai spec lama, dan manifest baru memakai spec baru. Prinsip inilah yang membuat Iceberg aman untuk tabel berukuran petabyte.
Membuat Tabel dengan pyiceberg #
pyiceberg adalah implementasi Python resmi untuk Apache Iceberg. Versi 0.12 yang terpasang di lingkungan ini mendukung pembuatan tabel, penulisan, pembacaan, dan evolusi schema. Catalog yang kita pakai adalah sql dengan SQLite — ringan untuk pembelajaran; di produksi umumnya diganti REST catalog atau Hive metastore.
Blok berikut membuat namespace default dan tabel pesanan dengan tiga kolom; jalankan lebih dulu karena dua blok berikutnya membaca state hasil blok ini:
import os
import shutil
from pyiceberg.catalog import load_catalog
from pyiceberg.schema import Schema
from pyiceberg.types import LongType, NestedField, StringType, TimestamptzType
# bersihkan hasil eksekusi sebelumnya agar contoh bisa diulang
if os.path.exists("lakehouse-demo"):
shutil.rmtree("lakehouse-demo")
os.makedirs("lakehouse-demo", exist_ok=True)
catalog = load_catalog(
"demo",
**{
"type": "sql",
"uri": "sqlite:///lakehouse-demo/katalog.db",
"warehouse": "file://lakehouse-demo/warehouse",
},
)
catalog.create_namespace("default")
skema = Schema(
NestedField(1, "id", LongType(), required=True),
NestedField(2, "nama", StringType(), required=False),
NestedField(3, "dibuat_pada", TimestamptzType(), required=False),
)
tabel = catalog.create_table("default.pesanan", schema=skema)
print("namespace dibuat: default")
print("tabel:", ".".join(tabel.name()))
print("format_version:", tabel.format_version)
print("lokasi:", tabel.location())
for kolom in tabel.schema().fields:
wajib = "required" if kolom.required else "optional"
print(f"kolom {kolom.field_id}: {kolom.name} ({wajib} {kolom.field_type})")Output yang Dihasilkan:
namespace dibuat: default
tabel: default.pesanan
format_version: 2
lokasi: file://lakehouse-demo/warehouse/default/pesanan
kolom 1: id (required long)
kolom 2: nama (optional string)
kolom 3: dibuat_pada (optional timestamptz)Mari kita telaah baris-baris kode di atas:
- Baris 4 (
from pyiceberg.catalog import load_catalog): pyiceberg menyediakan banyak jenis catalog;load_catalogmemilih implementasi berdasarkan kuncitype. - Baris 13-20 (
load_catalog(...)): catalogsqlmenyimpan peta nama tabel ke lokasi metadata di dalam SQLite, sementarawarehousemenentukan direktori dasar penyimpanan berkas tabel difile://local storage. - Baris 21 (
catalog.create_namespace): namespace adalah wadah tabel, setara schema di PostgreSQL; seluruh contoh chapter ini memakai namespacedefault. - Baris 23-27 (
Schema(...)): setiap kolom diberi nomor field yang stabil — nomor inilah yang dirujuk metadata.json, sehingga mengganti nama kolom tidak merusak rujukan berkas lama. - Baris 28 (
catalog.create_table): tabel terdaftar di catalog dan metadata.json pertama ditulis kewarehouse/default/pesanan/metadata/.
Perhatikan baris output format_version: 2 — tabel dibuat mengikuti table spec versi 2. Spek v2 menambahkan sequence number dan delete file (penghapusan baris tingkat berkas); pyiceberg memakai spec ini secara bawaan untuk tabel baru.
Menulis dan Membaca Data #
Penulisan dilakukan dengan tabel.append() yang menerima tabel PyArrow. Dua batch berturut-turut menghasilkan dua snapshot — masing-masing satu commit atomik di metadata:
import pyarrow as pa
from pyiceberg.catalog import load_catalog
catalog = load_catalog(
"demo",
**{
"type": "sql",
"uri": "sqlite:///lakehouse-demo/katalog.db",
"warehouse": "file://lakehouse-demo/warehouse",
},
)
tabel = catalog.load_table("default.pesanan")
skema_arrow = pa.schema(
[
pa.field("id", pa.int64(), nullable=False),
pa.field("nama", pa.string()),
pa.field("dibuat_pada", pa.timestamp("ms", tz="UTC")),
]
)
def batch_pesanan(rows):
return pa.Table.from_arrays(
[
pa.array([r[0] for r in rows], pa.int64()),
pa.array([r[1] for r in rows]),
pa.array([r[2] for r in rows], pa.timestamp("ms", tz="UTC")),
],
schema=skema_arrow,
)
hari_ms = 86_400_000
dasar = 1_741_000_000_000 # 2025-03-03 11:06:40 UTC
tabel.append(batch_pesanan([(1, "Budi", dasar), (2, "Siti", dasar + hari_ms), (3, "Dewi", dasar + 2 * hari_ms)]))
tabel.refresh()
snap1 = tabel.current_snapshot()
print(f"append 1: seq {snap1.sequence_number}, {snap1.summary['total-records']} baris")
tabel.append(batch_pesanan([(4, "Ani", dasar + 3 * hari_ms), (5, "Rina", dasar + 4 * hari_ms)]))
tabel.refresh()
snap2 = tabel.current_snapshot()
print(f"append 2: seq {snap2.sequence_number}, {snap2.summary['total-records']} baris")
hasil = tabel.scan().to_arrow()
print("baca kembali:", hasil.num_rows, "baris")
for baris in sorted(hasil.to_pylist(), key=lambda r: r["id"]):
print(f" {baris['id']} {baris['nama']} {baris['dibuat_pada'].isoformat()}")Output yang Dihasilkan:
append 1: seq 1, 3 baris
append 2: seq 2, 5 baris
baca kembali: 5 baris
1 Budi 2025-03-03T11:06:40+00:00
2 Siti 2025-03-04T11:06:40+00:00
3 Dewi 2025-03-05T11:06:40+00:00
4 Ani 2025-03-06T11:06:40+00:00
5 Rina 2025-03-07T11:06:40+00:00Snapshot summary bersifat kumulatif: setelah append kedua, snapshot seq 2 mencatat lima baris dan dua data file. Baris pa.field("id", ..., nullable=False) wajib ada karena kolom id dideklarasikan required di schema — pyiceberg menolak batch PyArrow yang kolom kuncinya masih nullable. Inilah schema enforcement bekerja di sisi penulisan.
Memindai Metadata dan Mengevolusikan Schema #
Blok ketiga membuka isi metadata tabel, menambahkan kolom kota lewat update_schema, lalu menulis batch baru yang mengisi kolom tersebut:
import glob
import pyarrow as pa
from pyiceberg.catalog import load_catalog
from pyiceberg.types import StringType
catalog = load_catalog(
"demo",
**{
"type": "sql",
"uri": "sqlite:///lakehouse-demo/katalog.db",
"warehouse": "file://lakehouse-demo/warehouse",
},
)
tabel = catalog.load_table("default.pesanan")
print("== memindai metadata ==")
urutan = {s.snapshot_id: s.sequence_number for s in tabel.snapshots()}
for snap in tabel.snapshots():
parent = urutan.get(snap.parent_snapshot_id)
parent_label = "None" if parent is None else f"seq {parent}"
print(
f"snapshot seq {snap.sequence_number}: parent={parent_label},"
f" {snap.summary['total-records']} baris,"
f" {snap.summary['total-data-files']} data file"
)
print("history:", len(tabel.history()), "entri")
print("== evolusi schema ==")
with tabel.update_schema() as pembaruan:
pembaruan.add_column("kota", StringType())
tabel.refresh()
nama_kolom = [f.name for f in tabel.schema().fields]
print("schema baru:", nama_kolom)
skema_arrow = pa.schema(
[
pa.field("id", pa.int64(), nullable=False),
pa.field("nama", pa.string()),
pa.field("dibuat_pada", pa.timestamp("ms", tz="UTC")),
pa.field("kota", pa.string()),
]
)
hari_ms = 86_400_000
dasar = 1_741_000_000_000
batch = pa.Table.from_arrays(
[
pa.array([6, 7], pa.int64()),
pa.array(["Joko", "Tuti"]),
pa.array([dasar + 5 * hari_ms, dasar + 6 * hari_ms], pa.timestamp("ms", tz="UTC")),
pa.array(["Bandung", "Depok"]),
],
schema=skema_arrow,
)
tabel.append(batch)
tabel.refresh()
print(f"append 3: seq {tabel.current_snapshot().sequence_number},"
f" {tabel.current_snapshot().summary['total-records']} baris")
hasil = tabel.scan().to_arrow()
print("baca akhir:", hasil.num_rows, "baris")
for baris in sorted(hasil.to_pylist(), key=lambda r: r["id"]):
print(f" {baris['id']} {baris['nama']} {baris['dibuat_pada'].isoformat()} kota={baris['kota']}")
print("== inventaris berkas ==")
lokasi = tabel.location().replace("file://", "")
print("parquet:", len(glob.glob(f"{lokasi}/data/*.parquet")))
print("metadata.json:", len(glob.glob(f"{lokasi}/metadata/*.metadata.json")))
print("manifest:", len(glob.glob(f"{lokasi}/metadata/*-m0.avro")))
print("manifest list:", len(glob.glob(f"{lokasi}/metadata/snap-*.avro")))Output yang Dihasilkan:
== memindai metadata ==
snapshot seq 1: parent=None, 3 baris, 1 data file
snapshot seq 2: parent=seq 1, 5 baris, 2 data file
history: 2 entri
== evolusi schema ==
schema baru: ['id', 'nama', 'dibuat_pada', 'kota']
append 3: seq 3, 7 baris
baca akhir: 7 baris
1 Budi 2025-03-03T11:06:40+00:00 kota=None
2 Siti 2025-03-04T11:06:40+00:00 kota=None
3 Dewi 2025-03-05T11:06:40+00:00 kota=None
4 Ani 2025-03-06T11:06:40+00:00 kota=None
5 Rina 2025-03-07T11:06:40+00:00 kota=None
6 Joko 2025-03-08T11:06:40+00:00 kota=Bandung
7 Tuti 2025-03-09T11:06:40+00:00 kota=Depok
== inventaris berkas ==
parquet: 3
metadata.json: 5
manifest: 3
manifest list: 3Hasil inventaris memetakan satu-ke-satu dengan konsep arsitektur di atas: tiga append menghasilkan tiga data file Parquet, tiga manifest, tiga manifest list, dan lima metadata.json — satu untuk pembuatan tabel, tiga untuk penulisan data, satu untuk perubahan schema. Kolom kota bernilai None pada lima baris pertama karena berkas Parquet lama tidak memuat kolom tersebut; pembacaan mengisi nilai kosong, bukan menulis ulang data lama.
Fitur Khas Iceberg #
| Fitur | Cara kerja | Dampak praktis |
|---|---|---|
| Hidden partitioning | nilai partisi dihitung dari kolom via transform (misal days(dibuat_pada)), disimpan di manifest | query tidak perlu menyebut kolom partisi; filter dibuat_pada < ... otomatis terpotong ke berkas relevan |
| Partition evolution | snapshot baru boleh memakai spec partisi baru; manifest lama tetap memakai spec lama | strategi partisi bisa berubah tanpa memindahkan data |
| Schema evolution | kolom baru diberi field id baru; rename hanya mengubah pemetaan nama | evolusi aman dibaca engine lama; tidak ada kolom yang bergeser posisi |
| Spek v2 | sequence number dan delete file (position/equality delete) | upsert dan penghapusan baris efisien tanpa rewrite penuh |
Perlu dicatat batas pyiceberg pada lingkungan ini: pembuatan tabel, append, scan, dan evolusi schema berjalan seperti tiga blok di atas, sedangkan penulisan data dengan partition transform membutuhkan paket tambahan pyiceberg-core. Hidden partitioning dan partition evolution tetap bisa dipelajari dari struktur metadata — keduanya hanya mengubah isi manifest dan partition spec, bukan bentuk data file.
Ekosistem dan Versi #
Iceberg diciptakan di Netflix untuk memperbaiki keterbatasan tabel Hive pada tabel yang berubah cepat, kemudian disumbangkan ke Apache Foundation. Versi stabil yang terverifikasi saat riset course ini adalah Apache Iceberg 1.12.0, dengan pyiceberg 0.12 untuk sisi Python. Spark, Trino, Flink, dan DuckDB dapat membuka tabel yang sama, dan banyak vendor warehouse menambahkan dukungan Iceberg sebagai format terbuka — rujukan resmi ada di dokumentasi Iceberg dan dokumentasi pyiceberg .
Setelah memahami arsitektur berlapis Iceberg, kita bandingkan pendekatan yang berbeda: satu berkas log JSON per transaksi. Pendekatan itulah yang dipakai Delta Lake, topik chapter berikutnya.