blob: e5472cac25760042776f4817f749c03ebff4426d [file]
################################################################################
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
################################################################################
"""Correctness tests for distributed Daft writes to primary-key tables."""
from __future__ import annotations
import importlib.util
import os
import subprocess
import sys
import textwrap
from unittest.mock import Mock
import pyarrow as pa
import pytest
pypaimon = pytest.importorskip("pypaimon")
daft = pytest.importorskip("daft")
from daft.recordbatch.micropartition import MicroPartition
from pypaimon.execution.python.read.table_scan import PythonTableScan
from pypaimon.daft import daft_datasink
from pypaimon.daft.daft_paimon import _read_table, _write_table
from pypaimon.daft.daft_datasink import PaimonDataSink
from pypaimon.index.index_file_handler import IndexFileHandler
@pytest.fixture
def catalog(tmp_path):
result = pypaimon.CatalogFactory.create({"warehouse": str(tmp_path)})
result.create_database("test_db", ignore_if_exists=True)
return result
def _create_pk_table(catalog, name, *, partitioned=False, options=None):
fields = [
pa.field("id", pa.int64()),
pa.field("value", pa.string()),
]
partition_keys = []
primary_keys = ["id"]
if partitioned:
fields.append(pa.field("dt", pa.string()))
partition_keys = ["dt"]
primary_keys.append("dt")
schema = pypaimon.Schema.from_pyarrow_schema(
pa.schema(fields),
partition_keys=partition_keys,
primary_keys=primary_keys,
options={"file.format": "parquet", **(options or {})},
)
identifier = f"test_db.{name}"
catalog.create_table(identifier, schema, ignore_if_exists=False)
return catalog.get_table(identifier)
def test_hash_fixed_pk_uses_one_writer_per_partition_bucket(catalog):
table = _create_pk_table(
catalog,
"fixed_pk",
partitioned=True,
options={"bucket": "1"},
)
data = daft.from_pydict(
{
"id": [1, 1],
"value": ["old", "new"],
"dt": ["2026-07-20", "2026-07-20"],
}
).into_partitions(2)
summary = _write_table(data, table).to_pydict()
# One complete (partition, bucket) group must be consumed by one
# BatchTableWrite. Two independent writers would produce two files with
# overlapping sequence-number ranges for this primary key.
assert len(summary["file_name"]) == 1
assert sum(summary["rows"]) == 1
def test_bare_datasink_supports_single_partition_primary_key_write(catalog):
table = _create_pk_table(
catalog,
"bare_sink_pk",
options={"bucket": "1"},
)
summary = daft.from_pydict(
{"id": [1, 2], "value": ["a", "b"]}
).write_sink(PaimonDataSink(table)).to_pydict()
assert sum(summary["rows"]) == 2
assert _read_table(table).sort("id").to_pydict() == {
"id": [1, 2],
"value": ["a", "b"],
}
def test_bare_datasink_rejects_multiple_primary_key_write_tasks(catalog):
table = _create_pk_table(
catalog,
"bare_sink_distributed_pk",
options={"bucket": "1"},
)
sink = PaimonDataSink(table)
results = []
for value in (1, 2):
micropartition = MicroPartition.from_arrow(
pa.table({"id": [value], "value": [f"v-{value}"]})
)
results.extend(sink.write(iter([micropartition])))
staged_paths = [
str(data_file.external_path or data_file.file_path)
for result in results
for message in result.result
for data_file in message.new_files
]
assert staged_paths
with pytest.raises(
ValueError, match="require a single non-empty Daft write task"
):
sink.finalize(results)
assert table.snapshot_manager().get_latest_snapshot() is None
assert all(table.file_io.exists(path) for path in staged_paths)
def test_bare_datasink_supports_single_task_dynamic_bucket(catalog):
table = _create_pk_table(
catalog,
"bare_sink_dynamic_pk",
options={"bucket": "-1"},
)
sink = PaimonDataSink(table)
micropartition = MicroPartition.from_arrow(
pa.table({"id": [1], "value": ["v-1"]})
)
results = list(sink.write(iter([micropartition])))
summary = sink.finalize(results).to_pydict()
assert sum(summary["rows"]) == 1
assert _read_table(table).to_pydict() == {
"id": [1],
"value": ["v-1"],
}
def test_bare_datasink_rejects_multi_task_dynamic_bucket(catalog):
table = _create_pk_table(
catalog,
"bare_sink_distributed_dynamic_pk",
options={"bucket": "-1"},
)
sink = PaimonDataSink(table)
results = []
for value in (1, 2):
micropartition = MicroPartition.from_arrow(
pa.table({"id": [value], "value": [f"v-{value}"]})
)
results.extend(sink.write(iter([micropartition])))
with pytest.raises(
ValueError, match="require a single non-empty Daft write task"
):
sink.finalize(results)
assert table.snapshot_manager().get_latest_snapshot() is None
def test_commit_exception_does_not_abort_possibly_committed_files(catalog):
table = _create_pk_table(
catalog,
"uncertain_commit_pk",
options={"bucket": "1"},
)
sink = PaimonDataSink(table)
micropartition = MicroPartition.from_arrow(
pa.table({"id": [1], "value": ["committed"]})
)
write_results = list(sink.write(iter([micropartition])))
real_commit = sink._write_builder.new_commit()
def commit_then_raise(messages):
real_commit.commit(messages)
raise RuntimeError("callback failed after snapshot commit")
uncertain_commit = Mock()
uncertain_commit.commit.side_effect = commit_then_raise
uncertain_commit.close.side_effect = real_commit.close
sink._write_builder.new_commit = Mock(return_value=uncertain_commit)
with pytest.raises(RuntimeError, match="callback failed"):
sink.finalize(write_results)
uncertain_commit.abort.assert_not_called()
uncertain_commit.close.assert_called_once_with()
assert _read_table(table).to_pydict() == {
"id": [1],
"value": ["committed"],
}
def test_postpone_mode_primary_key_write_preserves_existing_path(catalog):
table = _create_pk_table(
catalog,
"postpone_pk",
options={"bucket": "-2"},
)
summary = _write_table(
daft.from_pydict(
{"id": [1, 2], "value": ["a", "b"]}
).into_partitions(2),
table,
).to_pydict()
assert sum(summary["rows"]) == 2
assert table.snapshot_manager().get_latest_snapshot() is not None
scanner = PythonTableScan(table.new_read_builder().new_scan())._file_scanner()
manifests, _ = scanner.manifest_scanner()
entries = scanner.manifest_file_manager.read_entries_parallel(manifests)
assert {entry.bucket for entry in entries} == {-2}
def test_group_udfs_reuse_worker_table_metadata(catalog, monkeypatch):
table = _create_pk_table(
catalog,
"worker_table_cache_pk",
options={"bucket": "1"},
)
catalog_options = daft_datasink._extract_catalog_options(table)
identifier = daft_datasink._extract_identifier(table)
schema_token = (
table.table_schema.id,
table.table_schema.time_millis,
)
loader = Mock(wraps=daft_datasink._load_table)
monkeypatch.setattr(daft_datasink, "_load_table", loader)
daft_datasink._WORKER_TABLE_CACHE.clear()
first = daft_datasink._load_worker_table(
catalog_options, identifier, table.table_path, schema_token
)
second = daft_datasink._load_worker_table(
catalog_options, identifier, table.table_path, schema_token
)
assert first is second
loader.assert_called_once_with(
catalog_options, identifier, table.table_path
)
daft_datasink._WORKER_TABLE_CACHE.clear()
def test_hash_dynamic_pk_restores_bucket_mapping_across_commits(catalog):
table = _create_pk_table(
catalog,
"dynamic_pk",
options={
"bucket": "-1",
"dynamic-bucket.target-row-num": "1",
},
)
_write_table(
daft.from_pydict({"id": [1], "value": ["old"]}),
table,
)
_write_table(
daft.from_pydict(
{
# A fresh task-local SimpleDynamicBucketAssigner puts id=2 in
# bucket 0 and incorrectly moves the existing id=1 to bucket 1.
"id": [2, 1],
"value": ["other", "new"],
}
),
table,
)
result = _read_table(table).sort("id").to_pydict()
assert result == {"id": [1, 2], "value": ["new", "other"]}
snapshot = table.snapshot_manager().get_latest_snapshot()
hash_indexes = [
entry
for entry in IndexFileHandler(table).scan(snapshot)
if entry.index_file.index_type == "HASH"
]
assert hash_indexes
assert len({(tuple(e.partition.values), e.bucket) for e in hash_indexes}) == len(
hash_indexes
)
@pytest.mark.native_plan
@pytest.mark.parametrize('bucket', ['4', '-1'])
@pytest.mark.parametrize('partitioned', [False, True])
@pytest.mark.parametrize('overwrite', [False, True])
def test_grouped_pk_writes_use_native_without_python_fallback(
catalog, monkeypatch, bucket, partitioned, overwrite):
from pypaimon.write.write_dispatch import NativeTableWrite
def reject_fallback(self):
raise AssertionError('Grouped Daft write fell back to Python')
monkeypatch.setattr(NativeTableWrite, '_switch_to_python', reject_fallback)
table = _create_pk_table(catalog, 'native_grouped', partitioned=partitioned, options={
'bucket': bucket, 'dynamic-bucket.target-row-num': '1',
'write.native.enabled': 'true', 'read.native.enabled': 'true',
})
initial = {'id': [1, 2], 'value': ['old', 'keep']}
updated = {'id': [1, 3], 'value': ['new', 'added']}
if partitioned:
initial['dt'] = ['a', 'b']
updated['dt'] = ['a', 'c']
_write_table(daft.from_pydict(initial).into_partitions(2), table).to_pydict()
_write_table(daft.from_pydict(updated).into_partitions(2), table,
mode='overwrite' if overwrite else 'append').to_pydict()
# Partitioned tables overwrite only the incoming partitions by default.
expected = updated if overwrite and not partitioned else {
'id': [1, 2, 3], 'value': ['new', 'keep', 'added']}
if partitioned:
expected['dt'] = ['a', 'b', 'c']
assert _read_table(table).sort('id').to_pydict() == expected
if bucket == '-1':
indexes = [entry for entry in IndexFileHandler(table).scan(
table.snapshot_manager().get_latest_snapshot())
if entry.index_file.index_type == 'HASH']
assert sum(entry.index_file.row_count for entry in indexes) == len(expected['id'])
assert len({(tuple(entry.partition.values), entry.bucket) for entry in indexes}) == len(indexes)
@pytest.mark.parametrize('native', [False, pytest.param(True, marks=pytest.mark.native_plan)])
@pytest.mark.parametrize('ignore_option,kind', [
('ignore-delete', '-D'), ('ignore-update-before', '-U')])
def test_grouped_dynamic_row_kind_filter_keeps_surviving_key_indexed(catalog, native, ignore_option, kind):
options = {'bucket': '-1', 'file.format': 'parquet', 'rowkind.field': 'op',
ignore_option: 'true', 'write.native.enabled': str(native).lower()}
catalog.create_table('test_db.filtered_group', pypaimon.Schema.from_pyarrow_schema(
pa.schema([('id', pa.int64()), ('value', pa.string()), ('op', pa.string())]),
primary_keys=['id'], options=options), False)
table = catalog.get_table('test_db.filtered_group')
_write_table(daft.from_pydict({'id': [1, 1], 'value': ['ignored', 'retained'],
'op': [kind, '+I']}), table).to_pydict()
assert _read_table(table).to_pydict() == {'id': [1], 'value': ['retained'], 'op': ['+I']}
indexes = [entry for entry in IndexFileHandler(table).scan(
table.snapshot_manager().get_latest_snapshot()) if entry.index_file.index_type == 'HASH']
assert len(indexes) == 1
assert indexes[0].index_file.row_count == 1
def test_hash_dynamic_pins_one_driver_snapshot_for_all_group_udfs(
catalog, monkeypatch
):
table = _create_pk_table(
catalog,
"dynamic_pinned_snapshot",
options={"bucket": "-1"},
)
_write_table(
daft.from_pydict({"id": [1], "value": ["old"]}), table
).to_pydict()
expected_snapshot_id = table.snapshot_manager().get_latest_snapshot().id
assignment_factory = Mock(
wraps=daft_datasink.make_dynamic_bucket_assignment_udf
)
group_write_factory = Mock(wraps=daft_datasink.make_group_write_udf)
monkeypatch.setattr(
daft_datasink,
"make_dynamic_bucket_assignment_udf",
assignment_factory,
)
monkeypatch.setattr(
daft_datasink, "make_group_write_udf", group_write_factory
)
_write_table(
daft.from_pydict({"id": [1], "value": ["new"]}), table
).to_pydict()
assert (
assignment_factory.call_args.kwargs["base_snapshot_id"]
== expected_snapshot_id
)
assert (
group_write_factory.call_args.kwargs["base_snapshot_id"]
== expected_snapshot_id
)
def test_hash_dynamic_partitioned_pk_preserves_partition_columns(catalog):
table = _create_pk_table(
catalog,
"dynamic_partitioned_pk",
partitioned=True,
options={
"bucket": "-1",
"dynamic-bucket.target-row-num": "1",
},
)
_write_table(
daft.from_pydict(
{
"id": [1, 1],
"value": ["old-a", "old-b"],
"dt": ["a", "b"],
}
),
table,
)
_write_table(
daft.from_pydict(
{
"id": [2, 1, 2, 1],
"value": ["other-a", "new-a", "other-b", "new-b"],
"dt": ["a", "a", "b", "b"],
}
).into_partitions(2),
table,
)
rows = _read_table(table).to_pydict()
assert set(zip(rows["id"], rows["value"], rows["dt"])) == {
(1, "new-a", "a"),
(2, "other-a", "a"),
(1, "new-b", "b"),
(2, "other-b", "b"),
}
def test_hash_dynamic_pk_overwrite_replaces_hash_index(catalog):
table = _create_pk_table(
catalog,
"dynamic_pk_overwrite",
options={
"bucket": "-1",
"dynamic-bucket.target-row-num": "10",
},
)
_write_table(
daft.from_pydict({"id": [1, 2], "value": ["old-1", "old-2"]}),
table,
)
_write_table(
daft.from_pydict({"id": [3], "value": ["first"]}),
table,
mode="overwrite",
)
snapshot = table.snapshot_manager().get_latest_snapshot()
hash_indexes = [
entry
for entry in IndexFileHandler(table).scan(snapshot)
if entry.index_file.index_type == "HASH"
]
assert sum(entry.index_file.row_count for entry in hash_indexes) == 1
_write_table(
daft.from_pydict(
{"id": [4, 3], "value": ["other", "updated"]}
),
table,
)
assert _read_table(table).sort("id").to_pydict() == {
"id": [3, 4],
"value": ["updated", "other"],
}
def test_cross_partition_pk_write_requires_global_index(catalog):
schema = pypaimon.Schema.from_pyarrow_schema(
pa.schema(
[
pa.field("id", pa.int64()),
pa.field("value", pa.string()),
pa.field("dt", pa.string()),
]
),
partition_keys=["dt"],
primary_keys=["id"],
options={"bucket": "-1", "file.format": "parquet"},
)
catalog.create_table("test_db.cross_partition_pk", schema, False)
table = catalog.get_table("test_db.cross_partition_pk")
data = daft.from_pydict({
"id": [1, 2],
"value": ["a", "b"],
"dt": ["2026-07-20", "2026-07-21"],
}).into_partitions(2)
with pytest.raises(
ValueError, match="CROSS_PARTITION.*global primary-key index"
):
_write_table(data, table)
@pytest.mark.skipif(
importlib.util.find_spec("ray") is None,
reason="Distributed Daft correctness test requires Ray",
)
def test_pk_distributed_write_on_ray():
"""Exercise real multi-task ownership and compatible PK bucket modes."""
python_root = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "..", "..")
)
script = textwrap.dedent(
r'''
import os
import shutil
import tempfile
os.environ.setdefault("RAY_TMPDIR", "/tmp")
import daft
import pyarrow as pa
import ray
from daft import runners
from pypaimon import CatalogFactory, Schema
from pypaimon.daft.daft_paimon import _read_table, _write_table
from pypaimon.execution.python.write.row_key_extractor import DynamicBucketRowKeyExtractor
root = tempfile.mkdtemp(prefix="paimon-daft-pk-ray-")
try:
catalog = CatalogFactory.create({"warehouse": root})
catalog.create_database("test_db", False)
ray.init(num_cpus=2, include_dashboard=False)
runners.set_runner_ray()
fixed_schema = Schema.from_pyarrow_schema(
pa.schema([
pa.field("id", pa.int64()),
pa.field("value", pa.string()),
pa.field("dt", pa.string()),
]),
partition_keys=["dt"],
primary_keys=["id", "dt"],
options={"bucket": "1", "file.format": "parquet"},
)
catalog.create_table("test_db.fixed_pk", fixed_schema, False)
fixed = catalog.get_table("test_db.fixed_pk")
fixed_summary = _write_table(
daft.from_pydict({
"id": [1, 1],
"value": ["old", "new"],
"dt": ["2026-07-20", "2026-07-20"],
}).into_partitions(2),
fixed,
).to_pydict()
assert len(fixed_summary["file_name"]) == 1
assert sum(fixed_summary["rows"]) == 1
postpone_schema = Schema.from_pyarrow_schema(
pa.schema([
pa.field("id", pa.int64()),
pa.field("value", pa.string()),
]),
primary_keys=["id"],
options={"bucket": "-2", "file.format": "parquet"},
)
catalog.create_table(
"test_db.postpone_pk", postpone_schema, False
)
postpone = catalog.get_table("test_db.postpone_pk")
postpone_summary = _write_table(
daft.from_pydict({
"id": [1, 2, 3, 4],
"value": ["a", "b", "c", "d"],
}).into_partitions(2),
postpone,
).to_pydict()
assert sum(postpone_summary["rows"]) == 4
cross_partition_schema = Schema.from_pyarrow_schema(
pa.schema([
pa.field("id", pa.int64()),
pa.field("value", pa.string()),
pa.field("dt", pa.string()),
]),
partition_keys=["dt"],
primary_keys=["id"],
options={"bucket": "-1", "file.format": "parquet"},
)
catalog.create_table(
"test_db.cross_partition_pk",
cross_partition_schema,
False,
)
cross_partition = catalog.get_table(
"test_db.cross_partition_pk"
)
try:
_write_table(
daft.from_pydict({
"id": [1, 1],
"value": ["old", "new"],
"dt": ["p1", "p2"],
}).into_partitions(2),
cross_partition,
)
except ValueError as error:
assert "global primary-key index" in str(error)
else:
raise AssertionError("CROSS_PARTITION write was not rejected")
dynamic_schema = Schema.from_pyarrow_schema(
pa.schema([
pa.field("id", pa.int64()),
pa.field("value", pa.string()),
]),
primary_keys=["id"],
options={
"bucket": "-1",
"dynamic-bucket.target-row-num": "100",
"file.format": "parquet",
},
)
catalog.create_table("test_db.dynamic_pk", dynamic_schema, False)
dynamic = catalog.get_table("test_db.dynamic_pk")
_write_table(
daft.from_pydict({
"id": list(range(1, 9)),
"value": [f"old-{i}" for i in range(1, 9)],
}),
dynamic,
)
assigner_probe = pa.RecordBatch.from_pydict({
"id": list(range(1, 9)),
"value": [f"new-{i}" for i in range(1, 9)],
})
assigners = DynamicBucketRowKeyExtractor(
dynamic.table_schema
).extract_assigners_batch(assigner_probe, 2, 2)
assert set(assigners) == {0, 1}
dynamic_summary = _write_table(
daft.from_pydict({
"id": list(range(1, 9)),
"value": [f"new-{i}" for i in range(1, 9)],
}).into_partitions(2),
dynamic,
).to_pydict()
# The first commit used one assigner and placed every key in
# bucket 0. The second uses two assigners; both can resolve old
# keys to bucket 0, but the final bucket shuffle must still leave
# exactly one writer for it.
assert len(dynamic_summary["file_name"]) == 1
actual = _read_table(dynamic).sort("id").to_pydict()
assert actual == {
"id": list(range(1, 9)),
"value": [f"new-{i}" for i in range(1, 9)],
}
finally:
if ray.is_initialized():
ray.shutdown()
shutil.rmtree(root, ignore_errors=True)
'''
)
env = os.environ.copy()
env["PYTHONPATH"] = os.pathsep.join(
[python_root, env.get("PYTHONPATH", "")]
).rstrip(os.pathsep)
result = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
env=env,
text=True,
timeout=180,
)
assert result.returncode == 0, (
f"stdout:\n{result.stdout}\n"
f"stderr:\n{result.stderr}"
)