Sau khi đổi tên customer thành buyer trong Apache Iceberg, hai đơn hàng cũ vẫn giữ nguyên tên người mua. Cột channel vừa thêm trả null cho cả hai dòng. Trong lần chạy dưới đây, thao tác đổi schema tạo metadata mới, còn bảng vẫn trỏ đến snapshot dữ liệu cũ và file Parquet giữ nguyên mã băm. Sau khi ghi thêm một đơn hàng, ta dùng time travel để đọc lại đúng hai dòng ban đầu dưới tên cột customer.
Metadata của bảng và snapshot dữ liệu
Catalog SQLite lưu con trỏ đến metadata hiện tại của bảng. File metadata theo dõi schema, schema hiện hành và danh sách snapshot. Snapshot đại diện cho trạng thái dữ liệu tại một lần commit. Từ snapshot, Iceberg tìm các file dữ liệu qua manifest list và các manifest. Đặc tả Apache Iceberg về metadata và snapshot
Iceberg dùng field ID để nhận diện cột khi đọc file. Vì vậy, customer trong Parquet cũ và buyer trong schema mới vẫn chỉ cùng một trường nếu chúng có cùng ID. Cột mới channel nhận ID riêng. Trong bảng không phân vùng của ví dụ này, cột channel cho phép null và chưa có giá trị mặc định. Khi ID của cột này vắng mặt trong file cũ, kết quả đọc trả về null. Quy tắc schema evolution và đọc cột trong đặc tả Iceberg
Bài thực hành dùng bảng Iceberg format v2, catalog SQLite và các file Parquet trong thư mục tạm. Catalog SQLite phù hợp để phát triển và thử nghiệm cục bộ. Cấu hình SQL catalog trong PyIceberg
Chạy ví dụ với PyIceberg và SQLite
Môi trường chạy thử dùng Python 3.10.11 trên macOS arm64. Tạo môi trường Python riêng và cài đúng ba phiên bản dưới đây.
1python3 -m venv .venv
2source .venv/bin/activate
3python -m pip install 'pyiceberg[sql-sqlite,pyarrow]==0.10.0' 'pyarrow==20.0.0' 'SQLAlchemy==2.0.44'
Lưu đoạn mã sau thành experiment.py rồi chạy python experiment.py. Mỗi lần chạy tạo một catalog và warehouse riêng trong thư mục tạm, sau đó tự dọn thư mục khi kết thúc. Mã đọc lại bảng từ catalog sau mỗi thay đổi và dùng assert để kiểm tra số dòng, tên cột và quan hệ giữa hai snapshot.
1import hashlib
2import platform
3from pathlib import Path
4from tempfile import TemporaryDirectory
5
6import pyarrow as pa
7import pyiceberg
8import sqlalchemy
9from pyiceberg.catalog.sql import SqlCatalog
10from pyiceberg.schema import Schema
11from pyiceberg.types import LongType, NestedField, StringType
12
13
14def parquet_hashes(warehouse):
15 return {
16 path.relative_to(warehouse): hashlib.sha256(path.read_bytes()).hexdigest()
17 for path in warehouse.rglob("*.parquet")
18 }
19
20
21def read_rows(table, fields, snapshot_id=None):
22 data = table.scan(selected_fields=fields, snapshot_id=snapshot_id).to_arrow()
23 assert tuple(data.column_names) == fields
24 return sorted(data.to_pylist(), key=lambda row: row["order_id"])
25
26
27def main():
28 print(f"Python {platform.python_version()} ({platform.machine()}) | "
29 f"PyIceberg {pyiceberg.__version__} | PyArrow {pa.__version__} | "
30 f"SQLAlchemy {sqlalchemy.__version__}")
31 with TemporaryDirectory(prefix="iceberg-orders-") as temp:
32 root = Path(temp).resolve()
33 warehouse = root / "warehouse"
34 warehouse.mkdir()
35 catalog = SqlCatalog(
36 "demo", uri=f"sqlite:///{root / 'catalog.db'}", warehouse=warehouse.as_uri()
37 )
38 catalog.create_namespace("demo")
39 name = "demo.orders"
40 table = catalog.create_table(
41 name,
42 schema=Schema(
43 NestedField(1, "order_id", LongType(), required=True),
44 NestedField(2, "customer", StringType(), required=False),
45 ),
46 properties={"format-version": "2"},
47 )
48 original = [
49 {"order_id": 101, "customer": "An"},
50 {"order_id": 102, "customer": "Binh"},
51 ]
52 table.append(pa.Table.from_pylist(original, schema=table.schema().as_arrow()))
53 first = table.current_snapshot()
54 assert first is not None
55 before_schema = table.schema()
56 before_files = parquet_hashes(warehouse)
57 assert len(before_files) == 1
58 before_metadata = table.metadata_location
59 print(f"S1 id={first.snapshot_id}, schema_id={first.schema_id}")
60
61 with table.update_schema() as update:
62 update.rename_column("customer", "buyer")
63 update.add_column("channel", StringType(), required=False)
64
65 table = catalog.load_table(name)
66 after_schema = table.schema()
67 old_id = before_schema.find_field("customer").field_id
68 new_id = after_schema.find_field("buyer").field_id
69 channel = after_schema.find_field("channel")
70 assert new_id == old_id and channel.field_id != new_id and not channel.required
71 assert after_schema.schema_id != before_schema.schema_id
72 assert table.metadata_location != before_metadata
73 assert [s.snapshot_id for s in table.snapshots()] == [first.snapshot_id]
74 assert table.current_snapshot().snapshot_id == first.snapshot_id
75 assert parquet_hashes(warehouse) == before_files
76 print(f"Schema {before_schema.schema_id} -> {after_schema.schema_id}; "
77 f"customer ID {old_id} -> buyer ID {new_id}; channel ID {channel.field_id}")
78 print("After schema commit: 1 snapshot, current=S1, Parquet SHA-256 unchanged")
79
80 fields = ("order_id", "buyer", "channel")
81 expected_old_rows = [
82 {"order_id": 101, "buyer": "An", "channel": None},
83 {"order_id": 102, "buyer": "Binh", "channel": None},
84 ]
85 after_ddl = read_rows(table, fields)
86 assert after_ddl == expected_old_rows
87 print("Current after schema commit:", after_ddl)
88
89 appended = {"order_id": 103, "buyer": "Chi", "channel": "web"}
90 table.append(pa.Table.from_pylist([appended], schema=table.schema().as_arrow()))
91 table = catalog.load_table(name)
92 second = table.current_snapshot()
93 assert second is not None and second.parent_snapshot_id == first.snapshot_id
94 assert second.snapshot_id != first.snapshot_id and second.schema_id == after_schema.schema_id
95 assert len(table.snapshots()) == 2
96 print(f"S2 id={second.snapshot_id}, schema_id={second.schema_id}")
97
98 current = read_rows(table, fields)
99 historical = read_rows(table, ("order_id", "customer"), snapshot_id=first.snapshot_id)
100 assert current == expected_old_rows + [appended]
101 assert historical == original
102 print("Current S2:", current)
103 print("Time travel S1:", historical)
104
105 try:
106 read_rows(table, ("order_id", "buyer"), snapshot_id=first.snapshot_id)
107 except ValueError as error:
108 assert "buyer" in str(error)
109 print("Time travel S1 projection of buyer:", error)
110 else:
111 raise AssertionError("buyer should not be part of the S1 schema")
112 catalog.engine.dispose()
113
114
115if __name__ == "__main__":
116 main()
Kiểm tra từng bước commit
| Thao tác | Schema hiện tại | Snapshot hiện tại | Dòng đọc được với schema hiện tại |
|---|---|---|---|
| Ghi đơn hàng 101 và 102 | 0 | S1 | 2 |
Đổi customer thành buyer và thêm channel |
1 | S1 | 2 |
| Ghi thêm đơn hàng 103 | 1 | S2 | 3 |
Ở bước đổi schema, metadata_location chuyển sang file metadata mới. customer và buyer cùng có field ID 2. channel nhận ID 3. Danh sách snapshot vẫn chỉ có S1. Script cũng đối chiếu đường dẫn tương đối và SHA-256 của file Parquet trước và sau commit này. Cả hai đều khớp.
Lần append kế tiếp tạo S2 với S1 là snapshot cha. Trong lần chạy được ghi lại, ID của S1 là 2261421115454219206 và ID của S2 là 7705348231264186438. Mỗi lần chạy lấy ID từ bảng vừa tạo vì PyIceberg sinh ID mới cho snapshot.
Đọc hiện tại và time travel về S1
Ở PyIceberg 0.10.0, scan mặc định lấy danh sách cột từ schema hiện tại. Với scan(snapshot_id=...), nếu metadata có schema gắn với snapshot được chỉ định, PyIceberg dùng schema đó để giải tên cột trong selected_fields. Vì vậy, ngay sau commit chỉ đổi schema, scan mặc định đã trả tên buyer trong khi snapshot dữ liệu hiện tại vẫn là S1. Mã nguồn hàm DataScan.projection trong PyIceberg 0.10.0
Sau lần ghi thứ hai, scan mặc định chọn order_id, buyer và channel. Kết quả đọc được như sau.
| order_id | buyer | channel |
|---|---|---|
| 101 | An | null |
| 102 | Binh | null |
| 103 | Chi | web |
Scan chỉ định S1 với selected_fields=("order_id", "customer") đọc lại hai dòng sau.
| order_id | customer |
|---|---|
| 101 | An |
| 102 | Binh |
Thử chọn buyer trực tiếp từ S1 trả ValueError: Could not find column: 'buyer'. Nếu chương trình cần so sánh hai lần đọc dưới cùng một tên cột, có thể chuẩn hóa tên customer thành buyer sau khi đọc S1. Khi lưu công việc để chạy lại về sau, hãy lưu cả snapshot ID và danh sách cột hợp lệ với snapshot đó. Việc đọc lại phụ thuộc vào thời gian giữ snapshot và các file mà nó tham chiếu.
Ví dụ kiểm tra thao tác đổi tên và thêm cột nullable trên một bảng nhỏ, với một tiến trình ghi. Khi áp dụng cho một pipeline có nhiều công cụ, kiểm tra cách mỗi engine đọc snapshot và cách các job dùng tên cột cũ. Vai trò của catalog, metadata và snapshot trong Apache Iceberg, Những vấn đề Grab gặp khi chuyển Data Lake sang Iceberg
Bình luận