chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,660 @@
|
||||
import logging
|
||||
import random
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections import Counter
|
||||
from os import urandom
|
||||
from typing import Callable, Iterator
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pyarrow as pa
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray.data._internal.block_batching.interfaces import (
|
||||
Batch,
|
||||
BatchMetadata,
|
||||
ResolvedBlock,
|
||||
)
|
||||
from ray.data._internal.block_batching.util import (
|
||||
_calculate_ref_hits,
|
||||
blocks_to_batches,
|
||||
collate,
|
||||
finalize_batches,
|
||||
format_batches,
|
||||
iter_threaded,
|
||||
resolve_block_refs,
|
||||
)
|
||||
from ray.data._internal.stats import DatasetStats
|
||||
from ray.data._internal.util import make_async_gen
|
||||
|
||||
logger = logging.getLogger(__file__)
|
||||
|
||||
|
||||
def block_generator(num_rows: int, num_blocks: int):
|
||||
for _ in range(num_blocks):
|
||||
yield pa.table({"foo": [1] * num_rows})
|
||||
|
||||
|
||||
def test_resolve_block_refs(ray_start_regular_shared):
|
||||
block_refs = [ray.put(0), ray.put(1), ray.put(2)]
|
||||
|
||||
resolved_iter = resolve_block_refs(iter(block_refs))
|
||||
resolved = list(resolved_iter)
|
||||
assert all(isinstance(b, ResolvedBlock) for b in resolved)
|
||||
assert [b.block for b in resolved] == [0, 1, 2]
|
||||
|
||||
|
||||
def test_resolve_block_refs_accumulates_data_transfer_timer(
|
||||
ray_start_regular_shared,
|
||||
):
|
||||
"""resolve_block_refs accumulates ray.get() time into iter_get_s and
|
||||
captures a per-block data_transfer TimeSpan."""
|
||||
block_refs = [ray.put(i) for i in range(3)]
|
||||
|
||||
stats = DatasetStats(metadata={}, parent=None)
|
||||
resolved = list(resolve_block_refs(iter(block_refs), stats=stats))
|
||||
|
||||
assert len(resolved) == 3
|
||||
|
||||
# data_transfer TimeSpan captured per block.
|
||||
for r in resolved:
|
||||
assert r.stage_timings is not None
|
||||
assert r.stage_timings.data_transfer is not None
|
||||
assert r.stage_timings.data_transfer.duration >= 0.0
|
||||
|
||||
|
||||
def test_resolve_block_refs_captures_production_wait_span(
|
||||
ray_start_regular_shared,
|
||||
):
|
||||
"""resolve_block_refs captures a per-block production_wait TimeSpan
|
||||
around ``next(block_ref_iter)`` (manual capture, no Timer accumulation)."""
|
||||
block_refs = [ray.put(i) for i in range(3)]
|
||||
|
||||
stats = DatasetStats(metadata={}, parent=None)
|
||||
resolved = list(resolve_block_refs(iter(block_refs), stats=stats))
|
||||
|
||||
assert len(resolved) == 3
|
||||
for r in resolved:
|
||||
assert r.stage_timings is not None
|
||||
assert r.stage_timings.production_wait is not None
|
||||
assert r.stage_timings.production_wait.duration >= 0.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("block_size", [1, 10])
|
||||
@pytest.mark.parametrize("drop_last", [True, False])
|
||||
def test_blocks_to_batches(block_size, drop_last):
|
||||
num_blocks = 5
|
||||
block_iter = block_generator(num_rows=block_size, num_blocks=num_blocks)
|
||||
# Wrap raw blocks in ResolvedBlock (stage_timings=None) as blocks_to_batches now expects
|
||||
wrapped_blocks = (ResolvedBlock(block=b) for b in block_iter)
|
||||
|
||||
batch_size = 3
|
||||
batch_iter = list(
|
||||
blocks_to_batches(wrapped_blocks, batch_size=batch_size, drop_last=drop_last)
|
||||
)
|
||||
|
||||
if drop_last:
|
||||
for batch in batch_iter:
|
||||
assert len(batch.data) == batch_size
|
||||
else:
|
||||
full_batches = 0
|
||||
leftover_batches = 0
|
||||
|
||||
dataset_size = block_size * num_blocks
|
||||
for batch in batch_iter:
|
||||
if len(batch.data) == batch_size:
|
||||
full_batches += 1
|
||||
if len(batch.data) == (dataset_size % batch_size):
|
||||
leftover_batches += 1
|
||||
|
||||
assert leftover_batches == 1
|
||||
assert full_batches == (dataset_size // batch_size)
|
||||
|
||||
assert [batch.metadata.batch_idx for batch in batch_iter] == list(
|
||||
range(len(batch_iter))
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("batch_format", ["pandas", "numpy", "pyarrow"])
|
||||
def test_format_batches(batch_format):
|
||||
block_iter = block_generator(num_rows=2, num_blocks=2)
|
||||
batch_iter = (
|
||||
Batch(BatchMetadata(batch_idx=i), block) for i, block in enumerate(block_iter)
|
||||
)
|
||||
batch_iter = list(format_batches(batch_iter, batch_format=batch_format))
|
||||
|
||||
for batch in batch_iter:
|
||||
if batch_format == "pandas":
|
||||
assert isinstance(batch.data, pd.DataFrame)
|
||||
elif batch_format == "arrow":
|
||||
assert isinstance(batch.data, pa.Table)
|
||||
elif batch_format == "numpy":
|
||||
assert isinstance(batch.data, dict)
|
||||
assert isinstance(batch.data["foo"], np.ndarray)
|
||||
|
||||
assert [batch.metadata.batch_idx for batch in batch_iter] == list(
|
||||
range(len(batch_iter))
|
||||
)
|
||||
|
||||
|
||||
def test_collate():
|
||||
def collate_fn(batch):
|
||||
return pa.table({"bar": [1] * 2})
|
||||
|
||||
batches = [
|
||||
Batch(BatchMetadata(batch_idx=i), data)
|
||||
for i, data in enumerate(block_generator(num_rows=2, num_blocks=2))
|
||||
]
|
||||
batch_iter = collate(batches, collate_fn=collate_fn)
|
||||
|
||||
for i, batch in enumerate(batch_iter):
|
||||
assert batch.metadata.batch_idx == i
|
||||
assert batch.data == pa.table({"bar": [1] * 2})
|
||||
|
||||
|
||||
def test_finalize():
|
||||
def finalize_fn(batch):
|
||||
return pa.table({"bar": [1] * 2})
|
||||
|
||||
batches = [
|
||||
Batch(BatchMetadata(batch_idx=i), data)
|
||||
for i, data in enumerate(block_generator(num_rows=2, num_blocks=2))
|
||||
]
|
||||
batch_iter = finalize_batches(batches, finalize_fn=finalize_fn)
|
||||
|
||||
for i, batch in enumerate(batch_iter):
|
||||
assert batch.metadata.batch_idx == i
|
||||
assert batch.data == pa.table({"bar": [1] * 2})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("preserve_ordering", [True, False])
|
||||
@pytest.mark.parametrize("buffer_size", [0, 1, 2])
|
||||
def test_make_async_gen_fail(buffer_size: int, preserve_ordering):
|
||||
"""Tests that any errors raised in async threads are propagated to the main
|
||||
thread."""
|
||||
|
||||
def gen(base_iterator):
|
||||
raise ValueError("Fail")
|
||||
|
||||
iterator = make_async_gen(
|
||||
base_iterator=iter([1]),
|
||||
fn=gen,
|
||||
preserve_ordering=preserve_ordering,
|
||||
buffer_size=buffer_size,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError) as e:
|
||||
for _ in iterator:
|
||||
pass
|
||||
|
||||
assert e.match("Fail")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("preserve_ordering", [True, False])
|
||||
def test_make_async_gen_varying_seq_length_stress_test(preserve_ordering):
|
||||
"""This test executes make_async_gen against a function generating variable
|
||||
length sequences to stress test its concurrency control.
|
||||
"""
|
||||
|
||||
num_workers = 4
|
||||
|
||||
c = 0
|
||||
|
||||
# Roll the dice 100 times
|
||||
for i in range(100):
|
||||
# Fetch 8b seed from urandom
|
||||
seed = int.from_bytes(urandom(8), byteorder=sys.byteorder)
|
||||
r = random.Random(seed)
|
||||
|
||||
print(f">>> Seed: {seed}")
|
||||
|
||||
# NOTE: Number of seqs >> number of workers
|
||||
# to saturate the input queue
|
||||
num_seqs = num_workers * 10
|
||||
|
||||
lens = list(range(num_seqs))
|
||||
|
||||
r.shuffle(lens)
|
||||
|
||||
source = [range(len_) for len_ in lens]
|
||||
|
||||
print("===" * 8)
|
||||
print(source)
|
||||
print("===" * 8)
|
||||
|
||||
def flatten(list_iter):
|
||||
for l in list_iter:
|
||||
print(f">>> Flattening: {l}")
|
||||
yield from l
|
||||
|
||||
it = make_async_gen(
|
||||
iter(source),
|
||||
flatten,
|
||||
preserve_ordering=preserve_ordering,
|
||||
num_workers=4,
|
||||
buffer_size=1,
|
||||
)
|
||||
|
||||
total = 0
|
||||
|
||||
for i in it:
|
||||
total += i
|
||||
|
||||
assert total == 9880
|
||||
c += 1
|
||||
|
||||
assert c == 100
|
||||
|
||||
|
||||
@pytest.mark.parametrize("preserve_ordering", [True, False])
|
||||
def test_make_async_gen_non_reentrant(preserve_ordering):
|
||||
"""This test is asserting that make_async_gen iterating over the
|
||||
sequence as a whole and not re-entering provided transformation,
|
||||
as this might have substantial performance impact in extreme case
|
||||
of re-entering for every element of the sequence
|
||||
"""
|
||||
|
||||
logs = []
|
||||
finished = False
|
||||
|
||||
def _transform_inner(it):
|
||||
nonlocal finished
|
||||
|
||||
assert not finished
|
||||
|
||||
logs.append(">>> Entering Inner")
|
||||
|
||||
for i in it:
|
||||
logs.append(f">>> Inner: {i}")
|
||||
yield i
|
||||
|
||||
logs.append(">>> Leaving Inner")
|
||||
|
||||
# Once this transform finishes
|
||||
finished = True
|
||||
|
||||
def _transform_b(it):
|
||||
logs.append(">>> Entering Outer")
|
||||
|
||||
for i in _transform_inner(it):
|
||||
logs.append(f">>> Outer: {i}")
|
||||
yield i
|
||||
|
||||
logs.append(">>> Leaving Outer")
|
||||
|
||||
for _ in make_async_gen(
|
||||
iter(range(3)),
|
||||
_transform_b,
|
||||
preserve_ordering=preserve_ordering,
|
||||
):
|
||||
pass
|
||||
|
||||
assert [
|
||||
">>> Entering Outer",
|
||||
">>> Entering Inner",
|
||||
">>> Inner: 0",
|
||||
">>> Outer: 0",
|
||||
">>> Inner: 1",
|
||||
">>> Outer: 1",
|
||||
">>> Inner: 2",
|
||||
">>> Outer: 2",
|
||||
">>> Leaving Inner",
|
||||
">>> Leaving Outer",
|
||||
] == logs
|
||||
|
||||
|
||||
@pytest.mark.parametrize("preserve_ordering", [True, False])
|
||||
@pytest.mark.parametrize(
|
||||
"buffer_size, expected_gen_time",
|
||||
[
|
||||
(0, 5.5), # 5 x 1s + 0.5s buffer
|
||||
(1, 7.5), # 3 x 1s + 2 x 2s (limited buffer delay) + 0.5s buffer
|
||||
(2, 5.5), # 5 x 1s + 0.5s buffer
|
||||
],
|
||||
)
|
||||
def test_make_async_gen_x(buffer_size: int, expected_gen_time, preserve_ordering):
|
||||
"""Tests that make_async_gen overlaps compute."""
|
||||
|
||||
num_items = 5
|
||||
|
||||
def gen(base_iterator):
|
||||
gen_start = time.perf_counter()
|
||||
|
||||
for i in base_iterator:
|
||||
time.sleep(1)
|
||||
yield i
|
||||
print(f">>> ({time.time()}) Generating {i}")
|
||||
|
||||
gen_finish = time.perf_counter()
|
||||
|
||||
# 0.5s buffer
|
||||
assert gen_finish - gen_start < expected_gen_time
|
||||
|
||||
def sleepy_udf(item):
|
||||
time.sleep(2)
|
||||
return item
|
||||
|
||||
iterator = make_async_gen(
|
||||
base_iterator=iter(range(num_items)),
|
||||
fn=gen,
|
||||
preserve_ordering=preserve_ordering,
|
||||
num_workers=1,
|
||||
buffer_size=buffer_size,
|
||||
)
|
||||
|
||||
outputs = []
|
||||
|
||||
iter_start = time.perf_counter()
|
||||
for item in iterator:
|
||||
print(f">>> ({time.time()}) Iterating over {item}")
|
||||
print(item)
|
||||
outputs.append(sleepy_udf(item))
|
||||
iter_finish = time.perf_counter()
|
||||
|
||||
dur_s = iter_finish - iter_start
|
||||
|
||||
print(f">>> Took {dur_s}")
|
||||
|
||||
# 1s to yield first element
|
||||
# 10s to iterate t/h all 5
|
||||
# 0.5s extra buffer
|
||||
assert dur_s < num_items * 2 + 1.5
|
||||
|
||||
# Assert ordering is preserved
|
||||
assert outputs == list(range(num_items))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("preserve_ordering", [True, False])
|
||||
@pytest.mark.parametrize("buffer_size", [0, 1, 2])
|
||||
def test_make_async_gen_multiple_threads(buffer_size: int, preserve_ordering):
|
||||
"""Tests that using multiple threads can overlap compute even more."""
|
||||
|
||||
num_items = 5
|
||||
|
||||
gen_sleep = 2
|
||||
iter_sleep = 3
|
||||
|
||||
def gen(base_iterator):
|
||||
for i in base_iterator:
|
||||
time.sleep(gen_sleep)
|
||||
yield i
|
||||
|
||||
def sleep_udf(item):
|
||||
time.sleep(iter_sleep)
|
||||
return item
|
||||
|
||||
# All 5 items should be fetched concurrently.
|
||||
iterator = make_async_gen(
|
||||
base_iterator=iter(range(num_items)),
|
||||
fn=gen,
|
||||
preserve_ordering=preserve_ordering,
|
||||
num_workers=5,
|
||||
buffer_size=buffer_size,
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Only sleep for first item.
|
||||
elements = [sleep_udf(next(iterator))] + list(iterator)
|
||||
|
||||
# All subsequent items should already be prefetched and should be ready.
|
||||
end_time = time.time()
|
||||
|
||||
# Assert ordering is preserved
|
||||
if preserve_ordering:
|
||||
assert elements == list(range(num_items))
|
||||
|
||||
# - 2 second for every worker to handle their single element
|
||||
# - 3 seconds for overlapping one
|
||||
# - 0.5 seconds buffer
|
||||
assert end_time - start_time < gen_sleep + iter_sleep + 0.5
|
||||
|
||||
|
||||
@pytest.mark.parametrize("preserve_ordering", [True, False])
|
||||
@pytest.mark.parametrize("buffer_size", [0, 1, 2])
|
||||
def test_make_async_gen_multiple_threads_unfinished(
|
||||
buffer_size: int, preserve_ordering
|
||||
):
|
||||
"""Tests that using multiple threads can overlap compute even more.
|
||||
Do not finish iteration with break in the middle.
|
||||
"""
|
||||
|
||||
num_items = 5
|
||||
|
||||
def gen(base_iterator):
|
||||
for i in base_iterator:
|
||||
time.sleep(4)
|
||||
yield i
|
||||
|
||||
def sleep_udf(item):
|
||||
time.sleep(5)
|
||||
return item
|
||||
|
||||
# All 5 items should be fetched concurrently.
|
||||
iterator = make_async_gen(
|
||||
base_iterator=iter(range(num_items)),
|
||||
fn=gen,
|
||||
preserve_ordering=preserve_ordering,
|
||||
num_workers=5,
|
||||
buffer_size=buffer_size,
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Only sleep for first item.
|
||||
sleep_udf(next(iterator))
|
||||
|
||||
# All subsequent items should already be prefetched and should be ready.
|
||||
for i, _ in enumerate(iterator):
|
||||
if i > 2:
|
||||
break
|
||||
end_time = time.time()
|
||||
|
||||
# 4 second for first item, 5 seconds for udf, 0.5 seconds buffer
|
||||
assert end_time - start_time < 9.5
|
||||
|
||||
|
||||
def test_calculate_ref_hits(ray_start_regular_shared):
|
||||
refs = [ray.put(0), ray.put(1)]
|
||||
hits, misses, unknowns = _calculate_ref_hits(refs)
|
||||
# With ctx.enable_get_object_locations_for_metrics set to False
|
||||
# by default, `_calculate_ref_hits` returns -1 for all, since
|
||||
# getting object locations is disabled.
|
||||
assert hits == 0
|
||||
assert misses == 0
|
||||
assert unknowns == 0
|
||||
|
||||
ctx = ray.data.DataContext.get_current()
|
||||
prev_enable_get_object_locations_for_metrics = (
|
||||
ctx.enable_get_object_locations_for_metrics
|
||||
)
|
||||
try:
|
||||
ctx.enable_get_object_locations_for_metrics = True
|
||||
hits, misses, unknowns = _calculate_ref_hits(refs)
|
||||
assert hits == 2
|
||||
assert misses == 0
|
||||
assert unknowns == 0
|
||||
finally:
|
||||
ctx.enable_get_object_locations_for_metrics = (
|
||||
prev_enable_get_object_locations_for_metrics
|
||||
)
|
||||
|
||||
|
||||
def _identity(it: Iterator[int]) -> Iterator[int]:
|
||||
return it
|
||||
|
||||
|
||||
def _duplicate_each(it: Iterator[int]) -> Iterator[int]:
|
||||
for item in it:
|
||||
yield item
|
||||
yield item
|
||||
|
||||
|
||||
class TestIterThreaded:
|
||||
"""Unit tests for ``iter_threaded``."""
|
||||
|
||||
@pytest.mark.parametrize("num_workers", [1, 2, 4])
|
||||
@pytest.mark.parametrize("output_buffer_size", [1, 2, 4])
|
||||
@pytest.mark.parametrize(
|
||||
"fn,multiplier",
|
||||
[(_identity, 1), (_duplicate_each, 2)],
|
||||
ids=["identity", "duplicate"],
|
||||
)
|
||||
def test_processes_all_exactly_once(
|
||||
self,
|
||||
num_workers: int,
|
||||
output_buffer_size: int,
|
||||
fn: Callable[[Iterator[int]], Iterator[int]],
|
||||
multiplier: int,
|
||||
):
|
||||
"""Every input item is consumed and produced exactly the expected
|
||||
number of times across the worker pool (no losses, no duplicates).
|
||||
Output ordering is not required."""
|
||||
items = list(range(50))
|
||||
output = list(
|
||||
iter_threaded(
|
||||
iter(items),
|
||||
fn,
|
||||
num_workers=num_workers,
|
||||
output_buffer_size=output_buffer_size,
|
||||
)
|
||||
)
|
||||
assert len(output) == len(items) * multiplier
|
||||
assert Counter(output) == Counter(items * multiplier)
|
||||
|
||||
def test_stateful_base_iterator_thread_safe(self):
|
||||
"""Python generators are not thread-safe; concurrent ``next()``
|
||||
calls raise ``ValueError: generator already executing`` without
|
||||
a lock. This test passes only if ``iter_threaded`` serializes
|
||||
the underlying ``next()`` properly."""
|
||||
|
||||
def stateful_gen():
|
||||
for i in range(200):
|
||||
# Encourage interleaving across workers.
|
||||
time.sleep(0.001)
|
||||
yield i
|
||||
|
||||
output = list(iter_threaded(stateful_gen(), _identity, num_workers=4))
|
||||
assert sorted(output) == list(range(200))
|
||||
|
||||
@pytest.mark.parametrize("num_workers", [1, 4])
|
||||
def test_fn_exception_propagates(self, num_workers: int):
|
||||
"""An exception raised inside ``fn`` is surfaced to the consumer
|
||||
rather than silently swallowed or hanging the iterator."""
|
||||
|
||||
def fn(it: Iterator[int]) -> Iterator[int]:
|
||||
for i, item in enumerate(it):
|
||||
if i >= 3:
|
||||
raise ValueError("boom")
|
||||
yield item
|
||||
|
||||
it = iter_threaded(iter(range(100)), fn, num_workers=num_workers)
|
||||
with pytest.raises(ValueError, match="boom"):
|
||||
list(it)
|
||||
|
||||
@pytest.mark.parametrize("num_workers", [1, 4])
|
||||
def test_non_generator_fn_construction_raises(self, num_workers: int):
|
||||
"""When ``fn`` is a non-generator function that raises during
|
||||
construction (e.g., setup code before returning the iterator), the
|
||||
exception must surface to the consumer rather than hang. Regression
|
||||
for the case where ``fn(_locked_iter())`` was called outside the
|
||||
worker's try/finally."""
|
||||
|
||||
def fn(it: Iterator[int]) -> Iterator[int]:
|
||||
# Body runs eagerly at call time (not a generator function).
|
||||
raise ValueError("boom in fn construction")
|
||||
|
||||
it = iter_threaded(iter(range(100)), fn, num_workers=num_workers)
|
||||
with pytest.raises(ValueError, match="boom in fn construction"):
|
||||
list(it)
|
||||
|
||||
@pytest.mark.parametrize("num_workers", [1, 4])
|
||||
def test_consumer_break_stops_workers(self, num_workers: int):
|
||||
"""When the consumer breaks early and the iterator is no longer
|
||||
referenced, CPython GCs the generator immediately, which runs the
|
||||
``finally: stopped.set()`` cleanup path. Worker threads should
|
||||
terminate within the ``_put`` poll interval (~100ms) rather than
|
||||
leak."""
|
||||
|
||||
def slow_fn(it: Iterator[int]) -> Iterator[int]:
|
||||
for item in it:
|
||||
time.sleep(0.05)
|
||||
yield item
|
||||
|
||||
# Inline so `break` drops the last reference → GC → finally.
|
||||
for i, _ in enumerate(
|
||||
iter_threaded(iter(range(10_000)), slow_fn, num_workers=num_workers)
|
||||
):
|
||||
if i >= 5:
|
||||
break
|
||||
|
||||
# Workers poll `stopped` every 100ms inside `_put`; give a generous
|
||||
# margin for CI under load.
|
||||
deadline = time.time() + 5.0
|
||||
while time.time() < deadline:
|
||||
alive = [t for t in threading.enumerate() if t.name == "iter_threaded"]
|
||||
if not alive:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
pytest.fail(
|
||||
f"iter_threaded workers did not exit within 5s: "
|
||||
f"{[t.name for t in threading.enumerate() if t.name == 'iter_threaded']}"
|
||||
)
|
||||
|
||||
def test_num_workers_validation(self):
|
||||
with pytest.raises(ValueError, match="num_workers must be at least 1"):
|
||||
list(iter_threaded(iter([1]), _identity, num_workers=0))
|
||||
|
||||
def test_output_buffer_size_validation(self):
|
||||
with pytest.raises(ValueError, match="output_buffer_size must be at least 1"):
|
||||
list(iter_threaded(iter([1]), _identity, output_buffer_size=0))
|
||||
|
||||
def test_empty_base_iterator(self):
|
||||
output = list(iter_threaded(iter([]), _identity, num_workers=4))
|
||||
assert output == []
|
||||
|
||||
@pytest.mark.parametrize("num_workers,output_buffer_size", [(1, 1), (2, 2), (4, 2)])
|
||||
def test_in_flight_items_bounded_by_output_buffer_size(
|
||||
self, num_workers: int, output_buffer_size: int
|
||||
):
|
||||
"""Without consumption, workers must not pull more than
|
||||
``output_buffer_size`` items from the base iterator. Pulled-but-not-
|
||||
consumed items are 'in flight', and the bound caps them."""
|
||||
pulled = 0
|
||||
pulled_lock = threading.Lock()
|
||||
|
||||
def counting_iter() -> Iterator[int]:
|
||||
nonlocal pulled
|
||||
for i in range(1_000_000):
|
||||
with pulled_lock:
|
||||
pulled += 1
|
||||
yield i
|
||||
|
||||
it = iter_threaded(
|
||||
counting_iter(),
|
||||
_identity,
|
||||
num_workers=num_workers,
|
||||
output_buffer_size=output_buffer_size,
|
||||
)
|
||||
|
||||
# Trigger the generator body (which starts the workers), then stop
|
||||
# consuming. Workers fill in-flight up to the bound and then block
|
||||
# on _acquire_slot.
|
||||
next(it)
|
||||
time.sleep(0.3)
|
||||
|
||||
with pulled_lock:
|
||||
# Consumer took 1 → in-flight ≤ K. Plus the 1 already consumed.
|
||||
assert (
|
||||
pulled <= 1 + output_buffer_size
|
||||
), f"Pulled {pulled}, expected <= {1 + output_buffer_size}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
Reference in New Issue
Block a user