from __future__ import annotations import logging import math import numbers from collections.abc import Callable, Iterable from dataclasses import dataclass from typing import TYPE_CHECKING, Any, List, Optional import numpy as np from ray.data._internal.delegating_block_builder import DelegatingBlockBuilder from ray.data._internal.util import ( _check_import, _is_local_scheme, iterate_with_retry, ) from ray.data.block import Block, BlockMetadata from ray.data.datasource.datasource import Datasource, ReadTask logger = logging.getLogger(__name__) if TYPE_CHECKING: from fsspec.spec import AbstractFileSystem from pyarrow import fs as pyarrow_fs from zarr import Array as ZarrArray from zarr.hierarchy import Group as ZarrGroup from ray.data.context import DataContext ZarrRoot = ZarrGroup | ZarrArray @dataclass(frozen=True) class ZarrArrayMeta: """``shape``/``chunks``/``dtype`` for a single Zarr v2 array.""" shape: tuple[int, ...] chunks: tuple[int, ...] dtype: str @classmethod def from_zarr_array(cls, arr: "ZarrArray") -> ZarrArrayMeta: return cls( shape=tuple(int(s) for s in arr.shape), chunks=tuple(int(c) for c in arr.chunks), dtype=str(arr.dtype), ) @property def rank(self) -> int: return len(self.shape) @property def itemsize(self) -> int: return np.dtype(self.dtype).itemsize def effective_chunks( self, array_name: str, user_chunk_shape: tuple[int, ...] | dict[str, tuple[int, ...]] | None, ) -> tuple[int, ...]: """Resolve the user's ``chunk_shapes`` override(s) against this array's chunks. A single sequence overrides the leading axes (trailing axes keep the native chunks), so one ``chunk_shapes=[16]`` applies across arrays of different ranks. A dict maps array path → that array's override prefix; arrays absent from it keep native chunks. ``None`` keeps native chunks; an override longer than the array's rank raises ``ValueError``. """ if user_chunk_shape is None: return self.chunks if isinstance(user_chunk_shape, dict): user_chunk_shape = user_chunk_shape.get(array_name) if user_chunk_shape is None: return self.chunks if len(user_chunk_shape) > self.rank: raise ValueError( f"chunk_shapes override for array {array_name!r} has " f"{len(user_chunk_shape)} axes but array of shape " f"{self.shape!r} has rank {self.rank}. Each chunk_shapes " f"override may not be longer than its target array's rank." ) return user_chunk_shape + self.chunks[len(user_chunk_shape) :] def grid_shape(self, chunks: tuple[int, ...]) -> tuple[int, ...]: """Number of chunks along each axis under the given chunk shape.""" return tuple(math.ceil(s / c) for s, c in zip(self.shape, chunks)) def chunk_slices( self, chunk_index: tuple[int, ...], chunks: tuple[int, ...] ) -> tuple[tuple[int, int], ...]: """Per-axis ``(start, stop)`` for ``array[chunk_index]`` under ``chunks``. Trailing-edge chunks are clamped to ``shape[i]``, so they may be shorter than ``chunks[i]``. No padding is applied. """ return tuple( (i * c, min((i + 1) * c, s)) for i, c, s in zip(chunk_index, chunks, self.shape) ) # --------------------------------------------------------------------------- # Chunk reading # --------------------------------------------------------------------------- def _read_chunk( root: ZarrRoot, array_name: str, chunk_slices: tuple[tuple[int, int], ...], retry_match: Optional[List[str]] = None, ) -> np.ndarray: """Read ``array[chunk_slices]`` as an ndarray. The underlying filesystem's own retry policy still applies underneath. """ def _read() -> np.ndarray: indexer = tuple(slice(s, e) for s, e in chunk_slices) arr = root if array_name == "" else root[array_name] return np.asarray(arr[indexer]) if not retry_match: return _read() # TODO(Artur): This would be more elegant with a general retry helper for non-iterables. return next( iterate_with_retry( lambda: [_read()], description="read a Zarr chunk", match=retry_match ) ) @dataclass(frozen=True) class _ChunkRange: """A contiguous slice ``[flat_start, flat_stop)`` of an array's chunk grid. The flat indices address the row-major flattening of the chunk grid; the read fn unravels each to an N-D ``chunk_index`` lazily on the worker. Keeping a range (not a materialized per-chunk list) makes read-task planning O(parallelism) rather than O(total chunks) -- important for stores with very many chunks. """ array_name: str meta: ZarrArrayMeta chunks: tuple[int, ...] grid: tuple[int, ...] flat_start: int flat_stop: int @dataclass(frozen=True) class _AlignedChunkDescriptor: """One wide row: a global axis-0 range ``[t_start, t_stop)`` across the aligned arrays. With ``overlap > 0`` the row's data extends to ``t_stop_data = min(t_stop + overlap, shape[0])`` (lookahead so windows starting in this row reach their tail without crossing a row boundary). """ chunk_index: int t_start: int t_stop: int t_stop_data: int def _create_read_fn( chunk_range: _ChunkRange, root: ZarrRoot, per_task_row_limit: Optional[int], retry_match: Optional[List[str]], ) -> Callable[[], Iterable[Block]]: """Build a callable that materializes one block for a chunk-grid range. This is the case where arrays are not aligned. Chunks are enumerated lazily (on the worker) from ``chunk_range``. ``per_task_row_limit`` caps how many chunks this task reads so a downstream ``limit`` reads only what it needs (``None`` reads the whole range). """ cr = chunk_range stop = cr.flat_stop if per_task_row_limit is not None: stop = min(stop, cr.flat_start + per_task_row_limit) def read_fn() -> Iterable[Block]: builder = DelegatingBlockBuilder() for flat_index in range(cr.flat_start, stop): chunk_index = tuple(int(i) for i in np.unravel_index(flat_index, cr.grid)) chunk_slices = cr.meta.chunk_slices(chunk_index, cr.chunks) builder.add( { "array": cr.array_name, "chunk_index": chunk_index, "chunk_slices": chunk_slices, "chunk": _read_chunk( root, cr.array_name, chunk_slices, retry_match ), } ) yield builder.build() return read_fn def _create_aligned_read_fn( batch: list[_AlignedChunkDescriptor], aligned_array_names: list[str], root: ZarrRoot, per_task_row_limit: Optional[int], retry_match: Optional[List[str]], ) -> Callable[[], Iterable[Block]]: """Build a callable for aligned (wide-row) reads. Each output row carries ``t_start``, ``t_stop``, and one column per aligned array holding that array's ``[t_start:t_stop, ...]`` slice at its natural shape (edge rows may be shorter). All arrays in one row share the same axis-0 range. This is the case where arrays are aligned on axis 0. ``per_task_row_limit`` caps how many rows this task reads (``None`` reads the whole batch). """ batch = batch[:per_task_row_limit] def read_fn() -> Iterable[Block]: builder = DelegatingBlockBuilder() for d in batch: row: dict[str, Any] = {"t_start": d.t_start, "t_stop": d.t_stop} for name in aligned_array_names: row[name] = _read_chunk( root, name, ((d.t_start, d.t_stop_data),), retry_match ) builder.add(row) yield builder.build() return read_fn def _is_positive_int(x) -> bool: """True for a positive integer, including NumPy integers; False for bool.""" return not isinstance(x, bool) and isinstance(x, numbers.Integral) and int(x) > 0 def _validate_chunk_shapes_dict(chunk_shapes: dict) -> dict[str, tuple[int, ...]]: """Normalize chunk_shapes keys to store paths and validate their values.""" from zarr.util import normalize_storage_path normalized: dict[str, tuple[int, ...]] = {} for k, v in chunk_shapes.items(): if ( not isinstance(v, (tuple, list)) or not v or not all(_is_positive_int(x) for x in v) ): raise ValueError( f"chunk_shapes[{k!r}] must be a non-empty sequence of positive " f"integers (list or tuple), got {v!r}" ) normalized[normalize_storage_path(k)] = tuple(int(x) for x in v) return normalized # --------------------------------------------------------------------------- # Datasource # --------------------------------------------------------------------------- class ZarrV2Datasource(Datasource): """Reads one or more Zarr v2 arrays into a Ray Data ``Dataset``. Emits long-form rows (one per chunk per array) or, with ``align_axis_0=True``, wide rows (one per axis-0 chunk, one column per array). See :func:`ray.data.read_zarr` for the row schemas and full API. """ def __init__( self, path: str, filesystem: pyarrow_fs.FileSystem | AbstractFileSystem | None = None, chunk_shapes: dict[str, list] | list | None = None, array_paths: list[str] | None = None, allow_full_metadata_scan: bool = False, align_axis_0: bool = False, overlap: int = 0, ) -> None: super().__init__() _check_import(self, module="zarr", package="zarr") import zarr _check_import(self, module="fsspec", package="fsspec") from fsspec.spec import AbstractFileSystem if int(zarr.__version__.split(".")[0]) >= 3: raise ImportError( f"read_zarr supports zarr-python 2.x (Zarr v2 stores), but found " f"zarr=={zarr.__version__}. Install a compatible version with " f"`pip install 'zarr<3'`." ) self.allow_full_metadata_scan = allow_full_metadata_scan self.paths = [str(path)] # ``local://`` stores live on the driver's local disk, so pin reads to # the driver node (workers on other nodes can't see those files). self._supports_distributed_reads = not _is_local_scheme(self.paths) # Resolve filesystem + store path. The order of precedence: # 1. Explicit ``filesystem=`` always wins. # 2. ``.zip`` URL/path: auto-wrap with fsspec's ZipFileSystem. # 3. Otherwise delegate to Ray Data's standard URL to filesystem # helper (the same one every other ``read_*`` API uses). # "store path" is the path to the Zarr store, relative to the filesystem root. # It is used to construct the Zarr root object. if filesystem is None and self.paths[0].endswith(".zip"): import fsspec self._fs = fsspec.filesystem("zip", fo=self.paths[0]) self._store_path = "" elif filesystem is None: from fsspec.implementations.arrow import ArrowFSWrapper from ray.data.datasource.path_util import ( _resolve_paths_and_filesystem, ) resolved_paths, pa_fs = _resolve_paths_and_filesystem([self.paths[0]]) self._fs = ArrowFSWrapper(pa_fs) self._store_path = resolved_paths[0].rstrip("/") else: from pyarrow.fs import FileSystem if isinstance(filesystem, AbstractFileSystem): self._fs = filesystem elif isinstance(filesystem, FileSystem): from fsspec.implementations.arrow import ArrowFSWrapper self._fs = ArrowFSWrapper(filesystem) else: raise TypeError( f"filesystem must be pyarrow.fs.FileSystem or " f"fsspec.spec.AbstractFileSystem, got " f"{type(filesystem).__name__}" ) from fsspec.implementations.zip import ZipFileSystem if isinstance(self._fs, ZipFileSystem) and self.paths[0].endswith(".zip"): # An explicit archive filesystem: the store is the archive root, # not a ``.zip``-named entry inside it. self._store_path = "" else: from fsspec.core import split_protocol _, store_path = split_protocol(self.paths[0]) self._store_path = store_path.rstrip("/") if chunk_shapes is not None and not isinstance( chunk_shapes, (tuple, list, dict) ): raise ValueError( f"chunk_shapes must be a non-empty sequence of positive " f"integers (list or tuple), or a dict, got {chunk_shapes!r}" ) self.chunk_shapes: tuple[int, ...] | dict[str, tuple[int, ...]] | None = None if chunk_shapes is not None: if isinstance(chunk_shapes, dict): self.chunk_shapes = _validate_chunk_shapes_dict(chunk_shapes) else: if not chunk_shapes or not all( _is_positive_int(x) for x in chunk_shapes ): raise ValueError( "chunk_shapes must be a non-empty sequence of positive integers " f"(list or tuple), got {chunk_shapes!r}" ) self.chunk_shapes = tuple(int(x) for x in chunk_shapes) # Open the store with zarr (consolidated metadata when available). # Detect consolidation by *trying* ``open_consolidated``. store = self._fs.get_mapper(self._store_path) try: self.root = zarr.open_consolidated(store, mode="r") self._consolidated = True except KeyError: self.root = zarr.open(store, mode="r") self._consolidated = False self._metadata_by_path = self._load_metadata(array_paths) if not self._metadata_by_path: raise ValueError( f"No arrays discovered in Zarr store at {self.paths[0]!r}." ) # Reject per-array overrides that do not correspond to any selected # array in this read. if isinstance(self.chunk_shapes, dict): unknown_chunk_shape_keys = sorted( set(self.chunk_shapes) - set(self._metadata_by_path) ) if unknown_chunk_shape_keys: raise ValueError( f"Unknown array path(s) in chunk_shapes: {unknown_chunk_shape_keys}" ) if not align_axis_0: self._aligned_array_names = None else: scalar_arrays = sorted( name for name, meta in self._metadata_by_path.items() if not meta.shape ) if scalar_arrays: raise ValueError( f"align_axis_0=True requires every selected array to have " f"at least one axis, but these are 0-D (scalar): " f"{scalar_arrays}. Drop them with array_paths=[...]." ) shape0_by_array = { name: meta.shape[0] for name, meta in self._metadata_by_path.items() } if len(set(shape0_by_array.values())) > 1: raise ValueError( f"All selected arrays must share shape[0] when " f"align_axis_0=True. Got: {shape0_by_array}. Pass a " f"shape-compatible subset via array_paths=[...]." ) self._aligned_array_names = list(self._metadata_by_path.keys()) # Validate overlap. Only meaningful when arrays are co-iterated as # wide rows, since the trailing lookahead is exposed via the # per-array column being longer than ``t_stop - t_start``. if not isinstance(overlap, int) or overlap < 0: raise ValueError(f"overlap must be a non-negative integer, got {overlap!r}") if overlap and self._aligned_array_names is None: raise ValueError( "overlap requires align_axis_0=True. In the default long-form " "(chunk-per-row) mode, there's no wide row to extend forward — " "the ``chunk_slices`` column on each chunk row already exposes " "the global axis-0 range." ) self.overlap = overlap # Resolve per-array chunk geometry. ``effective_chunks`` raises a # ``ValueError`` if a shared ``chunk_shapes`` prefix or any per-array # ``chunk_shapes`` override is longer than the target array's rank — # so this loop is also where rank validation happens. self._array_chunks: dict[str, tuple[int, ...]] = {} self._array_grids: dict[str, tuple[int, ...]] = {} for name, meta in self._metadata_by_path.items(): chunks = meta.effective_chunks(name, self.chunk_shapes) self._array_chunks[name] = chunks self._array_grids[name] = meta.grid_shape(chunks) # If aligned, all listed arrays must share the same axis-0 chunk size # so each wide row corresponds to one axis-0 step across every array. if self._aligned_array_names is not None: axis_0_chunks = { name: self._array_chunks[name][0] for name in self._aligned_array_names } unique = set(axis_0_chunks.values()) if len(unique) > 1: raise ValueError( f"Aligned arrays must share the same axis-0 chunk size. " f"Got: {axis_0_chunks}. Pass chunk_shapes=[N] (or a " f"per-array chunk_shapes dict that resolves all aligned " f"arrays to the same axis-0 prefix) to re-tile them." ) @property def supports_distributed_reads(self) -> bool: return self._supports_distributed_reads def estimate_inmemory_data_size(self) -> Optional[int]: """Total bytes = sum over selected arrays of ``prod(shape) * itemsize``.""" return sum( math.prod(meta.shape) * meta.itemsize for meta in self._metadata_by_path.values() ) def get_read_tasks( self, parallelism: int, per_task_row_limit: Optional[int] = None, data_context: Optional["DataContext"] = None, ) -> List[ReadTask]: """Enumerate every chunk and wrap it (or batches of chunks) in ReadTasks.""" from ray.data.context import DataContext retry_match = (data_context or DataContext.get_current()).retried_io_errors if self._aligned_array_names is not None: return self._get_aligned_read_tasks( parallelism, per_task_row_limit, retry_match ) return self._get_long_form_read_tasks( parallelism, per_task_row_limit, retry_match ) def _get_long_form_read_tasks( self, parallelism: int, per_task_row_limit: Optional[int], retry_match: Optional[List[str]], ) -> List[ReadTask]: read_tasks: List[ReadTask] = [] for name, meta in self._metadata_by_path.items(): chunks = self._array_chunks[name] grid = self._array_grids[name] n_chunks = math.prod(grid) if n_chunks == 0: continue # Split the chunk grid into contiguous flat-index ranges. This is # O(n_tasks), not O(n_chunks): we never materialize a per-chunk list # on the driver -- the read fn unravels chunks lazily on the worker. n_tasks = max(1, min(parallelism, n_chunks)) batch_size = math.ceil(n_chunks / n_tasks) for flat_start in range(0, n_chunks, batch_size): flat_stop = min(flat_start + batch_size, n_chunks) chunk_range = _ChunkRange( name, meta, chunks, grid, flat_start, flat_stop ) read_tasks.append( ReadTask( _create_read_fn( chunk_range, self.root, per_task_row_limit, retry_match ), BlockMetadata( num_rows=flat_stop - flat_start, size_bytes=self._estimate_range_mem_size(chunk_range), input_files=(self.paths[0],), exec_stats=None, ), per_task_row_limit=per_task_row_limit, ) ) return read_tasks def _estimate_range_mem_size(self, chunk_range: _ChunkRange) -> int: """Upper-bound in-memory bytes for a chunk-grid range. Assumes a full-size chunk per index; trailing-edge chunks are smaller, so this slightly over-estimates. O(1) -- it does not enumerate the range. """ n = chunk_range.flat_stop - chunk_range.flat_start return n * math.prod(chunk_range.chunks) * chunk_range.meta.itemsize def _get_aligned_read_tasks( self, parallelism: int, per_task_row_limit: Optional[int], retry_match: Optional[List[str]], ) -> List[ReadTask]: """Aligned read tasks. See :meth:`get_read_tasks` for semantics.""" assert self._aligned_array_names is not None # All aligned arrays share the same axis-0 chunk size (validated in # ``__init__``) and the same shape[0]. Read the geometry off the first. first_name = self._aligned_array_names[0] axis_0_chunk = self._array_chunks[first_name][0] shape0 = self._metadata_by_path[first_name].shape[0] descriptors = [ _AlignedChunkDescriptor( chunk_index=i, t_start=i * axis_0_chunk, t_stop=min((i + 1) * axis_0_chunk, shape0), t_stop_data=min((i + 1) * axis_0_chunk + self.overlap, shape0), ) for i in range(math.ceil(shape0 / axis_0_chunk)) ] if not descriptors: return [] n_tasks = max(1, min(parallelism, len(descriptors))) batch_size = math.ceil(len(descriptors) / n_tasks) read_tasks: List[ReadTask] = [] for start in range(0, len(descriptors), batch_size): batch = descriptors[start : start + batch_size] read_tasks.append( ReadTask( _create_aligned_read_fn( batch, self._aligned_array_names, self.root, per_task_row_limit, retry_match, ), BlockMetadata( num_rows=len(batch), size_bytes=self._estimate_aligned_batch_mem_size(batch), input_files=(self.paths[0],), exec_stats=None, ), per_task_row_limit=per_task_row_limit, ) ) return read_tasks def _estimate_aligned_batch_mem_size( self, batch: list[_AlignedChunkDescriptor] ) -> int: """Sum bytes across all (row, aligned-array) pairs in a wide-row batch. Accounts for the trailing overlap data each row carries: the row's per-array slice covers ``[t_start, t_stop_data)``, not just ``[t_start, t_stop)``. """ assert self._aligned_array_names is not None return sum( (desc.t_stop_data - desc.t_start) * (math.prod(meta.shape[1:]) if len(meta.shape) > 1 else 1) * meta.itemsize for desc in batch for meta in ( self._metadata_by_path[name] for name in self._aligned_array_names ) ) def _load_metadata(self, array_paths) -> dict[str, ZarrArrayMeta]: """Read ``shape``/``chunks``/``dtype`` for the selected arrays off ``self.root``. zarr validated the store's metadata when it was opened, so this only adapts the resulting ``zarr.Array`` objects. Discovery uses consolidated metadata when present, then explicit ``array_paths``, then an optional full scan (``allow_full_metadata_scan``). If ``array_paths`` is given, the discovered set is filtered down to it. """ import zarr from zarr.util import normalize_storage_path root = self.root requested = ( {normalize_storage_path(p) for p in array_paths} if array_paths else None ) if isinstance(root, zarr.Array): # A store that is itself an array exposes exactly one path: "" (root). # Reject any requested path that isn't the root so a bad ``array_paths`` # fails loudly here instead of silently returning the root array. if requested is not None and requested != {""}: raise ValueError( f"This Zarr store is a single root-level array (path ''), " f"but array_paths={array_paths!r} requested other path(s). " f"Pass array_paths=[''] or omit it." ) return {"": ZarrArrayMeta.from_zarr_array(root)} if not self._consolidated and not self.allow_full_metadata_scan: if requested is None: raise ValueError( "No array_paths were provided and this Zarr store does not " "contain .zmetadata. Pass array_paths=[...] or set " "allow_full_metadata_scan=True." ) out: dict[str, ZarrArrayMeta] = {} for raw in array_paths: name = normalize_storage_path(raw) try: arr = root[name] except KeyError as e: raise ValueError( f"Array path {raw!r} not found in Zarr store." ) from e if not isinstance(arr, zarr.Array): raise ValueError(f"Array path {raw!r} is a group, not an array.") out[name] = ZarrArrayMeta.from_zarr_array(arr) return out all_arrays: dict[str, ZarrArrayMeta] = {} def _collect(name: str, obj) -> None: if isinstance(obj, zarr.Array): all_arrays[name] = ZarrArrayMeta.from_zarr_array(obj) root.visititems(_collect) if requested is not None: missing = sorted(requested - all_arrays.keys()) if missing: raise ValueError( f"Array(s) not found: {', '.join(repr(m) for m in missing)}. " f"Available: {', '.join(repr(a) for a in sorted(all_arrays))}" ) all_arrays = {k: v for k, v in all_arrays.items() if k in requested} return all_arrays