本文介绍如何使用 Daft 通过 LAS Iceberg REST Catalog 读取和写入 Iceberg 表。
本文提供一份完整的 Python 示例脚本,封装 LAS Iceberg REST Catalog 鉴权、TOS 文件访问和 Daft 读写 Iceberg 表所需的公共逻辑。用户可以通过 LAS_OPERATION 环境变量选择执行 read、append 或 overwrite。每次运行脚本只执行一种操作,不会依次执行全部操作。
完成本文操作后,可以验证以下链路:
您可参考本文的说明,创建本地脚本文件并复制完整代码,进行操作实践。
LAS Iceberg REST Catalog 提供表元数据查询、并发控制和 Snapshot 提交能力;TOS 用于存储 Parquet 数据文件,以及 metadata、manifest、manifest list 等 Iceberg 元数据文件。
完整读写链路如下:
开始操作前,请确认:
请准备具备所需权限的火山引擎 AK/SK。使用 STS 临时安全凭证时,还需准备 Session Token。
AK/SK 所属账号需要同时具备以下权限:
权限范围 | 读取表 | append/overwrite 写入 |
|---|---|---|
LAS Catalog | 查看目标 Catalog、数据库和 Iceberg 表,读取表元数据 | 在读取权限基础上,具备更新表元数据和提交 Snapshot 的权限 |
TOS | 读取目标表的数据文件和 Iceberg 元数据文件 | 在读取权限基础上,具备写入数据文件和 Iceberg 元数据文件的权限 |
相关权限配置可参考:
LAS Catalog 权限和 TOS 权限属于两条独立的访问链路。仅具备 Catalog 权限时,可能可以加载表元数据,但无法读取或写入 TOS 中的实际文件。
本文示例已在以下环境完成 read、append 和 overwrite 端到端验证。建议优先使用相同的 LAS 开发机镜像和依赖版本。
项目 | 已验证版本或状态 |
|---|---|
Python | 3.11.11 |
Daft / VeDaft | 0.7.2.post6,模块 version 显示为 0.7.2-6 |
PyIceberg | 0.11.1 |
PyArrow | 22.0.0 |
tosfs | 2026.3.1 |
httpx | 0.28.1 |
requests | 2.32.3 |
运行环境 | LAS 开发机 |
实际使用的 Daft 文件系统接口 | _infer_filesystem |
_infer_filesystem 或 _build_filesystem,不同 Daft 版本可能存在兼容性差异,不建议直接使用未经验证的最新版。append 和 overwrite 示例使用以下表结构:
字段名 | Iceberg 类型 | 说明 |
|---|---|---|
uuid | int | 示例记录的唯一标识 |
name | string | 示例名称 |
price | double | 示例数值 |
请提前在 LAS Catalog 中创建一张具有上述字段的专用测试表。测试表可以为空,也可以包含少量测试数据。
如果目标表使用其他 Schema,需要同时修改示例脚本中的 build_demo_write_dataframe() 和字段校验逻辑,使写入 DataFrame 的字段名称及类型与目标表兼容。
overwrite 示例会覆盖测试表当前可见的全部数据,并在操作前后执行全表行数统计。请仅对数据量较小的专用测试表执行。
执行以下命令,确认所需依赖可以正常导入:
python -c 'import daft, pyiceberg, pyarrow, requests, httpx, tosfs; print("dependencies OK")'
可以继续执行以下命令检查实际安装版本:
python --version python -c 'import importlib.metadata as m; print("pyiceberg", m.version("pyiceberg")); print("pyarrow", m.version("pyarrow")); print("tosfs", m.version("tosfs")); print("httpx", m.version("httpx")); print("requests", m.version("requests"))'
Daft 请优先使用已验证的 VeDaft 镜像版本 0.7.2.post6。
不要将真实 AK/SK 直接写入脚本、文档、命令历史或代码仓库。可以使用终端隐藏输入:
read -rsp "VOLC_ACCESSKEY: " VOLC_ACCESSKEY echo read -rsp "VOLC_SECRETKEY: " VOLC_SECRETKEY echo export VOLC_ACCESSKEY VOLC_SECRETKEY
使用 STS 临时安全凭证时,继续设置 Session Token:
read -rsp "VOLC_SESSION_TOKEN: " VOLC_SESSION_TOKEN echo export VOLC_SESSION_TOKEN
export LAS_REGION="<region,例如 cn-beijing>" export LAS_ICEBERG_REST_URI="https://<LAS-REST-ENDPOINT>/iceberg" # 仅在多网卡机器需要固定访问 Catalog 的源 IP 时设置。 # 不需要时请执行:unset LAS_REST_SOURCE_IP export LAS_REST_SOURCE_IP="<本机可访问 LAS 的私网 IP>" export TOS_REGION="<TOS region>" export TOS_ENDPOINT="https://<TOS-ENDPOINT>" export ICEBERG_NAMESPACE="<数据库或 namespace>" export ICEBERG_TABLE="<表名>" export ICEBERG_READ_LIMIT="10"
请以控制台或服务实际提供的 Endpoint 为准,不要自行拼接额外路径。
LAS 和 TOS 使用同一套 AK/SK 时,无需配置本节变量,脚本会自动复用 VOLC_ACCESSKEY、VOLC_SECRETKEY 和 VOLC_SESSION_TOKEN。
TOS 使用另一套凭证时,可以通过隐藏输入设置:
read -rsp "TOS_ACCESS_KEY: " TOS_ACCESS_KEY echo read -rsp "TOS_SECRET_KEY: " TOS_SECRET_KEY echo export TOS_ACCESS_KEY TOS_SECRET_KEY
使用 TOS STS 临时安全凭证时,继续设置:
read -rsp "TOS_SESSION_TOKEN: " TOS_SESSION_TOKEN echo export TOS_SESSION_TOKEN
创建名为 las_daft_iceberg_example.py 的 Python 文件,并将以下完整代码复制到文件中。
该示例脚本封装了 LAS Iceberg REST Catalog 鉴权、TOS 文件访问和 Daft 读写 Iceberg 表所需的完整逻辑。用户可以通过 LAS_OPERATION 环境变量选择执行以下操作:
read:使用 AK/SK 连接 LAS Iceberg REST Catalog,根据 Catalog 返回的表元数据访问 TOS,并通过 Daft 读取 Iceberg 表。append:向目标 Iceberg 表追加数据,并检查写入前后的 Snapshot 是否发生变化。overwrite:覆盖目标 Iceberg 表中的现有数据,并检查 Snapshot 和表行数是否符合预期。每次运行脚本只执行一种操作,不会依次执行 read、append 和 overwrite。脚本默认执行只读操作;只有将 LAS_OPERATION 设置为 append 或 overwrite,并正确配置 LAS_WRITE_CONFIRM 后,才会执行写入。
"""Read and optionally append to a LAS Iceberg table with Daft. This is a self-contained customer example. The script: 1. signs PyIceberg REST Catalog requests with Volcengine AK/SK; 2. loads an Iceberg table from LAS; 3. reads the table with Daft and TOS native I/O; 4. optionally appends or overwrites with one demo row and commits a new Iceberg snapshot. No endpoint, account, bucket, table, AK, or SK is hard-coded. Writes are disabled by default and require explicit operation and confirmation environment variables. """ from __future__ import annotations import hashlib import hmac import inspect import os import posixpath import re import secrets import shlex from dataclasses import dataclass from datetime import datetime, timezone from typing import Any from urllib.parse import parse_qs, quote, unquote, unquote_plus, urlparse import httpx import pyarrow as pa import pyarrow.fs as pafs from pyiceberg.catalog.rest import RestCatalog from pyiceberg.io import fsspec as iceberg_fsspec from requests.adapters import HTTPAdapter from requests.auth import AuthBase from tosfs import TosFileSystem import daft import daft.filesystem as daft_filesystem from daft.io import IOConfig, TosConfig def require_env(name: str) -> str: value = os.environ.get(name) if value is None or not value.strip(): raise RuntimeError(f"Missing required environment variable: {name}") return value.strip() def optional_env(name: str) -> str | None: value = os.environ.get(name) return value.strip() if value and value.strip() else None REGION = os.environ.get("LAS_REGION", "cn-beijing").strip() REST_URI = require_env("LAS_ICEBERG_REST_URI").rstrip("/") REST_SOURCE_IP = optional_env("LAS_REST_SOURCE_IP") NAMESPACE = require_env("ICEBERG_NAMESPACE") TABLE_NAME = require_env("ICEBERG_TABLE") READ_LIMIT = int(os.environ.get("ICEBERG_READ_LIMIT", "10")) LAS_ACCESS_KEY = require_env("VOLC_ACCESSKEY") LAS_SECRET_KEY = require_env("VOLC_SECRETKEY") LAS_SESSION_TOKEN = optional_env("VOLC_SESSION_TOKEN") TOS_REGION = os.environ.get("TOS_REGION", REGION).strip() TOS_ENDPOINT = require_env("TOS_ENDPOINT") TOS_ACCESS_KEY = optional_env("TOS_ACCESS_KEY") or LAS_ACCESS_KEY TOS_SECRET_KEY = optional_env("TOS_SECRET_KEY") or LAS_SECRET_KEY TOS_SESSION_TOKEN = optional_env("TOS_SESSION_TOKEN") or LAS_SESSION_TOKEN OPERATION = os.environ.get("LAS_OPERATION", "read").strip().lower() if OPERATION not in {"read", "append", "overwrite"}: raise RuntimeError( "LAS_OPERATION must be 'read', 'append', or 'overwrite'" ) ENABLE_WRITE = OPERATION in {"append", "overwrite"} FULL_TABLE_NAME = f"{NAMESPACE}.{TABLE_NAME}" @dataclass(frozen=True) class Credentials: access_key: str secret_key: str session_token: str | None = None class VolcOpenApiAuthProvider: """Generate Volcengine OpenAPI HMAC-SHA256 request signatures.""" def __init__( self, service: str, region: str, access_key: str, secret_key: str, session_token: str | None = None, ) -> None: if not access_key or not access_key.strip(): raise ValueError("access_key must not be empty") if not secret_key or not secret_key.strip(): raise ValueError("secret_key must not be empty") if not service or not service.strip(): raise ValueError("service must not be empty") if not region or not region.strip(): raise ValueError("region must not be empty") self.service = service self.region = region self.credentials = Credentials(access_key, secret_key, session_token) def authentication(self, request: httpx.Request) -> httpx.Request: request_url = str(request.url) parsed_url = urlparse(request_url) headers = request.headers request_date = self._reset_and_get_date_header(headers) scope_date = request_date[:8] payload = request.content if request.content is not None else b"" payload_hash = hashlib.sha256(payload).hexdigest() headers["x-content-sha256"] = payload_hash if self.credentials.session_token: headers["x-security-token"] = self.credentials.session_token if "host" in headers: # LAS validates the hostname, excluding the port, in the Host header. headers["host"] = parsed_url.netloc.split(":")[0] canonical_headers, signed_headers = self._extract_canonical_headers(headers) canonical_request = self._canonical_request( request_url, request.method, canonical_headers, signed_headers, payload_hash, ) scope = f"{scope_date}/{self.region}/{self.service}/request" string_to_sign = "\n".join( [ "HMAC-SHA256", request_date, scope, hashlib.sha256(canonical_request.encode("utf-8")).hexdigest(), ] ).encode("utf-8") signing_key = self._generate_signing_key( self.credentials.secret_key, scope_date, self.region, self.service, ) signature = hmac.new(signing_key, string_to_sign, hashlib.sha256).hexdigest() headers["Authorization"] = ( "HMAC-SHA256 " f"Credential={self.credentials.access_key}/{scope}, " f"SignedHeaders={signed_headers}, " f"Signature={signature}" ) return request @staticmethod def _reset_and_get_date_header(headers: httpx.Headers) -> str: date_value = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") for header in ("x-date", "date"): if header in headers: date_value = headers[header] break for header in ("x-date", "date"): if header in headers: del headers[header] headers["x-date"] = date_value return date_value @staticmethod def _normalize_whitespace(value: str) -> str: return " ".join(shlex.split(value, posix=False)) @classmethod def _extract_canonical_headers( cls, headers: httpx.Headers, ) -> tuple[str, str]: selected: dict[str, list[str]] = {} for name, value in headers.items(): name = name.strip().lower() if name not in {"host", "content-type", "date"} and not ( name.startswith("x-") and name != "x-client-context" ): continue selected.setdefault(name, []).append( cls._normalize_whitespace(value).strip() ) canonical_headers = "" signed_header_names: list[str] = [] for name in sorted(selected): canonical_headers += f"{name}:{','.join(sorted(selected[name]))}\n" signed_header_names.append(name) return canonical_headers, ";".join(signed_header_names) @staticmethod def _canonical_path(path: str, service: str) -> str: fixed_path = posixpath.normpath(path) fixed_path = re.sub("/+", "/", fixed_path) if path.endswith("/") and not fixed_path.endswith("/"): fixed_path += "/" if service in {"s3", "host"}: fixed_path = unquote(fixed_path) return quote(fixed_path, safe="/~") @staticmethod def _canonical_querystring(query: str) -> str: query = quote(unquote_plus(query), safe="&=") items: dict[str, list[str]] = {} for name, values in parse_qs(query, keep_blank_values=True).items(): encoded_name = quote(name, safe="-_.~") items[encoded_name] = [ quote(value, safe="-_.~") for value in values ] pairs: list[str] = [] for name in sorted(items): for value in items[name]: pairs.append(f"{name}={value}") return "&".join(pairs).replace("+", "%20") def _canonical_request( self, url: str, method: str, canonical_headers: str, signed_headers: str, payload_hash: str, ) -> str: parsed = urlparse(url) canonical_path = self._canonical_path(parsed.path, self.service) query = url.split("?", 1)[1] if "?" in url else "" canonical_query = self._canonical_querystring(query) return ( f"{method.upper()}\n{canonical_path}\n{canonical_query}\n" f"{canonical_headers}\n{signed_headers}\n{payload_hash}" ) @classmethod def _generate_signing_key( cls, secret_key: str, date: str, region: str, service: str, ) -> bytes: def sign(key: bytes, message: str) -> bytes: return hmac.new(key, message.encode("utf-8"), hashlib.sha256).digest() date_key = sign(secret_key.encode("utf-8"), date) region_key = sign(date_key, region) service_key = sign(region_key, service) return sign(service_key, "request") class SourceAddressAdapter(HTTPAdapter): """Make requests/urllib3 originate from a selected local IP.""" def __init__(self, source_ip: str, *args: Any, **kwargs: Any) -> None: self.source_ip = source_ip super().__init__(*args, **kwargs) def init_poolmanager( self, connections: int, maxsize: int, block: bool = False, **pool_kwargs: Any, ) -> None: pool_kwargs["source_address"] = (self.source_ip, 0) super().init_poolmanager( connections, maxsize, block=block, **pool_kwargs, ) class VolcRequestsAuth(AuthBase): """Adapt the standalone httpx signer to requests used by PyIceberg.""" def __init__( self, access_key: str, secret_key: str, session_token: str | None, region: str, ) -> None: self.region = region self.auth_provider = VolcOpenApiAuthProvider( service="las", region=region, access_key=access_key, secret_key=secret_key, session_token=session_token, ) def __call__(self, request: Any) -> Any: body = request.body if body is None: body = b"" elif not isinstance(body, (bytes, str)): raise TypeError(f"Unsupported request body type: {type(body)!r}") parsed_url = urlparse(request.url) headers = dict(request.headers) headers["Host"] = parsed_url.hostname headers["Region"] = self.region httpx_request = httpx.Request( method=request.method, url=request.url, headers=headers, content=body, ) signed_request = self.auth_provider.authentication(httpx_request) for header_name in ( "Host", "Authorization", "X-Date", "X-Content-Sha256", "X-Security-Token", ): if header_name in signed_request.headers: request.headers[header_name] = signed_request.headers[header_name] request.headers["Region"] = self.region return request class VolcRestCatalog(RestCatalog): """PyIceberg REST Catalog using Volcengine signing and optional source IP.""" def __init__( self, name: str, *, access_key: str, secret_key: str, session_token: str | None, region: str, source_ip: str | None, **properties: Any, ) -> None: self._volc_auth = VolcRequestsAuth( access_key=access_key, secret_key=secret_key, session_token=session_token, region=region, ) self._region = region self._source_ip = source_ip super().__init__(name=name, **properties) def _create_session(self) -> Any: session = super()._create_session() session.trust_env = False session.auth = self._volc_auth session.headers.update({"Region": self._region}) if self._source_ip: adapter = SourceAddressAdapter(self._source_ip) session.mount("http://", adapter) session.mount("https://", adapter) return session def create_tos_filesystem(properties: dict[str, str]) -> TosFileSystem: """Build TosFS for PyIceberg metadata and Daft's Python writer.""" kwargs: dict[str, Any] = { "endpoint": properties["tos.endpoint"], "region": properties["tos.region"], "key": properties["tos.access-key"], "secret": properties["tos.secret-key"], "max_retry_num": 1, "connection_timeout": 5, "socket_timeout": 30, } token = properties.get("tos.security-token") if token: parameters = inspect.signature(TosFileSystem.__init__).parameters token_parameter = next( ( name for name in ("security_token", "session_token", "token") if name in parameters ), None, ) if token_parameter is None: raise RuntimeError( "The installed tosfs does not expose a Session Token parameter. " "Use long-term TOS credentials or upgrade tosfs." ) kwargs[token_parameter] = token return TosFileSystem(**kwargs) TOS_PROPERTIES = { "py-io-impl": "pyiceberg.io.fsspec.FsspecFileIO", "tos.endpoint": TOS_ENDPOINT, "tos.region": TOS_REGION, "tos.access-key": TOS_ACCESS_KEY, "tos.secret-key": TOS_SECRET_KEY, } if TOS_SESSION_TOKEN: TOS_PROPERTIES["tos.security-token"] = TOS_SESSION_TOKEN def create_catalog() -> VolcRestCatalog: # PyIceberg does not recognize tos:// by default. iceberg_fsspec.SCHEME_TO_FS["tos"] = create_tos_filesystem return VolcRestCatalog( name="las", uri=REST_URI, access_key=LAS_ACCESS_KEY, secret_key=LAS_SECRET_KEY, session_token=LAS_SESSION_TOKEN, region=REGION, source_ip=REST_SOURCE_IP, **TOS_PROPERTIES, ) def create_daft_io_config() -> IOConfig: return IOConfig( tos=TosConfig( region=TOS_REGION, endpoint=TOS_ENDPOINT, access_key=TOS_ACCESS_KEY, secret_key=TOS_SECRET_KEY, security_token=TOS_SESSION_TOKEN, max_retries=1, connect_timeout_ms=5_000, read_timeout_ms=30_000, ) ) def enable_daft_tos_writer() -> None: """Teach Daft's Python Iceberg writer how to create files for tos:// paths. Older LAS-Daft builds use ``_infer_filesystem``. Newer builds use ``_build_filesystem``. This process-local adapter supports both layouts. """ if getattr(daft_filesystem, "_las_tos_writer_enabled", False): return tos_fsspec = create_tos_filesystem(TOS_PROPERTIES) tos_pyarrow_filesystem = pafs.PyFileSystem(pafs.FSSpecHandler(tos_fsspec)) if hasattr(daft_filesystem, "_infer_filesystem"): original_infer_filesystem = daft_filesystem._infer_filesystem def infer_filesystem_with_tos(path: Any, io_config: IOConfig) -> Any: parsed = urlparse(str(path)) if parsed.scheme.lower() == "tos": resolved_path = (parsed.netloc + parsed.path).lstrip("/") resolved_path = tos_pyarrow_filesystem.normalize_path(resolved_path) return resolved_path, tos_pyarrow_filesystem, None return original_infer_filesystem(path, io_config) daft_filesystem._infer_filesystem = infer_filesystem_with_tos elif hasattr(daft_filesystem, "_build_filesystem"): original_build_filesystem = daft_filesystem._build_filesystem def build_filesystem_with_tos(protocol: str, io_config: IOConfig) -> Any: if protocol.lower() == "tos": return tos_pyarrow_filesystem, None return original_build_filesystem(protocol, io_config) daft_filesystem._build_filesystem = build_filesystem_with_tos else: raise RuntimeError( "Unsupported Daft version: cannot locate its Python filesystem factory" ) daft_filesystem._las_tos_writer_enabled = True def build_demo_write_dataframe() -> tuple[daft.DataFrame, int]: """Build one row for the demo schema: uuid int, name string, price double.""" test_uuid = secrets.randbelow(1_000_000_000) arrow_table = pa.table( { "uuid": pa.array([test_uuid], type=pa.int32()), "name": pa.array([f"written_by_daft_{test_uuid}"], type=pa.string()), "price": pa.array([99.9], type=pa.float64()), } ) return daft.from_arrow(arrow_table), test_uuid def main() -> None: print("Target table:", FULL_TABLE_NAME) print("Operation:", OPERATION) print("REST URI:", REST_URI) print("Region:", REGION) print("REST source IP:", REST_SOURCE_IP or "default route") print("TOS endpoint:", TOS_ENDPOINT) print("Credentials loaded; secrets will not be printed.") catalog = create_catalog() table = catalog.load_table((NAMESPACE, TABLE_NAME)) io_config = create_daft_io_config() print("\nIceberg table loaded") print("Location:", table.location()) print("Schema:") print(table.schema()) print(f"\nReading the first {READ_LIMIT} rows with Daft...") daft.read_iceberg(table, io_config=io_config).limit(READ_LIMIT).show() if not ENABLE_WRITE: print("\nRead verification succeeded. Write is disabled.") print( "Set LAS_OPERATION=append or overwrite and provide the matching " "LAS_WRITE_CONFIRM value to enable a demo write." ) return write_confirmation = os.environ.get("LAS_WRITE_CONFIRM") required_confirmation = ( f"OVERWRITE:{FULL_TABLE_NAME}" if OPERATION == "overwrite" else FULL_TABLE_NAME ) if write_confirmation != required_confirmation: raise RuntimeError( "Write confirmation does not match the target table. " f"Set LAS_WRITE_CONFIRM={required_confirmation!r} only for a " "dedicated test table." ) actual_fields = { field.name: str(field.field_type).lower() for field in table.schema().fields } expected_fields = { "uuid": "int", "name": "string", "price": "double", } if set(actual_fields) != set(expected_fields): raise RuntimeError( "The bundled write payload only supports the demo schema " "(uuid int, name string, price double). Change " "build_demo_write_dataframe() to match the target table schema. " f"Actual fields: {actual_fields}" ) incompatible_fields = { name: {"expected": expected_fields[name], "actual": actual_fields[name]} for name in expected_fields if actual_fields[name] != expected_fields[name] } if incompatible_fields: raise RuntimeError( "The target table field types do not match the bundled demo payload. " f"Incompatible fields: {incompatible_fields}" ) enable_daft_tos_writer() write_df, test_uuid = build_demo_write_dataframe() snapshot_before = table.current_snapshot() rows_before = ( daft.read_iceberg(table, io_config=io_config).count_rows() if OPERATION == "overwrite" else None ) print( "\nSnapshot before:", snapshot_before.snapshot_id if snapshot_before else "none", ) if rows_before is not None: print("Rows before overwrite:", rows_before) print(f"Writing one demo row with Daft in {OPERATION!r} mode...") write_df.show() write_result = write_df.write_iceberg( table, mode=OPERATION, io_config=io_config, ) print("\nWrite result:") write_result.show() write_operations = set(write_result.to_pydict()["operation"]) if "ADD" not in write_operations: raise RuntimeError("The Iceberg write did not report an ADD operation") if OPERATION == "overwrite" and rows_before and "DELETE" not in write_operations: raise RuntimeError( "Overwrite verification failed: existing rows were present, but " "the write did not report a DELETE operation" ) refreshed_table = catalog.load_table((NAMESPACE, TABLE_NAME)) snapshot_after = refreshed_table.current_snapshot() if snapshot_after is None: raise RuntimeError(f"No Iceberg snapshot exists after the {OPERATION}") if snapshot_before and snapshot_after.snapshot_id == snapshot_before.snapshot_id: raise RuntimeError( f"The Iceberg snapshot ID did not change after the {OPERATION}" ) print("Snapshot after:", snapshot_after.snapshot_id) print("Reading back the inserted row...") result_df = daft.read_iceberg(refreshed_table, io_config=io_config) inserted_df = result_df.where(result_df["uuid"] == test_uuid) inserted_df.show() if inserted_df.count_rows() != 1: raise RuntimeError("The newly written row was not found exactly once") if OPERATION == "overwrite": rows_after = result_df.count_rows() print("Rows after overwrite:", rows_after) if rows_after != 1: raise RuntimeError( "Overwrite verification failed: expected the test table to " f"contain exactly 1 row, found {rows_after}" ) print(f"{OPERATION.capitalize()} and read-back succeeded. Test uuid:", test_uuid) if __name__ == "__main__": main()
首次运行建议先执行只读验证:
export LAS_OPERATION="read" unset LAS_WRITE_CONFIRM python las_daft_iceberg_example.py
脚本会执行以下操作:
ICEBERG_READ_LIMIT 行数据。成功时会输出:
Read verification succeeded. Write is disabled.
本操作会真实向 TOS 写入数据文件,并向 LAS Iceberg REST Catalog 提交新的 Snapshot。请确认当前目标为专用测试表。
export ICEBERG_NAMESPACE="<专用测试数据库或 namespace>" export ICEBERG_TABLE="<专用测试表>" export LAS_OPERATION="append" export LAS_WRITE_CONFIRM="${ICEBERG_NAMESPACE}.${ICEBERG_TABLE}" python las_daft_iceberg_example.py
LAS_WRITE_CONFIRM 必须与完整表名完全一致,否则脚本会拒绝执行写入。
脚本会生成一条符合演示 Schema 的测试记录,并执行:
write_result = write_df.write_iceberg( table, mode="append", io_config=io_config, )
写入完成后,脚本会:
ADD 操作。uuid 读取新记录,并确认该记录只存在一条。成功时会输出类似内容:
Append and read-back succeeded. Test uuid: 123456789
高风险操作:overwrite 会使用当前 DataFrame 的内容替换整张表当前可见的数据,不是更新某一条记录。请仅对专用测试表执行。
本示例会在 overwrite 前后统计整张表的行数,因此会扫描全表,仅适用于数据量较小的测试表。
为了降低误操作风险,overwrite 的确认值必须在完整表名前增加 OVERWRITE::
export ICEBERG_NAMESPACE="<专用测试数据库或 namespace>" export ICEBERG_TABLE="<专用测试表>" export LAS_OPERATION="overwrite" export LAS_WRITE_CONFIRM="OVERWRITE:${ICEBERG_NAMESPACE}.${ICEBERG_TABLE}" python las_daft_iceberg_example.py
脚本会自动完成以下校验:
write_iceberg(mode="overwrite") 写入一条新记录。ADD;覆盖前存在数据时,还必须包含 DELETE。成功时会输出类似内容:
Rows after overwrite: 1 Overwrite and read-back succeeded. Test uuid: 123456789
overwrite 会让旧数据文件不再属于表的当前 Snapshot,但通常不会立即从 TOS 物理删除。旧 Snapshot 在过期前仍可能引用这些文件,存储空间需要通过 Iceberg Snapshot 过期和孤儿文件清理机制释放。
验证完成后,可以删除本次创建的专用测试表。
如测试过程中产生了多个 Snapshot,请根据实际的数据治理策略执行 Snapshot 过期和孤儿文件清理。Iceberg 写入通常会先将数据文件写入对象存储,再提交 Snapshot;如果任务在数据文件写入成功后、Snapshot 提交前失败,可能产生未被任何 Snapshot 引用的孤儿文件。
完成操作后,建议清理终端中的访问凭证和写入确认变量:
unset VOLC_ACCESSKEY unset VOLC_SECRETKEY unset VOLC_SESSION_TOKEN unset TOS_ACCESS_KEY unset TOS_SECRET_KEY unset TOS_SESSION_TOKEN unset LAS_WRITE_CONFIRM
如不再继续操作,也可以清理其他环境变量:
unset LAS_REGION unset LAS_ICEBERG_REST_URI unset LAS_REST_SOURCE_IP unset TOS_REGION unset TOS_ENDPOINT unset ICEBERG_NAMESPACE unset ICEBERG_TABLE unset ICEBERG_READ_LIMIT unset LAS_OPERATION
现象 | 通常含义 | 建议检查 |
|---|---|---|
连接超时 | 网络、路由、VPC、白名单或源 IP 问题 | 检查 Endpoint、网络访问白名单、VPC 路由和 LAS_REST_SOURCE_IP。示例脚本默认不读取系统代理配置 |
HTTP 404 | REST Catalog 路径不正确 | 确认 LAS_ICEBERG_REST_URI 使用服务实际提供的地址,并以正确的 /iceberg 路径为根 |
HTTP 401/403 或 AccessDenied | 请求已到达服务,但鉴权或权限校验失败 | 检查 AK/SK 是否正确、是否属于目标账号、STS 凭证是否缺少 Session Token、LAS_REGION 是否正确,以及运行机器系统时间是否准确 |
Catalog 加载成功,但 TOS 读取失败 | Catalog 权限与数据文件权限是两条独立链路 | 检查 TOS Endpoint、TOS Region、桶权限、TOS 凭证、表存储位置和网络连通性 |
写入 Schema 不匹配 | 示例 DataFrame 与目标表字段不兼容 | 确认目标表具有 uuid int、name string、price double 字段,或同步修改 build_demo_write_dataframe() 和字段校验逻辑 |
提示不支持 Daft 文件系统接口 | 当前 Daft 版本与示例适配逻辑不兼容 | 优先切换到本文已验证的 VeDaft 版本,不建议直接修改已安装的 Daft 包文件 |
使用临时 TOS 凭证时报 Token 参数错误 | 当前 tosfs 版本未暴露 Session Token 参数 | 使用已验证的 tosfs 版本,或在满足安全要求的前提下改用长期凭证验证 |