Files
2026-07-13 12:24:33 +08:00

249 lines
7.6 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Tests for AffinityThreadPool."""
# Standard
import threading
import time
# Third Party
import pytest
# First Party
from lmcache.v1.multiprocess.affinity_pool import AffinityThreadPool
def test_submit_returns_correct_result():
pool = AffinityThreadPool(max_workers=2)
future = pool.submit(lambda x: x * 2, 21, affinity_key=0)
assert future.result(timeout=5) == 42
pool.shutdown()
def test_affinity_routing():
"""Tasks with the same affinity_key always run on the same thread."""
pool = AffinityThreadPool(max_workers=4, thread_name_prefix="test-affinity")
results: dict[int, str] = {}
def record_thread(key: int) -> str:
name = threading.current_thread().name
return name
futures = []
for key in [0, 1, 2, 3, 0, 1, 2, 3]:
f = pool.submit(record_thread, key, affinity_key=key)
futures.append((key, f))
for key, f in futures:
name = f.result(timeout=5)
if key in results:
assert results[key] == name, (
f"Key {key} ran on {name} but previously on {results[key]}"
)
else:
results[key] = name
pool.shutdown()
def test_distinct_keys_get_distinct_workers():
"""Up to ``max_workers`` distinct keys each land on their own worker.
Dynamic assignment binds each new key to the next free slot, so any set of
keys (dense or not) within the worker count gets a 1:1 spread across
threads -- no key collides onto a shared slot while another sits idle.
"""
num_workers = 8
pool = AffinityThreadPool(max_workers=num_workers, thread_name_prefix="dense")
def record_thread() -> str:
return threading.current_thread().name
futures = [
(rank, pool.submit(record_thread, affinity_key=rank))
for rank in range(num_workers)
]
threads = {rank: f.result(timeout=5) for rank, f in futures}
# Every rank ran on a distinct worker thread -> no idle workers, no sharing.
assert len(set(threads.values())) == num_workers
# Keys are assigned slots in arrival order, so rank N lands on slot N here.
for rank in range(num_workers):
assert threads[rank] == f"dense-{rank}"
pool.shutdown()
def test_modulo_colliding_keys_get_distinct_workers():
"""Keys that would collide under ``key % num_workers`` still get their own
worker under dynamic assignment.
With 4 workers the keys ``0, 4, 8, 12`` all map to slot 0 under a modulo
scheme -- the bug this pool avoids. Dynamic assignment instead binds them to
slots 0, 1, 2, 3, so each gets a dedicated thread.
"""
pool = AffinityThreadPool(max_workers=4, thread_name_prefix="collide")
def record_thread() -> str:
return threading.current_thread().name
keys = [0, 4, 8, 12] # all == 0 (mod 4)
threads = {
k: pool.submit(record_thread, affinity_key=k).result(timeout=5) for k in keys
}
assert len(set(threads.values())) == 4, threads
pool.shutdown()
def test_overflow_warning_on_shared_slot():
"""More distinct keys than workers wraps onto shared slots and warns once.
LMCache loggers set ``propagate = False``, so assert on the module logger
directly rather than via the ``caplog`` (root-logger) fixture.
"""
# Standard
from unittest.mock import patch
# First Party
from lmcache.v1.multiprocess import affinity_pool as affinity_pool_mod
pool = AffinityThreadPool(max_workers=2, thread_name_prefix="overflow")
with patch.object(affinity_pool_mod.logger, "warning") as mock_warning:
# Keys 0 and 1 fill the two slots; key 2 is the first overflow (wraps
# onto slot 0), and key 3 wraps too but must not warn a second time.
for key in [0, 1, 2, 3]:
pool.submit(lambda: None, affinity_key=key).result(timeout=5)
assert mock_warning.call_count == 1
assert "wrapped onto worker slot" in mock_warning.call_args.args[0]
pool.shutdown()
def test_overflow_keys_share_a_slot():
"""When keys outnumber workers, overflow keys reuse earlier slots."""
pool = AffinityThreadPool(max_workers=2, thread_name_prefix="share")
def record_thread() -> str:
return threading.current_thread().name
threads = {
key: pool.submit(record_thread, affinity_key=key).result(timeout=5)
for key in [0, 1, 2, 3]
}
# Only two threads exist, so the four keys collapse onto two threads, and
# the wrap is deterministic (key 2 shares key 0's slot, key 3 shares key 1's).
assert len(set(threads.values())) == 2
assert threads[0] == threads[2]
assert threads[1] == threads[3]
pool.shutdown()
def test_no_overflow_warning_within_worker_count():
"""Distinct keys within the worker count never warn, even when re-submitted."""
# Standard
from unittest.mock import patch
# First Party
from lmcache.v1.multiprocess import affinity_pool as affinity_pool_mod
pool = AffinityThreadPool(max_workers=4, thread_name_prefix="nooverflow")
with patch.object(affinity_pool_mod.logger, "warning") as mock_warning:
for rank in range(4):
pool.submit(lambda: None, affinity_key=rank).result(timeout=5)
# Re-submitting the same keys reuses their slots -> still no overflow.
for rank in range(4):
pool.submit(lambda: None, affinity_key=rank).result(timeout=5)
assert mock_warning.call_count == 0
pool.shutdown()
def test_same_key_serialization():
"""Tasks with the same affinity key execute sequentially."""
pool = AffinityThreadPool(max_workers=2)
order: list[int] = []
lock = threading.Lock()
def append_value(val: int) -> None:
with lock:
order.append(val)
# Small sleep to ensure ordering matters
time.sleep(0.01)
futures = [pool.submit(append_value, i, affinity_key=42) for i in range(5)]
for f in futures:
f.result(timeout=5)
assert order == [0, 1, 2, 3, 4]
pool.shutdown()
def test_different_keys_parallel():
"""Tasks with different affinity keys can run concurrently."""
pool = AffinityThreadPool(max_workers=2)
barrier = threading.Barrier(2, timeout=5)
def wait_at_barrier() -> bool:
barrier.wait()
return True
f1 = pool.submit(wait_at_barrier, affinity_key=0)
f2 = pool.submit(wait_at_barrier, affinity_key=1)
assert f1.result(timeout=5) is True
assert f2.result(timeout=5) is True
pool.shutdown()
def test_future_exception():
pool = AffinityThreadPool(max_workers=1)
def fail():
raise ValueError("test error")
future = pool.submit(fail, affinity_key=0)
with pytest.raises(ValueError, match="test error"):
future.result(timeout=5)
pool.shutdown()
def test_future_done_callback():
pool = AffinityThreadPool(max_workers=1)
callback_results: list[int] = []
event = threading.Event()
def task():
return 99
future = pool.submit(task, affinity_key=0)
def on_done(fut):
callback_results.append(fut.result())
event.set()
future.add_done_callback(on_done)
event.wait(timeout=5)
assert callback_results == [99]
pool.shutdown()
def test_shutdown_wait():
pool = AffinityThreadPool(max_workers=1)
completed = threading.Event()
def slow_task():
time.sleep(0.1)
completed.set()
pool.submit(slow_task, affinity_key=0)
pool.shutdown(wait=True)
assert completed.is_set()
def test_shutdown_no_wait():
pool = AffinityThreadPool(max_workers=1)
pool.submit(lambda: time.sleep(0.5), affinity_key=0)
# Should return immediately without waiting
pool.shutdown(wait=False)