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

938 lines
34 KiB
Python

import logging
import os
from pathlib import Path
from typing import Any
import fsspec
import numpy as np
import pandas as pd
import pyarrow.fs
import pytest
import zarr
from pytest_lazy_fixtures import lf as lazy_fixture
import ray
from ray.data._internal.datasource import zarrv2_datasource
from ray.data.block import BlockAccessor
from ray.data.tests.conftest import * # noqa: F401, F403
def _execute_read_tasks(tasks) -> pd.DataFrame:
frames = [
BlockAccessor.for_block(block).to_pandas() for task in tasks for block in task()
]
return pd.concat(frames, ignore_index=True)
def _reconstruct_array(df: pd.DataFrame, array_name: str) -> np.ndarray:
"""Concatenate all chunks of one array from a long-form result frame."""
sub = df[df["array"] == array_name].sort_values(
"chunk_index", key=lambda col: col.map(tuple)
)
return np.concatenate(list(sub["chunk"]), axis=0)
def _write_real_zarr_store(
store_path: Path,
arrays: dict, # {name: (data, chunks)}
) -> Path:
"""Write a real Zarr v2 store from numpy arrays and consolidate metadata."""
root = zarr.open_group(str(store_path), mode="w")
for name, (data, chunks) in arrays.items():
root.create_dataset(name, data=data, chunks=chunks, dtype=data.dtype)
zarr.consolidate_metadata(zarr.DirectoryStore(str(store_path)))
return store_path
@pytest.fixture
def zarrv2_group_store(tmp_path) -> Path:
"""Two arrays at the store root, both 2-D and 1-D, axis-0-aligned (shape[0]==5)."""
return _write_real_zarr_store(
tmp_path / "group.zarr",
{
"images": (np.arange(20, dtype="<i4").reshape(5, 4), (2, 4)),
"nested": (np.arange(5, dtype="|u1"), (2,)),
},
)
@pytest.fixture
def zarrv2_root_store(tmp_path) -> Path:
"""Single-array store with the array sitting directly at the store root."""
store_path = tmp_path / "root.zarr"
arr = zarr.open(
str(store_path),
mode="w",
shape=(5, 4),
chunks=(2, 4),
dtype="<i4",
)
arr[:] = np.arange(20, dtype="<i4").reshape(5, 4)
zarr.consolidate_metadata(zarr.DirectoryStore(str(store_path)))
return store_path
@pytest.fixture
def local_fsspec_fs():
"""fsspec local filesystem (for parametrized cross-fs read tests)."""
return fsspec.filesystem("file")
@pytest.fixture
def heterogeneous_zarrv2_store(tmp_path) -> Path:
"""A store mixing different ranks, shape[0]s, dtypes, and native chunk sizes.
Mirrors the UMI-style real-world layout where ``data/*`` arrays share an
axis-0 timestep count but differ in everything else, and ``meta/*``
arrays live in a separate axis-0 universe entirely. The chunk-per-row
datasource handles all of these in one read; nothing has to align.
"""
store_path = tmp_path / "heterogeneous.zarr"
root = zarr.open_group(str(store_path), mode="w")
# 4-D image tensor with tiny axis-0 chunks (1 image per chunk).
root.create_dataset(
"data/camera0_rgb",
data=np.arange(20 * 2 * 2 * 3, dtype="|u1").reshape(20, 2, 2, 3),
chunks=(1, 2, 2, 3),
)
# 2-D pose array, same shape[0]=20, much larger axis-0 chunks (10).
root.create_dataset(
"data/robot0_eef_pos",
data=np.arange(20 * 3, dtype="<f4").reshape(20, 3),
chunks=(10, 3),
)
# Episode-boundary metadata: separate axis-0 universe.
root.create_dataset(
"meta/episode_ends",
data=np.array([5, 12, 20], dtype="<i8"),
chunks=(3,),
)
zarr.consolidate_metadata(zarr.DirectoryStore(str(store_path)))
return store_path
@pytest.fixture
def unconsolidated_zarrv2_store(tmp_path) -> Path:
"""Two arrays at the store root, no ``.zmetadata``.
Exercises the no-``.zmetadata`` code paths (per-array ``.zarray``
discovery and full-store walk) — the common shape of real-world stores
behind plain HTTPS or other listing-less filesystems.
"""
store_path = tmp_path / "unconsolidated.zarr"
root = zarr.open_group(str(store_path), mode="w")
root.create_dataset(
"images", data=np.arange(20, dtype="<i4").reshape(5, 4), chunks=(2, 4)
)
root.create_dataset("nested", data=np.arange(5, dtype="|u1"), chunks=(2,))
return store_path
@pytest.fixture
def aligned_zarrv2_store(tmp_path) -> Path:
"""Three arrays sharing ``shape[0]=8``, different ranks and native chunks.
Models the UMI-style case where data arrays co-stride on the timestep
axis but differ in everything else.
"""
store_path = tmp_path / "aligned.zarr"
root = zarr.open_group(str(store_path), mode="w")
root.create_dataset(
"img",
data=np.arange(8 * 4 * 4 * 3, dtype="|u1").reshape(8, 4, 4, 3),
chunks=(2, 4, 4, 3),
)
root.create_dataset(
"state",
data=np.arange(8 * 3, dtype="<f4").reshape(8, 3),
chunks=(4, 3), # different native axis-0 chunks than img
)
root.create_dataset(
"label",
data=np.arange(8, dtype="<i8"),
chunks=(8,),
)
zarr.consolidate_metadata(zarr.DirectoryStore(str(store_path)))
return store_path
@pytest.fixture
def zarr_zip_store(tmp_path) -> Path:
"""A small Zarr store packed into a ``.zip`` for URL-detection tests."""
src = tmp_path / "src.zarr"
_write_real_zarr_store(
src,
{
"data": (np.arange(12, dtype="<i4").reshape(6, 2), (3, 2)),
},
)
zip_path = tmp_path / "store.zarr.zip"
import shutil
shutil.make_archive(
base_name=str(tmp_path / "store.zarr"),
format="zip",
root_dir=str(src),
)
assert zip_path.exists()
return zip_path
# ---------------------------------------------------------------------------
# Metadata discovery
# ---------------------------------------------------------------------------
def test_normalizes_requested_root_array_path(zarrv2_root_store):
datasource = zarrv2_datasource.ZarrV2Datasource(
str(zarrv2_root_store),
array_paths=[""],
)
assert list(datasource._metadata_by_path) == [""]
def test_normalizes_requested_array_paths(zarrv2_group_store):
datasource = zarrv2_datasource.ZarrV2Datasource(
str(zarrv2_group_store),
array_paths=["images/", "nested"],
)
assert list(datasource._metadata_by_path) == ["images", "nested"]
def test_rejects_missing_array_paths(zarrv2_group_store):
with pytest.raises(
ValueError,
match=r"Array\(s\) not found: 'missing'\. Available: 'images', 'nested'",
):
zarrv2_datasource.ZarrV2Datasource(
str(zarrv2_group_store),
array_paths=["missing"],
)
def test_loads_per_array_zarray_without_zmetadata(unconsolidated_zarrv2_store):
datasource = zarrv2_datasource.ZarrV2Datasource(
str(unconsolidated_zarrv2_store),
array_paths=["images", "nested"],
)
assert set(datasource._metadata_by_path) == {"images", "nested"}
def test_full_scan_discovers_arrays_without_zmetadata(unconsolidated_zarrv2_store):
datasource = zarrv2_datasource.ZarrV2Datasource(
str(unconsolidated_zarrv2_store),
allow_full_metadata_scan=True,
)
assert set(datasource._metadata_by_path) == {"images", "nested"}
def test_requires_array_paths_or_full_scan_when_unconsolidated(
unconsolidated_zarrv2_store,
):
with pytest.raises(
ValueError,
match=(
r"No array_paths were provided and this Zarr store does not "
r"contain \.zmetadata"
),
):
zarrv2_datasource.ZarrV2Datasource(str(unconsolidated_zarrv2_store))
def test_array_paths_missing_zarray_file_raises_value_error(
unconsolidated_zarrv2_store,
):
with pytest.raises(
ValueError,
match=r"Array path 'missing' not found",
):
zarrv2_datasource.ZarrV2Datasource(
str(unconsolidated_zarrv2_store),
array_paths=["missing"],
)
def test_local_scheme_pins_reads_to_driver_node(zarrv2_group_store):
"""``local://`` stores can't be read distributed; plain/cloud paths can."""
local = zarrv2_datasource.ZarrV2Datasource("local://" + str(zarrv2_group_store))
assert local.supports_distributed_reads is False
plain = zarrv2_datasource.ZarrV2Datasource(str(zarrv2_group_store))
assert plain.supports_distributed_reads is True
def test_consolidation_detected_via_open_consolidated(
zarrv2_group_store, unconsolidated_zarrv2_store
):
"""``_consolidated`` reflects whether ``.zmetadata`` actually opened."""
consolidated = zarrv2_datasource.ZarrV2Datasource(
str(zarrv2_group_store), array_paths=["images"]
)
assert consolidated._consolidated is True
unconsolidated = zarrv2_datasource.ZarrV2Datasource(
str(unconsolidated_zarrv2_store), array_paths=["images"]
)
assert unconsolidated._consolidated is False
def test_array_paths_rejects_group_path(tmp_path):
"""Requesting a group path (not an array) on an unconsolidated store errors."""
store_path = tmp_path / "withgroup.zarr"
root = zarr.open_group(str(store_path), mode="w")
grp = root.create_group("grp")
grp.create_dataset("inner", data=np.arange(4, dtype="<i4"), chunks=(2,))
# Not consolidated -> the per-array ``.zarray`` lookup path.
with pytest.raises(ValueError, match="is a group, not an array"):
zarrv2_datasource.ZarrV2Datasource(str(store_path), array_paths=["grp"])
def test_root_array_rejects_non_root_array_paths(zarrv2_root_store):
"""A single root-level array rejects array_paths that aren't the root ''."""
with pytest.raises(ValueError, match="single root-level array"):
zarrv2_datasource.ZarrV2Datasource(
str(zarrv2_root_store), array_paths=["missing"]
)
# ---------------------------------------------------------------------------
# chunk_shapes validation
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"chunk_shapes, match",
[
("invalid", "positive integers"),
({"images": 1}, "positive integers"),
({"does_not_exist": [2]}, "Unknown array path"),
],
)
def test_rejects_invalid_chunk_shapes(zarrv2_group_store, chunk_shapes, match):
with pytest.raises(ValueError, match=match):
zarrv2_datasource.ZarrV2Datasource(
str(zarrv2_group_store), chunk_shapes=chunk_shapes
)
@pytest.mark.parametrize(
"chunk_shapes,array_paths,expected",
[
# No chunk_shapes: every array reads at its native chunk size.
# 4-D image with tiny chunks coexists with 2-D pose with big chunks —
# nothing is forced into a shared min/max.
(
None,
None,
{
"data/camera0_rgb": (1, 2, 2, 3),
"data/robot0_eef_pos": (10, 3),
"meta/episode_ends": (3,),
},
),
# ``[5]`` prefix overrides axis 0 across arrays of all ranks at once.
(
[5],
None,
{
"data/camera0_rgb": (5, 2, 2, 3),
"data/robot0_eef_pos": (5, 3),
"meta/episode_ends": (5,),
},
),
# Length-2 prefix overrides axes 0+1; needs every selected array to
# have rank >= 2, so we filter out ``meta/episode_ends`` (rank 1).
(
[5, 1],
["data/camera0_rgb", "data/robot0_eef_pos"],
{
"data/camera0_rgb": (5, 1, 2, 3),
"data/robot0_eef_pos": (5, 1),
},
),
# Per-array overrides may retile only some arrays while others keep
# their native chunks.
(
{
"data/camera0_rgb": [5],
"data/robot0_eef_pos": [7],
},
None,
{
"data/camera0_rgb": (5, 2, 2, 3),
"data/robot0_eef_pos": (7, 3),
"meta/episode_ends": (3,),
},
),
],
)
def test_chunk_shapes_resolution_across_mixed_rank(
heterogeneous_zarrv2_store, chunk_shapes, array_paths, expected
):
datasource = zarrv2_datasource.ZarrV2Datasource(
str(heterogeneous_zarrv2_store),
chunk_shapes=chunk_shapes,
array_paths=array_paths,
)
assert datasource._array_chunks == expected
# ---------------------------------------------------------------------------
# align_axis_0 (wide-form mode)
# ---------------------------------------------------------------------------
def test_align_axis_0_emits_wide_rows(ray_start_regular_shared, aligned_zarrv2_store):
"""Wide-row schema: ``t_start``, ``t_stop``, one column per selected array."""
datasource = zarrv2_datasource.ZarrV2Datasource(
str(aligned_zarrv2_store),
align_axis_0=True,
chunk_shapes=[4],
)
df = _execute_read_tasks(datasource.get_read_tasks(parallelism=4))
assert set(df.columns) == {"t_start", "t_stop", "img", "state", "label"}
# shape[0]=8, chunk_shapes=[4] -> 2 rows.
assert len(df) == 2
# Reconstruct each array by concatenating slices in order.
img_recon = np.concatenate(list(df["img"]), axis=0)
assert img_recon.shape == (8, 4, 4, 3)
state_recon = np.concatenate(list(df["state"]), axis=0)
assert state_recon.shape == (8, 3)
label_recon = np.concatenate(list(df["label"]), axis=0)
assert label_recon.shape == (8,)
# t_start/t_stop are correct.
starts = sorted(df["t_start"].tolist())
stops = sorted(df["t_stop"].tolist())
assert starts == [0, 4]
assert stops == [4, 8]
def test_align_axis_0_column_set(ray_start_regular_shared, aligned_zarrv2_store):
"""array_paths selects which arrays are read; aligned mode emits one column
per selected array (plus t_start/t_stop)."""
datasource = zarrv2_datasource.ZarrV2Datasource(
str(aligned_zarrv2_store),
array_paths=["img", "state"],
align_axis_0=True,
chunk_shapes=[4],
)
df = _execute_read_tasks(datasource.get_read_tasks(parallelism=4))
assert set(df.columns) == {"t_start", "t_stop", "img", "state"}
def test_align_axis_0_rejects_misaligned_shape0(heterogeneous_zarrv2_store):
"""Misalignment raises with the per-array shape[0] breakdown."""
with pytest.raises(
ValueError,
match=r"All selected arrays must share shape\[0\]",
):
zarrv2_datasource.ZarrV2Datasource(
str(heterogeneous_zarrv2_store),
align_axis_0=True,
chunk_shapes=[5],
)
def test_align_axis_0_rejects_divergent_axis_0_chunks(aligned_zarrv2_store):
"""If aligned arrays end up with different axis-0 chunks, error clearly.
The native chunks differ (img=2, state=4, label=8) — without a
``chunk_shapes`` re-tile they all stay at native, and the validator
catches the mismatch.
"""
with pytest.raises(
ValueError, match="Aligned arrays must share the same axis-0 chunk size"
):
zarrv2_datasource.ZarrV2Datasource(
str(aligned_zarrv2_store),
align_axis_0=True,
)
# ---------------------------------------------------------------------------
# overlap (aligned-mode lookahead)
# ---------------------------------------------------------------------------
def test_overlap_extends_chunk_data(ray_start_regular_shared, aligned_zarrv2_store):
"""``overlap=N`` makes each row's per-array slice cover ``N`` extra timesteps.
Aligned store has shape[0]=8, ``chunk_shapes=[4]`` -> rows own [0,4) and [4,8).
With ``overlap=2``, row 0's data covers [0,6) and row 1's data covers [4,8)
(clipped at the store end since 4+4+2 > 8).
"""
datasource = zarrv2_datasource.ZarrV2Datasource(
str(aligned_zarrv2_store),
align_axis_0=True,
chunk_shapes=[4],
overlap=2,
)
df = _execute_read_tasks(datasource.get_read_tasks(parallelism=4))
# Ownership unchanged: 2 rows of width 4 each.
assert sorted(zip(df["t_start"], df["t_stop"])) == [(0, 4), (4, 8)]
# Data extents: row 0 has 6 timesteps, row 1 has 4 (clipped at shape[0]=8).
rows = sorted(df.to_dict("records"), key=lambda r: r["t_start"])
assert rows[0]["img"].shape[0] == 6 # 4 owned + 2 overlap
assert rows[0]["state"].shape[0] == 6
assert rows[1]["img"].shape[0] == 4 # 4 owned + 0 overlap (clipped)
assert rows[1]["state"].shape[0] == 4
def test_overlap_requires_align_axis_0(aligned_zarrv2_store):
"""``overlap`` in long-form (no ``align_axis_0``) is a clear error."""
with pytest.raises(ValueError, match="overlap requires align_axis_0=True"):
zarrv2_datasource.ZarrV2Datasource(
str(aligned_zarrv2_store),
overlap=2,
)
def test_overlap_rejects_negative_and_non_int(aligned_zarrv2_store):
bad_values: list[Any] = [-1, 1.5, "two"]
for bad in bad_values:
with pytest.raises(ValueError, match="overlap must be a non-negative integer"):
zarrv2_datasource.ZarrV2Datasource(
str(aligned_zarrv2_store),
align_axis_0=True,
chunk_shapes=[4],
overlap=bad,
)
def test_chunk_shapes_rejected_when_longer_than_smallest_array(
heterogeneous_zarrv2_store,
):
"""A shared ``chunk_shapes`` override longer than a target rank is an error."""
with pytest.raises(
ValueError,
match=r"chunk_shapes override for array .* has 2 axes but array of shape .* has rank 1",
):
zarrv2_datasource.ZarrV2Datasource(
str(heterogeneous_zarrv2_store),
chunk_shapes=[2, 2], # OK for 2-D and 4-D, fails for 1-D episode_ends
)
# ---------------------------------------------------------------------------
# Filesystem handling
# ---------------------------------------------------------------------------
def test_accepts_pyarrow_fs_filesystem(zarrv2_group_store):
"""A pyarrow.fs.FileSystem passed in is wrapped into fsspec internally."""
datasource = zarrv2_datasource.ZarrV2Datasource(
str(zarrv2_group_store),
filesystem=pyarrow.fs.LocalFileSystem(),
)
from fsspec.spec import AbstractFileSystem
assert isinstance(datasource._fs, AbstractFileSystem)
assert set(datasource._metadata_by_path) == {"images", "nested"}
def test_rejects_unsupported_filesystem_type():
"""Filesystem that's neither pyarrow.fs nor fsspec raises ``TypeError``."""
with pytest.raises(
TypeError,
match=r"filesystem must be pyarrow\.fs\.FileSystem or",
):
zarrv2_datasource.ZarrV2Datasource(
"/tmp/some.zarr",
filesystem="not-a-filesystem",
)
# ---------------------------------------------------------------------------
# .zarr.zip URL support
# ---------------------------------------------------------------------------
def test_reads_zarr_zip_local_path(ray_start_regular_shared, zarr_zip_store):
"""A local ``.zarr.zip`` path auto-wires fsspec's ZipFileSystem."""
datasource = zarrv2_datasource.ZarrV2Datasource(str(zarr_zip_store))
# The store has one array "data" of shape (6, 2) chunks (3, 2) -> 2 chunks.
df = _execute_read_tasks(datasource.get_read_tasks(parallelism=2))
assert len(df) == 2
assert set(df["array"]) == {"data"}
recon = _reconstruct_array(df, "data")
np.testing.assert_array_equal(recon, np.arange(12, dtype="<i4").reshape(6, 2))
# ---------------------------------------------------------------------------
# Read task generation and execution (end-to-end)
# ---------------------------------------------------------------------------
def test_get_read_tasks_batches_chunks_by_parallelism(tmp_path):
"""5 chunks split across parallelism=3 produces batches [2, 2, 1]."""
store_path = tmp_path / "store.zarr"
_write_real_zarr_store(
store_path,
{"images": (np.arange(5 * 4, dtype="<i4").reshape(5, 4), (1, 4))},
)
datasource = zarrv2_datasource.ZarrV2Datasource(str(store_path))
read_tasks = datasource.get_read_tasks(parallelism=3)
assert len(read_tasks) == 3
assert [task.metadata.num_rows for task in read_tasks] == [2, 2, 1]
assert all(task.metadata.input_files == (str(store_path),) for task in read_tasks)
def test_long_form_chunk_index_order_matches_grid(ray_start_regular_shared, tmp_path):
"""Lazy grid-range tasks emit chunk_index in the same row-major order as a
full grid enumeration (regression guard for the lazy-unravel refactor)."""
from itertools import product
store_path = tmp_path / "order.zarr"
# shape (6, 4), chunks (2, 2) -> grid (3, 2) = 6 chunks.
_write_real_zarr_store(
store_path, {"a": (np.arange(6 * 4, dtype="<i4").reshape(6, 4), (2, 2))}
)
datasource = zarrv2_datasource.ZarrV2Datasource(str(store_path))
# parallelism=2 -> two flat-index ranges; concatenated they must be in order.
df = _execute_read_tasks(datasource.get_read_tasks(parallelism=2))
got = [tuple(int(x) for x in ci) for ci in df["chunk_index"]]
assert got == list(product(range(3), range(2)))
def test_per_task_row_limit_caps_chunks_read(
ray_start_regular_shared, tmp_path, monkeypatch
):
"""per_task_row_limit bounds how many chunks a task actually reads, so a
downstream ``limit`` doesn't pull the whole batch's I/O."""
store_path = tmp_path / "limit.zarr"
_write_real_zarr_store(store_path, {"data": (np.arange(10, dtype="<i4"), (1,))})
datasource = zarrv2_datasource.ZarrV2Datasource(str(store_path))
reads = []
real_read_chunk = zarrv2_datasource._read_chunk
def _spy(*args, **kwargs):
reads.append(1)
return real_read_chunk(*args, **kwargs)
monkeypatch.setattr(zarrv2_datasource, "_read_chunk", _spy)
# parallelism=1 -> one task batching all 10 chunks; cap it at 3.
tasks = datasource.get_read_tasks(parallelism=1, per_task_row_limit=3)
blocks = [block for task in tasks for block in task()]
total_rows = sum(BlockAccessor.for_block(b).num_rows() for b in blocks)
assert total_rows == 3
# The fix: only 3 chunks were actually read (not all 10, then truncated).
assert len(reads) == 3
def test_read_chunk_retries_transient_io(monkeypatch):
"""_read_chunk retries reads whose error matches retry_match (Ray Data's
DataContext.retried_io_errors), then succeeds."""
monkeypatch.setattr("time.sleep", lambda *_: None) # no backoff in the test
class _FlakyArray:
attempts = 0
def __getitem__(self, _idx):
type(self).attempts += 1
if self.attempts < 3:
raise OSError("Connection reset by peer")
return np.arange(4, dtype="<i4")
class _Root:
def __getitem__(self, _name):
return _FlakyArray()
out = zarrv2_datasource._read_chunk(
_Root(), # pyrefly: ignore[bad-argument-type]
"x",
((0, 4),),
retry_match=["Connection reset"],
)
np.testing.assert_array_equal(out, np.arange(4, dtype="<i4"))
assert _FlakyArray.attempts == 3 # failed twice, then succeeded
def test_long_form_schema_and_materialization(ray_start_regular_shared, tmp_path):
"""End-to-end: long-form rows are emitted with the expected columns and data."""
store_path = tmp_path / "aligned.zarr"
images_src = np.arange(20, dtype="<i4").reshape(5, 4)
labels_src = np.arange(5, dtype="|u1")
_write_real_zarr_store(
store_path,
{
"images": (images_src, (2, 4)),
"labels": (labels_src, (2,)),
},
)
datasource = zarrv2_datasource.ZarrV2Datasource(str(store_path))
df = _execute_read_tasks(datasource.get_read_tasks(parallelism=16))
# Schema is the long-form quad.
assert list(df.columns) == ["array", "chunk_index", "chunk_slices", "chunk"]
# 3 chunks for images (5/2), 3 chunks for labels (5/2) = 6 rows total.
assert len(df) == 6
assert set(df["array"]) == {"images", "labels"}
np.testing.assert_array_equal(_reconstruct_array(df, "images"), images_src)
np.testing.assert_array_equal(_reconstruct_array(df, "labels"), labels_src)
# ``chunk_slices`` matches the actual chunk shape and indexes back to
# the source array: arr[start:stop, ...] equals the chunk.
for _, row in df.iterrows():
slices = row["chunk_slices"]
chunk = row["chunk"]
assert len(slices) == chunk.ndim
for axis, (start, stop) in enumerate(slices):
assert stop - start == chunk.shape[axis]
if row["array"] == "images":
np.testing.assert_array_equal(
chunk,
images_src[slices[0][0] : slices[0][1], slices[1][0] : slices[1][1]],
)
def test_chunk_shapes_override_changes_grid(ray_start_regular_shared, tmp_path):
"""User-supplied chunk_shapes controls the chunk grid and row count."""
store_path = tmp_path / "tile.zarr"
src = np.arange(10, dtype="<i4")
_write_real_zarr_store(store_path, {"data": (src, (2,))}) # native: 5 chunks
datasource = zarrv2_datasource.ZarrV2Datasource(str(store_path), chunk_shapes=[5])
df = _execute_read_tasks(datasource.get_read_tasks(parallelism=16))
assert sorted(chunk.shape[0] for chunk in df["chunk"]) == [5, 5]
def test_heterogeneous_store_emits_one_row_per_chunk(
ray_start_regular_shared, heterogeneous_zarrv2_store
):
"""Mixed-rank/shape/dtype arrays each contribute their chunk count to the output."""
datasource = zarrv2_datasource.ZarrV2Datasource(str(heterogeneous_zarrv2_store))
df = _execute_read_tasks(datasource.get_read_tasks(parallelism=16))
# Expected chunk counts:
# data/camera0_rgb shape=(20,2,2,3) chunks=(1,2,2,3) → 20 chunks
# data/robot0_eef_pos shape=(20,3) chunks=(10,3) → 2 chunks
# meta/episode_ends shape=(3,) chunks=(3,) → 1 chunk
counts = df.groupby("array").size().to_dict()
assert counts == {
"data/camera0_rgb": 20,
"data/robot0_eef_pos": 2,
"meta/episode_ends": 1,
}
# ---------------------------------------------------------------------------
# Estimator
# ---------------------------------------------------------------------------
def test_estimate_inmemory_data_size(tmp_path):
"""Estimate = sum over arrays of numel * dtype.itemsize."""
store_path = tmp_path / "est.zarr"
_write_real_zarr_store(
store_path,
{
"a": (np.zeros((5, 4), dtype="<i4"), (2, 4)),
"b": (np.zeros(5, dtype="|u1"), (2,)),
},
)
datasource = zarrv2_datasource.ZarrV2Datasource(str(store_path))
# 5*4*4 (a) + 5*1 (b) = 80 + 5 = 85
assert datasource.estimate_inmemory_data_size() == 85
# ---------------------------------------------------------------------------
# Cross-filesystem end-to-end (Ray Data convention)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"fs",
[
None,
lazy_fixture("local_fs"), # pyarrow.fs (gets wrapped to fsspec internally)
lazy_fixture("local_fsspec_fs"), # native fsspec
],
)
def test_read_zarr_basic_across_filesystems(ray_start_regular_shared, fs, local_path):
"""Round-trip a real Zarr store through read_zarr for each filesystem flavor.
Mirrors the parametrized read-path coverage other Ray Data datasources use
(lance, parquet, json, hudi, …) — exercises None / pyarrow.fs / fsspec
input shapes against the same store written to a local path.
"""
store_path = os.path.join(local_path, "data.zarr")
images_src = np.arange(20, dtype="<i4").reshape(5, 4)
labels_src = np.arange(5, dtype="|u1")
_write_real_zarr_store(
Path(store_path),
{
"images": (images_src, (2, 4)),
"labels": (labels_src, (2,)),
},
)
ds = ray.data.read_zarr(store_path, filesystem=fs)
# 3 chunks each for images and labels (5/2 → ceil = 3) → 6 rows total.
assert ds.count() == 6
df = pd.DataFrame(ds.take_all())
np.testing.assert_array_equal(_reconstruct_array(df, "images"), images_src)
np.testing.assert_array_equal(_reconstruct_array(df, "labels"), labels_src)
def test_rejects_zarr_v3(tmp_path, monkeypatch):
"""read_zarr targets zarr-python 2.x; an incompatible v3 install must raise a
clear, actionable error at construction, not a cryptic ImportError mid-read."""
monkeypatch.setattr(zarr, "__version__", "3.0.1")
with pytest.raises(ImportError, match=r"zarr-python 2\.x"):
zarrv2_datasource.ZarrV2Datasource(str(tmp_path))
def test_explicit_filesystem_strips_uri_scheme(ray_start_regular_shared, tmp_path):
"""An explicit ``filesystem=`` plus a scheme-prefixed path must strip the
scheme so the store path is backend-relative. Regression: pyarrow
filesystems can't resolve a ``file://`` / ``gs://`` prefix in the path."""
store_path = tmp_path / "scheme.zarr"
_write_real_zarr_store(store_path, {"data": (np.arange(6, dtype="<i4"), (2,))})
ds = zarrv2_datasource.ZarrV2Datasource(
f"file://{store_path}", filesystem=pyarrow.fs.LocalFileSystem()
)
assert ds._store_path == str(store_path)
df = _execute_read_tasks(ds.get_read_tasks(parallelism=2))
assert len(df) == 3
def test_get_read_tasks_parallelism_zero(tmp_path):
"""parallelism=0 must not divide by zero; fall back to a single task."""
store_path = tmp_path / "p0.zarr"
_write_real_zarr_store(store_path, {"data": (np.arange(10, dtype="<i4"), (2,))})
ds = zarrv2_datasource.ZarrV2Datasource(str(store_path))
tasks = ds.get_read_tasks(parallelism=0)
assert len(tasks) >= 1
def test_align_axis_0_rejects_scalar_array(tmp_path):
"""align_axis_0=True with a 0-D (scalar) array must raise a clear error
rather than an IndexError when reading the (empty) axis-0 chunk size."""
store_path = tmp_path / "scalar.zarr"
root = zarr.open_group(str(store_path), mode="w")
root.create_dataset("vec", data=np.arange(8, dtype="<i4"), chunks=(4,))
root.create_dataset("scalar", data=np.array(42, dtype="<i4")) # 0-D
zarr.consolidate_metadata(zarr.DirectoryStore(str(store_path)))
with pytest.raises(ValueError, match=r"0-D \(scalar\)"):
zarrv2_datasource.ZarrV2Datasource(str(store_path), align_axis_0=True)
def test_reads_zarr_zip_with_explicit_zip_filesystem(
ray_start_regular_shared, zarr_zip_store
):
"""A .zip path read through an explicitly-passed fsspec ZipFileSystem must
resolve the store at the archive root (store path ``""``), not treat the
``.zip`` name as an entry inside the archive."""
zip_fs = fsspec.filesystem("zip", fo=str(zarr_zip_store))
ds = zarrv2_datasource.ZarrV2Datasource(str(zarr_zip_store), filesystem=zip_fs)
assert ds._store_path == ""
df = _execute_read_tasks(ds.get_read_tasks(parallelism=2))
assert len(df) == 2
def test_align_axis_0_columns_unify_across_blocks(
ray_start_regular_shared, aligned_zarrv2_store
):
"""Wide-form gives each array its own column, so blocks combine cleanly
across the dataset even with trailing edge chunks of differing shape -- the
batch-safe schema for row-aligned arrays."""
from ray.data._internal.arrow_ops.transform_pyarrow import unify_schemas
from ray.data.block import BlockAccessor
ds = zarrv2_datasource.ZarrV2Datasource(
str(aligned_zarrv2_store), align_axis_0=True, chunk_shapes=[3]
)
blocks = [block for task in ds.get_read_tasks(parallelism=64) for block in task()]
assert len(blocks) > 1 # actually exercise cross-block unification
schemas = [BlockAccessor.for_block(b).to_arrow().schema for b in blocks]
unified = unify_schemas(schemas) # must not raise
assert {"t_start", "t_stop", "img", "state", "label"}.issubset(set(unified.names))
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))
# ---------------------------------------------------------------------------
# Custom codec registration in Ray workers
# ---------------------------------------------------------------------------
@pytest.fixture
def fresh_ray():
"""A clean Ray for a test that needs its own ``ray.init`` (e.g. a custom
``runtime_env``). Unlike ``shutdown_only`` (teardown only), it also shuts
down any pre-existing cluster, so isolation doesn't depend on test order.
"""
if ray.is_initialized():
ray.shutdown()
yield
if ray.is_initialized():
ray.shutdown()
def test_custom_codec_succeeds_with_worker_setup_hook(fresh_ray, tmp_path):
"""Test that we successfully register a custom codec.
numcodecs' registry is process-local.
"""
import numcodecs
def _register_codec():
import numcodecs
import numpy as np
class _RayZarrTestCodec(numcodecs.abc.Codec):
codec_id = "ray_zarr_test_codec"
def encode(self, buf):
return bytes(buf)
def decode(self, buf, out=None):
arr = np.frombuffer(buf, dtype=np.uint8)
if out is not None:
out[:] = arr.view(out.dtype)
return out
return arr.copy()
numcodecs.register_codec(_RayZarrTestCodec)
# Register driver-side so we can write the store.
_register_codec()
store_path = tmp_path / "codec_test.zarr"
arr = zarr.open(
str(store_path),
mode="w",
shape=(8,),
chunks=(4,),
dtype="u1",
compressor=numcodecs.get_codec({"id": "ray_zarr_test_codec"}),
)
arr[:] = np.arange(8, dtype="u1")
zarr.consolidate_metadata(zarr.DirectoryStore(str(store_path)))
ray.init(
num_cpus=1,
logging_level=logging.ERROR,
log_to_driver=False,
runtime_env={"worker_process_setup_hook": _register_codec},
)
ds = ray.data.read_zarr(str(store_path))
rows = sorted(ds.take_all(), key=lambda r: tuple(r["chunk_index"]))
recon = np.concatenate([r["chunk"] for r in rows])
np.testing.assert_array_equal(recon, np.arange(8, dtype="u1"))