969 lines
40 KiB
Python
969 lines
40 KiB
Python
"""
|
|
Module to write a Ray Dataset into an iceberg table, by using the Ray Datasink API.
|
|
"""
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional, Union
|
|
|
|
import ray
|
|
from ray._common.retry import call_with_retry
|
|
from ray.data._internal.datasource.parquet_datasource import (
|
|
PARQUET_ENCODING_RATIO_ESTIMATE_DEFAULT,
|
|
)
|
|
from ray.data._internal.execution.interfaces import TaskContext
|
|
from ray.data._internal.savemode import SaveMode
|
|
from ray.data._internal.util import MiB
|
|
from ray.data.block import Block, BlockAccessor
|
|
from ray.data.context import DataContext
|
|
from ray.data.datasource.datasink import Datasink, WriteResult
|
|
from ray.data.expressions import Expr
|
|
from ray.util.annotations import DeveloperAPI
|
|
|
|
if TYPE_CHECKING:
|
|
import pyarrow as pa
|
|
from pyiceberg.catalog import Catalog
|
|
from pyiceberg.expressions import BooleanExpression
|
|
from pyiceberg.io import FileIO
|
|
from pyiceberg.manifest import DataFile
|
|
from pyiceberg.schema import Schema
|
|
from pyiceberg.table import DataScan, FileScanTask, Table
|
|
from pyiceberg.table.metadata import TableMetadata
|
|
from pyiceberg.table.update.schema import UpdateSchema
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_REWRITE_STALL_TIMEOUT_S = 600
|
|
|
|
|
|
@ray.remote
|
|
def _rewrite_iceberg_file(
|
|
file_scan_task: "FileScanTask",
|
|
keys_ref: "pa.Table",
|
|
upsert_cols: List[str],
|
|
table_metadata: "TableMetadata",
|
|
io: "FileIO",
|
|
) -> "tuple[Optional[DataFile], List[DataFile]]":
|
|
"""Read one Iceberg file, anti-join against upsert keys, write preserved rows.
|
|
|
|
Preserved rows are rows in the file that are not in the upsert batch. The
|
|
coarse range filter would delete them (see ``IcebergDatasink._build_coarse_range_filter``),
|
|
so we preserve them by writing them as new data files before the delete.
|
|
|
|
The file is read in streaming fashion via ``ArrowScan.to_record_batches()``
|
|
so the full file is never materialised at once. The anti-join is applied
|
|
per RecordBatch and preserved rows are accumulated, then concatenated and
|
|
written as a single output once the stream is exhausted.
|
|
|
|
Returns (original DataFile to delete, list of new preserved DataFiles).
|
|
If the entire file is matched (no preserved rows), returns (file, []).
|
|
If the file has no matched rows at all, returns (None, []), leave it untouched.
|
|
"""
|
|
import hashlib
|
|
import time as _time
|
|
import uuid as _uuid
|
|
|
|
import numpy as np
|
|
import pyarrow as pa
|
|
from pyiceberg.expressions import AlwaysTrue
|
|
from pyiceberg.io.pyarrow import ArrowScan, _dataframe_to_data_files
|
|
|
|
file_path = file_scan_task.file.file_path
|
|
file_name = file_path.split("/")[-1]
|
|
file_size_mb = file_scan_task.file.file_size_in_bytes / MiB
|
|
t_start = _time.perf_counter()
|
|
|
|
# Cast target pulled from keys_ref once. Applied per batch so PyArrow's join
|
|
# doesn't raise ArrowInvalid on utf8/large_utf8 or similar width mismatches.
|
|
target_key_schema = pa.schema([keys_ref.schema.field(c) for c in upsert_cols])
|
|
|
|
record_batches = ArrowScan(
|
|
table_metadata=table_metadata,
|
|
io=io,
|
|
projected_schema=table_metadata.schema(),
|
|
row_filter=AlwaysTrue(),
|
|
).to_record_batches(tasks=[file_scan_task])
|
|
|
|
preserved_rows: Optional["pa.Table"] = None
|
|
total_in_rows = 0
|
|
total_preserved_rows = 0
|
|
n_batches = 0
|
|
|
|
for rb in record_batches:
|
|
n_batches += 1
|
|
batch_table = pa.Table.from_batches([rb])
|
|
if len(batch_table) == 0:
|
|
continue
|
|
total_in_rows += len(batch_table)
|
|
|
|
batch_keys = batch_table.select(upsert_cols).cast(target_key_schema)
|
|
|
|
idx_col = pa.array(np.arange(len(batch_table), dtype=np.int64))
|
|
preserved_keys = batch_keys.append_column("__row_idx__", idx_col).join(
|
|
keys_ref, keys=upsert_cols, join_type="left anti"
|
|
)
|
|
|
|
if len(preserved_keys) > 0:
|
|
new_rows = batch_table.take(preserved_keys["__row_idx__"])
|
|
if preserved_rows is None:
|
|
preserved_rows = new_rows
|
|
else:
|
|
preserved_rows = pa.concat_tables(
|
|
[preserved_rows, new_rows], promote_options="permissive"
|
|
)
|
|
total_preserved_rows += len(preserved_keys)
|
|
|
|
t_read = _time.perf_counter()
|
|
logger.debug(
|
|
"[rewrite] stream-read+join %d rows / %.1f MB (compressed) from %s "
|
|
"across %d batch(es) in %.2fs",
|
|
total_in_rows,
|
|
file_size_mb,
|
|
file_name,
|
|
n_batches,
|
|
t_read - t_start,
|
|
)
|
|
|
|
if total_in_rows == 0:
|
|
return (None, [])
|
|
|
|
if total_preserved_rows == 0:
|
|
# Every row in this file is being upserted — delete the whole file, no preserved file needed.
|
|
logger.debug(
|
|
"[rewrite] %s: all %d rows matched -> whole-file delete",
|
|
file_name,
|
|
total_in_rows,
|
|
)
|
|
return (file_scan_task.file, [])
|
|
|
|
if total_preserved_rows == total_in_rows:
|
|
# No rows in this file match any upsert key — leave it alone entirely.
|
|
logger.debug("[rewrite] %s: 0 rows matched -> untouched", file_name)
|
|
return (None, [])
|
|
|
|
# Derive a deterministic write_uuid from the source file path so that
|
|
# task retries overwrite the same object rather than leaking orphan files.
|
|
preserved_write_uuid = _uuid.UUID(hashlib.md5(file_path.encode()).hexdigest())
|
|
preserved_files = list(
|
|
_dataframe_to_data_files(
|
|
table_metadata=table_metadata,
|
|
df=preserved_rows,
|
|
io=io,
|
|
write_uuid=preserved_write_uuid,
|
|
)
|
|
)
|
|
logger.debug(
|
|
"[rewrite] %s: %d/%d rows preserved -> wrote %d preserved file(s) in %.2fs",
|
|
file_name,
|
|
total_preserved_rows,
|
|
total_in_rows,
|
|
len(preserved_files),
|
|
_time.perf_counter() - t_read,
|
|
)
|
|
return (file_scan_task.file, preserved_files)
|
|
|
|
|
|
@dataclass
|
|
class IcebergWriteResult:
|
|
"""Result from writing blocks to Iceberg storage.
|
|
|
|
Attributes:
|
|
data_files: List of DataFile objects containing metadata about written Parquet files.
|
|
upsert_keys: PyArrow table containing key columns for upsert operations.
|
|
schemas: List of PyArrow schemas from all non-empty blocks.
|
|
"""
|
|
|
|
data_files: List["DataFile"] = field(default_factory=list)
|
|
upsert_keys: Optional["pa.Table"] = None
|
|
schemas: List["pa.Schema"] = field(default_factory=list)
|
|
|
|
|
|
_UPSERT_COLS_ID = "join_cols"
|
|
|
|
|
|
@DeveloperAPI
|
|
class IcebergDatasink(Datasink[IcebergWriteResult]):
|
|
"""
|
|
Iceberg datasink to write a Ray Dataset into an existing Iceberg table.
|
|
This datasink handles concurrent writes by:
|
|
- Each worker writes Parquet files to storage and returns DataFile metadata
|
|
- The driver collects all DataFile objects and performs a single commit
|
|
|
|
Schema evolution is supported:
|
|
- New columns in incoming data are automatically added to the table schema
|
|
- Type promotion across blocks is handled via schema reconciliation on the driver
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
table_identifier: str,
|
|
catalog_kwargs: Optional[Dict[str, Any]] = None,
|
|
snapshot_properties: Optional[Dict[str, str]] = None,
|
|
mode: SaveMode = SaveMode.APPEND,
|
|
overwrite_filter: Optional["Expr"] = None,
|
|
upsert_kwargs: Optional[Dict[str, Any]] = None,
|
|
overwrite_kwargs: Optional[Dict[str, Any]] = None,
|
|
):
|
|
"""
|
|
Initialize the IcebergDatasink
|
|
|
|
Args:
|
|
table_identifier: The identifier of the table such as `default.taxi_dataset`
|
|
catalog_kwargs: Optional arguments to use when setting up the Iceberg catalog
|
|
snapshot_properties: Custom properties to write to snapshot summary
|
|
mode: Write mode - APPEND, UPSERT, or OVERWRITE. Defaults to APPEND.
|
|
- APPEND: Add new data without checking for duplicates
|
|
- UPSERT: Update existing rows or insert new ones based on a join condition
|
|
- OVERWRITE: Replace table data (all data or filtered subset)
|
|
overwrite_filter: Optional filter for OVERWRITE mode to perform partial overwrites.
|
|
Must be a Ray Data expression from `ray.data.expressions`. Only rows matching
|
|
this filter are replaced. If None with OVERWRITE mode, replaces all table data.
|
|
upsert_kwargs: Optional arguments for upsert operations.
|
|
Supported parameters: join_cols (List[str]), case_sensitive (bool),
|
|
branch (str). Note: This implementation uses a copy-on-write strategy
|
|
that always updates all columns for matched keys and inserts all new keys.
|
|
overwrite_kwargs: Optional arguments to pass through to PyIceberg's table.overwrite()
|
|
method. Supported parameters include case_sensitive (bool) and branch (str).
|
|
See PyIceberg documentation for details.
|
|
|
|
Note:
|
|
Schema evolution is automatically enabled. New columns in the incoming data
|
|
are automatically added to the table schema. The schema is extracted from
|
|
the first input bundle when on_write_start is called.
|
|
"""
|
|
self.table_identifier = table_identifier
|
|
self._catalog_kwargs = (catalog_kwargs or {}).copy()
|
|
self._snapshot_properties = (snapshot_properties or {}).copy()
|
|
self._mode = mode
|
|
self._overwrite_filter = overwrite_filter
|
|
self._upsert_kwargs = (upsert_kwargs or {}).copy()
|
|
self._overwrite_kwargs = (overwrite_kwargs or {}).copy()
|
|
|
|
# Validate kwargs are only set for relevant modes
|
|
if self._upsert_kwargs and self._mode != SaveMode.UPSERT:
|
|
raise ValueError(
|
|
f"upsert_kwargs can only be specified when mode is SaveMode.UPSERT, but mode is {self._mode}"
|
|
)
|
|
if self._overwrite_kwargs and self._mode != SaveMode.OVERWRITE:
|
|
raise ValueError(
|
|
f"overwrite_kwargs can only be specified when mode is SaveMode.OVERWRITE, but mode is {self._mode}"
|
|
)
|
|
if self._overwrite_filter and self._mode != SaveMode.OVERWRITE:
|
|
raise ValueError(
|
|
f"overwrite_filter can only be specified when mode is SaveMode.OVERWRITE, but mode is {self._mode}"
|
|
)
|
|
|
|
# Remove invalid parameters from overwrite_kwargs if present
|
|
for invalid_param, reason in [
|
|
(
|
|
"overwrite_filter",
|
|
"should be passed as a separate parameter to write_iceberg()",
|
|
),
|
|
(
|
|
"delete_filter",
|
|
"is an internal PyIceberg parameter; use 'overwrite_filter' instead",
|
|
),
|
|
]:
|
|
if self._overwrite_kwargs.pop(invalid_param, None) is not None:
|
|
logger.warning(
|
|
f"Removed '{invalid_param}' from overwrite_kwargs: {reason}"
|
|
)
|
|
|
|
if "name" in self._catalog_kwargs:
|
|
self._catalog_name = self._catalog_kwargs.pop("name")
|
|
else:
|
|
self._catalog_name = "default"
|
|
|
|
self._table: "Table" = None
|
|
self._io: "FileIO" = None
|
|
self._table_metadata: "TableMetadata" = None
|
|
self._data_context = DataContext.get_current()
|
|
|
|
def __getstate__(self) -> dict:
|
|
"""Exclude `_table` during pickling."""
|
|
state = self.__dict__.copy()
|
|
state.pop("_table", None)
|
|
return state
|
|
|
|
def __setstate__(self, state: dict) -> None:
|
|
self.__dict__.update(state)
|
|
self._table = None
|
|
|
|
def _with_retry(self, func: Callable, description: str) -> Any:
|
|
"""Execute a function with retry logic.
|
|
|
|
This helper encapsulates the common retry pattern for Iceberg catalog
|
|
operations, using the configured retry parameters from DataContext.
|
|
|
|
Args:
|
|
func: The callable to execute with retry logic.
|
|
description: Human-readable description for logging/error messages.
|
|
|
|
Returns:
|
|
The result of calling func.
|
|
"""
|
|
iceberg_config = self._data_context.iceberg_config
|
|
return call_with_retry(
|
|
func,
|
|
description=description,
|
|
match=iceberg_config.catalog_retried_errors,
|
|
max_attempts=iceberg_config.catalog_max_attempts,
|
|
max_backoff_s=iceberg_config.catalog_retry_max_backoff_s,
|
|
)
|
|
|
|
def _get_catalog(self) -> "Catalog":
|
|
from pyiceberg import catalog
|
|
|
|
return self._with_retry(
|
|
lambda: catalog.load_catalog(self._catalog_name, **self._catalog_kwargs),
|
|
description=f"load Iceberg catalog '{self._catalog_name}'",
|
|
)
|
|
|
|
def _reload_table(self) -> None:
|
|
"""Reload the Iceberg table from the catalog."""
|
|
cat = self._get_catalog()
|
|
self._table = self._with_retry(
|
|
lambda: cat.load_table(self.table_identifier),
|
|
description=f"load Iceberg table '{self.table_identifier}'",
|
|
)
|
|
self._io = self._table.io
|
|
self._table_metadata = self._table.metadata
|
|
|
|
def _get_upsert_cols(self) -> List[str]:
|
|
"""Get join columns for upsert, using table identifier fields as fallback."""
|
|
upsert_cols = self._upsert_kwargs.get(_UPSERT_COLS_ID, [])
|
|
if not upsert_cols:
|
|
# Use table's identifier fields as fallback
|
|
identifier_cols = []
|
|
schema = self._table_metadata.schema()
|
|
for field_id in schema.identifier_field_ids:
|
|
col_name = schema.find_column_name(field_id)
|
|
if col_name:
|
|
identifier_cols.append(col_name)
|
|
return identifier_cols
|
|
|
|
case_sensitive = self._upsert_kwargs.get("case_sensitive", True)
|
|
|
|
# To support case insensitivity, we need to define a mapping of
|
|
# provided (possibly case-modified) names to their original names in the schema
|
|
if not case_sensitive:
|
|
schema = self._table_metadata.schema()
|
|
lower_to_original_mapping = {
|
|
col.name.lower(): col.name for col in schema.fields
|
|
}
|
|
resolved_upsert_cols = []
|
|
for upsert_col in upsert_cols:
|
|
resolved_col = lower_to_original_mapping.get(upsert_col.lower())
|
|
if resolved_col is None:
|
|
raise ValueError(
|
|
f"Upsert join column {upsert_col!r} does not match any column in "
|
|
f"table schema (case-insensitive)."
|
|
)
|
|
resolved_upsert_cols.append(resolved_col)
|
|
upsert_cols = resolved_upsert_cols
|
|
|
|
return upsert_cols
|
|
|
|
def _build_coarse_range_filter(
|
|
self,
|
|
keys_table: "pa.Table",
|
|
upsert_cols: List[str],
|
|
) -> "BooleanExpression":
|
|
"""Build an O(1) coarse range filter covering all upsert key values.
|
|
|
|
For each upsert column computes AND(GTE(col, min), LTE(col, max)).
|
|
The filter may match rows outside the upsert batch (filter overshoot);
|
|
callers must anti-join to identify and preserve those rows.
|
|
"""
|
|
import pyarrow.compute as pc
|
|
from pyiceberg.expressions import (
|
|
AlwaysTrue,
|
|
And,
|
|
GreaterThanOrEqual,
|
|
LessThanOrEqual,
|
|
)
|
|
|
|
expr = None
|
|
for col_name in upsert_cols:
|
|
mm = pc.min_max(keys_table[col_name])
|
|
min_val = mm["min"].as_py()
|
|
max_val = mm["max"].as_py()
|
|
if min_val is None:
|
|
continue
|
|
col_expr = And(
|
|
GreaterThanOrEqual(col_name, min_val),
|
|
LessThanOrEqual(col_name, max_val),
|
|
)
|
|
expr = col_expr if expr is None else And(expr, col_expr)
|
|
|
|
return expr if expr is not None else AlwaysTrue()
|
|
|
|
def _commit_upsert_scan_merge(
|
|
self,
|
|
txn: "Table.transaction",
|
|
data_files: List["DataFile"],
|
|
keys_table: "pa.Table",
|
|
upsert_cols: List[str],
|
|
) -> None:
|
|
"""Upsert commit using coarse range filter + per-file distributed anti-join.
|
|
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ Stage 1: Build coarse filter (driver) │
|
|
│ keys_table ──► min/max per col ──► coarse_filter │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ Stage 2: Plan candidate files (driver) │
|
|
│ table.scan(coarse_filter).plan_files() │
|
|
│ ──► file_scan_tasks │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ Stage 3: Rewrite (one _rewrite_iceberg_file task per file) │
|
|
│ read file ─► anti-join keys ─► write preserved rows │
|
|
│ returns (old_file, preserved_files) │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ Stage 4: Atomic overwrite (driver) │
|
|
│ delete old_file (each rewritten candidate) │
|
|
│ append preserved_files (preserved rows kept) │
|
|
│ append data_files (new upsert payload) │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
commit_transaction
|
|
|
|
1. Build an O(1) coarse range filter using min-max covering upsert key values (for each column).
|
|
2. plan_files() on the driver to find candidate files that could be updated
|
|
3. Dispatch one Ray task per candidate file. Each task reads its file,
|
|
anti-joins against the upsert keys to find preserved rows (rows that
|
|
the coarse delete would remove but that are NOT being upserted), and
|
|
writes them as new data files directly to storage.
|
|
4. Commit atomically via txn.update_snapshot().overwrite(): delete each
|
|
original candidate file and append preserved files + new upsert data files.
|
|
"""
|
|
import time
|
|
|
|
case_sensitive = self._upsert_kwargs.get("case_sensitive", True)
|
|
branch = self._upsert_kwargs.get("branch", "main")
|
|
unknown = set(self._upsert_kwargs) - {
|
|
_UPSERT_COLS_ID,
|
|
"case_sensitive",
|
|
"branch",
|
|
}
|
|
if unknown:
|
|
logger.warning(
|
|
"[scan-merge] ignoring unsupported upsert_kwargs: %s", sorted(unknown)
|
|
)
|
|
|
|
# Dedup keys to minimise per-task anti-join hash table size.
|
|
keys_table = keys_table.group_by(upsert_cols).aggregate([])
|
|
|
|
coarse_filter = self._build_coarse_range_filter(keys_table, upsert_cols)
|
|
logger.debug("[scan-merge] coarse_filter=%s", coarse_filter)
|
|
|
|
# plan_files() reads only manifest metadata, no Parquet data on the driver.
|
|
t0 = time.perf_counter()
|
|
scan: "DataScan" = self._table.scan(
|
|
row_filter=coarse_filter, case_sensitive=case_sensitive
|
|
)
|
|
# Use the specific branch for the scan
|
|
scan = scan.use_ref(branch)
|
|
file_scan_tasks: List["FileScanTask"] = list(scan.plan_files())
|
|
|
|
logger.info(
|
|
"[scan-merge] planned %d candidate file(s) in %.2fs",
|
|
len(file_scan_tasks),
|
|
time.perf_counter() - t0,
|
|
)
|
|
|
|
if not file_scan_tasks:
|
|
# No existing files match the coarse filter, so it's a pure insert.
|
|
self._append_and_commit(txn, data_files, branch=branch)
|
|
return
|
|
|
|
# Put the deduped keys in the object store once; all tasks share one copy.
|
|
keys_ref = ray.put(keys_table)
|
|
|
|
t0 = time.perf_counter()
|
|
refs = [
|
|
_rewrite_iceberg_file.options(
|
|
memory=int(
|
|
task.file.file_size_in_bytes
|
|
* PARQUET_ENCODING_RATIO_ESTIMATE_DEFAULT
|
|
* 3 # Bump memory estimate to account for the anti-join and the preserved rows (also since to_record_batches materializes the entire table in memory, see https://github.com/apache/iceberg-python/issues/3036)
|
|
),
|
|
num_cpus=1,
|
|
).remote(task, keys_ref, upsert_cols, self._table_metadata, self._io)
|
|
for task in file_scan_tasks
|
|
]
|
|
logger.info("[scan-merge] dispatched %d rewrite task(s)", len(refs))
|
|
|
|
# Collect results with periodic progress logs so long rewrites aren't silent.
|
|
results = []
|
|
pending = list(refs)
|
|
_LOG_INTERVAL = max(1, len(refs) // 10) # log ~10 times total
|
|
while pending:
|
|
done, pending = ray.wait(
|
|
pending,
|
|
num_returns=min(_LOG_INTERVAL, len(pending)),
|
|
timeout=_REWRITE_STALL_TIMEOUT_S,
|
|
fetch_local=True,
|
|
)
|
|
results.extend(ray.get(done))
|
|
logger.debug(
|
|
"[scan-merge] rewrite progress: %d/%d file(s) done (%.1fs elapsed)",
|
|
len(results),
|
|
len(refs),
|
|
time.perf_counter() - t0,
|
|
)
|
|
|
|
logger.info(
|
|
"[scan-merge] all %d file(s) rewritten in %.2fs",
|
|
len(refs),
|
|
time.perf_counter() - t0,
|
|
)
|
|
|
|
# Count how many files were wholly deleted vs partially rewritten.
|
|
n_whole_delete = n_partial = n_untouched = 0
|
|
for old, preserved_files in results:
|
|
if old is None:
|
|
n_untouched += 1
|
|
elif preserved_files:
|
|
n_partial += 1
|
|
else:
|
|
n_whole_delete += 1
|
|
logger.info(
|
|
"[scan-merge] files: %d whole-delete, %d partial-rewrite, %d untouched",
|
|
n_whole_delete,
|
|
n_partial,
|
|
n_untouched,
|
|
)
|
|
|
|
# Single atomic commit: schema update (already staged in txn), and overwrite.
|
|
# _OverwriteFiles handles both file-level deletes and appends in one snapshot.
|
|
t0 = time.perf_counter()
|
|
with txn.update_snapshot(
|
|
snapshot_properties=self._snapshot_properties, branch=branch
|
|
).overwrite() as snap:
|
|
for old_file, preserved_files in results:
|
|
if old_file is not None:
|
|
snap.delete_data_file(old_file)
|
|
for preserved_file in preserved_files:
|
|
snap.append_data_file(preserved_file)
|
|
for df in data_files:
|
|
snap.append_data_file(df)
|
|
|
|
self._with_retry(
|
|
txn.commit_transaction,
|
|
description=f"commit upsert transaction to Iceberg table '{self.table_identifier}'",
|
|
)
|
|
logger.info("[scan-merge] committed in %.2fs", time.perf_counter() - t0)
|
|
|
|
def _append_and_commit(
|
|
self,
|
|
txn: "Table.transaction",
|
|
data_files: List["DataFile"],
|
|
branch: str = "main",
|
|
) -> None:
|
|
"""Append data files to a transaction and commit.
|
|
|
|
Args:
|
|
txn: PyIceberg transaction object
|
|
data_files: List of DataFile objects to append
|
|
branch: Iceberg branch to commit the snapshot to. Defaults to "main"
|
|
to match pyiceberg's default
|
|
"""
|
|
with txn._append_snapshot_producer(
|
|
self._snapshot_properties, branch=branch
|
|
) as append_files:
|
|
for data_file in data_files:
|
|
append_files.append_data_file(data_file)
|
|
|
|
self._with_retry(
|
|
txn.commit_transaction,
|
|
description=f"commit transaction to Iceberg table '{self.table_identifier}'",
|
|
)
|
|
|
|
def _commit_upsert(
|
|
self,
|
|
txn: "Table.transaction",
|
|
data_files: List["DataFile"],
|
|
upsert_keys: Optional["pa.Table"],
|
|
) -> None:
|
|
"""
|
|
Commit upsert transaction with copy-on-write strategy.
|
|
|
|
Args:
|
|
txn: PyIceberg transaction object
|
|
data_files: List of DataFile objects to commit
|
|
upsert_keys: PyArrow table containing upsert key columns
|
|
"""
|
|
import functools
|
|
import time
|
|
|
|
import pyarrow as pa
|
|
|
|
# Create delete filter if we have join keys
|
|
if upsert_keys is not None and len(upsert_keys) > 0:
|
|
# Filter out rows with any NULL values in join columns
|
|
# (NULL != NULL in SQL semantics)
|
|
upsert_cols = self._get_upsert_cols()
|
|
logger.info(
|
|
"[upsert commit] Filtering NULL keys from %d rows on cols %s",
|
|
len(upsert_keys),
|
|
upsert_cols,
|
|
)
|
|
t0 = time.perf_counter()
|
|
masks = (pa.compute.is_valid(upsert_keys[col]) for col in upsert_cols)
|
|
mask = functools.reduce(pa.compute.and_, masks)
|
|
keys_table = upsert_keys.filter(mask)
|
|
logger.info(
|
|
"[upsert commit] NULL filter done in %.2fs: %d -> %d rows (dropped %d NULLs)",
|
|
time.perf_counter() - t0,
|
|
len(upsert_keys),
|
|
len(keys_table),
|
|
len(upsert_keys) - len(keys_table),
|
|
)
|
|
|
|
# Only delete if we have non-NULL keys
|
|
if len(keys_table) > 0:
|
|
self._commit_upsert_scan_merge(txn, data_files, keys_table, upsert_cols)
|
|
return
|
|
else:
|
|
logger.info("[upsert commit] No upsert keys — skipping delete phase")
|
|
|
|
# No non-NULL keys — just append new data files and commit
|
|
logger.info(
|
|
"[upsert commit] Appending %d data files and committing ...",
|
|
len(data_files),
|
|
)
|
|
t0 = time.perf_counter()
|
|
branch = self._upsert_kwargs.get("branch", "main")
|
|
self._append_and_commit(txn, data_files, branch=branch)
|
|
logger.info(
|
|
"[upsert commit] Append+commit done in %.2fs",
|
|
time.perf_counter() - t0,
|
|
)
|
|
|
|
def _preserve_identifier_field_requirements(
|
|
self, update: "UpdateSchema", table_schema: "Schema"
|
|
) -> None:
|
|
"""Ensure identifier fields remain required after schema union.
|
|
|
|
When union_by_name is called with a schema that has nullable fields,
|
|
PyIceberg may make identifier fields optional. Since identifier fields
|
|
must be required, this helper ensures they remain required after union.
|
|
|
|
Example:
|
|
Table schema: id: int (required, identifier), val: string
|
|
Input schema: id: int (optional), val: string
|
|
|
|
`union_by_name` merges them to:
|
|
id: int (optional), val: string
|
|
|
|
This violates the identifier constraint. This function forces `id`
|
|
back to required in the pending update.
|
|
|
|
Args:
|
|
update: The UpdateSchema object from update_schema() context manager
|
|
table_schema: The current table schema to get identifier field IDs from
|
|
"""
|
|
from pyiceberg.types import NestedField
|
|
|
|
identifier_field_ids = table_schema.identifier_field_ids
|
|
for field_id in identifier_field_ids:
|
|
# Check if this field has a pending update
|
|
if field_id in update._updates:
|
|
updated_field = update._updates[field_id]
|
|
# If it was made optional (likely by union_by_name), force it back to required
|
|
if not updated_field.required:
|
|
# Directly update the pending change to enforce required=True.
|
|
# We create a new NestedField because it might be immutable.
|
|
# We bypass _set_column_requirement because it has a check that
|
|
# incorrectly returns early if the original field is already required,
|
|
# ignoring the fact that we are overwriting a pending update.
|
|
update._updates[field_id] = NestedField(
|
|
field_id=updated_field.field_id,
|
|
name=updated_field.name,
|
|
field_type=updated_field.field_type,
|
|
doc=updated_field.doc,
|
|
required=True,
|
|
initial_default=updated_field.initial_default,
|
|
write_default=updated_field.write_default,
|
|
)
|
|
|
|
def _update_schema_with_union(
|
|
self,
|
|
update: "UpdateSchema",
|
|
new_schema: Union["pa.Schema", "Schema"],
|
|
table_schema: "Schema",
|
|
) -> None:
|
|
"""Update schema using union_by_name while preserving identifier field requirements.
|
|
|
|
Args:
|
|
update: The UpdateSchema object.
|
|
new_schema: The new schema to union with the table schema.
|
|
table_schema: The current table schema.
|
|
"""
|
|
update.union_by_name(new_schema)
|
|
self._preserve_identifier_field_requirements(update, table_schema)
|
|
|
|
def on_write_start(self, schema: Optional["pa.Schema"] = None) -> None:
|
|
"""Initialize table for writing and create a shared write UUID.
|
|
|
|
Args:
|
|
schema: The PyArrow schema of the data being written. This is
|
|
automatically extracted from the first input bundle by the
|
|
Write operator. Used to evolve the table schema before writing
|
|
to avoid PyIceberg name mapping errors.
|
|
"""
|
|
self._reload_table()
|
|
|
|
# Evolve schema BEFORE any files are written
|
|
# This prevents PyIceberg name mapping errors when incoming data has new columns
|
|
if schema is not None:
|
|
table_schema = self._table.metadata.schema()
|
|
|
|
def _update_schema():
|
|
with self._table.update_schema() as update:
|
|
self._update_schema_with_union(update, schema, table_schema)
|
|
|
|
self._with_retry(
|
|
_update_schema,
|
|
description=f"update schema for Iceberg table '{self.table_identifier}'",
|
|
)
|
|
# Succeeded, reload to get latest table version and exit.
|
|
self._reload_table()
|
|
|
|
# Validate join_cols for UPSERT mode before writing any files
|
|
if self._mode == SaveMode.UPSERT:
|
|
upsert_cols = self._upsert_kwargs.get(_UPSERT_COLS_ID, [])
|
|
if not upsert_cols:
|
|
# Check if table has identifier fields as fallback
|
|
identifier_field_ids = (
|
|
self._table.metadata.schema().identifier_field_ids
|
|
)
|
|
if not identifier_field_ids:
|
|
raise ValueError(
|
|
"join_cols must be specified in upsert_kwargs for UPSERT mode "
|
|
"when table has no identifier fields"
|
|
)
|
|
|
|
def write(self, blocks: Iterable[Block], ctx: TaskContext) -> IcebergWriteResult:
|
|
"""
|
|
Write blocks to Parquet files in storage and return DataFile metadata with schemas.
|
|
|
|
This runs on each worker in parallel. Files are written directly to storage
|
|
(S3, HDFS, etc.) and only metadata is returned to the driver.
|
|
Schema updates are NOT performed here - they happen on the driver.
|
|
|
|
Args:
|
|
blocks: Iterable of Ray Data blocks to write
|
|
ctx: TaskContext object containing task-specific information
|
|
|
|
Returns:
|
|
IcebergWriteResult containing DataFile objects, upsert keys, and schemas.
|
|
"""
|
|
from pyiceberg.io.pyarrow import _dataframe_to_data_files
|
|
|
|
all_data_files = []
|
|
upsert_keys_tables = []
|
|
block_schemas = []
|
|
use_copy_on_write_upsert = self._mode == SaveMode.UPSERT
|
|
|
|
for block in blocks:
|
|
pa_table = BlockAccessor.for_block(block).to_arrow()
|
|
if pa_table.num_rows > 0:
|
|
block_schemas.append(pa_table.schema)
|
|
|
|
# Extract join key values for copy-on-write upsert
|
|
if use_copy_on_write_upsert:
|
|
upsert_cols = self._get_upsert_cols()
|
|
if len(upsert_cols) > 0:
|
|
upsert_keys_tables.append(pa_table.select(upsert_cols))
|
|
|
|
# Write data files to storage with retry for transient errors
|
|
def _write_data_files():
|
|
return list(
|
|
_dataframe_to_data_files(
|
|
table_metadata=self._table_metadata,
|
|
df=pa_table,
|
|
io=self._io,
|
|
)
|
|
)
|
|
|
|
iceberg_config = self._data_context.iceberg_config
|
|
data_files = call_with_retry(
|
|
_write_data_files,
|
|
description=f"write data files to Iceberg table '{self.table_identifier}'",
|
|
match=self._data_context.retried_io_errors,
|
|
max_attempts=iceberg_config.write_file_max_attempts,
|
|
max_backoff_s=iceberg_config.write_file_retry_max_backoff_s,
|
|
)
|
|
all_data_files.extend(data_files)
|
|
|
|
# Combine all upsert key tables into one
|
|
from ray.data._internal.arrow_ops.transform_pyarrow import concat
|
|
|
|
upsert_keys = concat(upsert_keys_tables) if upsert_keys_tables else None
|
|
|
|
return IcebergWriteResult(
|
|
data_files=all_data_files,
|
|
upsert_keys=upsert_keys,
|
|
schemas=block_schemas,
|
|
)
|
|
|
|
def _commit_overwrite(
|
|
self, txn: "Table.transaction", data_files: List["DataFile"]
|
|
) -> None:
|
|
"""Commit data files using OVERWRITE mode."""
|
|
from pyiceberg.expressions import AlwaysTrue
|
|
|
|
# Default - Full overwrite - delete all
|
|
pyi_filter = AlwaysTrue()
|
|
|
|
# Delete matching data if filter provided
|
|
if self._overwrite_filter is not None:
|
|
from ray.data._internal.datasource.iceberg_datasource import (
|
|
_IcebergExpressionVisitor,
|
|
)
|
|
|
|
visitor = _IcebergExpressionVisitor()
|
|
pyi_filter = visitor.visit(self._overwrite_filter)
|
|
|
|
txn.delete(
|
|
delete_filter=pyi_filter,
|
|
snapshot_properties=self._snapshot_properties,
|
|
**self._overwrite_kwargs,
|
|
)
|
|
|
|
# Append on the same branch the delete targeted (defaults to "main").
|
|
branch = self._overwrite_kwargs.get("branch", "main")
|
|
self._append_and_commit(txn, data_files, branch=branch)
|
|
|
|
def on_write_complete(self, write_result: WriteResult) -> None:
|
|
"""
|
|
Complete the write by reconciling schemas and committing all data files.
|
|
|
|
This runs on the driver after all workers finish writing files.
|
|
Collects all DataFile objects and schemas from all workers, reconciles schemas
|
|
(allowing type promotion), updates table schema if needed, then performs a single
|
|
atomic commit.
|
|
"""
|
|
import time
|
|
|
|
t_start = time.perf_counter()
|
|
logger.info("[on_write_complete] Starting commit phase (mode=%s)", self._mode)
|
|
|
|
# Collect all data files and schemas from all workers
|
|
all_data_files: List["DataFile"] = []
|
|
all_schemas: List["pa.Schema"] = []
|
|
upsert_keys_tables: List["pa.Table"] = []
|
|
|
|
for write_return in write_result.write_returns:
|
|
if not write_return:
|
|
continue
|
|
|
|
if write_return.data_files: # Only add schema if we have data files
|
|
all_data_files.extend(write_return.data_files)
|
|
all_schemas.extend(write_return.schemas)
|
|
if write_return.upsert_keys is not None:
|
|
upsert_keys_tables.append(write_return.upsert_keys)
|
|
|
|
logger.info(
|
|
"[on_write_complete] Collected results: %d data files, %d schema blocks, "
|
|
"%d upsert key batches from workers (%.2fs)",
|
|
len(all_data_files),
|
|
len(all_schemas),
|
|
len(upsert_keys_tables),
|
|
time.perf_counter() - t_start,
|
|
)
|
|
|
|
if not all_data_files:
|
|
logger.info("[on_write_complete] No data files written, nothing to commit")
|
|
return
|
|
|
|
# Concatenate all upsert keys from all workers into a single table
|
|
from ray.data._internal.arrow_ops.transform_pyarrow import concat
|
|
|
|
if upsert_keys_tables:
|
|
total_key_rows = sum(len(t) for t in upsert_keys_tables)
|
|
logger.info(
|
|
"[on_write_complete] Concatenating %d upsert key batches (%d total rows) ...",
|
|
len(upsert_keys_tables),
|
|
total_key_rows,
|
|
)
|
|
t0 = time.perf_counter()
|
|
upsert_keys = concat(upsert_keys_tables)
|
|
logger.info(
|
|
"[on_write_complete] upsert key concat done in %.2fs: %d rows, cols=%s",
|
|
time.perf_counter() - t0,
|
|
len(upsert_keys),
|
|
upsert_keys.column_names,
|
|
)
|
|
else:
|
|
upsert_keys = None
|
|
|
|
# Reconcile all schemas from all blocks across all workers
|
|
# Get table schema and union with reconciled schema using unify_schemas with promotion
|
|
from pyiceberg.io import pyarrow as pyi_pa_io
|
|
|
|
from ray.data._internal.arrow_ops.transform_pyarrow import unify_schemas
|
|
|
|
logger.info("[on_write_complete] Reconciling %d schemas ...", len(all_schemas))
|
|
t0 = time.perf_counter()
|
|
table_schema = pyi_pa_io.schema_to_pyarrow(self._table.schema())
|
|
final_reconciled_schema = unify_schemas(
|
|
[table_schema] + all_schemas, promote_types=True
|
|
)
|
|
logger.info(
|
|
"[on_write_complete] Schema reconciliation done in %.2fs",
|
|
time.perf_counter() - t0,
|
|
)
|
|
|
|
# Create transaction and commit schema update + data files atomically
|
|
txn = self._table.transaction()
|
|
|
|
# Update table schema within the transaction if it differs
|
|
if not final_reconciled_schema.equals(table_schema):
|
|
logger.info(
|
|
"[on_write_complete] Schema changed — updating table schema ..."
|
|
)
|
|
t0 = time.perf_counter()
|
|
current_table_schema = self._table.metadata.schema()
|
|
with txn.update_schema() as update:
|
|
self._update_schema_with_union(
|
|
update, final_reconciled_schema, current_table_schema
|
|
)
|
|
logger.info(
|
|
"[on_write_complete] Schema update done in %.2fs",
|
|
time.perf_counter() - t0,
|
|
)
|
|
else:
|
|
logger.info("[on_write_complete] Schema unchanged, skipping update")
|
|
|
|
# Create transaction and commit based on mode
|
|
logger.info(
|
|
"[on_write_complete] Starting %s commit for %d data files ...",
|
|
self._mode,
|
|
len(all_data_files),
|
|
)
|
|
t0 = time.perf_counter()
|
|
if self._mode == SaveMode.APPEND:
|
|
self._append_and_commit(txn, all_data_files)
|
|
elif self._mode == SaveMode.OVERWRITE:
|
|
self._commit_overwrite(txn, all_data_files)
|
|
elif self._mode == SaveMode.UPSERT:
|
|
self._commit_upsert(txn, all_data_files, upsert_keys)
|
|
else:
|
|
raise ValueError(f"Unsupported mode: {self._mode}")
|
|
logger.info(
|
|
"[on_write_complete] Commit complete in %.2fs (total on_write_complete=%.2fs)",
|
|
time.perf_counter() - t0,
|
|
time.perf_counter() - t_start,
|
|
)
|