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

380 lines
13 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Tests for the POST /cache/checksums endpoint (and other MP HTTP routes)."""
# Standard
from unittest.mock import MagicMock, PropertyMock
# Third Party
from fastapi.testclient import TestClient
import pytest
import torch
# First Party
from lmcache.v1.multiprocess.http_apis.dependencies import build_context
from lmcache.v1.multiprocess.http_server import app
import lmcache.c_ops as lmc_ops
def _make_kv_tensors(
num_layers: int = 2,
num_blocks: int = 4,
block_size: int = 4,
num_heads: int = 2,
head_size: int = 8,
dtype: torch.dtype = torch.float32,
) -> list[torch.Tensor]:
"""Create deterministic CPU KV tensors for testing."""
torch.manual_seed(42)
return [
torch.randn(
2,
num_blocks,
block_size,
num_heads,
head_size,
dtype=dtype,
)
for _ in range(num_layers)
]
@pytest.fixture
def mock_gpu_ctx():
"""Create a mock GPUCacheContext with kv_tensors."""
ctx = MagicMock()
tensors = _make_kv_tensors()
type(ctx).kv_tensors = PropertyMock(return_value=tensors)
type(ctx).block_size = PropertyMock(return_value=4)
# KV tensors are built as [2, NB, BS, NH, HS] -> NL_X_TWO_NB_BS_NH_HS;
# one homogeneous kernel group, so every layer reports the same format.
fmt = lmc_ops.EngineKVFormat.NL_X_TWO_NB_BS_NH_HS
ctx.engine_kv_formats.return_value = [fmt]
ctx.engine_kv_format_per_layer.return_value = [fmt] * len(tensors)
return ctx
@pytest.fixture
def mock_mixed_engine():
"""Engine whose context mixes two KV formats (a key+value layer and a
key-only layer), so the endpoint gathers each along its own axis."""
ctx = MagicMock()
kv_kv = torch.randn(2, 4, 4, 2, 8)
kv_idx = torch.randn(4, 4, 8)
type(ctx).kv_tensors = PropertyMock(return_value=[kv_kv, kv_idx])
ctx.engine_kv_format_per_layer.return_value = [
lmc_ops.EngineKVFormat.NL_X_TWO_NB_BS_NH_HS,
lmc_ops.EngineKVFormat.NL_X_NB_BS_HS,
]
engine = MagicMock()
engine.cache_contexts = {0: ctx}
return engine
@pytest.fixture
def mock_engine(mock_gpu_ctx):
"""Create a mock engine with cache_contexts."""
engine = MagicMock()
engine.cache_contexts = {0: mock_gpu_ctx}
return engine
@pytest.fixture
def client_with_engine(mock_engine):
"""Create a test client with a mocked engine wired onto the per-app context.
The cache handlers resolve the engine through ``get_context`` (i.e.
``app.state.context``), so the context must be built, not just
``app.state.engine``.
"""
app.state.engine = mock_engine
app.state.context = build_context(mock_engine)
client = TestClient(app)
yield client
client.close()
app.state.engine = None
app.state.context = None
@pytest.fixture
def client_no_engine():
"""Create a test client without an engine/context."""
app.state.engine = None
app.state.context = None
client = TestClient(app)
yield client
client.close()
class TestCacheChecksumsEndpoint:
"""Tests for POST /cache/checksums."""
def test_success_basic(self, client_with_engine):
"""Basic successful checksum request."""
# chunk_size is in blocks; 1 block + chunk_size=1 -> 1 chunk.
resp = client_with_engine.post(
"/cache/checksums", json={"block_ids": [0], "chunk_size": 1}
)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "success"
assert data["chunk_size"] == 1
assert data["num_chunks"] == 1
assert len(data["chunk_checksums"]) == 1
assert "block_id_ranges" in data
def test_success_layerwise(self, client_with_engine):
"""Layerwise mode returns per-layer checksums."""
resp = client_with_engine.post(
"/cache/checksums",
json={"block_ids": [0], "chunk_size": 1, "layerwise": True},
)
assert resp.status_code == 200
data = resp.json()
assert data["layerwise"] is True
cks = data["chunk_checksums"]
assert "layer_0" in cks
assert "layer_1" in cks
def test_deterministic(self, client_with_engine):
"""Same request produces identical checksums."""
body = {"block_ids": [0], "chunk_size": 1}
d1 = client_with_engine.post("/cache/checksums", json=body).json()
d2 = client_with_engine.post("/cache/checksums", json=body).json()
assert d1["chunk_checksums"] == d2["chunk_checksums"]
def test_mixed_format_supported(self, mock_mixed_engine):
"""Two different KV formats are gathered per layer, not rejected."""
app.state.engine = mock_mixed_engine
app.state.context = build_context(mock_mixed_engine)
client = TestClient(app)
try:
resp = client.post(
"/cache/checksums",
json={"block_ids": [0], "chunk_size": 1, "layerwise": True},
)
finally:
client.close()
app.state.engine = None
app.state.context = None
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "success"
# Both layers checksummed despite different formats / block axes.
assert set(data["chunk_checksums"]) == {"layer_0", "layer_1"}
def test_range_block_ids(self, client_with_engine):
"""Two block ids -> two single-block chunks."""
resp = client_with_engine.post(
"/cache/checksums", json={"block_ids": [0, 1], "chunk_size": 1}
)
assert resp.status_code == 200
data = resp.json()
# 2 blocks, chunk_size=1 block -> 2 chunks
assert data["num_chunks"] == 2
def test_multiple_block_ids(self, client_with_engine):
"""Three block ids -> three single-block chunks."""
resp = client_with_engine.post(
"/cache/checksums", json={"block_ids": [0, 1, 2], "chunk_size": 1}
)
assert resp.status_code == 200
data = resp.json()
# 3 blocks, chunk_size=1 block -> 3 chunks
assert data["num_chunks"] == 3
# ------------------------------------------------------------------
# Error cases
# ------------------------------------------------------------------
def test_no_engine(self, client_no_engine):
"""503 when the engine/context is not initialized."""
resp = client_no_engine.post(
"/cache/checksums", json={"block_ids": [0], "chunk_size": 1}
)
assert resp.status_code == 503
def test_no_cache_contexts(self, client_with_engine, mock_engine):
"""501 when engine has no cache_contexts attribute."""
mock_engine.cache_contexts = None
resp = client_with_engine.post(
"/cache/checksums", json={"block_ids": [0], "chunk_size": 1}
)
assert resp.status_code == 501
def test_unknown_instance_id(self, client_with_engine):
"""404 when instance_id is not registered."""
resp = client_with_engine.post(
"/cache/checksums",
json={"block_ids": [0], "chunk_size": 1, "instance_id": 99},
)
assert resp.status_code == 404
def test_missing_block_ids(self, client_with_engine):
"""400 when block_ids is empty."""
resp = client_with_engine.post("/cache/checksums", json={"chunk_size": 1})
assert resp.status_code == 400
assert "block_ids" in resp.json()["detail"]
def test_missing_chunk_size(self, client_with_engine):
"""400 when chunk_size is missing (defaults to 0)."""
resp = client_with_engine.post("/cache/checksums", json={"block_ids": [0]})
assert resp.status_code == 400
assert "chunk_size" in resp.json()["detail"]
def test_zero_chunk_size(self, client_with_engine):
"""400 when chunk_size is zero."""
resp = client_with_engine.post(
"/cache/checksums", json={"block_ids": [0], "chunk_size": 0}
)
assert resp.status_code == 400
def test_negative_chunk_size(self, client_with_engine):
"""400 when chunk_size is negative."""
resp = client_with_engine.post(
"/cache/checksums", json={"block_ids": [0], "chunk_size": -1}
)
assert resp.status_code == 400
def test_empty_kv_caches(self, client_with_engine, mock_gpu_ctx):
"""404 when kv_tensors is empty."""
type(mock_gpu_ctx).kv_tensors = PropertyMock(
return_value=[],
)
resp = client_with_engine.post(
"/cache/checksums", json={"block_ids": [0], "chunk_size": 1}
)
assert resp.status_code == 404
# ------------------------------------------------------------------
# Chunk boundary edge cases
# ------------------------------------------------------------------
def test_partial_last_chunk(self, client_with_engine):
"""3 blocks with chunk_size=2 blocks -> 2 chunks (2+1)."""
resp = client_with_engine.post(
"/cache/checksums", json={"block_ids": [0, 1, 2], "chunk_size": 2}
)
data = resp.json()
assert data["num_chunks"] == 2
assert len(data["chunk_checksums"]) == 2
def test_single_block_single_chunk(self, client_with_engine):
"""Single block with chunk_size=1 block produces one chunk."""
resp = client_with_engine.post(
"/cache/checksums", json={"block_ids": [0], "chunk_size": 1}
)
data = resp.json()
assert data["num_chunks"] == 1
# ------------------------------------------------------------------
# Checksum validity
# ------------------------------------------------------------------
def test_checksums_are_valid_md5(self, client_with_engine):
"""All checksums are 32-char hex strings."""
# 2 blocks, chunk_size=1 block -> 2 md5 digests.
resp = client_with_engine.post(
"/cache/checksums", json={"block_ids": [0, 1], "chunk_size": 1}
)
data = resp.json()
for cksum in data["chunk_checksums"]:
assert len(cksum) == 32
int(cksum, 16) # must be valid hex
def test_block_id_ranges_in_response(
self,
client_with_engine,
):
"""Response includes compressed block_id_ranges."""
resp = client_with_engine.post(
"/cache/checksums", json={"block_ids": [0], "chunk_size": 1}
)
data = resp.json()
assert "block_id_ranges" in data
# Single block id is kept as-is (no range compression).
assert data["block_id_ranges"] == [0]
# ------------------------------------------------------------------
# block_ids addressing
# ------------------------------------------------------------------
def test_block_ids_basic(self, client_with_engine):
"""block_ids with a block-level chunk_size."""
# 2 blocks, chunk_size=1 block -> 2 chunks.
resp = client_with_engine.post(
"/cache/checksums", json={"block_ids": [0, 1], "chunk_size": 1}
)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "success"
assert data["num_chunks"] == 2
assert data["block_id_ranges"] == [0, 1]
def test_block_ids_invalid_type(self, client_with_engine):
"""422 when block_ids is not a list of ints (FastAPI validation)."""
resp = client_with_engine.post(
"/cache/checksums", json={"block_ids": "abc", "chunk_size": 1}
)
assert resp.status_code == 422
class TestHealthAndMiscEndpoints:
"""Smoke tests for other endpoints on the MP HTTP server."""
@pytest.fixture(autouse=True)
def _reset_state(self):
yield
app.state.engine = None
app.state.context = None
def test_root(self, client_no_engine):
"""GET / returns ok."""
resp = client_no_engine.get("/")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
def test_healthcheck_no_engine(self, client_no_engine):
"""503 when engine is not set."""
resp = client_no_engine.get("/healthcheck")
assert resp.status_code == 503
def test_healthcheck_with_engine(
self,
client_with_engine,
):
"""200 when engine is available."""
resp = client_with_engine.get("/healthcheck")
assert resp.status_code == 200
assert resp.json()["status"] == "healthy"
def test_clear_cache_no_engine(self, client_no_engine):
"""503 when engine/context is not set."""
resp = client_no_engine.post("/cache/clear", json={})
assert resp.status_code == 503
def test_clear_cache_success(
self,
client_with_engine,
mock_engine,
):
"""200 and engine.clear() called."""
resp = client_with_engine.post("/cache/clear", json={"tier": "l1"})
assert resp.status_code == 200
mock_engine.clear.assert_called_once()
def test_status_no_engine(self, client_no_engine):
"""503 when engine is not set."""
resp = client_no_engine.get("/status")
assert resp.status_code == 503
def test_status_success(
self,
client_with_engine,
mock_engine,
):
"""200 and engine.report_status() called."""
mock_engine.report_status.return_value = {"ok": True}
resp = client_with_engine.get("/status")
assert resp.status_code == 200
assert resp.json()["ok"] is True