blob: c8f7a3ad17bd0195cabb070237615666209d157c [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.
import pickle
import multiprocessing
from unittest.mock import patch
import pyarrow as pa
import pytest
import pypaimon.multimodal as pm
from pypaimon.multimodal.lerobot.reader import _PaimonTableFrameReader
from pypaimon.execution.native.runtime import native_runtime_available
from pypaimon.execution.native.read.plan import native_plan
pytest.importorskip("torch")
def _read_in_worker(reader, output):
try:
assert reader._table.options.native_read_enabled()
with patch.object(reader._locator, "locate", side_effect=AssertionError("Python frame lookup")):
output.put(reader.read_indices([10, 40], ["index"])["index"].to_pylist())
finally:
reader.close()
@pytest.fixture
def frames(tmp_path):
conn = pm.connect(options={"warehouse": str(tmp_path)})
table = conn.create_table("frames", data=pa.table({
"index": [10, 20, 30], "value": [1, 2, 3],
}), options={
"row-tracking.enabled": "true",
"data-evolution.enabled": "true",
"global-index.enabled": "true",
"bucket": "-1",
})
table.raw_table.create_global_index("index", index_type="btree")
return table
def reader(table, **options):
return _PaimonTableFrameReader(
table.raw_table.copy(options), columns=["index", "value"])
@pytest.mark.skipif(not native_runtime_available(), reason="native reader required")
@pytest.mark.parametrize("option", [None, "read.native.enabled", "scan.native-plan.enabled"])
def test_native_frame_lookup_keeps_residual_and_snapshot(frames, option):
# The index covers the original rows, not this append.
frames.add(pa.table({"index": [40, 50], "value": [4, 5]}))
current = reader(frames, **({option: "true"} if option else {}))
if option is None:
assert current._table.options.native_read_enabled()
frames.add(pa.table({"index": [60], "value": [6]}))
native_plans = []
def checked_plan(*args, **kwargs):
plan = native_plan(*args, **kwargs)
native_plans.append(plan)
return plan
try:
with patch.object(current._locator, "locate",
side_effect=AssertionError("Python frame lookup")), \
patch("pypaimon.execution.native.read.plan.native_plan", side_effect=checked_plan):
for indices, expected in [([40, 20, 40], [20, 40]),
([60, 99], []), ([], [])]:
result = current.read_indices(indices, ["value", "index"])
assert result.column_names == ["value", "index"]
assert sorted(result["index"].to_pylist()) == expected
assert len(native_plans) == 3
assert all(plan.snapshot_id == current.snapshot_id for plan in native_plans)
finally:
current.close()
@pytest.mark.skipif(not native_runtime_available(), reason="native reader required")
def test_native_frame_lookup_preserves_tag_after_pickle(frames):
frames.raw_table.create_tag("training")
current = reader(frames, **{
"scan.tag-name": "training",
})
restored = pickle.loads(pickle.dumps(current))
frames.add(pa.table({"index": [40], "value": [4]}))
frames.raw_table.replace_tag("training")
try:
assert current.snapshot_id == restored.snapshot_id
assert restored.read_indices([10, 40], ["index"])["index"].to_pylist() == [10]
finally:
current.close()
restored.close()
@pytest.mark.skipif(not native_runtime_available(), reason="native reader required")
def test_native_frame_lookup_in_spawn_worker(frames):
current = reader(frames)
frames.add(pa.table({"index": [40], "value": [4]}))
context = multiprocessing.get_context("spawn")
output = context.Queue()
process = context.Process(target=_read_in_worker, args=(current, output))
try:
process.start()
assert output.get(timeout=30) == [10]
process.join(timeout=30)
assert process.exitcode == 0
finally:
if process.is_alive():
process.terminate()
process.join()
output.close()
current.close()
def test_missing_native_runtime_keeps_cached_python_lookup(frames):
current = reader(frames, **{"read.native.enabled": "true"})
try:
with patch("pypaimon.execution.native.runtime.native_runtime_available", return_value=False), \
patch.object(current._locator, "locate", wraps=current._locator.locate) as locate:
assert current.read_indices([30], ["value"])["value"].to_pylist() == [3]
locate.assert_called_once_with([30])
finally:
current.close()
@pytest.mark.skipif(not native_runtime_available(), reason="native reader required")
def test_native_plan_failure_reuses_scanner_and_keeps_predicate(frames):
from pypaimon.globalindex import DataEvolutionGlobalIndexScanner
frames.add(pa.table({"index": [40, 50], "value": [4, 5]}))
current = reader(frames, **{
"scan.native-plan.enabled": "true", "read.native.enabled": "false",
})
try:
with patch("pypaimon.execution.native.read.plan.native_plan",
side_effect=RuntimeError("unavailable")), \
patch.object(current._locator, "locate", wraps=current._locator.locate) as locate, \
patch.object(DataEvolutionGlobalIndexScanner, "create",
wraps=DataEvolutionGlobalIndexScanner.create) as create:
for indices, expected in [([20], [2]), ([40], [4]), ([10, 99], [1])]:
assert current.read_indices(indices, ["value"])["value"].to_pylist() == expected
assert locate.call_count == 3
assert create.call_count == 1
scanner = current._locator._scanner
with patch.object(scanner, "close", wraps=scanner.close) as close:
current.close()
close.assert_called_once()
finally:
current.close()
@pytest.mark.python_plan
@pytest.mark.python_read
@pytest.mark.parametrize("options", [
{"read.native.enabled": "false"},
{"scan.native-plan.enabled": "false"},
])
def test_explicit_python_lookup_is_preserved(frames, options):
current = reader(frames, **options)
try:
assert not current._table.options.native_read_enabled()
with patch.object(current._locator, "locate", wraps=current._locator.locate) as locate:
assert current.read_indices([20], ["value"])["value"].to_pylist() == [2]
locate.assert_called_once_with([20])
finally:
current.close()
@pytest.mark.python_plan
@pytest.mark.python_read
@pytest.mark.parametrize("available", [False, True])
def test_auto_native_uses_private_table_copy(frames, available):
original = dict(frames.raw_table.options.options.to_map())
with patch("pypaimon.execution.native.runtime.native_runtime_available", return_value=available):
current = reader(frames)
try:
assert current._table.options.native_read_enabled() == available
assert frames.raw_table.options.options.to_map() == original
if not available:
with patch.object(current._locator, "locate", wraps=current._locator.locate) as locate:
assert current.read_indices([30], ["value"])["value"].to_pylist() == [3]
locate.assert_called_once_with([30])
finally:
current.close()
def test_unsupported_native_plan_reuses_python_index_scanner(frames):
from pypaimon.globalindex import DataEvolutionGlobalIndexScanner
frames.add(pa.table({"index": [40], "value": [4]}))
with patch("pypaimon.execution.native.runtime.native_runtime_available", return_value=True):
current = reader(frames)
try:
assert current._table.options.native_read_enabled()
with patch("pypaimon.execution.native.runtime.native_runtime_available", return_value=True), \
patch("pypaimon.execution.native.read.plan.NativeTableScan._native_plan_supported",
return_value=False), \
patch("pypaimon.execution.native.read.plan.native_plan",
side_effect=AssertionError("unsupported native plan")), \
patch.object(DataEvolutionGlobalIndexScanner, "create",
wraps=DataEvolutionGlobalIndexScanner.create) as create:
for indices, expected in [([20], [2]), ([40], [4]), ([10, 99], [1])]:
result = current.read_indices(indices, ["value"])
assert result["value"].to_pylist() == expected
assert create.call_count == 1
scanner = current._locator._scanner
with patch.object(scanner, "close", wraps=scanner.close) as close:
current.close()
close.assert_called_once()
finally:
current.close()
@pytest.mark.skipif(not native_runtime_available(), reason="native reader required")
@pytest.mark.parametrize("denied", [False, True])
def test_native_lookup_preserves_query_authorization(frames, denied):
from pypaimon.read.table_scan import TableScan
current = reader(frames)
calls = []
def authorize(*args, **kwargs):
calls.append(True)
if denied:
raise PermissionError("denied")
return None
original_init = TableScan.__init__
def init_with_auth(scan, *args, **kwargs):
original_init(scan, *args, **kwargs)
scan._query_auth_fn = authorize
try:
with patch.object(TableScan, "__init__", init_with_auth), \
patch.object(current._locator, "locate", side_effect=AssertionError("authorization bypass")), \
patch("pypaimon.execution.native.read.plan.native_plan",
side_effect=RuntimeError("unavailable")):
if denied:
with pytest.raises(PermissionError, match="denied"):
current.read_indices([20], ["value"])
else:
assert current.read_indices([20], ["value"])["value"].to_pylist() == [2]
assert calls
finally:
current.close()
@pytest.mark.parametrize("option", ["scan.native-plan.enabled", "read.native.enabled"])
def test_pickled_native_locator_rebuilds_pinned_fallback_once(frames, option):
from pypaimon.globalindex import DataEvolutionGlobalIndexScanner
frames.add(pa.table({"index": [40], "value": [4]}))
current = reader(frames, **{
"scan.native-plan.enabled": "false", "read.native.enabled": "false",
option: "true"})
restored = pickle.loads(pickle.dumps(current))
assert restored._locator._splits is None
frames.add(pa.table({"index": [60], "value": [6]}))
try:
with patch("pypaimon.execution.native.read.plan.native_plan",
side_effect=RuntimeError("native unavailable")), \
patch.object(DataEvolutionGlobalIndexScanner, "create",
wraps=DataEvolutionGlobalIndexScanner.create) as create:
for unused in range(3):
result = restored.read_indices([10, 40, 60], ["value", "index"])
assert result.column_names == ["value", "index"]
assert sorted(result["index"].to_pylist()) == [10, 40]
assert sorted(result["value"].to_pylist()) == [1, 4]
assert create.call_count == 1
assert restored._locator._splits is not None
finally:
current.close()
restored.close()
def test_pickled_native_locator_rebuilds_original_projection(tmp_path):
from pypaimon.catalog.table_query_auth import TableQueryAuthResult
from pypaimon.read.table_scan import TableScan
conn = pm.connect(options={"warehouse": str(tmp_path)})
table = conn.create_table("projected_frames", data=pa.table({
"index": [10, 20, 30],
"value": [1, 2, 3],
"secret": [4, 5, 6],
}), options={
"row-tracking.enabled": "true",
"data-evolution.enabled": "true",
"global-index.enabled": "true",
"bucket": "-1",
})
table.raw_table.create_global_index("index", index_type="btree")
selected = []
def authorize(columns):
selected.append(columns)
if "secret" in columns:
raise PermissionError("secret column denied")
return TableQueryAuthResult(None, None)
original_init = TableScan.__init__
def init_with_auth(scan, *args, **kwargs):
original_init(scan, *args, **kwargs)
scan._query_auth_fn = authorize
with patch.object(TableScan, "__init__", init_with_auth):
current = reader(table, **{
"scan.native-plan.enabled": "false",
"read.native.enabled": "true",
})
restored = pickle.loads(pickle.dumps(current))
try:
assert restored._locator._splits is None
with patch.object(TableScan, "__init__", init_with_auth), \
patch("pypaimon.execution.native.runtime.native_runtime_available",
return_value=False):
result = restored.read_indices([20], ["value"])
assert result["value"].to_pylist() == [2]
assert selected == [["index", "value"], ["index", "value"]]
finally:
current.close()
restored.close()
def test_pickled_python_locator_keeps_fallback_splits(frames):
current = reader(frames, **{
"scan.native-plan.enabled": "false", "read.native.enabled": "false"})
restored = pickle.loads(pickle.dumps(current))
try:
assert restored._locator._splits is not None
assert restored.read_indices([20], ["value"])["value"].to_pylist() == [2]
finally:
current.close()
restored.close()