lmcache--lmcache
789 行
26 KiB
Python
789 行
26 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
# Standard
|
|
from unittest.mock import MagicMock, patch
|
|
import asyncio
|
|
import mmap
|
|
import threading
|
|
|
|
# Third Party
|
|
import pytest
|
|
import torch
|
|
|
|
# First Party
|
|
from lmcache.utils import CacheEngineKey
|
|
from lmcache.v1.config import LMCacheEngineConfig
|
|
from lmcache.v1.memory_management import MemoryFormat, TensorMemoryObj
|
|
from lmcache.v1.pin_monitor import PinMonitor
|
|
from lmcache.v1.storage_backend.abstract_backend import AllocatorBackendInterface
|
|
from tests.v1.utils import (
|
|
check_method_signatures,
|
|
get_abstract_methods,
|
|
get_methods_implemented_in_class,
|
|
)
|
|
|
|
maru = pytest.importorskip("maru", reason="maru package not installed")
|
|
maru_lmcache = pytest.importorskip(
|
|
"maru_lmcache", reason="maru_lmcache package not installed"
|
|
)
|
|
|
|
# Third Party
|
|
from maru_handler.memory import AllocHandle # noqa: E402
|
|
from maru_handler.memory.types import MappedRegion, MemoryInfo # noqa: E402
|
|
from maru_lmcache.adapter import CxlMemoryAdapter # noqa: E402
|
|
|
|
# First Party
|
|
from lmcache.v1.storage_backend.maru_backend import MaruBackend # noqa: E402
|
|
|
|
# =========================================================================
|
|
# Constants
|
|
# =========================================================================
|
|
|
|
TEST_CHUNK_SIZE = 1024
|
|
TEST_DTYPE = torch.float32
|
|
TEST_SHAPE = torch.Size([256]) # 256 * 4B = 1024 bytes = chunk_size
|
|
|
|
|
|
# =========================================================================
|
|
# Helpers
|
|
# =========================================================================
|
|
|
|
|
|
def _make_mock_handler(pool_size=4096, chunk_size=TEST_CHUNK_SIZE):
|
|
"""Create a mock MaruHandler with mmap-backed regions."""
|
|
handler = MagicMock()
|
|
handler._connected = True
|
|
|
|
region_id = 100
|
|
page_count = pool_size // chunk_size
|
|
|
|
mmap_obj = mmap.mmap(-1, pool_size)
|
|
mapped_region = MappedRegion(
|
|
region_id=region_id,
|
|
handle=MagicMock(region_id=region_id, length=pool_size),
|
|
size=pool_size,
|
|
_mmap_obj=mmap_obj,
|
|
)
|
|
|
|
handler.get_buffer_view.side_effect = lambda rid, offset, size: (
|
|
mapped_region.get_buffer_view(offset, size) if rid == region_id else None
|
|
)
|
|
handler.get_region_page_count.side_effect = lambda rid: (
|
|
page_count if rid == region_id else None
|
|
)
|
|
handler.get_owned_region_ids.return_value = [region_id]
|
|
handler.get_chunk_size.return_value = chunk_size
|
|
|
|
def mock_set_on_region_added(callback):
|
|
if callback is not None:
|
|
callback(region_id, page_count)
|
|
|
|
handler.set_on_region_added.side_effect = mock_set_on_region_added
|
|
|
|
page_counter = [0]
|
|
|
|
def mock_alloc(size):
|
|
idx = page_counter[0]
|
|
page_counter[0] += 1
|
|
buf = mapped_region.get_buffer_view(idx * chunk_size, size)
|
|
return AllocHandle(buf=buf, _region_id=region_id, _page_index=idx, _size=size)
|
|
|
|
handler.alloc.side_effect = mock_alloc
|
|
handler.free = MagicMock()
|
|
handler.connect.return_value = True
|
|
handler.close.return_value = None
|
|
handler.store.return_value = True
|
|
handler.batch_store.return_value = None
|
|
handler.retrieve.return_value = None
|
|
handler.batch_retrieve.return_value = []
|
|
handler.exists.return_value = False
|
|
handler.batch_exists.return_value = []
|
|
handler.delete.return_value = True
|
|
handler.pin.return_value = True
|
|
handler.unpin.return_value = True
|
|
handler.batch_pin.return_value = []
|
|
handler.batch_unpin.return_value = None
|
|
|
|
return handler
|
|
|
|
|
|
def _make_cache_key(chunk_hash: int = 12345) -> CacheEngineKey:
|
|
"""Create a CacheEngineKey for testing."""
|
|
return CacheEngineKey(
|
|
model_name="test-model",
|
|
world_size=1,
|
|
worker_id=0,
|
|
chunk_hash=chunk_hash,
|
|
dtype=torch.float32,
|
|
)
|
|
|
|
|
|
def _make_memory_obj(adapter: CxlMemoryAdapter) -> TensorMemoryObj:
|
|
"""Allocate a TensorMemoryObj from the adapter."""
|
|
obj = adapter.allocate(TEST_SHAPE, TEST_DTYPE)
|
|
assert obj is not None
|
|
return obj
|
|
|
|
|
|
# =========================================================================
|
|
# Fixtures
|
|
# =========================================================================
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _init_pin_monitor():
|
|
"""Initialize PinMonitor singleton required by TensorMemoryObj.pin()."""
|
|
PinMonitor._instance = None
|
|
PinMonitor.GetOrCreate(LMCacheEngineConfig.from_defaults())
|
|
yield
|
|
PinMonitor._instance = None
|
|
|
|
|
|
@pytest.fixture
|
|
def async_loop():
|
|
"""Provide an asyncio event loop running in a background thread."""
|
|
loop = asyncio.new_event_loop()
|
|
thread = threading.Thread(target=loop.run_forever, daemon=True)
|
|
thread.start()
|
|
yield loop
|
|
loop.call_soon_threadsafe(loop.stop)
|
|
thread.join(timeout=5)
|
|
loop.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_handler():
|
|
return _make_mock_handler()
|
|
|
|
|
|
@pytest.fixture
|
|
def adapter(mock_handler):
|
|
return CxlMemoryAdapter(
|
|
handler=mock_handler,
|
|
shapes=[TEST_SHAPE],
|
|
dtypes=[TEST_DTYPE],
|
|
fmt=MemoryFormat.KV_2LTD,
|
|
chunk_size=TEST_CHUNK_SIZE,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def backend(mock_handler, adapter, async_loop):
|
|
"""Create a MaruBackend with mocked internals."""
|
|
# Local
|
|
|
|
with patch.object(MaruBackend, "initialize_allocator", return_value=adapter):
|
|
backend = MaruBackend.__new__(MaruBackend)
|
|
backend.dst_device = "cpu"
|
|
backend.config = MagicMock()
|
|
backend.config.maru_pool_size = 4.0
|
|
backend.loop = async_loop
|
|
backend.memory_allocator = adapter
|
|
backend._handler = mock_handler
|
|
|
|
backend._full_chunk_size_bytes = TEST_CHUNK_SIZE
|
|
backend._single_token_size = TEST_CHUNK_SIZE // 256 # 4 bytes per token
|
|
backend._mla_worker_id_as0_mode = False
|
|
|
|
backend.put_lock = threading.Lock()
|
|
backend.put_tasks = set()
|
|
return backend
|
|
|
|
|
|
def _run_async(loop, coro):
|
|
"""Submit a coroutine to a running event loop and wait for result."""
|
|
future = asyncio.run_coroutine_threadsafe(coro, loop)
|
|
return future.result(timeout=5)
|
|
|
|
|
|
# =========================================================================
|
|
# Tests — Init & Interface Compliance
|
|
# =========================================================================
|
|
|
|
|
|
class TestMaruBackendInit:
|
|
def test_str(self, backend):
|
|
assert str(backend) == "MaruBackend"
|
|
|
|
def test_get_allocator_backend_returns_self(self, backend):
|
|
assert backend.get_allocator_backend() is backend
|
|
|
|
def test_get_memory_allocator_returns_adapter(self, backend, adapter):
|
|
assert backend.get_memory_allocator() is adapter
|
|
|
|
|
|
class TestMaruBackendPoolSizeGbToBytes:
|
|
"""Test _pool_size_gb_to_bytes static method."""
|
|
|
|
def test_4gb(self):
|
|
assert MaruBackend._pool_size_gb_to_bytes(4.0) == 4 * 1024**3
|
|
|
|
def test_half_gb(self):
|
|
assert MaruBackend._pool_size_gb_to_bytes(0.5) == 512 * 1024**2
|
|
|
|
def test_1gb(self):
|
|
assert MaruBackend._pool_size_gb_to_bytes(1.0) == 1024**3
|
|
|
|
def test_zero(self):
|
|
assert MaruBackend._pool_size_gb_to_bytes(0.0) == 0
|
|
|
|
|
|
class TestMaruBackendInterfaceCompliance:
|
|
"""Verify MaruBackend implements all required interface methods."""
|
|
|
|
def test_implements_all_abstract_methods(self):
|
|
abstract = get_abstract_methods(AllocatorBackendInterface)
|
|
implemented = get_methods_implemented_in_class(
|
|
MaruBackend, AllocatorBackendInterface
|
|
)
|
|
missing = abstract - implemented
|
|
assert not missing, f"Missing abstract methods: {missing}"
|
|
|
|
def test_method_signatures_match(self):
|
|
# Known: batched_submit_put_task uses 'memory_objs' instead of 'objs'
|
|
# TODO: Rename to 'objs' for full compliance
|
|
known_param_renames = {"batched_submit_put_task"}
|
|
|
|
mismatches = check_method_signatures(AllocatorBackendInterface, MaruBackend)
|
|
unexpected = [m for m in mismatches if m["method"] not in known_param_renames]
|
|
assert not unexpected, f"Signature mismatches: {unexpected}"
|
|
|
|
|
|
# =========================================================================
|
|
# Tests — Allocate
|
|
# =========================================================================
|
|
|
|
|
|
class TestMaruBackendAllocate:
|
|
def test_allocate_returns_memory_obj(self, backend):
|
|
obj = backend.allocate(TEST_SHAPE, TEST_DTYPE)
|
|
assert obj is not None
|
|
assert obj.tensor is not None
|
|
assert obj.metadata.dtype == TEST_DTYPE
|
|
|
|
def test_batched_allocate_returns_list(self, backend):
|
|
objs = backend.batched_allocate(TEST_SHAPE, TEST_DTYPE, batch_size=3)
|
|
assert objs is not None
|
|
assert len(objs) == 3
|
|
for obj in objs:
|
|
assert obj.tensor is not None
|
|
|
|
|
|
# =========================================================================
|
|
# Tests — Put (async)
|
|
# =========================================================================
|
|
|
|
|
|
class TestMaruBackendPut:
|
|
def test_submit_put_task_returns_future(self, backend, adapter):
|
|
obj = _make_memory_obj(adapter)
|
|
obj.parent_allocator = None
|
|
key = _make_cache_key()
|
|
|
|
future = backend.submit_put_task(key, obj)
|
|
assert future is not None
|
|
future.result(timeout=5)
|
|
|
|
backend._handler.store.assert_called_once()
|
|
|
|
def test_submit_put_task_tracks_in_flight(self, backend, adapter):
|
|
obj = _make_memory_obj(adapter)
|
|
obj.parent_allocator = None
|
|
key = _make_cache_key()
|
|
|
|
assert not backend.exists_in_put_tasks(key)
|
|
|
|
future = backend.submit_put_task(key, obj)
|
|
future.result(timeout=5)
|
|
|
|
# After completion, key should be removed from put_tasks
|
|
assert not backend.exists_in_put_tasks(key)
|
|
|
|
def test_exists_in_put_tasks_true_during_store(self, backend, adapter):
|
|
"""Verify exists_in_put_tasks returns True while store is in progress."""
|
|
obj = _make_memory_obj(adapter)
|
|
obj.parent_allocator = None
|
|
key = _make_cache_key()
|
|
|
|
store_entered = threading.Event()
|
|
store_proceed = threading.Event()
|
|
|
|
def blocking_store(*args, **kwargs):
|
|
store_entered.set()
|
|
store_proceed.wait(timeout=5)
|
|
return True
|
|
|
|
backend._handler.store.side_effect = blocking_store
|
|
|
|
future = backend.submit_put_task(key, obj)
|
|
|
|
# Wait until store is actually running
|
|
assert store_entered.wait(timeout=5)
|
|
assert backend.exists_in_put_tasks(key)
|
|
|
|
# Let store complete
|
|
store_proceed.set()
|
|
future.result(timeout=5)
|
|
assert not backend.exists_in_put_tasks(key)
|
|
|
|
def test_batched_submit_put_task(self, backend, adapter):
|
|
keys = [_make_cache_key(i) for i in range(3)]
|
|
objs = [_make_memory_obj(adapter) for _ in range(3)]
|
|
for obj in objs:
|
|
obj.parent_allocator = None
|
|
|
|
backend._handler.batch_store.return_value = [True, True, True]
|
|
|
|
futures = backend.batched_submit_put_task(keys, objs)
|
|
assert futures is not None
|
|
|
|
for future in futures:
|
|
future.result(timeout=5)
|
|
|
|
backend._handler.batch_store.assert_called_once()
|
|
|
|
def test_submit_put_calls_callback(self, backend, adapter):
|
|
obj = _make_memory_obj(adapter)
|
|
obj.parent_allocator = None
|
|
key = _make_cache_key()
|
|
callback_called = []
|
|
|
|
def callback(k):
|
|
callback_called.append(k)
|
|
|
|
future = backend.submit_put_task(key, obj, on_complete_callback=callback)
|
|
future.result(timeout=5)
|
|
|
|
assert len(callback_called) == 1
|
|
assert callback_called[0] == key
|
|
|
|
def test_batched_submit_put_calls_callback_per_key(self, backend, adapter):
|
|
keys = [_make_cache_key(i) for i in range(3)]
|
|
objs = [_make_memory_obj(adapter) for _ in range(3)]
|
|
for obj in objs:
|
|
obj.parent_allocator = None
|
|
|
|
backend._handler.batch_store.return_value = [True, True, True]
|
|
callback_keys = []
|
|
|
|
def callback(k):
|
|
callback_keys.append(k)
|
|
|
|
futures = backend.batched_submit_put_task(
|
|
keys, objs, on_complete_callback=callback
|
|
)
|
|
for future in futures:
|
|
future.result(timeout=5)
|
|
|
|
assert set(callback_keys) == set(keys)
|
|
|
|
def test_submit_put_task_skips_in_mla_mode(self, backend, adapter):
|
|
"""In MLA worker_id_as0 mode, submit_put_task should skip store."""
|
|
backend._mla_worker_id_as0_mode = True
|
|
obj = _make_memory_obj(adapter)
|
|
obj.parent_allocator = None
|
|
key = _make_cache_key()
|
|
|
|
future = backend.submit_put_task(key, obj)
|
|
assert future.result(timeout=5) is None
|
|
backend._handler.store.assert_not_called()
|
|
|
|
def test_submit_put_task_refcount_down_on_failure(self, backend, adapter):
|
|
"""On store failure, ref_count should return to pre-submit level."""
|
|
obj = _make_memory_obj(adapter)
|
|
obj.parent_allocator = None
|
|
key = _make_cache_key()
|
|
initial_ref = obj.get_ref_count()
|
|
|
|
backend._handler.store.side_effect = RuntimeError("store failed")
|
|
|
|
future = backend.submit_put_task(key, obj)
|
|
with pytest.raises(RuntimeError):
|
|
future.result(timeout=5)
|
|
|
|
assert obj.get_ref_count() == initial_ref
|
|
assert not backend.exists_in_put_tasks(key)
|
|
|
|
def test_batched_submit_put_task_refcount_down_on_failure(self, backend, adapter):
|
|
"""On batch_store failure, ref_count should return to pre-submit level."""
|
|
keys = [_make_cache_key(i) for i in range(3)]
|
|
objs = [_make_memory_obj(adapter) for _ in range(3)]
|
|
for obj in objs:
|
|
obj.parent_allocator = None
|
|
initial_refs = [obj.get_ref_count() for obj in objs]
|
|
|
|
backend._handler.batch_store.side_effect = RuntimeError("batch failed")
|
|
|
|
futures = backend.batched_submit_put_task(keys, objs)
|
|
for future in futures:
|
|
with pytest.raises(RuntimeError):
|
|
future.result(timeout=5)
|
|
|
|
for obj, initial_ref in zip(objs, initial_refs, strict=False):
|
|
assert obj.get_ref_count() == initial_ref
|
|
for key in keys:
|
|
assert not backend.exists_in_put_tasks(key)
|
|
|
|
def test_batched_submit_put_task_skips_in_mla_mode(self, backend, adapter):
|
|
"""In MLA worker_id_as0 mode, batched_submit_put_task should skip."""
|
|
backend._mla_worker_id_as0_mode = True
|
|
keys = [_make_cache_key(i) for i in range(3)]
|
|
objs = [_make_memory_obj(adapter) for _ in range(3)]
|
|
for obj in objs:
|
|
obj.parent_allocator = None
|
|
|
|
result = backend.batched_submit_put_task(keys, objs)
|
|
assert result is None
|
|
backend._handler.batch_store.assert_not_called()
|
|
|
|
|
|
# =========================================================================
|
|
# Tests — Get (sync)
|
|
# =========================================================================
|
|
|
|
|
|
class TestMaruBackendGet:
|
|
def test_get_blocking_hit(self, backend, adapter):
|
|
key = _make_cache_key()
|
|
|
|
data_size = TEST_CHUNK_SIZE
|
|
data = bytearray(data_size)
|
|
mock_info = MemoryInfo(
|
|
view=memoryview(data),
|
|
region_id=100,
|
|
page_index=0,
|
|
)
|
|
backend._handler.retrieve.return_value = mock_info
|
|
|
|
result = backend.get_blocking(key)
|
|
assert result is not None
|
|
backend._handler.retrieve.assert_called_once()
|
|
|
|
def test_get_blocking_miss(self, backend):
|
|
key = _make_cache_key()
|
|
backend._handler.retrieve.return_value = None
|
|
|
|
result = backend.get_blocking(key)
|
|
assert result is None
|
|
|
|
def test_get_blocking_ref_count_increases(self, backend, adapter):
|
|
"""After get_blocking, the returned MemoryObj should have ref_count
|
|
incremented."""
|
|
# Pre-allocate so pool has page 0
|
|
_make_memory_obj(adapter)
|
|
|
|
key = _make_cache_key()
|
|
mock_info = MemoryInfo(
|
|
view=memoryview(bytearray(TEST_CHUNK_SIZE)),
|
|
region_id=100,
|
|
page_index=0,
|
|
)
|
|
backend._handler.retrieve.return_value = mock_info
|
|
|
|
result = backend.get_blocking(key)
|
|
assert result is not None
|
|
# Pool objects start with ref_count=1, get_blocking calls ref_count_up
|
|
assert result.get_ref_count() >= 2
|
|
|
|
def test_batched_get_blocking(self, backend, adapter):
|
|
"""batched_get_blocking returns list of MemoryObj via batch_retrieve."""
|
|
objs = [_make_memory_obj(adapter) for _ in range(2)]
|
|
keys = [_make_cache_key(i) for i in range(2)]
|
|
|
|
infos = []
|
|
for obj in objs:
|
|
rid, pid = CxlMemoryAdapter.decode_address(obj.metadata.address)
|
|
infos.append(
|
|
MemoryInfo(
|
|
view=memoryview(bytearray(TEST_CHUNK_SIZE)),
|
|
region_id=rid,
|
|
page_index=pid,
|
|
)
|
|
)
|
|
backend._handler.batch_retrieve.return_value = infos
|
|
|
|
results = backend.batched_get_blocking(keys)
|
|
assert len(results) == 2
|
|
for r in results:
|
|
assert r is not None
|
|
|
|
def test_batched_get_blocking_with_miss(self, backend, adapter):
|
|
"""batched_get_blocking returns None for missing keys."""
|
|
obj = _make_memory_obj(adapter)
|
|
keys = [_make_cache_key(i) for i in range(2)]
|
|
|
|
rid, pid = CxlMemoryAdapter.decode_address(obj.metadata.address)
|
|
info = MemoryInfo(
|
|
view=memoryview(bytearray(TEST_CHUNK_SIZE)),
|
|
region_id=rid,
|
|
page_index=pid,
|
|
)
|
|
backend._handler.batch_retrieve.return_value = [info, None]
|
|
|
|
results = backend.batched_get_blocking(keys)
|
|
assert len(results) == 2
|
|
assert results[0] is not None
|
|
assert results[1] is None
|
|
|
|
|
|
# =========================================================================
|
|
# Tests — Contains
|
|
# =========================================================================
|
|
|
|
|
|
class TestMaruBackendContains:
|
|
def test_contains_true(self, backend):
|
|
key = _make_cache_key()
|
|
backend._handler.exists.return_value = True
|
|
|
|
assert backend.contains(key) is True
|
|
backend._handler.exists.assert_called_once_with(key.to_string())
|
|
|
|
def test_contains_false(self, backend):
|
|
key = _make_cache_key()
|
|
backend._handler.exists.return_value = False
|
|
|
|
assert backend.contains(key) is False
|
|
|
|
def test_batched_contains_all_hit(self, backend):
|
|
keys = [_make_cache_key(i) for i in range(3)]
|
|
backend._handler.batch_exists.return_value = [True, True, True]
|
|
|
|
result = backend.batched_contains(keys)
|
|
assert result == 3
|
|
|
|
def test_batched_contains_partial_prefix(self, backend):
|
|
keys = [_make_cache_key(i) for i in range(3)]
|
|
backend._handler.batch_exists.return_value = [True, True, False]
|
|
|
|
result = backend.batched_contains(keys)
|
|
assert result == 2
|
|
|
|
def test_batched_contains_first_miss(self, backend):
|
|
keys = [_make_cache_key(i) for i in range(3)]
|
|
backend._handler.batch_exists.return_value = [False, True, True]
|
|
|
|
result = backend.batched_contains(keys)
|
|
assert result == 0
|
|
|
|
def test_contains_with_pin(self, backend):
|
|
key = _make_cache_key()
|
|
backend._handler.pin.return_value = True
|
|
|
|
assert backend.contains(key, pin=True) is True
|
|
backend._handler.pin.assert_called_once_with(key.to_string())
|
|
backend._handler.exists.assert_not_called()
|
|
|
|
def test_contains_with_pin_false(self, backend):
|
|
key = _make_cache_key()
|
|
backend._handler.pin.return_value = False
|
|
|
|
assert backend.contains(key, pin=True) is False
|
|
|
|
def test_batched_contains_with_pin(self, backend):
|
|
keys = [_make_cache_key(i) for i in range(3)]
|
|
backend._handler.batch_pin.return_value = [True, True, True]
|
|
|
|
result = backend.batched_contains(keys, pin=True)
|
|
assert result == 3
|
|
backend._handler.batch_pin.assert_called_once_with(
|
|
[k.to_string() for k in keys]
|
|
)
|
|
backend._handler.batch_exists.assert_not_called()
|
|
|
|
def test_batched_contains_with_pin_partial(self, backend):
|
|
keys = [_make_cache_key(i) for i in range(3)]
|
|
backend._handler.batch_pin.return_value = [True, False, True]
|
|
|
|
result = backend.batched_contains(keys, pin=True)
|
|
assert result == 1
|
|
|
|
def test_batched_contains_empty(self, backend):
|
|
backend._handler.batch_exists.return_value = []
|
|
assert backend.batched_contains([]) == 0
|
|
|
|
|
|
# =========================================================================
|
|
# Tests — Async Lookup
|
|
# =========================================================================
|
|
|
|
|
|
class TestMaruBackendAsyncLookup:
|
|
def test_batched_async_contains_all_hit(self, backend, async_loop):
|
|
keys = [_make_cache_key(i) for i in range(3)]
|
|
backend._handler.batch_exists.return_value = [True, True, True]
|
|
|
|
result = _run_async(
|
|
async_loop, backend.batched_async_contains("lookup-1", keys)
|
|
)
|
|
assert result == 3
|
|
|
|
def test_batched_async_contains_partial_prefix(self, backend, async_loop):
|
|
keys = [_make_cache_key(i) for i in range(3)]
|
|
backend._handler.batch_exists.return_value = [True, False, True]
|
|
|
|
result = _run_async(
|
|
async_loop, backend.batched_async_contains("lookup-2", keys)
|
|
)
|
|
assert result == 1
|
|
|
|
def test_batched_async_contains_empty(self, backend, async_loop):
|
|
backend._handler.batch_exists.return_value = []
|
|
result = _run_async(async_loop, backend.batched_async_contains("lookup-3", []))
|
|
assert result == 0
|
|
|
|
def test_batched_get_non_blocking_all_hit(self, backend, adapter, async_loop):
|
|
keys = [_make_cache_key(i) for i in range(2)]
|
|
|
|
objs = [_make_memory_obj(adapter) for _ in range(2)]
|
|
infos = []
|
|
for obj in objs:
|
|
rid, pid = CxlMemoryAdapter.decode_address(obj.metadata.address)
|
|
infos.append(
|
|
MemoryInfo(
|
|
view=memoryview(bytearray(TEST_CHUNK_SIZE)),
|
|
region_id=rid,
|
|
page_index=pid,
|
|
)
|
|
)
|
|
backend._handler.batch_retrieve.return_value = infos
|
|
|
|
results = _run_async(
|
|
async_loop, backend.batched_get_non_blocking("lookup-4", keys)
|
|
)
|
|
assert len(results) == 2
|
|
for obj in results:
|
|
assert obj is not None
|
|
|
|
def test_batched_get_non_blocking_prefix_stop_on_miss(
|
|
self, backend, adapter, async_loop
|
|
):
|
|
"""Second key is a miss -> only first returned (prefix semantics)."""
|
|
keys = [_make_cache_key(i) for i in range(3)]
|
|
|
|
obj = _make_memory_obj(adapter)
|
|
rid, pid = CxlMemoryAdapter.decode_address(obj.metadata.address)
|
|
info = MemoryInfo(
|
|
view=memoryview(bytearray(TEST_CHUNK_SIZE)),
|
|
region_id=rid,
|
|
page_index=pid,
|
|
)
|
|
# hit, miss, hit -> should return only [hit]
|
|
backend._handler.batch_retrieve.return_value = [info, None, info]
|
|
|
|
results = _run_async(
|
|
async_loop, backend.batched_get_non_blocking("lookup-5", keys)
|
|
)
|
|
assert len(results) == 1
|
|
|
|
def test_batched_get_non_blocking_empty(self, backend, async_loop):
|
|
backend._handler.batch_retrieve.return_value = []
|
|
results = _run_async(
|
|
async_loop, backend.batched_get_non_blocking("lookup-6", [])
|
|
)
|
|
assert results == []
|
|
|
|
|
|
# =========================================================================
|
|
# Tests — Pin / Unpin / Remove
|
|
# =========================================================================
|
|
|
|
|
|
class TestMaruBackendPinRemove:
|
|
def test_pin_delegates_to_handler(self, backend):
|
|
key = _make_cache_key()
|
|
backend._handler.pin.return_value = True
|
|
|
|
assert backend.pin(key) is True
|
|
backend._handler.pin.assert_called_once_with(key.to_string())
|
|
|
|
def test_pin_returns_false_on_failure(self, backend):
|
|
key = _make_cache_key()
|
|
backend._handler.pin.return_value = False
|
|
|
|
assert backend.pin(key) is False
|
|
|
|
def test_unpin_delegates_to_handler(self, backend):
|
|
key = _make_cache_key()
|
|
backend._handler.unpin.return_value = True
|
|
|
|
assert backend.unpin(key) is True
|
|
backend._handler.unpin.assert_called_once_with(key.to_string())
|
|
|
|
def test_unpin_returns_false_on_failure(self, backend):
|
|
key = _make_cache_key()
|
|
backend._handler.unpin.return_value = False
|
|
|
|
assert backend.unpin(key) is False
|
|
|
|
def test_batched_unpin(self, backend):
|
|
keys = [_make_cache_key(i) for i in range(3)]
|
|
|
|
backend.batched_unpin(keys)
|
|
backend._handler.batch_unpin.assert_called_once_with(
|
|
[k.to_string() for k in keys]
|
|
)
|
|
|
|
def test_batched_unpin_empty(self, backend):
|
|
backend.batched_unpin([])
|
|
backend._handler.batch_unpin.assert_not_called()
|
|
|
|
def test_remove_existing_key(self, backend):
|
|
key = _make_cache_key()
|
|
backend._handler.delete.return_value = True
|
|
|
|
result = backend.remove(key)
|
|
assert result is True
|
|
backend._handler.delete.assert_called_once_with(key.to_string())
|
|
|
|
def test_remove_nonexistent_key(self, backend):
|
|
key = _make_cache_key()
|
|
backend._handler.delete.return_value = False
|
|
|
|
result = backend.remove(key)
|
|
assert result is False
|
|
|
|
|
|
# =========================================================================
|
|
# Tests — Lifecycle
|
|
# =========================================================================
|
|
|
|
|
|
class TestMaruBackendLifecycle:
|
|
def test_close_calls_handler_and_allocator(self, backend):
|
|
backend.memory_allocator = MagicMock()
|
|
backend.close()
|
|
backend.memory_allocator.close.assert_called_once()
|
|
backend._handler.close.assert_called_once()
|
|
|
|
def test_close_drains_pending_put_tasks(self, backend, adapter):
|
|
"""close() should wait for in-flight put tasks to complete."""
|
|
obj = _make_memory_obj(adapter)
|
|
obj.parent_allocator = None
|
|
key = _make_cache_key()
|
|
|
|
# Submit a real put task that will complete via the event loop
|
|
future = backend.submit_put_task(key, obj)
|
|
future.result(timeout=5)
|
|
|
|
# After drain, close should succeed
|
|
backend.close()
|
|
backend._handler.close.assert_called_once()
|
|
|
|
|
|
# =========================================================================
|
|
# Tests — Store Handle Roundtrip
|
|
# =========================================================================
|
|
|
|
|
|
class TestMaruBackendStoreHandle:
|
|
def test_store_handle_roundtrip(self, backend, adapter):
|
|
"""AllocHandle from create_store_handle should match original."""
|
|
obj = _make_memory_obj(adapter)
|
|
obj.parent_allocator = None
|
|
|
|
handle = adapter.create_store_handle(obj)
|
|
assert handle.region_id == 100
|
|
assert handle.page_index == 0
|
|
assert handle._size == obj.metadata.phy_size
|