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

462 lines
21 KiB
Python

"""Metadata-fetch strategy for the streaming executor.
``DataOpTask.on_data_ready`` pulls ``(block_ref, meta_ref)`` pairs from a task's
streaming generator; a ``MetadataFetcher`` turns each pair into an emitted
``RefBundle``. Two modes, selected by ``RAY_DATA_METADATA_PREFETCH_ON_THREAD``
(default on):
- :class:`ThreadedMetadataFetcher` (default): defer every pair and fetch its
metadata on a dedicated background thread, so the scheduling loop never blocks
on ``ray.get(meta_ref)``. The output-budget size comes from the block's local
``object_size`` (no RPC); completion is postponed until the task's deferred
pairs have emitted, and the per-operator FIFO preserves emission order.
- :class:`InlineMetadataFetcher`: fetch each pair's metadata inline with
``ray.get`` and emit the ``RefBundle`` immediately, budgeting off
``meta.size_bytes``; completion and task-failure are handled inline by
``on_data_ready``.
"""
import logging
import pickle
import queue as queue_module
import threading
from abc import ABC, abstractmethod
from collections import defaultdict, deque
from collections.abc import Hashable
from enum import Enum
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
import ray
import ray.exceptions
from ray._common.utils import env_bool
from ray.data._internal.execution.interfaces.physical_operator import (
METADATA_GET_TIMEOUT_S,
METADATA_WAIT_TIMEOUT_S,
DataOpTask,
DeferredEmit,
)
from ray.data.block import BlockMetadataWithSchema
from ray.exceptions import GetTimeoutError
from ray.experimental.locations import get_local_object_locations
from ray.util.debug import log_once
logger = logging.getLogger(__name__)
# How long ``ThreadedMetadataFetcher.stop`` waits for the fetch thread to exit.
_FETCH_THREAD_JOIN_TIMEOUT_S = 5.0
# How long the fetch thread's ``ray.wait`` blocks each pass — bounds the
# busy-wait when nothing is ready, and how long a straggler can delay a batch.
_FETCH_WAIT_TIMEOUT_S = 0.1
# Selects the mode (see module docstring). Threaded by default; set to 0/false to
# fall back to the synchronous inline path.
_PREFETCH_ON_THREAD = env_bool("RAY_DATA_METADATA_PREFETCH_ON_THREAD", True)
class MetadataFetcher(ABC):
def start(self) -> None:
"""Start any background machinery."""
def stop(self) -> None:
"""Stop any background machinery."""
@abstractmethod
def in_data_ready_get_object_size(self, task: DataOpTask) -> Optional[int]:
"""Handle one pulled pair inside the ``on_data_ready`` loop. The pair's
refs are read off the task (``task.pending_block_ref`` /
``task.pending_meta_ref``).
Returns the output-budget bytes for this pair (0 if the size is
unknown), or ``None`` to mean "the metadata isn't available yet — stop
and retry next iteration" (the caller breaks, leaving the refs set).
``None`` must be returned ONLY when the pair was NOT consumed: the
caller will hand the same pair back on the next call, so returning
``None`` after emitting/deferring it would emit the block twice.
"""
def in_data_ready_done(self, task: DataOpTask) -> None:
"""Called once a task is drained (generator exhausted/failed)."""
def submit(self, op_key: Hashable, tasks: List[DataOpTask]) -> None:
"""Hand the operator's deferred pairs off for processing, and record
any end-of-stream/failed tasks whose completion is postponed."""
def emit_ready_and_fire_done_callbacks(self) -> List[Tuple[str, BaseException]]:
"""Run once at the end of ``process_completed_tasks``. Returns
``(operator_name, exception)`` for each block-level fetch failure, for
the caller's ``max_errored_blocks`` accounting. Default: nothing to do."""
return []
class InlineMetadataFetcher(MetadataFetcher):
"""Synchronous mode: fetch metadata inline and emit immediately. Holds no
state and starts no thread."""
def in_data_ready_get_object_size(self, task: DataOpTask) -> Optional[int]:
# The ref resolves to pickled metadata bytes, not a BlockMetadata.
meta_ref: "ray.ObjectRef[Any]" = task.pending_meta_ref
try:
# The timeout includes the time to ship the metadata to this node,
# so a 0 timeout could cancel an in-flight download. Use a small
# non-zero value to avoid that.
meta_bytes: bytes = ray.get(meta_ref, timeout=METADATA_GET_TIMEOUT_S)
except ray.exceptions.GetTimeoutError:
# We have refs to the block and its metadata, but the metadata
# object isn't available. This can happen if the node dies. Leave
# the pair pending and retry next iteration.
logger.warning(
f"Timed out ({METADATA_GET_TIMEOUT_S}s) waiting for metadata from "
f"operator '{task.operator_name}' "
f"(metadata_ref={meta_ref.hex()}). "
f"Possible causes include a worker crash, node preemption, or an "
f"overloaded worker or head node. Will retry next iteration. "
f"If this repeats, check the Ray dashboard and logs for worker "
f"crashes, node preemption, or overload."
)
return None
return task.produce_block(task.pending_block_ref, meta_bytes)
def in_data_ready_done(self, task: DataOpTask) -> None:
# Inline mode fires the done-callback the moment the generator drains:
# all of the task's pairs have already emitted inline, so
# ``_pending_emit_count`` is 0. A task failure is re-raised after the
# callback.
task.mark_done()
if task.task_error is not None:
raise task.task_error from None
class _Signal(Enum):
"""Sentinels used by :class:`ThreadedMetadataFetcher`.
``STOP`` is enqueued on the request queue to tell the fetch thread to exit;
``NOT_READY`` marks "ref not fetched yet" in the result store. Members of a
single enum so identity checks narrow cleanly under type checkers.
"""
STOP = "stop"
NOT_READY = "not_ready"
# A request-queue item: a batch of meta_refs to fetch, or the stop sentinel.
_Request = Union[List["ray.ObjectRef"], _Signal]
class ThreadedMetadataFetcher(MetadataFetcher):
"""Asynchronous mode: defer every pulled pair and fetch its metadata on a
dedicated background thread, so the scheduling (executor) thread never blocks
on ``ray.get(meta_refs)``.
The two threads communicate through one thread-safe queue (``_request_q``);
fetched bytes come back via ``_results``. The background thread fetches the
refs ``ray.wait(fetch_local=True)`` reports ready; a ref stuck on a bad node
merely stays pending instead of wedging the thread.
Data flow (for a single operator)::
Executor thread Fetch thread
--------------- ------------
on_data_ready --defer--> _pending_deferred
submit(op) --meta_refs--> _request_q -----> ray.wait(ready)
+ ray.get
|
_results <----- fetched bytes --------+
emit_ready_and_fire_done_callbacks():
_fifos[op]: head [d0] -> [d1] -> [d2] tail (append = yield order)
|
`- emit front-first while its bytes are in
_results; stop at the first pair not back yet,
so this op's RefBundle order is preserved.
Operators each get their own FIFO and are independent, so one operator
waiting on metadata never blocks another.
"""
def __init__(
self,
*,
get_objects: Optional[Callable] = None,
wait_for_objects: Optional[Callable] = None,
get_object_locations: Optional[Callable] = None,
):
"""Create a ThreadedMetadataFetcher.
Args:
get_objects: Fetches object values by ref, like ``ray.get``
(called as ``get_objects(refs, timeout=...)``). Injectable so
tests can drive the fetch path without a real cluster.
wait_for_objects: Reports which refs are locally available, like
``ray.wait`` (called with ``fetch_local=True``).
get_object_locations: Returns per-ref location info (including
``object_size``), like ``get_local_object_locations``.
"""
self._get_objects: Callable = get_objects or ray.get
self._wait_for_objects: Callable = wait_for_objects or ray.wait
self._get_object_locations: Callable = (
get_object_locations or get_local_object_locations
)
self._request_q: "queue_module.Queue[_Request]" = queue_module.Queue()
# fetch thread -> executor: meta_ref -> bytes (or captured Exception).
self._results: Dict["ray.ObjectRef", Any] = {}
self._results_lock = threading.Lock()
# Executor-thread-only state below.
# Pairs deferred by ``in_data_ready_get_object_size`` for the current operator,
# flushed into the FIFOs by ``submit``.
self._pending_deferred: List[DeferredEmit] = []
# Per-operator (keyed by the caller's op key) FIFO of pairs awaiting
# metadata, in append (= emission) order. Each op's deque is drained
# front-first so that op's RefBundle emission order is preserved.
self._fifos: "defaultdict[Hashable, deque[DeferredEmit]]" = defaultdict(deque)
# Drained (end-of-stream/failed) tasks whose done-callback is postponed
# until all of their deferred pairs have been emitted. A set so a task
# re-seen on a later iteration (still pending) isn't registered — or
# fired — twice.
self._drained_tasks: Set[DataOpTask] = set()
self._thread = threading.Thread(
target=self._run, name="ray-data-metadata-prefetch", daemon=True
)
self._started = False
self._stopped = False
def start(self) -> None:
if not self._started:
self._started = True
self._thread.start()
def stop(self) -> None:
if self._stopped:
return
self._stopped = True
self._request_q.put(_Signal.STOP)
if self._started:
try:
self._thread.join(timeout=_FETCH_THREAD_JOIN_TIMEOUT_S)
if self._thread.is_alive():
logger.warning(
"Metadata-fetch thread did not exit within "
f"{_FETCH_THREAD_JOIN_TIMEOUT_S}s; leaving the daemon "
"thread behind."
)
except Exception:
logger.warning(
"Failed to join the metadata-fetch thread.", exc_info=True
)
def in_data_ready_get_object_size(self, task: DataOpTask) -> Optional[int]:
block_ref = task.pending_block_ref
meta_ref = task.pending_meta_ref
# Output-budget size from the block's local object_size (no RPC).
# Normally known: the driver owns the just-yielded block ref, so the
# value (which matches ``meta.size_bytes``) is in the local object
# directory.
# TODO: ``object_size`` is the object-store size of the block, which can
# differ from ``meta.size_bytes`` (the in-memory/logical size). We should
# add an explicit ``object_size_bytes`` to ``BlockMetadata`` and use it
# directly, so the fallback below doesn't conflate the two.
info: Optional[Dict[str, Any]] = self._get_object_locations([block_ref]).get(
block_ref
)
object_size: Optional[int] = (
info.get("object_size") if info is not None else None
)
if object_size is None:
# Rare: no local size record. Fall back to a short metadata
# ``ray.get`` for the size. Log once to flag the path without
# spamming if it recurs.
if log_once(f"data_object_size_unavailable_{task.operator_name}"):
logger.warning(
"Local object_size unavailable for a block from operator "
"'%s'; falling back to its metadata for the output-budget "
"size.",
task.operator_name,
)
try:
meta_with_schema: BlockMetadataWithSchema = pickle.loads(
self._get_objects(meta_ref, timeout=METADATA_WAIT_TIMEOUT_S)
)
except ray.exceptions.GetTimeoutError:
# Metadata isn't local yet either. Leave this pair pending and
# retry next iteration.
return None
# Coalesce a missing size to 0: None is reserved for the "pair not
# consumed, retry" signal above, and this pair IS consumed
# (deferred) below — returning None here would defer it twice.
object_size = meta_with_schema.metadata.size_bytes or 0
self._pending_deferred.append(
DeferredEmit(task=task, block_ref=block_ref, meta_ref=meta_ref)
)
return object_size
def submit(self, op_key: Hashable, tasks: List[DataOpTask]) -> None:
"""Queue the current operator's deferred pairs for metadata fetch +
emission — append them to the op's FIFO (preserving emission order) and
hand their ``meta_ref``s to the fetch thread — and record any drained
(end-of-stream/failed) tasks so their done-callback fires once all of
their deferred pairs have emitted. Must run on the executor thread."""
deferred = self._pending_deferred
self._pending_deferred = []
if deferred:
fifo = self._fifos[op_key]
for d in deferred:
d.task.add_pending_metadata_ref()
fifo.append(d)
self._request_q.put([d.meta_ref for d in deferred])
for task in tasks:
if task.is_drained():
self._drained_tasks.add(task)
def emit_ready_and_fire_done_callbacks(self) -> List[Tuple[str, BaseException]]:
"""Emit whatever's ready (per-op order) then fire postponed done
callbacks. Returns ``(operator_name, exception)`` for each pair whose
metadata fetch failed, for the caller's ``max_errored_blocks``
accounting. Must run on the executor thread."""
return self._emit_ready() + self._fire_done_callbacks()
def _emit_ready(self) -> List[Tuple[str, BaseException]]:
# Emit every pair whose metadata is now available, in per-op append
# order. A failed fetch is accounted as emitted (so the task can still
# complete) but its block is dropped and the error is surfaced to the
# caller rather than raised.
failures: List[Tuple[str, BaseException]] = []
for fifo in self._fifos.values():
while fifo:
d = fifo[0]
result = self._pop_result(d.meta_ref)
if result is _Signal.NOT_READY:
# Preserve order: stop at the first pair still in flight;
# this operator is retried next call.
# TODO: order only needs to be preserved when
# ``DataContext.get_current().execution_options.preserve_order``
# is True; otherwise we could skip past in-flight pairs and
# emit any ready ones.
break
fifo.popleft()
d.task.mark_emitted()
if isinstance(result, BaseException):
failures.append((d.task.operator_name, result))
continue
try:
d.task.produce_block(d.block_ref, result)
except Exception as e:
# Deserializing/emitting the fetched metadata can also fail
# (e.g. ``pickle.loads`` raising on a corrupt object). Treat
# it as a block-level error and route it through the same
# accounting, rather than letting it escape.
failures.append((d.task.operator_name, e))
return failures
def _fire_done_callbacks(self) -> List[Tuple[str, BaseException]]:
# Fire postponed done-callbacks for drained tasks whose pairs have all
# emitted. A failed task fires with its error, which is also surfaced for
# ``max_errored_blocks`` accounting.
if not self._drained_tasks:
return []
failures: List[Tuple[str, BaseException]] = []
to_mark_done = [t for t in self._drained_tasks if not t.has_pending_emits()]
for task in to_mark_done:
if task.task_error is not None:
failures.append((task.operator_name, task.task_error))
task.mark_done()
self._drained_tasks.difference_update(to_mark_done)
return failures
def _pop_result(self, ref: "ray.ObjectRef") -> Any:
with self._results_lock:
return self._results.pop(ref, _Signal.NOT_READY)
def _run(self) -> None:
# Fetch-thread loop: accumulate requested meta_refs into a pending set
# and hand them to ``_fetch``, which fetches the locally-available ones
# and returns those still in flight.
pending: List["ray.ObjectRef"] = []
while True:
# Block on the queue only when idle; while refs are in flight,
# don't block here — get back to ``_fetch`` to keep them moving.
try:
item = self._request_q.get(block=not pending)
except queue_module.Empty:
item = None
# Drain whatever else is already queued into a single fetch batch.
while item is not None:
if isinstance(item, _Signal):
assert item is _Signal.STOP
# ``stop()`` enqueued the STOP sentinel: fast teardown —
# drop any in-flight refs and exit. ``stop`` runs after the
# scheduling loop (which feeds us) is joined, so there's
# nothing left to emit.
return
pending.extend(item)
try:
item = self._request_q.get_nowait()
except queue_module.Empty:
item = None
if pending:
pending = self._fetch(pending)
def _fetch(self, pending: List["ray.ObjectRef"]) -> List["ray.ObjectRef"]:
"""One fetch pass over ``pending``:
1. ``ray.wait(fetch_local=True)`` pulls the metadata objects to this
(driver) node and reports which are locally available.
2. ``ray.get`` the ready refs in one batch (``timeout=0`` — they're
local) and publish the bytes to ``_results``.
3. If the batched get raises (it hides which ref failed), fall back to
per-ref gets to isolate the failure and keep the rest.
4. Return the refs to retry next pass: the not-yet-local ones, plus any
that raced out of the local store. A ref that resolved to an error is
published as that exception for ``_emit_ready`` to surface.
"""
ready, not_ready = self._wait_for_objects(
pending,
num_returns=len(pending),
timeout=_FETCH_WAIT_TIMEOUT_S,
fetch_local=True,
)
if not ready:
return not_ready
retry: List["ray.ObjectRef"] = []
try:
values = self._get_objects(ready, timeout=0)
results: Dict["ray.ObjectRef", Any] = dict(zip(ready, values))
except Exception:
# A batched get raises on the first error and hides which ref
# failed; retry per-ref to isolate it and keep the rest.
results = {}
for ref in ready:
try:
results[ref] = self._get_objects(ref, timeout=0)
except GetTimeoutError:
# ray.wait reported it ready but it's no longer local (e.g. a
# raced eviction). Re-queue rather than treating it as a
# block-level error. Shouldn't be common — log once.
if log_once("ray_data_metadata_prefetch_not_local"):
logger.warning(
"A metadata object reported ready by ray.wait was "
"not locally available for ray.get; re-queuing it. "
"If this repeats, the object store may be under "
"memory pressure (objects evicted/spilled)."
)
retry.append(ref)
except Exception as e:
results[ref] = e
if results:
with self._results_lock:
self._results.update(results)
return not_ready + retry
def make_metadata_fetcher() -> MetadataFetcher:
"""Build the metadata fetcher for the configured mode (see module
docstring)."""
if _PREFETCH_ON_THREAD:
return ThreadedMetadataFetcher()
return InlineMetadataFetcher()