blob: 356e390bf74c7df15de15f7862a801c2b4dbb713 [file]
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""Shared metrics and reusable timers for a Python worker process."""
import threading
import time
from typing import Any, ClassVar, Optional
class _WorkerTimer:
"""Measure one named duration and add each interval to the collector's task total.
The collector owns the duration dictionary. This scope keeps a reference to it and a
start time for its current with block. Nested scopes measure inclusive time.
Repeatedly entering and exiting this context is costly: every interval reads the
clock twice and updates the duration dictionary. In a hot batch loop, accumulate
locally and call add_duration_ns once instead of entering this context per batch.
Pipelined reads can overlap UDF work on another thread. An additive phase breakdown
for that mode would require tracking those overlapping intervals.
"""
def __init__(self, shared_duration_totals_ns: dict[str, int], metric_name: str) -> None:
"""Bind one metric name to the collector's shared duration totals."""
# Keep the collector's dictionary by reference so updates reach its task totals.
self._shared_duration_totals_ns = shared_duration_totals_ns
self._metric_name = metric_name
self._block_start_ns: Optional[int] = None
def __enter__(self) -> None:
"""Start the clock when Python enters the with block."""
# Re-entering this instance would overwrite an unfinished measurement's start time.
if self._block_start_ns is not None:
raise RuntimeError("Worker timer is already running")
self._block_start_ns = time.perf_counter_ns()
def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
"""Accumulate elapsed time when Python leaves the block, including on an exception."""
assert self._block_start_ns is not None
elapsed_ns = time.perf_counter_ns() - self._block_start_ns
self._block_start_ns = None
self._shared_duration_totals_ns[self._metric_name] += elapsed_ns
# Python supplies the exception arguments; returning None lets the error propagate.
def add_duration_ns(self, duration_ns: int) -> None:
"""Flush a duration accumulated in local variables across a hot loop.
This updates the task-bound dictionary once when the loop ends, rather than
calling a timer scope and updating the dictionary for every batch.
"""
self._shared_duration_totals_ns[self._metric_name] += duration_ns
class WorkerMetrics:
"""One collector per Python worker process, reset by the worker before each task.
set stores a supplied value; increment adds to a counter.
set_current_timestamp reads the clock and stores epoch milliseconds.
measure creates timer scopes that accumulate nanoseconds in a separate shared dictionary.
to_dict combines both dictionaries, converting only durations to milliseconds.
"""
_instance: ClassVar[Optional["WorkerMetrics"]] = None
_instance_lock = threading.Lock()
def __new__(cls) -> "WorkerMetrics":
"""Return the shared collector without resetting an active task."""
instance = cls._instance
if instance is None:
with cls._instance_lock:
instance = cls._instance
if instance is None:
instance = super().__new__(cls)
instance.reset()
cls._instance = instance
return instance
def reset(self) -> None:
"""Start fresh task state while keeping the singleton's identity."""
# Counters, spill byte counts, and epoch timestamps need no conversion at report time.
self._values_in_report_units: dict[str, int] = {}
# Existing timers keep the old dictionary. A fresh dictionary keeps their
# measurements out of the next task's report.
self._duration_totals_ns: dict[str, int] = {}
def set_current_timestamp(self, name: str) -> None:
"""Read the current clock and store its epoch milliseconds under name."""
self.set(name, time.time_ns() // 1_000_000)
def set(self, name: str, value: int) -> None:
"""Store the supplied value unchanged, replacing any earlier value."""
self._values_in_report_units[name] = value
def increment(self, name: str, value: int = 1) -> None:
"""Add to a counter in its reporting unit, starting from zero.
The default increment is one.
"""
self._values_in_report_units[name] = self._values_in_report_units.get(name, 0) + value
def measure(self, name: str) -> _WorkerTimer:
"""Create a task-bound duration handle for with or add_duration_ns."""
# Register zero so an empty supported task can still report this timing.
self._duration_totals_ns.setdefault(name, 0)
return _WorkerTimer(self._duration_totals_ns, name)
def to_dict(self) -> dict[str, int]:
"""Build the numeric report dictionary consumed by report_metrics.
Counters, bytes, and epoch timestamps already have their reporting units. Only the
accumulated duration totals require conversion from nanoseconds to milliseconds.
"""
# Round once per task, preserving sub-millisecond contributions from individual batches.
return {
**self._values_in_report_units,
**{name: duration // 1_000_000 for name, duration in self._duration_totals_ns.items()},
}