Files
ray-project--ray/python/ray/data/tests/datasource/test_iceberg.py
T
2026-07-13 13:17:40 +08:00

2034 lines
68 KiB
Python

import os
import random
from typing import Any, Dict, Generator, List, Tuple, Type
import numpy as np
import pandas as pd
import pyarrow as pa
import pytest
from pkg_resources import parse_version
from pyiceberg import (
catalog as pyi_catalog,
expressions as pyi_expr,
schema as pyi_schema,
types as pyi_types,
)
from pyiceberg.catalog import Catalog
from pyiceberg.catalog.sql import SqlCatalog
from pyiceberg.partitioning import PartitionField, PartitionSpec
from pyiceberg.table import Table
from pyiceberg.transforms import IdentityTransform
import ray
from ray.data import read_iceberg
from ray.data._internal.datasource.iceberg_datasource import IcebergDatasource
from ray.data._internal.logical.operators import Filter, Project
from ray.data._internal.logical.optimizers import LogicalOptimizer
from ray.data._internal.util import rows_same
from ray.data._internal.utils.arrow_utils import get_pyarrow_version
from ray.data.expressions import col
from ray.data.tests.test_util import (
get_operator_types as _get_operator_types,
plan_has_operator as _has_operator_type,
)
_CATALOG_NAME = "ray_catalog"
_DB_NAME = "ray_db"
_TABLE_NAME = "ray_test"
_WAREHOUSE_PATH = "/tmp/warehouse"
_CATALOG_KWARGS = {
"name": _CATALOG_NAME,
"type": "sql",
"uri": f"sqlite:///{_WAREHOUSE_PATH}/ray_pyiceberg_test_catalog.db",
"warehouse": f"file://{_WAREHOUSE_PATH}",
}
_SCHEMA = pa.schema(
[
pa.field("col_a", pa.int32()),
pa.field("col_b", pa.string()),
pa.field("col_c", pa.int16()),
]
)
def create_pa_table():
return pa.Table.from_pydict(
mapping={
"col_a": list(range(120)),
"col_b": random.choices(["a", "b", "c", "d"], k=120),
"col_c": random.choices(list(range(10)), k=120),
},
schema=_SCHEMA,
)
@pytest.fixture(autouse=True, scope="function")
def pyiceberg_table():
if not os.path.exists(_WAREHOUSE_PATH):
os.makedirs(_WAREHOUSE_PATH)
dummy_catalog = SqlCatalog(
_CATALOG_NAME,
**{
"uri": f"sqlite:///{_WAREHOUSE_PATH}/ray_pyiceberg_test_catalog.db",
"warehouse": f"file://{_WAREHOUSE_PATH}",
},
)
pya_table = create_pa_table()
if (_DB_NAME,) not in dummy_catalog.list_namespaces():
dummy_catalog.create_namespace(_DB_NAME)
if (_DB_NAME, _TABLE_NAME) in dummy_catalog.list_tables(_DB_NAME):
dummy_catalog.drop_table(f"{_DB_NAME}.{_TABLE_NAME}")
# Create the table, and add data to it
table = dummy_catalog.create_table(
f"{_DB_NAME}.{_TABLE_NAME}",
schema=pyi_schema.Schema(
pyi_types.NestedField(
field_id=1,
name="col_a",
field_type=pyi_types.IntegerType(),
required=False,
),
pyi_types.NestedField(
field_id=2,
name="col_b",
field_type=pyi_types.StringType(),
required=False,
),
pyi_types.NestedField(
field_id=3,
name="col_c",
field_type=pyi_types.IntegerType(),
required=False,
),
),
partition_spec=PartitionSpec(
PartitionField(
source_id=3, field_id=3, transform=IdentityTransform(), name="col_c"
)
),
)
table.append(pya_table)
# Delete some data so there are delete file(s)
table.delete(delete_filter=pyi_expr.GreaterThanOrEqual("col_a", 101))
@pytest.fixture
def fast_retry_config():
"""Configure DataContext for fast retry testing."""
from ray.data.context import DataContext
ctx = DataContext.get_current()
iceberg_config = ctx.iceberg_config
original_max_attempts = iceberg_config.write_file_max_attempts
original_max_backoff = iceberg_config.write_file_retry_max_backoff_s
original_errors = ctx.retried_io_errors
iceberg_config.write_file_max_attempts = 3
iceberg_config.write_file_retry_max_backoff_s = 1
ctx.retried_io_errors = list(original_errors) + ["TestTransientError"]
yield ctx
# Restore original settings
iceberg_config.write_file_max_attempts = original_max_attempts
iceberg_config.write_file_retry_max_backoff_s = original_max_backoff
ctx.retried_io_errors = original_errors
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("14.0.0"),
reason="PyIceberg 0.7.0 fails on pyarrow <= 14.0.0",
)
def test_get_catalog():
iceberg_ds = IcebergDatasource(
table_identifier=f"{_DB_NAME}.{_TABLE_NAME}",
catalog_kwargs=_CATALOG_KWARGS.copy(),
)
catalog = iceberg_ds._get_catalog()
assert catalog.name == _CATALOG_NAME
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("14.0.0"),
reason="PyIceberg 0.7.0 fails on pyarrow <= 14.0.0",
)
def test_plan_files():
iceberg_ds = IcebergDatasource(
table_identifier=f"{_DB_NAME}.{_TABLE_NAME}",
catalog_kwargs=_CATALOG_KWARGS.copy(),
)
plan_files = iceberg_ds.plan_files
assert len(plan_files) == 10
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("14.0.0"),
reason="PyIceberg 0.7.0 fails on pyarrow <= 14.0.0",
)
def test_chunk_plan_files():
iceberg_ds = IcebergDatasource(
table_identifier=f"{_DB_NAME}.{_TABLE_NAME}",
catalog_kwargs=_CATALOG_KWARGS.copy(),
)
chunks = iceberg_ds._distribute_tasks_into_equal_chunks(iceberg_ds.plan_files, 5)
assert (len(c) == 2 for c in chunks), chunks
chunks = iceberg_ds._distribute_tasks_into_equal_chunks(iceberg_ds.plan_files, 20)
assert (
sum(len(c) == 1 for c in chunks) == 10
and sum(len(c) == 0 for c in chunks) == 10
), chunks
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("14.0.0"),
reason="PyIceberg 0.7.0 fails on pyarrow <= 14.0.0",
)
def test_get_read_tasks():
iceberg_ds = IcebergDatasource(
table_identifier=f"{_DB_NAME}.{_TABLE_NAME}",
catalog_kwargs=_CATALOG_KWARGS.copy(),
)
read_tasks = iceberg_ds.get_read_tasks(5)
assert len(read_tasks) == 5
assert all(len(rt.metadata.input_files) == 2 for rt in read_tasks)
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("14.0.0"),
reason="PyIceberg 0.7.0 fails on pyarrow <= 14.0.0",
)
def test_filtered_read():
iceberg_ds = IcebergDatasource(
table_identifier=f"{_DB_NAME}.{_TABLE_NAME}",
row_filter=pyi_expr.In("col_c", {1, 2, 3, 4}),
selected_fields=("col_b",),
catalog_kwargs=_CATALOG_KWARGS.copy(),
)
read_tasks = iceberg_ds.get_read_tasks(5)
# Should be capped to 4, as there will be only 4 files
assert len(read_tasks) == 4, read_tasks
assert all(len(rt.metadata.input_files) == 1 for rt in read_tasks)
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("14.0.0"),
reason="PyIceberg 0.7.0 fails on pyarrow <= 14.0.0",
)
def test_read_basic():
row_filter = pyi_expr.In("col_c", {1, 2, 3, 4, 5, 6, 7, 8})
ray_ds = read_iceberg(
table_identifier=f"{_DB_NAME}.{_TABLE_NAME}",
row_filter=row_filter,
selected_fields=("col_a", "col_b"),
catalog_kwargs=_CATALOG_KWARGS.copy(),
)
table: pa.Table = pa.concat_tables((ray.get(ref) for ref in ray_ds.to_arrow_refs()))
expected_schema = pa.schema(
[pa.field("col_a", pa.int32()), pa.field("col_b", pa.string())]
)
assert table.schema.equals(expected_schema)
# Read the raw table from PyIceberg
sql_catalog = pyi_catalog.load_catalog(**_CATALOG_KWARGS)
orig_table_p = (
sql_catalog.load_table(f"{_DB_NAME}.{_TABLE_NAME}")
.scan(row_filter=row_filter, selected_fields=("col_a", "col_b"))
.to_pandas()
.sort_values(["col_a", "col_b"])
.reset_index(drop=True)
)
# Actually compare the tables now
table_p = ray_ds.to_pandas().sort_values(["col_a", "col_b"]).reset_index(drop=True)
orig_table_p = orig_table_p.astype(table_p.dtypes.to_dict())
pd.testing.assert_frame_equal(orig_table_p, table_p)
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("14.0.0"),
reason="PyIceberg 0.7.0 fails on pyarrow <= 14.0.0",
)
def test_write_basic():
sql_catalog = pyi_catalog.load_catalog(**_CATALOG_KWARGS)
table = sql_catalog.load_table(f"{_DB_NAME}.{_TABLE_NAME}")
table.delete()
ds = ray.data.from_arrow(create_pa_table())
ds.write_iceberg(
table_identifier=f"{_DB_NAME}.{_TABLE_NAME}",
catalog_kwargs=_CATALOG_KWARGS.copy(),
)
# Read the raw table from PyIceberg after writing
table = sql_catalog.load_table(f"{_DB_NAME}.{_TABLE_NAME}")
orig_table_p = (
table.scan()
.to_pandas()
.sort_values(["col_a", "col_b", "col_c"])
.reset_index(drop=True)
)
table_p = (
ds.to_pandas().sort_values(["col_a", "col_b", "col_c"]).reset_index(drop=True)
)
assert rows_same(table_p, orig_table_p)
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("14.0.0"),
reason="PyIceberg 0.7.0 fails on pyarrow <= 14.0.0",
)
def test_write_concurrency():
sql_catalog = pyi_catalog.load_catalog(**_CATALOG_KWARGS)
table = sql_catalog.load_table(f"{_DB_NAME}.{_TABLE_NAME}")
table.delete()
data = pd.DataFrame(
{
"col_a": np.array([1, 2, 3, 4], dtype=np.int32),
"col_b": ["1", "2", "3", "4"],
"col_c": np.array([1, 2, 3, 4], dtype=np.int32),
}
)
write_ds = ray.data.from_pandas(data).repartition(2)
write_ds.write_iceberg(
table_identifier=f"{_DB_NAME}.{_TABLE_NAME}",
catalog_kwargs=_CATALOG_KWARGS.copy(),
concurrency=2,
)
read_ds = ray.data.read_iceberg(
table_identifier=f"{_DB_NAME}.{_TABLE_NAME}",
catalog_kwargs=_CATALOG_KWARGS.copy(),
selected_fields=("col_a",),
)
df = read_ds.to_pandas().sort_values("col_a").reset_index(drop=True)
assert df["col_a"].tolist() == [1, 2, 3, 4]
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("14.0.0"),
reason="PyIceberg 0.7.0 fails on pyarrow <= 14.0.0",
)
def test_predicate_pushdown():
"""Test that predicate pushdown works correctly with Iceberg datasource."""
# Read the table and apply filters using Ray Data expressions
ds = ray.data.read_iceberg(
table_identifier=f"{_DB_NAME}.{_TABLE_NAME}",
catalog_kwargs=_CATALOG_KWARGS.copy(),
)
# Apply filter using Ray Data expression syntax
filtered_ds = ds.filter(expr=col("col_c") >= 5)
# Verify the filter is pushed down to the read operation
# by checking the optimized logical plan
logical_plan = filtered_ds._logical_plan
optimized_plan = LogicalOptimizer().optimize(logical_plan)
# The plan should only contain the Read operator, with no Filter operator
# This indicates the filter was pushed down to the datasource
assert not _has_operator_type(
optimized_plan, Filter
), f"Filter should be pushed down to read, got operators: {_get_operator_types(optimized_plan)}"
# Verify the results are correct
result = filtered_ds.to_pandas()
assert all(result["col_c"] >= 5), "All rows should have col_c >= 5"
assert len(result) > 0, "Should have some results"
# Verify against direct PyIceberg read with the same filter
sql_catalog = pyi_catalog.load_catalog(**_CATALOG_KWARGS)
expected_table = (
sql_catalog.load_table(f"{_DB_NAME}.{_TABLE_NAME}")
.scan(row_filter=pyi_expr.GreaterThanOrEqual("col_c", 5))
.to_pandas()
)
assert rows_same(result, expected_table)
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("14.0.0"),
reason="PyIceberg 0.7.0 fails on pyarrow <= 14.0.0",
)
def test_predicate_pushdown_with_initial_filter():
"""Test that predicate pushdown works when combined with initial row_filter."""
# Read with an initial PyIceberg filter
initial_filter = pyi_expr.LessThan("col_a", 50)
# Expect deprecation warning for row_filter
with pytest.warns(DeprecationWarning, match="row_filter.*deprecated"):
ds = ray.data.read_iceberg(
table_identifier=f"{_DB_NAME}.{_TABLE_NAME}",
row_filter=initial_filter,
catalog_kwargs=_CATALOG_KWARGS.copy(),
)
# Apply additional filter using Ray Data expression
filtered_ds = ds.filter(expr=col("col_c") >= 5)
# Verify both filters are pushed down
logical_plan = filtered_ds._logical_plan
optimized_plan = LogicalOptimizer().optimize(logical_plan)
# No Filter operator should remain in the plan
assert not _has_operator_type(
optimized_plan, Filter
), f"Filters should be pushed down to read, got operators: {_get_operator_types(optimized_plan)}"
# Verify the results satisfy both conditions
result = filtered_ds.to_pandas()
assert all(result["col_a"] < 50), "All rows should have col_a < 50"
assert all(result["col_c"] >= 5), "All rows should have col_c >= 5"
assert len(result) > 0, "Should have some results"
# Verify against direct PyIceberg read with combined filter
sql_catalog = pyi_catalog.load_catalog(**_CATALOG_KWARGS)
combined_filter = pyi_expr.And(
pyi_expr.LessThan("col_a", 50), pyi_expr.GreaterThanOrEqual("col_c", 5)
)
expected_table = (
sql_catalog.load_table(f"{_DB_NAME}.{_TABLE_NAME}")
.scan(row_filter=combined_filter)
.to_pandas()
)
assert rows_same(result, expected_table)
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("14.0.0"),
reason="PyIceberg 0.7.0 fails on pyarrow <= 14.0.0",
)
def test_projection_pushdown():
"""Test that projection pushdown works correctly with Iceberg datasource."""
# Read the table and apply projection using select
ds = ray.data.read_iceberg(
table_identifier=f"{_DB_NAME}.{_TABLE_NAME}",
catalog_kwargs=_CATALOG_KWARGS.copy(),
)
# Select only specific columns
projected_ds = ds.select_columns(["col_a", "col_c"])
# Verify the projection is pushed down to the read operation
logical_plan = projected_ds._logical_plan
optimized_plan = LogicalOptimizer().optimize(logical_plan)
# The plan should only contain the Read operator, with no Project operator
# This indicates the projection was pushed down to the datasource
assert not _has_operator_type(
optimized_plan, Project
), f"Projection should be pushed down to read, got operators: {_get_operator_types(optimized_plan)}"
# Verify the results only contain the selected columns
result = projected_ds.to_pandas()
assert set(result.columns) == {
"col_a",
"col_c",
}, f"Expected only col_a and col_c, got: {result.columns}"
assert len(result) > 0, "Should have some results"
# Verify against direct PyIceberg read with the same projection
sql_catalog = pyi_catalog.load_catalog(**_CATALOG_KWARGS)
expected_table = (
sql_catalog.load_table(f"{_DB_NAME}.{_TABLE_NAME}")
.scan(selected_fields=("col_a", "col_c"))
.to_pandas()
)
assert rows_same(result, expected_table)
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("14.0.0"),
reason="PyIceberg 0.7.0 fails on pyarrow <= 14.0.0",
)
@pytest.mark.parametrize(
"selected_cols,filter_expr,pyi_filter,expected_cols",
[
# Test 1: Projection only on col_a
(["col_a"], None, None, {"col_a"}),
# Test 2: Projection on col_a and col_b with filter on col_c
(
["col_a", "col_b"],
col("col_c") >= 5,
pyi_expr.GreaterThanOrEqual("col_c", 5),
{"col_a", "col_b"},
),
# Test 3: Projection on all columns with filter
(
["col_a", "col_b", "col_c"],
col("col_a") < 50,
pyi_expr.LessThan("col_a", 50),
{"col_a", "col_b", "col_c"},
),
# Test 4: Single column projection with complex filter
(
["col_b"],
(col("col_c") >= 3) & (col("col_c") <= 7),
pyi_expr.And(
pyi_expr.GreaterThanOrEqual("col_c", 3),
pyi_expr.LessThanOrEqual("col_c", 7),
),
{"col_b"},
),
],
)
def test_projection_and_predicate_pushdown(
selected_cols, filter_expr, pyi_filter, expected_cols
):
"""Test that both projection and predicate pushdown work together."""
# Read the table
ds = ray.data.read_iceberg(
table_identifier=f"{_DB_NAME}.{_TABLE_NAME}",
catalog_kwargs=_CATALOG_KWARGS.copy(),
)
# Apply projection
projected_ds = ds.select_columns(selected_cols)
# Apply filter if provided
if filter_expr is not None:
filtered_ds = projected_ds.filter(expr=filter_expr)
else:
filtered_ds = projected_ds
# Verify both optimizations are applied
logical_plan = filtered_ds._logical_plan
optimized_plan = LogicalOptimizer().optimize(logical_plan)
# Both Filter and Project should be pushed down
assert not _has_operator_type(
optimized_plan, Filter
), f"Filter should be pushed down, got operators: {_get_operator_types(optimized_plan)}"
assert not _has_operator_type(
optimized_plan, Project
), f"Projection should be pushed down, got operators: {_get_operator_types(optimized_plan)}"
# Verify the results
result = filtered_ds.to_pandas()
assert (
set(result.columns) == expected_cols
), f"Expected columns {expected_cols}, got: {result.columns}"
# Verify results match direct PyIceberg query
sql_catalog = pyi_catalog.load_catalog(**_CATALOG_KWARGS)
table = sql_catalog.load_table(f"{_DB_NAME}.{_TABLE_NAME}")
if pyi_filter is not None:
expected_table = table.scan(
row_filter=pyi_filter, selected_fields=tuple(selected_cols)
).to_pandas()
else:
expected_table = table.scan(selected_fields=tuple(selected_cols)).to_pandas()
assert rows_same(result, expected_table)
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("14.0.0"),
reason="PyIceberg 0.7.0 fails on pyarrow <= 14.0.0",
)
@pytest.mark.parametrize(
"rename_map,select_cols,filter_expr,pyi_filter,expected_cols",
[
# Test 1: Just rename
(
{"col_a": "column_a", "col_b": "column_b"},
None,
None,
None,
{"column_a", "column_b", "col_c"},
),
# Test 2: Just select (no rename, no filter)
(
None,
["col_a", "col_c"],
None,
None,
{"col_a", "col_c"},
),
# Test 3: Just filter (no rename, no select)
(
None,
None,
col("col_c") >= 5,
pyi_expr.GreaterThanOrEqual("col_c", 5),
{"col_a", "col_b", "col_c"},
),
# Test 4: Rename + Select
(
{"col_a": "column_a"},
["column_a", "col_b"],
None,
None,
{"column_a", "col_b"},
),
# Test 5: Rename + Filter
(
{"col_a": "column_a", "col_c": "column_c"},
None,
col("column_c") >= 5,
pyi_expr.GreaterThanOrEqual("col_c", 5),
{"column_a", "col_b", "column_c"},
),
# Test 6: Select + Filter (different columns)
(
None,
["col_a", "col_b"],
col("col_c") >= 5,
pyi_expr.GreaterThanOrEqual("col_c", 5),
{"col_a", "col_b"},
),
# Test 7: Rename + Select + Filter (all three together).
# Filter references a selected column (the renamed ``column_a``);
# filtering on a column the select dropped is not a valid plan
# after projection pushdown.
(
{"col_a": "column_a", "col_b": "column_b"},
["column_a", "column_b"],
col("column_a") >= 50,
pyi_expr.GreaterThanOrEqual("col_a", 50),
{"column_a", "column_b"},
),
# Test 8: Complex rename + select with multiple renames
(
{"col_a": "id", "col_b": "name", "col_c": "value"},
["id", "value"],
None,
None,
{"id", "value"},
),
# Test 9: Rename + Select + Complex filter
(
{"col_a": "id", "col_c": "value"},
["id", "value"],
(col("value") >= 3) & (col("value") <= 7),
pyi_expr.And(
pyi_expr.GreaterThanOrEqual("col_c", 3),
pyi_expr.LessThanOrEqual("col_c", 7),
),
{"id", "value"},
),
],
)
def test_rename_select_filter_combinations(
rename_map, select_cols, filter_expr, pyi_filter, expected_cols
):
"""Test all combinations of rename_columns, select_columns, and filter operations."""
# Read the table
ds = ray.data.read_iceberg(
table_identifier=f"{_DB_NAME}.{_TABLE_NAME}",
catalog_kwargs=_CATALOG_KWARGS.copy(),
)
# Apply rename if provided
if rename_map is not None:
ds = ds.rename_columns(rename_map)
# Apply select if provided
if select_cols is not None:
ds = ds.select_columns(select_cols)
# Apply filter if provided
if filter_expr is not None:
ds = ds.filter(expr=filter_expr)
# Verify optimizations are applied
logical_plan = ds._logical_plan
optimized_plan = LogicalOptimizer().optimize(logical_plan)
# Filter, when present, should always be pushed into the scan.
if filter_expr is not None:
assert not _has_operator_type(
optimized_plan, Filter
), f"Filter should be pushed down, got operators: {_get_operator_types(optimized_plan)}"
# Pure column selection (no renames) is subsumed by the scan's column
# pruning, so no ``Project`` remains. Renames stay as a ``Project`` of
# ``AliasExpr``s above the pruned scan after filter pushdown.
if rename_map is not None:
assert _has_operator_type(
optimized_plan, Project
), f"Renames should remain as a Project above the scan, got operators: {_get_operator_types(optimized_plan)}"
elif select_cols is not None:
assert not _has_operator_type(
optimized_plan, Project
), f"Pure column selection should be pushed into the scan, got operators: {_get_operator_types(optimized_plan)}"
# Verify the results
result = ds.to_pandas()
assert (
set(result.columns) == expected_cols
), f"Expected columns {expected_cols}, got: {result.columns}"
assert len(result) > 0, "Should have some results"
# Verify results match direct PyIceberg query
sql_catalog = pyi_catalog.load_catalog(**_CATALOG_KWARGS)
table = sql_catalog.load_table(f"{_DB_NAME}.{_TABLE_NAME}")
# Build the column mapping for verification
# We need to map renamed columns back to original names for PyIceberg
reverse_rename_map = {}
if rename_map is not None:
reverse_rename_map = {v: k for k, v in rename_map.items()}
# Determine which original columns to select
if select_cols is not None:
# Map selected columns back to original names
original_cols = tuple(reverse_rename_map.get(col, col) for col in select_cols)
else:
# All columns
original_cols = ("col_a", "col_b", "col_c")
# Get expected data from PyIceberg
if pyi_filter is not None:
expected_table = table.scan(
row_filter=pyi_filter, selected_fields=original_cols
).to_pandas()
else:
expected_table = table.scan(selected_fields=original_cols).to_pandas()
# Apply renames to expected table to match result
if rename_map is not None:
cols_to_rename = {
orig: new
for orig, new in rename_map.items()
if orig in expected_table.columns
}
expected_table = expected_table.rename(columns=cols_to_rename)
assert rows_same(result, expected_table)
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("14.0.0"),
reason="PyIceberg 0.7.0 fails on pyarrow <= 14.0.0",
)
def test_predicate_pushdown_complex_expression():
"""Test predicate pushdown with complex expressions."""
# Apply a complex filter expression
ds = ray.data.read_iceberg(
table_identifier=f"{_DB_NAME}.{_TABLE_NAME}",
catalog_kwargs=_CATALOG_KWARGS.copy(),
)
# Complex expression: (col_c >= 3) & (col_c <= 7) & (col_a <= 50)
filtered_ds = (
ds.filter(expr=(col("col_c") >= 3))
.filter(expr=col("col_c") <= 7)
.filter(expr=col("col_a") <= 50)
)
# Verify the results
result = filtered_ds.to_pandas()
# Verify optimizations are applied
logical_plan = filtered_ds._logical_plan
optimized_plan = LogicalOptimizer().optimize(logical_plan)
assert not _has_operator_type(
optimized_plan, Filter
), f"Filter should be pushed down, got operators: {_get_operator_types(optimized_plan)}"
assert not _has_operator_type(
optimized_plan, Project
), f"Projection should be pushed down, got operators: {_get_operator_types(optimized_plan)}"
assert all(result["col_c"] >= 3), "All rows should have col_c >= 3"
assert all(result["col_c"] <= 7), "All rows should have col_c <= 7"
assert all(result["col_a"] <= 50), "All rows should have col_a <= 50"
assert len(result) > 0, "Should have some results"
# Verify against direct PyIceberg read
sql_catalog = pyi_catalog.load_catalog(**_CATALOG_KWARGS)
combined_filter = pyi_expr.And(
pyi_expr.And(
pyi_expr.GreaterThanOrEqual("col_c", 3),
pyi_expr.LessThanOrEqual("col_c", 7),
),
pyi_expr.LessThanOrEqual("col_a", 50),
)
expected_table = (
sql_catalog.load_table(f"{_DB_NAME}.{_TABLE_NAME}")
.scan(row_filter=combined_filter)
.to_pandas()
)
assert rows_same(result, expected_table)
# Helper functions and fixtures for schema evolution tests
@pytest.fixture
def clean_table() -> Generator[Tuple[Catalog, Table], None, None]:
"""Pytest fixture to get a clean Iceberg table by deleting all data."""
sql_catalog = pyi_catalog.load_catalog(**_CATALOG_KWARGS)
table = sql_catalog.load_table(f"{_DB_NAME}.{_TABLE_NAME}")
table.delete()
yield sql_catalog, table
def _create_typed_dataframe(data_dict: Dict[str, List[Any]]) -> pd.DataFrame:
"""Create a pandas DataFrame with proper int32 dtypes for col_a and col_c."""
df = pd.DataFrame(data_dict)
if "col_a" in df.columns:
# Use nullable Int32 to support NaN values
df["col_a"] = df["col_a"].astype("Int32")
if "col_c" in df.columns:
# Use nullable Int32 to support NaN values
df["col_c"] = df["col_c"].astype("Int32")
# Cast object/string columns to a nullable string dtype so ``None`` is
# represented as ``<NA>``, matching the Arrow-backed ``string[pyarrow]``
# produced by ``read_iceberg().to_pandas()``.
for column in df.columns:
if df[column].dtype == object:
df[column] = df[column].astype("string")
return df
def _write_to_iceberg(df: pd.DataFrame, **kwargs: Any) -> None:
"""Write a DataFrame to the test Iceberg table."""
ds = ray.data.from_pandas(df)
write_kwargs: Dict[str, Any] = {
"table_identifier": f"{_DB_NAME}.{_TABLE_NAME}",
"catalog_kwargs": _CATALOG_KWARGS.copy(),
}
write_kwargs.update(kwargs)
ds.write_iceberg(**write_kwargs)
def _read_from_iceberg(
sort_by: "str | List[str] | None" = None,
) -> pd.DataFrame:
"""Read data from the test Iceberg table and optionally sort."""
ds = ray.data.read_iceberg(
table_identifier=f"{_DB_NAME}.{_TABLE_NAME}",
catalog_kwargs=_CATALOG_KWARGS.copy(),
)
result_df = ds.to_pandas()
if sort_by:
result_df = result_df.sort_values(sort_by).reset_index(drop=True)
return result_df
def _verify_schema(expected_fields: Dict[str, Type[pyi_types.IcebergType]]) -> None:
"""
Verify the Iceberg table schema matches expected fields.
Args:
expected_fields: Dict mapping field names to PyIceberg type classes
e.g., {"col_a": pyi_types.IntegerType, "col_b": pyi_types.StringType}
"""
sql_catalog = pyi_catalog.load_catalog(**_CATALOG_KWARGS)
table = sql_catalog.load_table(f"{_DB_NAME}.{_TABLE_NAME}")
schema = {field.name: field.field_type for field in table.schema().fields}
assert len(schema) == len(
expected_fields
), f"Expected {len(expected_fields)} fields, got {len(schema)}"
for field_name, expected_type in expected_fields.items():
assert field_name in schema, f"Field {field_name} not found in schema"
assert isinstance(schema[field_name], expected_type), (
f"Field {field_name} expected type {expected_type}, "
f"got {type(schema[field_name])}"
)
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("14.0.0"),
reason="PyIceberg 0.7.0 fails on pyarrow <= 14.0.0",
)
class TestSchemaEvolution:
"""Test schema evolution during writes."""
def test_schema_evolution_add_column(self, clean_table):
"""Test adding new columns works."""
initial_data = _create_typed_dataframe(
{"col_a": [1, 2], "col_b": ["row_1", "row_2"], "col_c": [1, 2]}
)
_write_to_iceberg(initial_data)
new_data = _create_typed_dataframe(
{
"col_a": [3, 4],
"col_b": ["row_3", "row_4"],
"col_c": [3, 4],
"col_d": ["extra_3", "extra_4"],
}
)
_write_to_iceberg(new_data)
_verify_schema(
{
"col_a": pyi_types.IntegerType,
"col_b": pyi_types.StringType,
"col_c": pyi_types.IntegerType,
"col_d": pyi_types.StringType,
}
)
result_df = _read_from_iceberg(sort_by="col_a")
expected = _create_typed_dataframe(
{
"col_a": [1, 2, 3, 4],
"col_b": ["row_1", "row_2", "row_3", "row_4"],
"col_c": [1, 2, 3, 4],
"col_d": [None, None, "extra_3", "extra_4"],
}
)
assert rows_same(result_df, expected)
def test_multiple_schema_evolutions(self, clean_table):
"""Test multiple sequential schema evolutions."""
initial_data = _create_typed_dataframe(
{"col_a": [1], "col_b": ["row_1"], "col_c": [10]}
)
_write_to_iceberg(initial_data)
# First evolution: add col_d
data_with_d = _create_typed_dataframe(
{"col_a": [2], "col_b": ["row_2"], "col_c": [20], "col_d": ["extra_2"]}
)
_write_to_iceberg(data_with_d)
_verify_schema(
{
"col_a": pyi_types.IntegerType,
"col_b": pyi_types.StringType,
"col_c": pyi_types.IntegerType,
"col_d": pyi_types.StringType,
}
)
# Second evolution: add col_e
data_with_e = _create_typed_dataframe(
{
"col_a": [3],
"col_b": ["row_3"],
"col_c": [30],
"col_d": ["extra_3"],
"col_e": ["bonus_3"],
}
)
_write_to_iceberg(data_with_e)
_verify_schema(
{
"col_a": pyi_types.IntegerType,
"col_b": pyi_types.StringType,
"col_c": pyi_types.IntegerType,
"col_d": pyi_types.StringType,
"col_e": pyi_types.StringType,
}
)
result_df = _read_from_iceberg(sort_by="col_a")
expected = _create_typed_dataframe(
{
"col_a": [1, 2, 3],
"col_b": ["row_1", "row_2", "row_3"],
"col_c": [10, 20, 30],
"col_d": [None, "extra_2", "extra_3"],
"col_e": [None, None, "bonus_3"],
}
)
assert rows_same(result_df, expected)
def test_column_order_independence(self, clean_table):
"""Test writing data with columns in different order works."""
initial_data = _create_typed_dataframe(
{"col_a": [1, 2], "col_b": ["row_1", "row_2"], "col_c": [1, 2]}
)
_write_to_iceberg(initial_data)
# Append data with columns in different order
reordered_data = _create_typed_dataframe(
{"col_c": [3, 4], "col_a": [3, 4], "col_b": ["row_3", "row_4"]}
)
_write_to_iceberg(reordered_data)
result_df = _read_from_iceberg(sort_by="col_a")
expected = _create_typed_dataframe(
{
"col_a": [1, 2, 3, 4],
"col_b": ["row_1", "row_2", "row_3", "row_4"],
"col_c": [1, 2, 3, 4],
}
)
assert rows_same(result_df, expected)
@pytest.mark.parametrize(
"initial_dtype,promoted_dtype,promoted_iceberg_type",
[
(np.int32, np.int64, pyi_types.LongType),
(np.float32, np.float64, pyi_types.DoubleType),
],
ids=["int32_to_int64", "float32_to_float64"],
)
def test_schema_evolution_type_promotion(
self, clean_table, initial_dtype, promoted_dtype, promoted_iceberg_type
):
"""Test type promotion (int32 -> int64, float32 -> float64) works."""
initial_data = _create_typed_dataframe(
{"col_a": [1, 2], "col_b": ["a", "b"], "col_c": [1, 2]}
)
initial_data["col_new"] = np.array([10, 20], dtype=initial_dtype)
_write_to_iceberg(initial_data)
new_data = _create_typed_dataframe(
{"col_a": [3, 4], "col_b": ["c", "d"], "col_c": [3, 4]}
)
new_data["col_new"] = np.array([30, 40], dtype=promoted_dtype)
_write_to_iceberg(new_data)
_verify_schema(
{
"col_a": pyi_types.IntegerType,
"col_b": pyi_types.StringType,
"col_c": pyi_types.IntegerType,
"col_new": promoted_iceberg_type,
}
)
result_df = _read_from_iceberg(sort_by="col_a")
expected = _create_typed_dataframe(
{
"col_a": [1, 2, 3, 4],
"col_b": ["a", "b", "c", "d"],
"col_c": [1, 2, 3, 4],
}
)
expected["col_new"] = np.array([10, 20, 30, 40], dtype=promoted_dtype)
assert rows_same(result_df, expected)
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("14.0.0"),
reason="PyIceberg 0.7.0 fails on pyarrow <= 14.0.0",
)
class TestOverwriteMode:
"""Test overwrite mode functionality."""
def test_write_overwrite_full(self, clean_table):
"""Test full table overwrite replaces all data."""
from ray.data import SaveMode
# Write initial data
initial_data = _create_typed_dataframe(
{"col_a": [1, 2, 3], "col_b": ["a", "b", "c"], "col_c": [10, 20, 30]}
)
_write_to_iceberg(initial_data)
# Verify initial write
result = _read_from_iceberg(sort_by="col_a")
expected = _create_typed_dataframe(
{"col_a": [1, 2, 3], "col_b": ["a", "b", "c"], "col_c": [10, 20, 30]}
)
assert rows_same(result, expected)
# Overwrite with new data
new_data = _create_typed_dataframe(
{"col_a": [100, 200], "col_b": ["x", "y"], "col_c": [1, 2]}
)
_write_to_iceberg(new_data, mode=SaveMode.OVERWRITE)
# Verify overwrite replaced all data
result = _read_from_iceberg(sort_by="col_a")
expected = _create_typed_dataframe(
{"col_a": [100, 200], "col_b": ["x", "y"], "col_c": [1, 2]}
)
assert rows_same(result, expected)
def test_write_overwrite_with_filter(self, clean_table):
"""Test partial overwrite using a filter expression."""
from ray.data import SaveMode
# Write initial data with different col_c values
initial_data = _create_typed_dataframe(
{
"col_a": [1, 2, 3, 4, 5, 6],
"col_b": ["a", "b", "c", "d", "e", "f"],
"col_c": [1, 1, 2, 2, 3, 3],
}
)
_write_to_iceberg(initial_data)
# Overwrite only rows where col_c == 2
new_data = _create_typed_dataframe(
{"col_a": [30, 40], "col_b": ["new_c", "new_d"], "col_c": [2, 2]}
)
_write_to_iceberg(
new_data, mode=SaveMode.OVERWRITE, overwrite_filter=col("col_c") == 2
)
# Verify: rows with col_c == 2 should be replaced, others unchanged
result = _read_from_iceberg(sort_by="col_a")
# Expected: rows with col_c in {1, 3} unchanged, col_c == 2 rows replaced
expected = _create_typed_dataframe(
{
"col_a": [1, 2, 5, 6, 30, 40],
"col_b": ["a", "b", "e", "f", "new_c", "new_d"],
"col_c": [1, 1, 3, 3, 2, 2],
}
)
assert rows_same(result, expected)
def test_write_overwrite_with_complex_filter(self, clean_table):
"""Test partial overwrite with a complex filter expression."""
from ray.data import SaveMode
# Write initial data
initial_data = _create_typed_dataframe(
{
"col_a": [1, 2, 3, 4, 5],
"col_b": ["a", "b", "c", "d", "e"],
"col_c": [1, 2, 3, 4, 5],
}
)
_write_to_iceberg(initial_data)
# Overwrite rows where col_c >= 3 AND col_c <= 4
new_data = _create_typed_dataframe(
{"col_a": [30, 40], "col_b": ["new_c", "new_d"], "col_c": [3, 4]}
)
_write_to_iceberg(
new_data,
mode=SaveMode.OVERWRITE,
overwrite_filter=(col("col_c") >= 3) & (col("col_c") <= 4),
)
result = _read_from_iceberg(sort_by="col_a")
expected = _create_typed_dataframe(
{
"col_a": [1, 2, 5, 30, 40],
"col_b": ["a", "b", "e", "new_c", "new_d"],
"col_c": [1, 2, 5, 3, 4],
}
)
assert rows_same(result, expected)
def test_write_overwrite_empty_result(self, clean_table):
"""Test overwrite with filter that matches no rows just appends."""
from ray.data import SaveMode
# Write initial data
initial_data = _create_typed_dataframe(
{"col_a": [1, 2, 3], "col_b": ["a", "b", "c"], "col_c": [1, 2, 3]}
)
_write_to_iceberg(initial_data)
# Overwrite with filter that matches nothing
new_data = _create_typed_dataframe(
{"col_a": [100], "col_b": ["new"], "col_c": [999]}
)
_write_to_iceberg(
new_data, mode=SaveMode.OVERWRITE, overwrite_filter=col("col_c") == 999
)
# Original data should remain plus new data
result = _read_from_iceberg(sort_by="col_a")
expected = _create_typed_dataframe(
{
"col_a": [1, 2, 3, 100],
"col_b": ["a", "b", "c", "new"],
"col_c": [1, 2, 3, 999],
}
)
assert rows_same(result, expected)
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("14.0.0"),
reason="PyIceberg 0.7.0 fails on pyarrow <= 14.0.0",
)
class TestUpsertMode:
"""Test upsert mode functionality."""
def test_write_upsert_basic(self, clean_table):
"""Test basic upsert with explicit join columns."""
from ray.data import SaveMode
# Write initial data
initial_data = _create_typed_dataframe(
{"col_a": [1, 2, 3], "col_b": ["a", "b", "c"], "col_c": [10, 20, 30]}
)
_write_to_iceberg(initial_data)
# Upsert: update col_a=2, insert col_a=4
upsert_data = _create_typed_dataframe(
{"col_a": [2, 4], "col_b": ["updated_b", "new_d"], "col_c": [25, 40]}
)
_write_to_iceberg(
upsert_data, mode=SaveMode.UPSERT, upsert_kwargs={"join_cols": ["col_a"]}
)
result = _read_from_iceberg(sort_by="col_a")
expected = _create_typed_dataframe(
{
"col_a": [1, 2, 3, 4],
"col_b": ["a", "updated_b", "c", "new_d"],
"col_c": [10, 25, 30, 40],
}
)
assert rows_same(result, expected)
def test_write_upsert_multiple_join_cols(self, clean_table):
"""Test upsert with multiple join columns."""
from ray.data import SaveMode
# Write initial data
initial_data = _create_typed_dataframe(
{
"col_a": [1, 1, 2, 2],
"col_b": ["a", "b", "a", "b"],
"col_c": [10, 20, 30, 40],
}
)
_write_to_iceberg(initial_data)
# Upsert using (col_a, col_b) as composite key
upsert_data = _create_typed_dataframe(
{
"col_a": [1, 2, 3],
"col_b": ["b", "a", "c"],
"col_c": [25, 35, 50], # Update (1,b) and (2,a), insert (3,c)
}
)
_write_to_iceberg(
upsert_data,
mode=SaveMode.UPSERT,
upsert_kwargs={"join_cols": ["col_a", "col_b"]},
)
result = _read_from_iceberg(sort_by=["col_a", "col_b"])
expected = _create_typed_dataframe(
{
"col_a": [1, 1, 2, 2, 3],
"col_b": ["a", "b", "a", "b", "c"],
"col_c": [10, 25, 35, 40, 50],
}
)
assert rows_same(result, expected)
def test_write_upsert_all_updates(self, clean_table):
"""Test upsert where all rows are updates (no inserts)."""
from ray.data import SaveMode
# Write initial data
initial_data = _create_typed_dataframe(
{"col_a": [1, 2, 3], "col_b": ["a", "b", "c"], "col_c": [10, 20, 30]}
)
_write_to_iceberg(initial_data)
# Upsert all existing rows
upsert_data = _create_typed_dataframe(
{
"col_a": [1, 2, 3],
"col_b": ["updated_a", "updated_b", "updated_c"],
"col_c": [100, 200, 300],
}
)
_write_to_iceberg(
upsert_data, mode=SaveMode.UPSERT, upsert_kwargs={"join_cols": ["col_a"]}
)
result = _read_from_iceberg(sort_by="col_a")
expected = _create_typed_dataframe(
{
"col_a": [1, 2, 3],
"col_b": ["updated_a", "updated_b", "updated_c"],
"col_c": [100, 200, 300],
}
)
assert rows_same(result, expected)
def test_write_upsert_all_inserts(self, clean_table):
"""Test upsert where all rows are inserts (no updates)."""
from ray.data import SaveMode
# Write initial data
initial_data = _create_typed_dataframe(
{"col_a": [1, 2, 3], "col_b": ["a", "b", "c"], "col_c": [10, 20, 30]}
)
_write_to_iceberg(initial_data)
# Upsert all new rows
upsert_data = _create_typed_dataframe(
{"col_a": [4, 5, 6], "col_b": ["d", "e", "f"], "col_c": [40, 50, 60]}
)
_write_to_iceberg(
upsert_data, mode=SaveMode.UPSERT, upsert_kwargs={"join_cols": ["col_a"]}
)
result = _read_from_iceberg(sort_by="col_a")
expected = _create_typed_dataframe(
{
"col_a": [1, 2, 3, 4, 5, 6],
"col_b": ["a", "b", "c", "d", "e", "f"],
"col_c": [10, 20, 30, 40, 50, 60],
}
)
assert rows_same(result, expected)
def test_write_upsert_empty_table(self, clean_table):
"""Test upsert into empty table is equivalent to insert."""
from ray.data import SaveMode
# Table is already empty from fixture
# Upsert data into empty table
upsert_data = _create_typed_dataframe(
{"col_a": [1, 2, 3], "col_b": ["a", "b", "c"], "col_c": [10, 20, 30]}
)
_write_to_iceberg(
upsert_data, mode=SaveMode.UPSERT, upsert_kwargs={"join_cols": ["col_a"]}
)
result = _read_from_iceberg(sort_by="col_a")
expected = _create_typed_dataframe(
{"col_a": [1, 2, 3], "col_b": ["a", "b", "c"], "col_c": [10, 20, 30]}
)
assert rows_same(result, expected)
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("14.0.0"),
reason="PyIceberg 0.7.0 fails on pyarrow <= 14.0.0",
)
class TestUpsertScanMerge:
"""Test the scan-merge upsert algorithm for correctness.
See ``IcebergDatasink._commit_upsert_scan_merge`` for algorithm details.
"""
def test_upsert_preserves_rows_sparse_keys(self, clean_table):
"""Sparse upsert keys leave intermediate rows that must be preserved
after the rewrite."""
from ray.data import SaveMode
seed = _create_typed_dataframe(
{
"col_a": list(range(1, 11)),
"col_b": [f"seed_{i}" for i in range(1, 11)],
"col_c": [1] * 10,
}
)
_write_to_iceberg(seed)
upsert_data = _create_typed_dataframe(
{
"col_a": [1, 10],
"col_b": ["updated_1", "updated_10"],
"col_c": [1, 1],
}
)
_write_to_iceberg(
upsert_data,
mode=SaveMode.UPSERT,
upsert_kwargs={"join_cols": ["col_a"]},
)
result = _read_from_iceberg(sort_by="col_a")
expected = _create_typed_dataframe(
{
"col_a": list(range(1, 11)),
"col_b": ["updated_1"]
+ [f"seed_{i}" for i in range(2, 10)]
+ ["updated_10"],
"col_c": [1] * 10,
}
)
assert rows_same(result, expected)
def test_upsert_across_multiple_files(self, clean_table):
"""Two separate seed writes produce at least two data files. A sparse
upsert that spans both files must preserve non-upsert rows in each."""
from ray.data import SaveMode
_write_to_iceberg(
_create_typed_dataframe(
{
"col_a": [1, 2, 3],
"col_b": ["seed_1", "seed_2", "seed_3"],
"col_c": [1, 1, 1],
}
)
)
_write_to_iceberg(
_create_typed_dataframe(
{
"col_a": [10, 11, 12],
"col_b": ["seed_10", "seed_11", "seed_12"],
"col_c": [1, 1, 1],
}
)
)
upsert_data = _create_typed_dataframe(
{
"col_a": [1, 12],
"col_b": ["updated_1", "updated_12"],
"col_c": [1, 1],
}
)
_write_to_iceberg(
upsert_data,
mode=SaveMode.UPSERT,
upsert_kwargs={"join_cols": ["col_a"]},
)
result = _read_from_iceberg(sort_by="col_a")
expected = _create_typed_dataframe(
{
"col_a": [1, 2, 3, 10, 11, 12],
"col_b": [
"updated_1",
"seed_2",
"seed_3",
"seed_10",
"seed_11",
"updated_12",
],
"col_c": [1] * 6,
}
)
assert rows_same(result, expected)
def test_upsert_whole_file_delete_when_all_keys_match(self, clean_table):
"""When every seed row in the coarse range is in the upsert batch, the
original file is wholly deleted and only upsert rows remain — no
duplicates."""
from ray.data import SaveMode
seed = _create_typed_dataframe(
{
"col_a": [1, 2, 3, 4, 5],
"col_b": ["seed_1", "seed_2", "seed_3", "seed_4", "seed_5"],
"col_c": [1] * 5,
}
)
_write_to_iceberg(seed)
upsert_data = _create_typed_dataframe(
{
"col_a": [1, 2, 3, 4, 5],
"col_b": [f"updated_{i}" for i in range(1, 6)],
"col_c": [1] * 5,
}
)
_write_to_iceberg(
upsert_data,
mode=SaveMode.UPSERT,
upsert_kwargs={"join_cols": ["col_a"]},
)
result = _read_from_iceberg(sort_by="col_a")
expected = _create_typed_dataframe(
{
"col_a": [1, 2, 3, 4, 5],
"col_b": [f"updated_{i}" for i in range(1, 6)],
"col_c": [1] * 5,
}
)
assert rows_same(result, expected)
def test_upsert_pure_insert_short_circuit(self, clean_table):
"""Upsert keys outside the seed's coarse range hit zero candidate
files; the new rows must still be appended."""
from ray.data import SaveMode
seed = _create_typed_dataframe(
{
"col_a": [1, 2, 3],
"col_b": ["seed_1", "seed_2", "seed_3"],
"col_c": [1, 1, 1],
}
)
_write_to_iceberg(seed)
upsert_data = _create_typed_dataframe(
{
"col_a": [100, 101],
"col_b": ["new_100", "new_101"],
"col_c": [1, 1],
}
)
_write_to_iceberg(
upsert_data,
mode=SaveMode.UPSERT,
upsert_kwargs={"join_cols": ["col_a"]},
)
result = _read_from_iceberg(sort_by="col_a")
expected = _create_typed_dataframe(
{
"col_a": [1, 2, 3, 100, 101],
"col_b": ["seed_1", "seed_2", "seed_3", "new_100", "new_101"],
"col_c": [1] * 5,
}
)
assert rows_same(result, expected)
def test_upsert_composite_key_preserves_rows(self, clean_table):
"""Composite-key anti-join must match on all join columns; rows that
share one column with an upsert key but not the full composite must be
preserved."""
from ray.data import SaveMode
composites = [(a, b) for a in [1, 2, 3] for b in ["x", "y", "z"]]
seed = _create_typed_dataframe(
{
"col_a": [a for a, _ in composites],
"col_b": [b for _, b in composites],
"col_c": [1] * len(composites),
}
)
_write_to_iceberg(seed)
upsert_data = _create_typed_dataframe(
{
"col_a": [1, 3],
"col_b": ["x", "z"],
"col_c": [99, 99],
}
)
_write_to_iceberg(
upsert_data,
mode=SaveMode.UPSERT,
upsert_kwargs={"join_cols": ["col_a", "col_b"]},
)
result = _read_from_iceberg(sort_by=["col_a", "col_b"])
expected_col_c = [
99 if (a, b) in {(1, "x"), (3, "z")} else 1 for a, b in sorted(composites)
]
expected = _create_typed_dataframe(
{
"col_a": [a for a, _ in sorted(composites)],
"col_b": [b for _, b in sorted(composites)],
"col_c": expected_col_c,
}
)
assert rows_same(result, expected)
def test_upsert_string_key(self, clean_table):
"""String join column exercises the type-cast path in
_rewrite_iceberg_file that aligns utf8 / large_utf8 between the file
batch and the upsert-keys table."""
from ray.data import SaveMode
seed = _create_typed_dataframe(
{
"col_a": [1, 2, 3, 4, 5],
"col_b": ["a", "b", "c", "d", "e"],
"col_c": [1] * 5,
}
)
_write_to_iceberg(seed)
upsert_data = _create_typed_dataframe(
{
"col_a": [10, 20],
"col_b": ["a", "e"],
"col_c": [1, 1],
}
)
_write_to_iceberg(
upsert_data,
mode=SaveMode.UPSERT,
upsert_kwargs={"join_cols": ["col_b"]},
)
result = _read_from_iceberg(sort_by="col_b")
expected = _create_typed_dataframe(
{
"col_a": [10, 2, 3, 4, 20],
"col_b": ["a", "b", "c", "d", "e"],
"col_c": [1] * 5,
}
)
assert rows_same(result, expected)
def test_upsert_case_insensitive_join_cols(self, clean_table):
"""``case_sensitive=False`` should let join_cols match table columns
whose casing differs from the supplied names."""
from ray.data import SaveMode
seed = _create_typed_dataframe(
{
"col_a": [1, 2, 3, 4, 5],
"col_b": ["seed_1", "seed_2", "seed_3", "seed_4", "seed_5"],
"col_c": [1] * 5,
}
)
_write_to_iceberg(seed)
upsert_data = _create_typed_dataframe(
{
"col_a": [1, 5, 6],
"col_b": ["updated_1", "updated_5", "new_6"],
"col_c": [1, 1, 1],
}
)
_write_to_iceberg(
upsert_data,
mode=SaveMode.UPSERT,
upsert_kwargs={"join_cols": ["COL_A"], "case_sensitive": False},
)
result = _read_from_iceberg(sort_by="col_a")
expected = _create_typed_dataframe(
{
"col_a": [1, 2, 3, 4, 5, 6],
"col_b": [
"updated_1",
"seed_2",
"seed_3",
"seed_4",
"updated_5",
"new_6",
],
"col_c": [1] * 6,
}
)
assert rows_same(result, expected)
def test_upsert_with_new_column(self, clean_table):
"""Upsert that introduces a new column must evolve the table schema,
populate the new column for upserted rows, and leave NULLs for
untouched seed rows (including preserved rows rewritten during upsert)."""
from ray.data import SaveMode
seed = _create_typed_dataframe(
{
"col_a": list(range(1, 6)),
"col_b": [f"seed_{i}" for i in range(1, 6)],
"col_c": [1] * 5,
}
)
_write_to_iceberg(seed)
# Upsert touches col_a=1 and col_a=5 (preserved rows at 2, 3, 4 in the
# same file) and introduces a new column ``col_d``.
upsert_data = _create_typed_dataframe(
{
"col_a": [1, 5, 6],
"col_b": ["updated_1", "updated_5", "new_6"],
"col_c": [1, 1, 1],
"col_d": ["d_1", "d_5", "d_6"],
}
)
_write_to_iceberg(
upsert_data,
mode=SaveMode.UPSERT,
upsert_kwargs={"join_cols": ["col_a"]},
)
_verify_schema(
{
"col_a": pyi_types.IntegerType,
"col_b": pyi_types.StringType,
"col_c": pyi_types.IntegerType,
"col_d": pyi_types.StringType,
}
)
result = _read_from_iceberg(sort_by="col_a")
expected = _create_typed_dataframe(
{
"col_a": [1, 2, 3, 4, 5, 6],
"col_b": [
"updated_1",
"seed_2",
"seed_3",
"seed_4",
"updated_5",
"new_6",
],
"col_c": [1] * 6,
"col_d": ["d_1", None, None, None, "d_5", "d_6"],
}
)
assert rows_same(result, expected)
@pytest.fixture
def table_with_identifier_fields() -> Generator[Tuple[Catalog, Table], None, None]:
"""Pytest fixture to create a table with identifier fields for upsert tests."""
sql_catalog = pyi_catalog.load_catalog(**_CATALOG_KWARGS)
# Drop existing table if it exists
identifier_table_name = "identifier_test"
if (_DB_NAME, identifier_table_name) in sql_catalog.list_tables(_DB_NAME):
sql_catalog.drop_table(f"{_DB_NAME}.{identifier_table_name}")
# Create table with identifier fields (col_a is the key)
table = sql_catalog.create_table(
f"{_DB_NAME}.{identifier_table_name}",
schema=pyi_schema.Schema(
pyi_types.NestedField(
field_id=1,
name="col_a",
field_type=pyi_types.IntegerType(),
required=True,
),
pyi_types.NestedField(
field_id=2,
name="col_b",
field_type=pyi_types.StringType(),
required=False,
),
pyi_types.NestedField(
field_id=3,
name="col_c",
field_type=pyi_types.IntegerType(),
required=False,
),
identifier_field_ids=[1], # col_a is the identifier field
),
)
yield sql_catalog, table, identifier_table_name
# Cleanup
if (_DB_NAME, identifier_table_name) in sql_catalog.list_tables(_DB_NAME):
sql_catalog.drop_table(f"{_DB_NAME}.{identifier_table_name}")
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("14.0.0"),
reason="PyIceberg 0.7.0 fails on pyarrow <= 14.0.0",
)
class TestUpsertWithIdentifierFields:
"""Test upsert using table's identifier fields."""
def test_upsert_uses_table_identifier_fields(self, table_with_identifier_fields):
"""Test upsert without explicit join_cols uses table's identifier fields."""
from ray.data import SaveMode
sql_catalog, table, table_name = table_with_identifier_fields
table_identifier = f"{_DB_NAME}.{table_name}"
# Write initial data
initial_data = _create_typed_dataframe(
{"col_a": [1, 2, 3], "col_b": ["a", "b", "c"], "col_c": [10, 20, 30]}
)
ds = ray.data.from_pandas(initial_data)
ds.write_iceberg(
table_identifier=table_identifier,
catalog_kwargs=_CATALOG_KWARGS.copy(),
)
# Upsert without join_cols - should use identifier fields from table schema
upsert_data = _create_typed_dataframe(
{"col_a": [2, 4], "col_b": ["updated_b", "new_d"], "col_c": [25, 40]}
)
ds_upsert = ray.data.from_pandas(upsert_data)
ds_upsert.write_iceberg(
table_identifier=table_identifier,
catalog_kwargs=_CATALOG_KWARGS.copy(),
mode=SaveMode.UPSERT,
)
# Read back and verify
result = ray.data.read_iceberg(
table_identifier=table_identifier,
catalog_kwargs=_CATALOG_KWARGS.copy(),
).to_pandas()
result = result.sort_values("col_a").reset_index(drop=True)
expected = _create_typed_dataframe(
{
"col_a": [1, 2, 3, 4],
"col_b": ["a", "updated_b", "c", "new_d"],
"col_c": [10, 25, 30, 40],
}
)
assert rows_same(result, expected)
def test_upsert_explicit_join_cols_override_identifier_fields(
self, table_with_identifier_fields
):
"""Test that explicit join_cols override table's identifier fields."""
from ray.data import SaveMode
sql_catalog, table, table_name = table_with_identifier_fields
table_identifier = f"{_DB_NAME}.{table_name}"
# Write initial data
initial_data = _create_typed_dataframe(
{"col_a": [1, 2, 3], "col_b": ["a", "b", "c"], "col_c": [10, 20, 30]}
)
ds = ray.data.from_pandas(initial_data)
ds.write_iceberg(
table_identifier=table_identifier,
catalog_kwargs=_CATALOG_KWARGS.copy(),
)
# Upsert with explicit join_cols on col_b instead of table's col_a identifier
upsert_data = _create_typed_dataframe(
{"col_a": [100, 200], "col_b": ["b", "d"], "col_c": [25, 40]}
)
ds_upsert = ray.data.from_pandas(upsert_data)
ds_upsert.write_iceberg(
table_identifier=table_identifier,
catalog_kwargs=_CATALOG_KWARGS.copy(),
mode=SaveMode.UPSERT,
upsert_kwargs={"join_cols": ["col_b"]},
)
# Read back and verify - join on col_b means row with col_b="b" was updated
result = ray.data.read_iceberg(
table_identifier=table_identifier,
catalog_kwargs=_CATALOG_KWARGS.copy(),
).to_pandas()
result = result.sort_values("col_a").reset_index(drop=True)
expected = _create_typed_dataframe(
{
"col_a": [1, 3, 100, 200],
"col_b": ["a", "c", "b", "d"],
"col_c": [10, 30, 25, 40],
}
)
assert rows_same(result, expected)
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("14.0.0"),
reason="PyIceberg 0.7.0 fails on pyarrow <= 14.0.0",
)
class TestUpsertAndOverwriteMissingJoinCols:
"""Test error handling for upsert without join columns."""
def test_upsert_without_join_cols_or_identifier_fields_raises(self, clean_table):
"""Test that upsert without join_cols on table without identifier fields raises."""
from ray.data import SaveMode
# Write some initial data
initial_data = _create_typed_dataframe(
{"col_a": [1, 2], "col_b": ["a", "b"], "col_c": [10, 20]}
)
_write_to_iceberg(initial_data)
# Upsert without join_cols should raise since table has no identifier fields
upsert_data = _create_typed_dataframe(
{"col_a": [2, 3], "col_b": ["updated_b", "c"], "col_c": [25, 30]}
)
with pytest.raises(ValueError, match="join_cols"):
_write_to_iceberg(upsert_data, mode=SaveMode.UPSERT)
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("14.0.0"),
reason="PyIceberg 0.7.0 fails on pyarrow <= 14.0.0",
)
class TestSchemaEvolutionWithModes:
"""Test schema evolution with different write modes."""
def test_schema_evolution_with_overwrite(self, clean_table):
"""Test schema evolution works with overwrite mode."""
from ray.data import SaveMode
# Write initial data
initial_data = _create_typed_dataframe(
{"col_a": [1, 2], "col_b": ["a", "b"], "col_c": [10, 20]}
)
_write_to_iceberg(initial_data)
# Overwrite with data that has a new column
new_data = _create_typed_dataframe(
{"col_a": [3, 4], "col_b": ["c", "d"], "col_c": [30, 40]}
)
new_data["col_d"] = ["extra_3", "extra_4"]
_write_to_iceberg(new_data, mode=SaveMode.OVERWRITE)
# Verify schema evolved
_verify_schema(
{
"col_a": pyi_types.IntegerType,
"col_b": pyi_types.StringType,
"col_c": pyi_types.IntegerType,
"col_d": pyi_types.StringType,
}
)
result = _read_from_iceberg(sort_by="col_a")
expected = _create_typed_dataframe(
{"col_a": [3, 4], "col_b": ["c", "d"], "col_c": [30, 40]}
)
expected["col_d"] = ["extra_3", "extra_4"]
assert rows_same(result, expected)
def test_schema_evolution_with_upsert(self, clean_table):
"""Test schema evolution works with upsert mode."""
from ray.data import SaveMode
# Write initial data
initial_data = _create_typed_dataframe(
{"col_a": [1, 2], "col_b": ["a", "b"], "col_c": [10, 20]}
)
_write_to_iceberg(initial_data)
# Upsert with data that has a new column
upsert_data = _create_typed_dataframe(
{"col_a": [2, 3], "col_b": ["updated_b", "c"], "col_c": [25, 30]}
)
upsert_data["col_d"] = ["extra_2", "extra_3"]
_write_to_iceberg(
upsert_data, mode=SaveMode.UPSERT, upsert_kwargs={"join_cols": ["col_a"]}
)
# Verify schema evolved
_verify_schema(
{
"col_a": pyi_types.IntegerType,
"col_b": pyi_types.StringType,
"col_c": pyi_types.IntegerType,
"col_d": pyi_types.StringType,
}
)
result = _read_from_iceberg(sort_by="col_a")
expected = _create_typed_dataframe(
{
"col_a": [1, 2, 3],
"col_b": ["a", "updated_b", "c"],
"col_c": [10, 25, 30],
"col_d": [None, "extra_2", "extra_3"],
}
)
assert rows_same(result, expected)
def test_schema_evolution_with_partial_overwrite(self, clean_table):
"""Test schema evolution works with partial overwrite."""
from ray.data import SaveMode
# Write initial data
initial_data = _create_typed_dataframe(
{
"col_a": [1, 2, 3, 4],
"col_b": ["a", "b", "c", "d"],
"col_c": [1, 1, 2, 2],
}
)
_write_to_iceberg(initial_data)
# Partial overwrite with new column for col_c == 2
overwrite_data = _create_typed_dataframe(
{"col_a": [30, 40], "col_b": ["new_c", "new_d"], "col_c": [2, 2]}
)
overwrite_data["col_d"] = ["extra_c", "extra_d"]
_write_to_iceberg(
overwrite_data,
mode=SaveMode.OVERWRITE,
overwrite_filter=col("col_c") == 2,
)
# Verify schema evolved
_verify_schema(
{
"col_a": pyi_types.IntegerType,
"col_b": pyi_types.StringType,
"col_c": pyi_types.IntegerType,
"col_d": pyi_types.StringType,
}
)
result = _read_from_iceberg(sort_by="col_a")
expected = _create_typed_dataframe(
{
"col_a": [1, 2, 30, 40],
"col_b": ["a", "b", "new_c", "new_d"],
"col_c": [1, 1, 2, 2],
"col_d": [None, None, "extra_c", "extra_d"],
}
)
assert rows_same(result, expected)
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("14.0.0"),
reason="PyIceberg 0.7.0 fails on pyarrow <= 14.0.0",
)
def test_write_retry_on_transient_error(pyiceberg_table, fast_retry_config):
"""Test that transient errors during file writes trigger retries."""
from unittest.mock import patch
from ray.data._internal.datasource.iceberg_datasink import IcebergDatasink
from ray.data._internal.execution.interfaces import TaskContext
# Create datasink and initialize it
datasink = IcebergDatasink(
table_identifier=f"{_DB_NAME}.{_TABLE_NAME}",
catalog_kwargs=_CATALOG_KWARGS.copy(),
)
datasink.on_write_start()
# Track call count to simulate transient failures
call_count = {"count": 0}
# Import original function before patching
from pyiceberg.io.pyarrow import _dataframe_to_data_files
original_func = _dataframe_to_data_files
def flaky_dataframe_to_data_files(*args, **kwargs):
call_count["count"] += 1
if call_count["count"] <= 2:
# Fail first 2 attempts with a retryable error
raise IOError("TestTransientError: simulated transient failure")
# Succeed on 3rd attempt
return original_func(*args, **kwargs)
# Create test data
data = pa.Table.from_pydict(
{"col_a": [200, 201], "col_b": ["x", "y"], "col_c": [5, 5]},
schema=_SCHEMA,
)
# Patch at pyiceberg module level and call write directly
with patch(
"pyiceberg.io.pyarrow._dataframe_to_data_files",
side_effect=flaky_dataframe_to_data_files,
):
# Call write directly (bypassing Ray workers)
task_ctx = TaskContext(task_idx=0, op_name="Write")
result = datasink.write([data], task_ctx)
# Verify retries occurred (called 3 times: 2 failures + 1 success)
assert (
call_count["count"] == 3
), f"Expected 3 calls (2 retries + 1 success), got {call_count['count']}"
# Verify write result has data files
assert len(result.data_files) > 0, "Expected data files in result"
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))