Files
lmcache--lmcache/tests/v1/distributed/serde/test_async_processor.py
T
2026-07-13 12:24:33 +08:00

257 lines
9.8 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""
Unit tests for AsyncSerdeProcessor.
Verifies the async, notifier-based contract:
- submit returns a task id immediately.
- The corresponding notifier fd is signaled on completion.
- query_result returns the bool outcome, None before completion and after
being consumed (non-idempotent).
- Failing serialize/deserialize produces result=False and still signals fd.
- Serialize and deserialize use distinct event fds.
"""
# Standard
from typing import Callable, Optional
import select
import time
# First Party
from lmcache.v1.distributed.api import MemoryLayoutDesc
from lmcache.v1.distributed.serde import (
AsyncSerdeProcessor,
Deserializer,
Serializer,
)
from lmcache.v1.platform import consume_fd
class _FakeSerializer(Serializer):
def __init__(self, transform: Optional[Callable[[int], None]] = None) -> None:
self._transform = transform
self.calls = 0
def serialize(self, src, dst) -> int: # type: ignore[no-untyped-def]
if self._transform is not None:
self._transform(self.calls)
self.calls += 1
return 1
def estimate_serialized_size(self, layout_desc: MemoryLayoutDesc) -> int:
return 1
class _FakeDeserializer(Deserializer):
def __init__(self, transform: Optional[Callable[[int], None]] = None) -> None:
self._transform = transform
self.calls = 0
def deserialize(self, src, dst) -> None: # type: ignore[no-untyped-def]
if self._transform is not None:
self._transform(self.calls)
self.calls += 1
def _wait_for_fd(fd: int, timeout_s: float = 2.0) -> bool:
"""Wait until ``fd`` is readable or timeout. Drains the pending signal."""
poller = select.poll()
poller.register(fd, select.POLLIN)
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
remaining_ms = int(max(0, (deadline - time.monotonic()) * 1000))
if poller.poll(remaining_ms):
try:
consume_fd(fd)
except OSError:
pass
return True
return False
def test_serialize_and_deserialize_fds_are_distinct() -> None:
processor = AsyncSerdeProcessor(_FakeSerializer(), _FakeDeserializer())
try:
assert (
processor.get_serialize_event_fd() != processor.get_deserialize_event_fd()
)
finally:
processor.close()
def test_serialize_signals_fd_and_result_is_true() -> None:
serializer = _FakeSerializer()
processor = AsyncSerdeProcessor(serializer, _FakeDeserializer())
try:
task_id = processor.submit_serialize([object()], [object()]) # type: ignore[list-item]
assert _wait_for_fd(processor.get_serialize_event_fd()), "fd never signaled"
assert processor.query_serialize_result(task_id) is True
# Non-idempotent: second query returns None.
assert processor.query_serialize_result(task_id) is None
assert serializer.calls == 1
finally:
processor.close()
def test_deserialize_signals_fd_and_result_is_true() -> None:
deserializer = _FakeDeserializer()
processor = AsyncSerdeProcessor(_FakeSerializer(), deserializer)
try:
task_id = processor.submit_deserialize([object()], [object()]) # type: ignore[list-item]
assert _wait_for_fd(processor.get_deserialize_event_fd()), "fd never signaled"
assert processor.query_deserialize_result(task_id) is True
assert processor.query_deserialize_result(task_id) is None
assert deserializer.calls == 1
finally:
processor.close()
def test_serialize_failure_reports_false() -> None:
"""If the sync serializer raises, query_serialize_result returns False."""
def _boom(_i: int) -> None:
raise RuntimeError("serialize failed")
processor = AsyncSerdeProcessor(_FakeSerializer(_boom), _FakeDeserializer())
try:
task_id = processor.submit_serialize([object()], [object()]) # type: ignore[list-item]
assert _wait_for_fd(processor.get_serialize_event_fd()), "fd never signaled"
assert processor.query_serialize_result(task_id) is False
finally:
processor.close()
def test_query_returns_none_before_completion() -> None:
"""Querying an unknown/not-yet-completed task yields None, not an error."""
processor = AsyncSerdeProcessor(_FakeSerializer(), _FakeDeserializer())
try:
# No task submitted with id 42 — should be None, not raise.
assert processor.query_serialize_result(42) is None
assert processor.query_deserialize_result(42) is None
finally:
processor.close()
def test_estimate_serialized_size_delegates_to_serializer() -> None:
serializer = _FakeSerializer()
processor = AsyncSerdeProcessor(serializer, _FakeDeserializer())
try:
layout = MemoryLayoutDesc(shapes=[], dtypes=[])
assert processor.estimate_serialized_size(layout) == 1
finally:
processor.close()
# ---------------------------------------------------------------------------
# set_used_size propagation: after serialize returns ``n``, the processor
# narrows the destination's logical size so downstream L2 adapters that
# read ``obj.get_size()`` / ``obj.byte_array`` see exactly ``n`` bytes.
# ---------------------------------------------------------------------------
class _RecordingSerializer(Serializer):
"""A serializer that always claims to have written ``n_bytes`` so
tests can assert the processor narrows the destination accordingly.
"""
def __init__(self, n_bytes: int) -> None:
self._n_bytes = n_bytes
def serialize(self, src, dst) -> int: # type: ignore[no-untyped-def]
return self._n_bytes
def estimate_serialized_size(self, layout_desc: MemoryLayoutDesc) -> int:
return self._n_bytes
class _NarrowableDst:
"""A minimal MemoryObj-like destination that records every
``set_used_size`` call. Not a real ``MemoryObj`` -- the test only
needs the part of the interface the processor touches."""
def __init__(self) -> None:
self.used_sizes: list[int] = []
def set_used_size(self, n: int) -> None:
self.used_sizes.append(n)
def test_serialize_narrows_dst_to_actual_bytes_written() -> None:
"""After a successful serialize that returns ``n``, the processor
calls ``dst.set_used_size(n)`` so the over-allocated destination
buffer reports the actual written length, not its allocated size."""
serializer = _RecordingSerializer(n_bytes=213)
processor = AsyncSerdeProcessor(serializer, _FakeDeserializer())
try:
dst = _NarrowableDst()
task_id = processor.submit_serialize([object()], [dst]) # type: ignore[list-item]
assert _wait_for_fd(processor.get_serialize_event_fd()), "fd never signaled"
assert processor.query_serialize_result(task_id) is True
assert dst.used_sizes == [213]
finally:
processor.close()
def test_serialize_failure_does_not_narrow_dst() -> None:
"""If the serializer raises, the processor must NOT narrow the
destination -- failure cleanup typically reuses the original
allocation and the next attempt depends on the layout-derived
size."""
class _BoomSerializer(Serializer):
def serialize(self, src, dst) -> int: # type: ignore[no-untyped-def]
raise RuntimeError("serialize failed")
def estimate_serialized_size(self, layout_desc: MemoryLayoutDesc) -> int:
return 1
processor = AsyncSerdeProcessor(_BoomSerializer(), _FakeDeserializer())
try:
dst = _NarrowableDst()
task_id = processor.submit_serialize([object()], [dst]) # type: ignore[list-item]
assert _wait_for_fd(processor.get_serialize_event_fd()), "fd never signaled"
assert processor.query_serialize_result(task_id) is False
assert dst.used_sizes == [], "narrowing must not happen on failure"
finally:
processor.close()
def test_serialize_works_when_dst_lacks_set_used_size() -> None:
"""Backward compatibility: a duck-typed destination that does NOT
implement ``set_used_size`` (e.g. a bare ``object()`` in older
tests) must still produce a successful task; the processor guards
the narrowing call with ``hasattr``."""
processor = AsyncSerdeProcessor(_FakeSerializer(), _FakeDeserializer())
try:
task_id = processor.submit_serialize([object()], [object()]) # type: ignore[list-item]
assert _wait_for_fd(processor.get_serialize_event_fd()), "fd never signaled"
# Success despite dst lacking set_used_size.
assert processor.query_serialize_result(task_id) is True
finally:
processor.close()
def test_serialize_skips_narrowing_when_serializer_returns_non_int() -> None:
"""A serializer that does not honor the ``serialize() -> int``
contract and returns ``None`` must not crash the processor. The
narrowing call is skipped (``isinstance(n, int)`` guard) rather than
passing ``None`` into ``set_used_size``, where the range check would
raise ``TypeError: '<' not supported between NoneType and int``."""
class _NoneReturningSerializer(Serializer):
def serialize(self, src, dst): # type: ignore[no-untyped-def]
return None # type: ignore[return-value]
def estimate_serialized_size(self, layout_desc: MemoryLayoutDesc) -> int:
return 1
processor = AsyncSerdeProcessor(_NoneReturningSerializer(), _FakeDeserializer())
try:
dst = _NarrowableDst()
task_id = processor.submit_serialize([object()], [dst]) # type: ignore[list-item]
assert _wait_for_fd(processor.get_serialize_event_fd()), "fd never signaled"
# Task still succeeds; narrowing was skipped, not attempted.
assert processor.query_serialize_result(task_id) is True
assert dst.used_sizes == [], "must not call set_used_size with a non-int"
finally:
processor.close()