Files
2026-07-13 13:17:40 +08:00

391 lines
12 KiB
Python

import os
import lance
import pyarrow as pa
import pytest
from packaging.version import Version
from pytest_lazy_fixtures import lf as lazy_fixture
import ray
from ray._common.test_utils import wait_for_condition
from ray.data import Schema
from ray.data._internal.datasource.lance_datasink import (
_WRITE_LANCE_FRAGMENTS_DESCRIPTION,
LanceDatasink,
_write_fragment,
)
from ray.data.datasource import SaveMode
from ray.data.datasource.path_util import _unwrap_protocol
# Skip tests for older pylance versions (<=0.3.19) due to incompatible lance API changes with pyarrow v9.0.0
pytestmark = pytest.mark.skipif(
Version(lance.__version__) <= Version("0.3.19"),
reason=f"pylance {lance.__version__} <= 0.3.19; API incompatible",
)
@pytest.mark.parametrize(
"fs,data_path",
[
(None, lazy_fixture("local_path")),
(lazy_fixture("local_fs"), lazy_fixture("local_path")),
(lazy_fixture("s3_fs"), lazy_fixture("s3_path")),
(
lazy_fixture("s3_fs_with_space"),
lazy_fixture("s3_path_with_space"),
), # Path contains space.
(
lazy_fixture("s3_fs_with_anonymous_crendential"),
lazy_fixture("s3_path_with_anonymous_crendential"),
),
],
)
@pytest.mark.parametrize(
"batch_size",
[None, 100],
)
def test_lance_read_basic(fs, data_path, batch_size):
df1 = pa.table({"one": [2, 1, 3, 4, 6, 5], "two": ["b", "a", "c", "e", "g", "f"]})
setup_data_path = _unwrap_protocol(data_path)
path = os.path.join(setup_data_path, "test.lance")
lance.write_dataset(df1, path)
ds_lance = lance.dataset(path)
assert ds_lance is not None
df2 = pa.table(
{
"one": [1, 2, 3, 4, 5, 6],
"three": [4, 5, 8, 9, 12, 13],
"four": ["u", "v", "w", "x", "y", "z"],
}
)
ds_lance.merge(df2, "one")
if batch_size is None:
ds = ray.data.read_lance(path)
else:
ds = ray.data.read_lance(path, scanner_options={"batch_size": batch_size})
# Test metadata-only ops.
assert ds.count() == 6
assert ds.schema() == Schema(
pa.schema(
{
"one": pa.int64(),
"two": pa.string(),
"three": pa.int64(),
"four": pa.string(),
}
)
)
# Test read.
values = [[s["one"], s["two"]] for s in ds.take_all()]
assert sorted(values) == [
[1, "a"],
[2, "b"],
[3, "c"],
[4, "e"],
[5, "f"],
[6, "g"],
]
# Test column projection.
ds = ray.data.read_lance(path, columns=["one"])
values = [s["one"] for s in ds.take_all()]
assert sorted(values) == [1, 2, 3, 4, 5, 6]
assert ds.schema().names == ["one"]
@pytest.mark.parametrize("data_path", [lazy_fixture("local_path")])
def test_lance_read_with_scanner_fragments(data_path):
table = pa.table({"one": [2, 1, 3, 4, 6, 5], "two": ["b", "a", "c", "e", "g", "f"]})
setup_data_path = _unwrap_protocol(data_path)
path = os.path.join(setup_data_path, "test.lance")
dataset = lance.write_dataset(table, path, max_rows_per_file=2)
assert dataset is not None
fragments = dataset.get_fragments()
ds = ray.data.read_lance(path, scanner_options={"fragments": fragments[:1]})
values = [[s["one"], s["two"]] for s in ds.take_all()]
assert values == [
[2, "b"],
[1, "a"],
]
@pytest.mark.parametrize("data_path", [lazy_fixture("local_path")])
def test_lance_read_many_files(data_path):
setup_data_path = _unwrap_protocol(data_path)
path = os.path.join(setup_data_path, "test.lance")
num_rows = 1024
data = pa.table({"id": pa.array(range(num_rows))})
lance.write_dataset(data, path, max_rows_per_file=1)
def test_lance():
ds = ray.data.read_lance(path)
return ds.count() == num_rows
wait_for_condition(test_lance, timeout=10)
@pytest.mark.parametrize("data_path", [lazy_fixture("local_path")])
def test_lance_write(data_path):
schema = pa.schema([pa.field("id", pa.int64()), pa.field("str", pa.string())])
ray.data.range(10).map(
lambda x: {"id": x["id"], "str": f"str-{x['id']}"}
).write_lance(data_path, schema=schema)
ds = lance.dataset(data_path)
assert ds is not None
ds.count_rows() == 10
assert ds.schema.names == schema.names
# The schema is platform-dependent, because numpy uses int32 on Windows.
# So we observe the schema that is written and use that.
schema = ds.schema
tbl = ds.to_table()
assert sorted(tbl["id"].to_pylist()) == list(range(10))
assert set(tbl["str"].to_pylist()) == {f"str-{i}" for i in range(10)}
ray.data.range(10).map(
lambda x: {"id": x["id"] + 10, "str": f"str-{x['id'] + 10}"}
).write_lance(data_path, mode=SaveMode.APPEND)
ds = lance.dataset(data_path)
assert ds is not None
ds.count_rows() == 20
tbl = ds.to_table()
assert sorted(tbl["id"].to_pylist()) == list(range(20))
assert set(tbl["str"].to_pylist()) == {f"str-{i}" for i in range(20)}
ray.data.range(10).map(
lambda x: {"id": x["id"], "str": f"str-{x['id']}"}
).write_lance(data_path, schema=schema, mode=SaveMode.OVERWRITE)
ds = lance.dataset(data_path)
assert ds is not None
ds.count_rows() == 10
assert ds.schema == schema
@pytest.mark.parametrize("data_path", [lazy_fixture("local_path")])
def test_lance_write_create_errors_if_exists(data_path):
table_path = os.path.join(data_path, "my_table")
ds = ray.data.range(10)
# First CREATE succeeds on an empty destination.
ds.write_lance(table_path, mode=SaveMode.CREATE)
assert lance.dataset(table_path).count_rows() == 10
# A second CREATE must error instead of silently overwriting.
with pytest.raises(ValueError, match="already exists"):
ray.data.range(5).write_lance(table_path, mode=SaveMode.CREATE)
# Existing data is untouched.
assert lance.dataset(table_path).count_rows() == 10
# CREATE is also the default mode, so it must guard too.
with pytest.raises(ValueError, match="already exists"):
ray.data.range(5).write_lance(table_path)
assert lance.dataset(table_path).count_rows() == 10
# OVERWRITE replaces the existing data.
ray.data.range(5).write_lance(table_path, mode=SaveMode.OVERWRITE)
assert lance.dataset(table_path).count_rows() == 5
@pytest.mark.parametrize("data_path", [lazy_fixture("local_path")])
def test_lance_write_append_errors_if_missing(data_path):
table_path = os.path.join(data_path, "missing_table")
# APPEND surfaces Lance's own "not found" error. We don't pin the message,
# since it can change across Lance versions.
expected_errors: tuple[type[Exception], ...] = (
ValueError,
OSError,
FileNotFoundError,
)
with pytest.raises(expected_errors):
ray.data.range(5).write_lance(table_path, mode=SaveMode.APPEND)
assert not os.path.exists(table_path)
@pytest.mark.parametrize("data_path", [lazy_fixture("local_path")])
def test_lance_write_min_rows_per_file(data_path):
schema = pa.schema([pa.field("id", pa.int64()), pa.field("str", pa.string())])
ray.data.range(10).map(
lambda x: {"id": x["id"], "str": f"str-{x['id']}"}
).write_lance(data_path, schema=schema, min_rows_per_file=100)
ds = lance.dataset(data_path)
assert ds is not None
assert ds.count_rows() == 10
assert ds.schema == schema
assert len(ds.get_fragments()) == 1
@pytest.mark.parametrize("data_path", [lazy_fixture("local_path")])
def test_lance_write_max_rows_per_file(data_path):
schema = pa.schema([pa.field("id", pa.int64()), pa.field("str", pa.string())])
ray.data.range(10).map(
lambda x: {"id": x["id"], "str": f"str-{x['id']}"}
).write_lance(data_path, schema=schema, max_rows_per_file=1)
ds = lance.dataset(data_path)
assert ds is not None
assert ds.count_rows() == 10
assert ds.schema == schema
assert len(ds.get_fragments()) == 10
@pytest.mark.parametrize("data_path", [lazy_fixture("local_path")])
def test_lance_read_with_version(data_path):
# Write an initial dataset (version 1)
df1 = pa.table({"one": [2, 1, 3, 4, 6, 5], "two": ["b", "a", "c", "e", "g", "f"]})
setup_data_path = _unwrap_protocol(data_path)
path = os.path.join(setup_data_path, "test_version.lance")
lance.write_dataset(df1, path)
# Merge new data to create a later version (latest)
ds_lance = lance.dataset(path)
assert ds_lance is not None
# Get the initial version
initial_version = ds_lance.version
df2 = pa.table(
{
"one": [1, 2, 3, 4, 5, 6],
"three": [4, 5, 8, 9, 12, 13],
"four": ["u", "v", "w", "x", "y", "z"],
}
)
ds_lance.merge(df2, "one")
# Default read should return the latest (merged) dataset.
ds_latest = ray.data.read_lance(path)
assert ds_latest.count() == 6
# Latest dataset should contain merged columns
assert "three" in ds_latest.schema().names
# Read the initial version and ensure it contains the original columns
ds_prev = ray.data.read_lance(path, version=initial_version)
assert ds_prev.count() == 6
assert ds_prev.schema().names == ["one", "two"]
values_prev = [[s["one"], s["two"]] for s in ds_prev.take_all()]
assert sorted(values_prev) == [
[1, "a"],
[2, "b"],
[3, "c"],
[4, "e"],
[5, "f"],
[6, "g"],
]
@pytest.fixture
def mock_lance_write(monkeypatch):
captured = {}
class _FakeLanceDatasink:
def __init__(self, path, **kwargs):
captured["path"] = path
captured["kwargs"] = kwargs
def _fake_write_datasink(self, datasink, **kwargs):
captured["datasink"] = datasink
captured["write_kwargs"] = kwargs
monkeypatch.setattr(ray.data.dataset, "LanceDatasink", _FakeLanceDatasink)
monkeypatch.setattr(ray.data.Dataset, "write_datasink", _fake_write_datasink)
return captured, _FakeLanceDatasink
def test_write_lance_passes_namespace_args(mock_lance_write):
captured, fake_lance_datasink_cls = mock_lance_write
table_id = ["db", "table"]
namespace_impl = "dir"
namespace_properties = {"path": "/tmp/ns"}
ds = ray.data.range(1)
ds.write_lance(
"/tmp/lance-namespace-test",
table_id=table_id,
namespace_impl=namespace_impl,
namespace_properties=namespace_properties,
)
assert captured["path"] == "/tmp/lance-namespace-test"
assert captured["kwargs"]["table_id"] == table_id
assert captured["kwargs"]["namespace_impl"] == namespace_impl
assert captured["kwargs"]["namespace_properties"] == namespace_properties
assert isinstance(captured["datasink"], fake_lance_datasink_cls)
@pytest.mark.parametrize("mode", [SaveMode.APPEND, SaveMode.OVERWRITE])
def test_lance_namespace_write_rejects_non_create_mode(monkeypatch, mode):
class _FakeNamespace:
pass
monkeypatch.setattr(
"ray.data._internal.datasource.lance_datasink.get_or_create_namespace",
lambda namespace_impl, namespace_properties: _FakeNamespace(),
)
with pytest.raises(ValueError, match="Namespace writes currently only support"):
LanceDatasink(
uri="/tmp/lance-namespace-test",
mode=mode,
table_id=["db", "table"],
namespace_impl="dir",
namespace_properties={"path": "/tmp/ns"},
)
@pytest.mark.parametrize(
"max_attempts,expected_blocks_consumed_before_write",
[(1, 1), (2, 3)],
)
def test_write_fragment_only_materializes_stream_when_retrying(
monkeypatch, max_attempts, expected_blocks_consumed_before_write
):
import lance.fragment
consumed = {"count": 0}
blocks = [pa.table({"id": [i]}) for i in range(3)]
def block_stream():
for block in blocks:
consumed["count"] += 1
yield block
def fake_write_fragments(reader, uri, **kwargs):
assert consumed["count"] == expected_blocks_consumed_before_write
return []
monkeypatch.setattr(lance.fragment, "write_fragments", fake_write_fragments)
_write_fragment(
block_stream(),
"/tmp/lance-materialization-test",
retry_params={
"description": _WRITE_LANCE_FRAGMENTS_DESCRIPTION,
"match": [],
"max_attempts": max_attempts,
"max_backoff_s": 0,
},
)
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))