Files
ray-project--ray/python/ray/data/tests/test_block_ref_counter.py
T
2026-07-13 13:17:40 +08:00

147 lines
5.4 KiB
Python

import gc
import time
import numpy as np
import pytest
import ray
from ray._common.test_utils import wait_for_condition
from ray.data._internal.execution.block_ref_counter import BlockRefCounter
from ray.data._internal.util import MiB
from ray.tests.conftest import * # noqa
# Grace period for asserting a callback has NOT fired. Must be shorter than
# the task sleep in test_task_ref_keeps_counter_alive (1.0s); 0.3s leaves
# wide margin even on slow CI while still surfacing early-fire bugs.
_EARLY_FIRE_GRACE_S = 0.3
@ray.remote
def _hold_ref_for(block_ref, sleep_s: float) -> bool:
"""Hold *block_ref* as a task argument for *sleep_s* seconds, then return.
Ray keeps an object alive for the duration of any task that received it as
an argument, so this lets tests assert the callback has not fired while the
task is still running.
"""
time.sleep(sleep_s)
return True
@pytest.fixture(params=["inlined", "regular"])
def make_block(request):
"""Factory for a block (ObjectRef, size_bytes), parametrized over the two
storage paths.
Ray Core inlines tiny objects in the in-process store and puts larger ones
in the shared-memory object store; the out-of-scope callback must work for
both. Returning a factory (rather than the ref itself) avoids pytest holding
an extra reference that would keep the object alive past the test's own ``del``.
"""
def _make() -> tuple["ray.ObjectRef", int]:
if request.param == "inlined":
data = np.zeros(1, dtype=np.uint8)
else:
data = np.zeros(1 * MiB, dtype=np.uint8)
return ray.put(data), len(data)
return _make
def _wait_for_counter(*, counter, producer_id, expected, timeout_s: float = 10.0):
"""Wait until *counter* reports *expected* bytes for *producer_id*.
``gc.collect()`` runs on each poll so any pending Python-level ObjectRef
destructors get a chance to run; the polling/timeout loop is delegated to
``wait_for_condition`` (raises on timeout).
"""
def _reached():
gc.collect()
return counter.get_object_store_memory_usage(producer_id) == expected
wait_for_condition(_reached, timeout=timeout_s)
class TestBlockRefCounterLifecycle:
def test_callback_fires_after_last_python_ref_deleted(
self, ray_start_regular_shared, make_block
):
"""Counter reaches 0 once the only Python ObjectRef is GC'd."""
counter = BlockRefCounter()
ref, size_bytes = make_block()
counter.on_block_produced(ref, size_bytes, "op_basic")
assert counter.get_object_store_memory_usage("op_basic") == size_bytes
del ref # last Python ref gone
_wait_for_counter(counter=counter, producer_id="op_basic", expected=0)
def test_second_python_ref_keeps_counter_alive(
self, ray_start_regular_shared, make_block
):
"""Counter stays non-zero while a second Python ObjectRef is alive.
Dropping one of two refs that point at the same ObjectID must NOT fire
the callback. Only the final ref drop may do so.
"""
counter = BlockRefCounter()
ref1, size_bytes = make_block()
ref2 = ref1 # second Python ref to the same ObjectID
counter.on_block_produced(ref1, size_bytes, "op_two_refs")
assert counter.get_object_store_memory_usage("op_two_refs") == size_bytes
del ref1
gc.collect()
time.sleep(_EARLY_FIRE_GRACE_S) # counter must still be non-zero
assert (
counter.get_object_store_memory_usage("op_two_refs") == size_bytes
), "Callback fired too early — counter decremented while ref2 was still alive"
del ref2 # last ref gone; callback must now fire
_wait_for_counter(counter=counter, producer_id="op_two_refs", expected=0)
def test_task_ref_keeps_counter_alive_until_task_completes(
self, ray_start_regular_shared
):
"""Counter stays non-zero while a running Ray task holds the block.
Ray keeps any object alive for the duration of a task that received it
as an argument. The callback should not fire until both conditions hold:
(a) the task has completed, and (b) all Python refs are dropped.
Uses a plasma (by-reference) object specifically: tiny objects are
inlined into the task by value, so they would not get a task-argument
reference and this lifetime-extension behavior would not apply.
"""
counter = BlockRefCounter()
ref = ray.put(
np.zeros(1 * MiB, dtype=np.uint8) # pyrefly: ignore[bad-argument-type]
)
counter.on_block_produced(ref, 1 * MiB, "op_task")
assert counter.get_object_store_memory_usage("op_task") == 1 * MiB
# Submit a task that sleeps while holding the block, then drop the Python
# ref so only the task's argument reference remains.
task_future = _hold_ref_for.remote(ref, 1.0)
del ref
gc.collect()
time.sleep(_EARLY_FIRE_GRACE_S) # task still running; callback must NOT fire
assert (
counter.get_object_store_memory_usage("op_task") == 1 * MiB
), "Callback fired too early: counter decremented while task was still running"
ray.get(task_future) # task completes; now both refs are gone
_wait_for_counter(counter=counter, producer_id="op_task", expected=0)
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))