486 lines
17 KiB
Python
486 lines
17 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""
|
|
Tests for the P2P protocol and P2PController: enum registration, protocol
|
|
definitions, MemoryLayoutDesc wire serialization, and server handlers.
|
|
"""
|
|
|
|
# Standard
|
|
from unittest.mock import MagicMock
|
|
|
|
# Third Party
|
|
import httpx
|
|
import torch
|
|
|
|
# First Party
|
|
from lmcache.v1.distributed.api import MemoryLayoutDesc, ObjectKey
|
|
from lmcache.v1.distributed.l2_adapters.p2p_l2_adapter import P2PL2AdapterConfig
|
|
from lmcache.v1.distributed.transfer_channel.api import TransferChannelAddress
|
|
from lmcache.v1.multiprocess.config import CoordinatorConfig, P2PConfig
|
|
from lmcache.v1.multiprocess.modules.p2p_controller import (
|
|
_MAX_MISSES,
|
|
P2PController,
|
|
_P2PState,
|
|
_PeerInstance,
|
|
)
|
|
from lmcache.v1.multiprocess.mq import msgspec_decode, msgspec_encode
|
|
from lmcache.v1.multiprocess.protocol import (
|
|
RequestType,
|
|
get_handler_type,
|
|
get_payload_classes,
|
|
get_response_class,
|
|
)
|
|
from lmcache.v1.multiprocess.protocols.base import HandlerType
|
|
|
|
|
|
def _make_key(i: int) -> ObjectKey:
|
|
return ObjectKey(
|
|
chunk_hash=f"hash{i}".encode(),
|
|
model_name="test_model",
|
|
kv_rank=1,
|
|
)
|
|
|
|
|
|
def _make_layout_desc() -> MemoryLayoutDesc:
|
|
return MemoryLayoutDesc(
|
|
shapes=[torch.Size([2, 3]), torch.Size([4])],
|
|
dtypes=[torch.float16, torch.bfloat16],
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Protocol definition tests
|
|
# ============================================================================
|
|
|
|
|
|
def test_p2p_request_types_registered():
|
|
"""The three P2P request types should be members of RequestType."""
|
|
for name in (
|
|
"P2P_LOOKUP_AND_LOCK",
|
|
"P2P_QUERY_LOOKUP_RESULTS",
|
|
"P2P_UNLOCK_OBJECTS",
|
|
):
|
|
assert hasattr(RequestType, name)
|
|
assert isinstance(getattr(RequestType, name), RequestType)
|
|
|
|
|
|
def test_p2p_lookup_and_lock_protocol():
|
|
"""P2P_LOOKUP_AND_LOCK payload is [list[ObjectKey], MemoryLayoutDesc],
|
|
returns int, and is BLOCKING."""
|
|
payload_classes = get_payload_classes(RequestType.P2P_LOOKUP_AND_LOCK)
|
|
assert payload_classes == [list[ObjectKey], MemoryLayoutDesc]
|
|
assert get_response_class(RequestType.P2P_LOOKUP_AND_LOCK) is int
|
|
assert get_handler_type(RequestType.P2P_LOOKUP_AND_LOCK) == HandlerType.BLOCKING
|
|
|
|
|
|
def test_p2p_query_lookup_results_protocol():
|
|
"""P2P_QUERY_LOOKUP_RESULTS payload is [int], returns the optional address
|
|
list, and is BLOCKING."""
|
|
assert get_payload_classes(RequestType.P2P_QUERY_LOOKUP_RESULTS) == [int]
|
|
assert (
|
|
get_response_class(RequestType.P2P_QUERY_LOOKUP_RESULTS)
|
|
== list[TransferChannelAddress] | None
|
|
)
|
|
assert (
|
|
get_handler_type(RequestType.P2P_QUERY_LOOKUP_RESULTS) == HandlerType.BLOCKING
|
|
)
|
|
|
|
|
|
def test_p2p_unlock_objects_protocol():
|
|
"""P2P_UNLOCK_OBJECTS payload is [list[ObjectKey]], returns None, BLOCKING."""
|
|
assert get_payload_classes(RequestType.P2P_UNLOCK_OBJECTS) == [list[ObjectKey]]
|
|
assert get_response_class(RequestType.P2P_UNLOCK_OBJECTS) is None
|
|
assert get_handler_type(RequestType.P2P_UNLOCK_OBJECTS) == HandlerType.BLOCKING
|
|
|
|
|
|
# ============================================================================
|
|
# MemoryLayoutDesc serialization tests
|
|
# ============================================================================
|
|
|
|
|
|
def test_memory_layout_desc_mq_roundtrip():
|
|
"""The mq encode/decode dispatch must round-trip MemoryLayoutDesc, whose
|
|
torch.Size / torch.dtype fields ride the customized enc_hook / dec_hook."""
|
|
desc = MemoryLayoutDesc(
|
|
shapes=[torch.Size([8, 16, 128]), torch.Size([4])],
|
|
dtypes=[torch.float32, torch.int8],
|
|
)
|
|
decoded = msgspec_decode(
|
|
msgspec_encode(desc, cls=MemoryLayoutDesc), cls=MemoryLayoutDesc
|
|
)
|
|
assert decoded == desc
|
|
assert all(isinstance(s, torch.Size) for s in decoded.shapes)
|
|
assert all(isinstance(d, torch.dtype) for d in decoded.dtypes)
|
|
|
|
|
|
def test_memory_layout_desc_empty_mq_roundtrip():
|
|
"""An empty layout descriptor must round-trip through the mq dispatch."""
|
|
desc = MemoryLayoutDesc(shapes=[], dtypes=[])
|
|
decoded = msgspec_decode(
|
|
msgspec_encode(desc, cls=MemoryLayoutDesc), cls=MemoryLayoutDesc
|
|
)
|
|
assert decoded == desc
|
|
|
|
|
|
# ============================================================================
|
|
# TransferChannelAddress tests
|
|
# ============================================================================
|
|
|
|
|
|
def test_transfer_channel_address_validity():
|
|
"""A non-negative offset is valid; a negative one is not."""
|
|
assert TransferChannelAddress(offset=128, size=64).is_valid()
|
|
assert not TransferChannelAddress(offset=-1, size=0).is_valid()
|
|
|
|
|
|
# ============================================================================
|
|
# Server handler tests
|
|
# ============================================================================
|
|
|
|
|
|
def _make_controller() -> tuple[P2PController, MagicMock]:
|
|
"""Build a P2P-disabled controller (no thread / transfer channel)."""
|
|
ctx = MagicMock()
|
|
controller = P2PController(
|
|
ctx,
|
|
P2PConfig(),
|
|
CoordinatorConfig(),
|
|
instance_id="self",
|
|
)
|
|
return controller, ctx
|
|
|
|
|
|
def test_lookup_and_lock_submits_skip_l2_and_returns_task_id():
|
|
"""p2p_lookup_and_lock submits a skip_l2 prefetch and returns a fresh id."""
|
|
controller, ctx = _make_controller()
|
|
handle = MagicMock(l1_found_indices=(0, 1))
|
|
ctx.storage_manager.submit_prefetch_task.return_value = handle
|
|
|
|
keys = [_make_key(0), _make_key(1)]
|
|
layout_desc = _make_layout_desc()
|
|
task_id = controller.p2p_lookup_and_lock(keys, layout_desc)
|
|
|
|
assert task_id == 0
|
|
args, kwargs = ctx.storage_manager.submit_prefetch_task.call_args
|
|
assert args[0] == keys
|
|
assert args[1] is layout_desc
|
|
assert kwargs["skip_l2"] is True
|
|
# A second call gets a distinct id.
|
|
assert controller.p2p_lookup_and_lock(keys, layout_desc) == 1
|
|
|
|
|
|
def test_query_lookup_results_builds_addresses_for_prefix():
|
|
"""A completed lookup returns one address per key: real offsets for the
|
|
found prefix, invalid offsets for the rest."""
|
|
controller, ctx = _make_controller()
|
|
handle = MagicMock(l1_found_indices=(0, 1))
|
|
ctx.storage_manager.submit_prefetch_task.return_value = handle
|
|
|
|
keys = [_make_key(0), _make_key(1), _make_key(2)]
|
|
task_id = controller.p2p_lookup_and_lock(keys, _make_layout_desc())
|
|
|
|
found = MagicMock()
|
|
found.count_leading_ones.return_value = 2
|
|
ctx.storage_manager.query_prefetch_status.return_value = found
|
|
obj0 = MagicMock(shm_offset=100, shm_byte_length=10)
|
|
obj1 = MagicMock(shm_offset=200, shm_byte_length=20)
|
|
ctx.storage_manager.unsafe_read.return_value = ([keys[0], keys[1]], [obj0, obj1])
|
|
|
|
addresses = controller.p2p_query_lookup_results(task_id)
|
|
assert addresses == [
|
|
TransferChannelAddress(offset=100, size=10),
|
|
TransferChannelAddress(offset=200, size=20),
|
|
TransferChannelAddress(offset=-1, size=0),
|
|
]
|
|
|
|
|
|
def test_query_lookup_results_exactly_once():
|
|
"""Re-querying a completed task returns None (the job is consumed)."""
|
|
controller, ctx = _make_controller()
|
|
ctx.storage_manager.submit_prefetch_task.return_value = MagicMock(
|
|
l1_found_indices=()
|
|
)
|
|
task_id = controller.p2p_lookup_and_lock([_make_key(0)], _make_layout_desc())
|
|
|
|
found = MagicMock()
|
|
found.count_leading_ones.return_value = 0
|
|
ctx.storage_manager.query_prefetch_status.return_value = found
|
|
|
|
assert controller.p2p_query_lookup_results(task_id) == [
|
|
TransferChannelAddress(offset=-1, size=0)
|
|
]
|
|
assert controller.p2p_query_lookup_results(task_id) is None
|
|
|
|
|
|
def test_query_lookup_results_unknown_task():
|
|
"""Querying an unknown task id returns None."""
|
|
controller, _ = _make_controller()
|
|
assert controller.p2p_query_lookup_results(999) is None
|
|
|
|
|
|
def test_query_lookup_results_in_progress():
|
|
"""A lookup whose prefetch is not done yet returns None without consuming
|
|
the job."""
|
|
controller, ctx = _make_controller()
|
|
ctx.storage_manager.submit_prefetch_task.return_value = MagicMock(
|
|
l1_found_indices=()
|
|
)
|
|
task_id = controller.p2p_lookup_and_lock([_make_key(0)], _make_layout_desc())
|
|
|
|
ctx.storage_manager.query_prefetch_status.return_value = None
|
|
assert controller.p2p_query_lookup_results(task_id) is None
|
|
# Job is still alive; status flips to done on the next poll.
|
|
found = MagicMock()
|
|
found.count_leading_ones.return_value = 0
|
|
ctx.storage_manager.query_prefetch_status.return_value = found
|
|
assert controller.p2p_query_lookup_results(task_id) is not None
|
|
|
|
|
|
def test_unlock_objects_calls_finish_read_prefetched():
|
|
"""p2p_unlock_objects forwards the keys to finish_read_prefetched."""
|
|
controller, ctx = _make_controller()
|
|
keys = [_make_key(0), _make_key(1)]
|
|
controller.p2p_unlock_objects(keys)
|
|
ctx.storage_manager.finish_read_prefetched.assert_called_once_with(keys)
|
|
|
|
|
|
def test_unlock_objects_empty_is_noop():
|
|
"""Unlocking an empty key list does nothing."""
|
|
controller, ctx = _make_controller()
|
|
controller.p2p_unlock_objects([])
|
|
ctx.storage_manager.finish_read_prefetched.assert_not_called()
|
|
|
|
|
|
def test_report_status_counts_active_jobs():
|
|
"""report_status reflects the number of in-flight lookup jobs."""
|
|
controller, ctx = _make_controller()
|
|
ctx.storage_manager.submit_prefetch_task.return_value = MagicMock(
|
|
l1_found_indices=()
|
|
)
|
|
assert controller.report_status()["active_p2p_lookup_jobs"] == 0
|
|
controller.p2p_lookup_and_lock([_make_key(0)], _make_layout_desc())
|
|
status = controller.report_status()
|
|
assert status["active_p2p_lookup_jobs"] == 1
|
|
assert status["p2p_enabled"] is False
|
|
assert status["p2p_state"] == _P2PState.UNREGISTERED.value
|
|
|
|
|
|
def test_get_handlers_covers_all_p2p_request_types():
|
|
"""get_handlers wires exactly the three P2P request types."""
|
|
controller, _ = _make_controller()
|
|
request_types = {spec.request_type for spec in controller.get_handlers()}
|
|
assert request_types == {
|
|
RequestType.P2P_LOOKUP_AND_LOCK,
|
|
RequestType.P2P_QUERY_LOOKUP_RESULTS,
|
|
RequestType.P2P_UNLOCK_OBJECTS,
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# Orchestration: adapter reconcile
|
|
# ============================================================================
|
|
|
|
|
|
def _peer(
|
|
instance_id: str,
|
|
url: str = "tc-host:9",
|
|
ip: str = "10.0.0.2",
|
|
mq_port: int = 5555,
|
|
) -> _PeerInstance:
|
|
return _PeerInstance(
|
|
instance_id=instance_id,
|
|
ip=ip,
|
|
p2p_advertised_url=url,
|
|
mq_port=mq_port,
|
|
)
|
|
|
|
|
|
def test_reconcile_adds_new_peer():
|
|
"""A newly discovered peer gets one P2P L2 adapter with the right urls."""
|
|
controller, ctx = _make_controller()
|
|
ctx.storage_manager.add_l2_adapter.return_value = 7
|
|
|
|
controller._apply_state(_P2PState.REGISTERED, {"peerA": _peer("peerA")})
|
|
|
|
ctx.storage_manager.add_l2_adapter.assert_called_once()
|
|
config = ctx.storage_manager.add_l2_adapter.call_args.args[0]
|
|
assert isinstance(config, P2PL2AdapterConfig)
|
|
assert config.peer_mq_server_url == "tcp://10.0.0.2:5555"
|
|
assert config.peer_transfer_channel_server_url == "tc-host:9"
|
|
assert controller.report_status()["p2p_peers"] == ["peerA"]
|
|
|
|
|
|
def test_reconcile_no_op_when_peer_unchanged():
|
|
"""A still-present, unchanged peer is not re-added on the next cycle."""
|
|
controller, ctx = _make_controller()
|
|
ctx.storage_manager.add_l2_adapter.return_value = 7
|
|
|
|
controller._apply_state(_P2PState.REGISTERED, {"peerA": _peer("peerA")})
|
|
controller._apply_state(_P2PState.REGISTERED, {"peerA": _peer("peerA")})
|
|
|
|
ctx.storage_manager.add_l2_adapter.assert_called_once()
|
|
ctx.storage_manager.delete_l2_adapter.assert_not_called()
|
|
|
|
|
|
def test_reconcile_keeps_peer_within_grace():
|
|
"""A peer absent for up to _MAX_MISSES cycles keeps its adapter."""
|
|
controller, ctx = _make_controller()
|
|
ctx.storage_manager.add_l2_adapter.return_value = 7
|
|
|
|
controller._apply_state(_P2PState.REGISTERED, {"peerA": _peer("peerA")})
|
|
for _ in range(_MAX_MISSES):
|
|
controller._apply_state(_P2PState.REGISTERED, {})
|
|
|
|
ctx.storage_manager.delete_l2_adapter.assert_not_called()
|
|
assert controller.report_status()["p2p_peers"] == ["peerA"]
|
|
|
|
|
|
def test_reconcile_removes_peer_after_grace():
|
|
"""A peer absent beyond _MAX_MISSES cycles has its adapter removed."""
|
|
controller, ctx = _make_controller()
|
|
ctx.storage_manager.add_l2_adapter.return_value = 7
|
|
|
|
controller._apply_state(_P2PState.REGISTERED, {"peerA": _peer("peerA")})
|
|
for _ in range(_MAX_MISSES + 1):
|
|
controller._apply_state(_P2PState.REGISTERED, {})
|
|
|
|
ctx.storage_manager.delete_l2_adapter.assert_called_once_with(7)
|
|
assert controller.report_status()["p2p_peers"] == []
|
|
|
|
|
|
def test_reconcile_readds_on_url_change():
|
|
"""A peer whose advertised url changes is removed and re-added."""
|
|
controller, ctx = _make_controller()
|
|
ctx.storage_manager.add_l2_adapter.side_effect = [7, 8]
|
|
|
|
controller._apply_state(_P2PState.REGISTERED, {"peerA": _peer("peerA", url="a:1")})
|
|
controller._apply_state(_P2PState.REGISTERED, {"peerA": _peer("peerA", url="b:2")})
|
|
|
|
ctx.storage_manager.delete_l2_adapter.assert_called_once_with(7)
|
|
assert ctx.storage_manager.add_l2_adapter.call_count == 2
|
|
latest = ctx.storage_manager.add_l2_adapter.call_args.args[0]
|
|
assert latest.peer_transfer_channel_server_url == "b:2"
|
|
|
|
|
|
def test_close_removes_all_adapters():
|
|
"""close drains every tracked peer adapter."""
|
|
controller, ctx = _make_controller()
|
|
ctx.storage_manager.add_l2_adapter.side_effect = [7, 8]
|
|
controller._apply_state(
|
|
_P2PState.REGISTERED,
|
|
{"peerA": _peer("peerA"), "peerB": _peer("peerB", mq_port=5556)},
|
|
)
|
|
|
|
controller.close()
|
|
|
|
removed = {c.args[0] for c in ctx.storage_manager.delete_l2_adapter.call_args_list}
|
|
assert removed == {7, 8}
|
|
|
|
|
|
# ============================================================================
|
|
# Orchestration: poll-cycle state machine
|
|
# ============================================================================
|
|
|
|
|
|
def _enable_polling(
|
|
controller: P2PController, advertise_url: str = "me:1"
|
|
) -> MagicMock:
|
|
"""Point a controller's poll loop at a mock coordinator client."""
|
|
controller._p2p_config = P2PConfig(advertise_url=advertise_url)
|
|
controller._instances_url = "http://coordinator/instances"
|
|
client = MagicMock()
|
|
controller._http_client = client
|
|
return client
|
|
|
|
|
|
def _instances_response(instances: list[dict]) -> MagicMock:
|
|
response = MagicMock()
|
|
response.raise_for_status.return_value = None
|
|
response.json.return_value = {"instances": instances}
|
|
return response
|
|
|
|
|
|
def _entry(
|
|
instance_id: str,
|
|
url: str,
|
|
ip: str = "10.0.0.9",
|
|
mq_port: int = 5555,
|
|
) -> dict:
|
|
return {
|
|
"instance_id": instance_id,
|
|
"ip": ip,
|
|
"p2p_advertised_url": url,
|
|
"mq_port": mq_port,
|
|
}
|
|
|
|
|
|
def test_poll_cycle_timeout_is_disconnected():
|
|
"""A request timeout maps to the Disconnected state."""
|
|
controller, _ = _make_controller()
|
|
client = _enable_polling(controller)
|
|
client.get.side_effect = httpx.TimeoutException("boom")
|
|
|
|
controller._poll_cycle()
|
|
assert controller.report_status()["p2p_state"] == _P2PState.DISCONNECTED.value
|
|
|
|
|
|
def test_poll_cycle_http_error_is_unregistered():
|
|
"""A non-timeout HTTP error maps to the Unregistered state."""
|
|
controller, _ = _make_controller()
|
|
client = _enable_polling(controller)
|
|
client.get.side_effect = httpx.HTTPError("boom")
|
|
|
|
controller._poll_cycle()
|
|
assert controller.report_status()["p2p_state"] == _P2PState.UNREGISTERED.value
|
|
|
|
|
|
def test_poll_cycle_self_absent_is_unregistered():
|
|
"""Not finding our own instance in the listing is Unregistered."""
|
|
controller, _ = _make_controller()
|
|
client = _enable_polling(controller)
|
|
client.get.return_value = _instances_response([_entry("other", "tc:1")])
|
|
|
|
controller._poll_cycle()
|
|
assert controller.report_status()["p2p_state"] == _P2PState.UNREGISTERED.value
|
|
|
|
|
|
def test_poll_cycle_registered_excludes_self_and_adds_peer():
|
|
"""When registered, peers (but not self) get adapters."""
|
|
controller, ctx = _make_controller()
|
|
ctx.storage_manager.add_l2_adapter.return_value = 7
|
|
client = _enable_polling(controller, advertise_url="me:1")
|
|
client.get.return_value = _instances_response(
|
|
[_entry("self", "me:1"), _entry("peerA", "tc:1")]
|
|
)
|
|
|
|
controller._poll_cycle()
|
|
|
|
status = controller.report_status()
|
|
assert status["p2p_state"] == _P2PState.REGISTERED.value
|
|
assert status["p2p_peers"] == ["peerA"]
|
|
|
|
|
|
def test_poll_cycle_unexpected_error_does_not_crash():
|
|
"""An unexpected parsing error is caught and maps to Unregistered."""
|
|
controller, _ = _make_controller()
|
|
client = _enable_polling(controller)
|
|
# A non-dict entry makes parsing raise AttributeError (raw.get on a str).
|
|
client.get.return_value = _instances_response(["not-a-dict"])
|
|
|
|
summary = controller._poll_cycle() # must not raise
|
|
assert summary.success is True
|
|
assert controller.report_status()["p2p_state"] == _P2PState.UNREGISTERED.value
|
|
|
|
|
|
def test_poll_cycle_skips_peer_without_p2p_url():
|
|
"""A peer that advertises no p2p url is not adapted."""
|
|
controller, ctx = _make_controller()
|
|
client = _enable_polling(controller, advertise_url="me:1")
|
|
client.get.return_value = _instances_response(
|
|
[_entry("self", "me:1"), _entry("peerA", "")]
|
|
)
|
|
|
|
controller._poll_cycle()
|
|
|
|
ctx.storage_manager.add_l2_adapter.assert_not_called()
|
|
assert controller.report_status()["p2p_peers"] == []
|