"""Ray Data usage-stats collector. Accumulates per-execution usage data (environment, workload description, performance) and flushes it to GCS via ``record_extra_usage_tag``. The usage payload for each execution is assembled by :class:`UsageCallback` this module owns the process-global buffer of recent executions and the builder functions collecting usage data. """ import hashlib import importlib.metadata import json import logging import os import threading from collections import OrderedDict from dataclasses import asdict, dataclass, field from functools import cache from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple import ray from ray._common.usage.usage_lib import ( TagKey, record_extra_usage_tag, usage_stats_enabled, ) from ray._private.internal_api import get_memory_info_reply, get_state_from_address from ray._private.worker import global_worker from ray.core.generated.gcs_pb2 import GcsNodeInfo from ray.data._internal.logical.interfaces import LogicalOperator from ray.data._internal.logical.operators import MapBatches from ray.data._internal.usage.util import anonymize_op_name from ray.data.block import VALID_BATCH_FORMATS, _apply_batch_format if TYPE_CHECKING: from ray.data._internal.execution.interfaces.physical_operator import ( PhysicalOperator, ) from ray.data._internal.issue_detection.issue_detector import IssueType from ray.data._internal.logical.interfaces.logical_plan import LogicalPlan logger = logging.getLogger(__name__) # Bounded timeout for the GCS get_all_node_info query used to count dead nodes. _NODE_INFO_RPC_TIMEOUT_S = 5.0 @dataclass(frozen=True) class OpConfig: """Configuration for an operator""" batch_format: Optional[str] = None @dataclass(frozen=True) class LogicalOp: """An operator in the plan""" usage_id: str name: str config: Optional[OpConfig] = None @dataclass(frozen=True) class PlanNode: """A node in the anonymized plan tree (one logical operator).""" usage_id: str op: str inputs: List["PlanNode"] = field(default_factory=list) @dataclass(frozen=True) class WorkloadInfo: """The anonymized plan tree, a human-readable rendering of it, and the per-op flat list (with config).""" plan: PlanNode plan_str: str ops: List[LogicalOp] @dataclass(frozen=True) class EnvInfo: pyarrow: Optional[str] @dataclass(frozen=True) class PipelinePerf: bytes_spilled: Optional[int] oom_kills: Optional[int] = None unexpected_worker_kills: Optional[int] = None node_deaths: Optional[int] = None @dataclass(frozen=True) class Issue: """An issue detected during execution, tied to an anonymized operator.""" issue_type: str operator: str @dataclass class UsageInfo: """Per-execution usage payload: the entry buffered and flushed to GCS.""" id: str started_at: float env: EnvInfo workload: WorkloadInfo performance: Optional[PipelinePerf] = None detected_issues: List[Issue] = field(default_factory=list) # A callable that records config information for a logical operator. OpConfigFn = Callable[[LogicalOperator], Optional[OpConfig]] # A callable that returns process-wide environment info. Overridable so # subclasses can collect richer env details. EnvFn = Callable[[], EnvInfo] # A callable that returns the anonymized name for a logical operator. # Allows subclasses to add custom anonymization logic. OpNameFn = Callable[[LogicalOperator], str] # A callable that samples a cluster metric (spilled bytes, dead node # count, ...) MetricReader = Callable[[], Optional[int]] # Bounded buffer of recent executions. OrderedDict so eviction picks the # oldest-inserted entry _MAX_EXECUTIONS_TO_TRACK = 100 # Module state. Mutations are serialized through ``_lock``. _executions: "OrderedDict[str, UsageInfo]" = OrderedDict() _lock = threading.Lock() def usage_collection_disabled() -> bool: """True when the user has opted out of usage stats (via ``RAY_USAGE_STATS_ENABLED=0``, ``ray disable-usage-stats``, or ``~/.ray/config.json``) or when ``RAY_DATA_USAGE_DISABLED=1`` is set. """ return not usage_stats_enabled() or os.environ.get("RAY_DATA_USAGE_DISABLED") == "1" def cluster_spilled_bytes() -> Optional[int]: """Cluster-wide cumulative spilled bytes from Ray core's store_stats. Returns None on any failure — usage collection must never break execution. """ if not ray.is_initialized(): return None try: reply = get_memory_info_reply( get_state_from_address(ray.get_runtime_context().gcs_address), timeout_seconds=10.0, ) return int(reply.store_stats.spilled_bytes_total) except Exception: logger.debug("Failed to read cluster spilled bytes", exc_info=True) return None def cluster_dead_node_count() -> Optional[int]: """Number of dead nodes in the GCS node table. Queries GCS with a bounded timeout and a server-side DEAD state filter. Returns None on any failure. """ if not ray.is_initialized(): return None try: gcs_client = global_worker.gcs_client # pyrefly: ignore[missing-attribute] dead_nodes = gcs_client.get_all_node_info( timeout=_NODE_INFO_RPC_TIMEOUT_S, state_filter=GcsNodeInfo.GcsNodeState.DEAD, ) return len(dead_nodes) except Exception: logger.debug("Failed to read cluster dead node count", exc_info=True) return None def compute_delta(start: Optional[int], end: Optional[int]) -> Optional[int]: """Non-negative delta between two cumulative samples. Returns None if either sample is missing""" if start is None or end is None: return None return max(0, end - start) def record_usage_info(info: UsageInfo) -> None: """Buffer ``info`` (evicting the oldest entry when full) and flush the whole buffer to GCS via ``record_extra_usage_tag``. The callback calls this both before execution starts (so attempted executions are captured even if the execution fails) and after it finishes (to overwrite the same entry with performance and issue data). Short-circuits when the user has opted out of Ray usage stats (via ``RAY_USAGE_STATS_ENABLED=0``, ``ray disable-usage-stats``, or ``~/.ray/config.json``) or when ``RAY_DATA_USAGE_DISABLED=1`` is set. """ if usage_collection_disabled(): return try: with _lock: if ( info.id not in _executions and len(_executions) >= _MAX_EXECUTIONS_TO_TRACK ): _executions.popitem(last=False) _executions[info.id] = info payload = _serialize_locked() record_extra_usage_tag(TagKey.DATA_USAGE, payload) except Exception: logger.debug("Failed to record usage info", exc_info=True) def build_usage_id_map( logical_plan: "LogicalPlan", op_name_fn: Optional[OpNameFn] = None, ) -> Dict[int, str]: """Build the ``id(logical_op) -> usage_id`` map for a plan. The IDs are computed based on the hash of the (post-order index, anonymized name) tuple. These are used to identify logical ops after anonymization (i.e. MapBatches-, MapBatches-, etc.). Short-circuits to an empty map when the user has opted out of usage stats: without a recorded payload there is nothing for the IDs to reference. """ if usage_collection_disabled(): return {} if op_name_fn is None: op_name_fn = anonymize_op_name try: ordered_logical_ops: List[Tuple[LogicalOperator, str]] = [] _build_plan(logical_plan.dag, ordered_logical_ops, op_name_fn) return {id(op): usage_id for op, usage_id in ordered_logical_ops} except Exception: logger.debug("Failed to build usage id map", exc_info=True) return {} def physical_op_name_with_id( operator: "PhysicalOperator", usage_id_map: Optional[Dict[int, str]] = None, op_name_fn: Optional[OpNameFn] = None, ) -> str: """Anonymized name for a physical op. Fused ops join their constituent logical ops with '->' to signal operator fusion. We need physical op name as issues from the issue detector reference physical ops.""" if op_name_fn is None: op_name_fn = anonymize_op_name logical_ops = operator._logical_operators if not logical_ops: return "Unknown" return "->".join( _logical_op_name_with_id(op, usage_id_map, op_name_fn) for op in logical_ops ) def _logical_op_name_with_id( logical_op: LogicalOperator, usage_id_map: Optional[Dict[int, str]] = None, op_name_fn: OpNameFn = anonymize_op_name, ) -> str: """Logical op is formatted as ``-``. The usage ID map is populated before execution starts in the usage callback.""" name = op_name_fn(logical_op) if usage_id_map: # Correlate with the IDs assigned to the logical ops in the workload plan. usage_id = usage_id_map.get(id(logical_op)) if usage_id is not None: return f"{name}-{usage_id}" return name def collect_issues( detected_issues: Optional[List[Tuple["IssueType", str]]], ) -> List[Issue]: """Convert (issue_type, operator) pairs into ``Issue`` records, mapping each ``IssueType`` enum to its string value.""" if not detected_issues: return [] return [ Issue(issue_type=issue_type.value, operator=operator) for issue_type, operator in detected_issues ] def _serialize_locked() -> str: """Serialize current state to JSON. Caller must hold ``_lock``.""" return json.dumps({"executions": [asdict(e) for e in _executions.values()]}) def collect_env() -> EnvInfo: """Process-wide environment info.""" return EnvInfo(pyarrow=_safe_version("pyarrow")) def _safe_version(pkg: str) -> Optional[str]: try: return importlib.metadata.version(pkg) except importlib.metadata.PackageNotFoundError: return None def collect_workload( logical_plan: "LogicalPlan", op_config_fn: Optional[OpConfigFn] = None, op_name_fn: Optional[OpNameFn] = None, ) -> WorkloadInfo: """Collect the anonymized plan tree, indented text rendering, and per-op config list in a single DAG walk. ``op_config_fn`` builds the per-op config; it defaults to ``collect_op_config`` and is overridable if subclasses need to extract custom config info. ``op_name_fn`` anonymizes each operator's name; it defaults to ``anonymize_op_name`` and is overridable the same way. """ if op_config_fn is None: op_config_fn = collect_op_config if op_name_fn is None: op_name_fn = anonymize_op_name dag = logical_plan.dag ordered_logical_ops: List[Tuple[LogicalOperator, str]] = [] plan = _build_plan(dag, ordered_logical_ops, op_name_fn) return WorkloadInfo( plan=plan, plan_str=_format_plan_str(dag, op_name_fn), ops=_build_ops(ordered_logical_ops, op_config_fn, op_name_fn), ) def _build_plan( op: LogicalOperator, ordered_logical_ops: List[Tuple[LogicalOperator, str]], op_name_fn: OpNameFn, ) -> PlanNode: """Build the plan tree and record logical ops in post-order. Deduplicates shared operator instances (e.g. ``ds.zip(ds)``), so each operator is assigned a single usage_id even when reachable via multiple plan branches. """ @cache def _build_cached(op: LogicalOperator) -> PlanNode: child_plans = [_build_cached(child) for child in op.input_dependencies] name = op_name_fn(op) usage_id = make_usage_op_id(len(ordered_logical_ops), name) ordered_logical_ops.append((op, usage_id)) return PlanNode(usage_id=usage_id, op=name, inputs=child_plans) return _build_cached(op) def _build_ops( ordered_logical_ops: List[Tuple[LogicalOperator, str]], op_config_fn: OpConfigFn, op_name_fn: OpNameFn, ) -> List[LogicalOp]: """Build the flat logical-op list from the canonical post-order traversal.""" ops: List[LogicalOp] = [] for op, usage_id in ordered_logical_ops: name = op_name_fn(op) ops.append( LogicalOp( usage_id=usage_id, name=name, config=op_config_fn(op), ) ) return ops def collect_op_config(op: LogicalOperator) -> Optional[OpConfig]: # MapBatches is the only operator with a user-facing batch_format. if not isinstance(op, MapBatches): return None batch_format = op.batch_format if batch_format == "default": batch_format = _apply_batch_format(batch_format) if batch_format in VALID_BATCH_FORMATS: return OpConfig(batch_format=batch_format) logger.debug(f"Unexpected batch format: {batch_format!r}") return OpConfig(batch_format="unknown") def make_usage_op_id(index: int, name: str) -> str: return hashlib.sha256(f"{index}:{name}".encode()).hexdigest()[:4] def _format_plan_str( op: LogicalOperator, op_name_fn: OpNameFn = anonymize_op_name, depth: int = 0, ) -> str: """Render the anonymized DAG as an indented tree, using ``op_name_fn`` to avoid leaking UDF / datasource details. """ name = op_name_fn(op) if depth == 0: line = f"{name}\n" else: line = f"{' ' * ((depth - 1) * 3)}+- {name}\n" for child in op.input_dependencies: line += _format_plan_str(child, op_name_fn, depth + 1) return line def reset_for_testing() -> None: """Reset module state. Tests only.""" with _lock: _executions.clear() def get_executions() -> "OrderedDict[str, UsageInfo]": """Get the current executions. Tests only.""" with _lock: return _executions.copy()