import logging from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Optional, Union import numpy as np from ray._common.retry import call_with_retry from ray.data._internal.util import _check_import from ray.data.block import BlockMetadata from ray.data.context import DataContext from ray.data.datasource.datasource import Datasource, ReadTask if TYPE_CHECKING: import pyarrow logger = logging.getLogger(__name__) class LanceDatasource(Datasource): """Lance datasource, for reading Lance dataset.""" def __init__( self, uri: str, version: Optional[Union[int, str]] = None, columns: Optional[List[str]] = None, filter: Optional[str] = None, storage_options: Optional[Dict[str, str]] = None, scanner_options: Optional[Dict[str, Any]] = None, ): super().__init__() _check_import(self, module="lance", package="pylance") import lance self._projection_map = None self.uri = uri self.scanner_options = scanner_options or {} if columns is not None: self.scanner_options["columns"] = columns if filter is not None: self.scanner_options["filter"] = filter self.storage_options = storage_options self.lance_ds = lance.dataset( uri=uri, version=version, storage_options=storage_options ) data_context = DataContext.get_current() lance_config = data_context.lance_config match = [] match.extend(lance_config.read_fragments_errors_to_retry) match.extend(data_context.retried_io_errors) self._retry_params = { "description": "read lance fragments", "match": match, "max_attempts": lance_config.read_fragments_max_attempts, "max_backoff_s": lance_config.read_fragments_retry_max_backoff_s, } def supports_predicate_pushdown(self) -> bool: return True def get_read_tasks( self, parallelism: int, per_task_row_limit: Optional[int] = None, data_context: Optional["DataContext"] = None, ) -> List[ReadTask]: read_tasks = [] ds_fragments = self.scanner_options.get("fragments") if ds_fragments is None: ds_fragments = self.lance_ds.get_fragments() # Lance scanner's filter attr accepts only a string (SQL). # See: https://github.com/lance-format/lance/blob/aac74b441cdb6df7d78700dbba33c521e6379ca5/python/python/lance/lance/__init__.pyi#L230 filter_expr = ( str(self._predicate_expr.to_pyarrow()) if self._predicate_expr is not None else None ) filter_from_arg = self.scanner_options.get("filter") if filter_from_arg is not None: filter_expr = ( filter_from_arg if filter_expr is None else f"({filter_expr}) AND ({filter_from_arg})" ) for fragments in np.array_split(ds_fragments, parallelism): if len(fragments) <= 0: continue fragment_ids = [f.metadata.id for f in fragments] num_rows = sum(f.count_rows() for f in fragments) input_files = [ data_file.path() for f in fragments for data_file in f.data_files() ] # TODO(chengsu): Take column projection into consideration for schema. metadata = BlockMetadata( num_rows=num_rows, size_bytes=None, input_files=input_files, exec_stats=None, ) # Use a copy per task to avoid mutation races when tasks run in parallel task_scanner_options = dict(self.scanner_options) if filter_expr is not None: task_scanner_options["filter"] = filter_expr lance_ds = self.lance_ds retry_params = self._retry_params read_task = ReadTask( lambda f=fragment_ids, opts=task_scanner_options: _read_fragments_with_retry( f, lance_ds, opts, retry_params, ), metadata, schema=fragments[0].schema, per_task_row_limit=per_task_row_limit, ) read_tasks.append(read_task) return read_tasks def estimate_inmemory_data_size(self) -> Optional[int]: # TODO(chengsu): Add memory size estimation to improve auto-tune of parallelism. return None def _read_fragments_with_retry( fragment_ids, lance_ds, scanner_options, retry_params, ) -> Iterator["pyarrow.Table"]: return call_with_retry( lambda: _read_fragments(fragment_ids, lance_ds, scanner_options), **retry_params, ) def _read_fragments( fragment_ids, lance_ds, scanner_options, ) -> Iterator["pyarrow.Table"]: """Read Lance fragments in batches. NOTE: Use fragment ids, instead of fragments as parameter, because pickling LanceFragment is expensive. """ import pyarrow fragments = [lance_ds.get_fragment(id) for id in fragment_ids] scanner_options["fragments"] = fragments scanner = lance_ds.scanner(**scanner_options) for batch in scanner.to_reader(): yield pyarrow.Table.from_batches([batch])