# SPDX-License-Identifier: Apache-2.0 # Standard from unittest.mock import MagicMock import asyncio import json import os import shutil import tempfile import threading # Third Party from fastapi.testclient import TestClient import pytest import torch import yaml # First Party from lmcache.utils import CacheEngineKey, start_loop_in_thread_with_exceptions from lmcache.v1.cache_engine import LMCacheEngine from lmcache.v1.config import LMCacheEngineConfig from lmcache.v1.internal_api_server.api_server import app from lmcache.v1.memory_allocators.ad_hoc_memory_allocator import AdHocMemoryAllocator from lmcache.v1.metadata import LMCacheMetadata from lmcache.v1.storage_backend.local_cpu_backend import LocalCPUBackend from lmcache.v1.storage_backend.remote_backend import RemoteBackend from tests.v1.utils import create_test_memory_obj class TestLoadFSChunksAPI: """Test suite for the /cache/load-fs-chunks API endpoint.""" @pytest.fixture def temp_fs_path(self): """Create a temporary directory for FSConnector storage.""" temp_dir = tempfile.mkdtemp(prefix="lmcache_test_") yield temp_dir if os.path.exists(temp_dir): shutil.rmtree(temp_dir) @pytest.fixture def async_loop(self): """Create an event loop for async operations.""" loop = asyncio.new_event_loop() thread = threading.Thread( target=start_loop_in_thread_with_exceptions, args=(loop,), name="test-async-loop", ) thread.start() yield loop loop.call_soon_threadsafe(loop.stop) thread.join(timeout=5.0) @pytest.fixture def local_cpu_backend(self, test_metadata): """Create a LocalCPUBackend for testing.""" config = LMCacheEngineConfig.from_defaults(chunk_size=256) allocator = AdHocMemoryAllocator(device="cpu") backend = LocalCPUBackend(config, test_metadata, memory_allocator=allocator) yield backend backend.memory_allocator.close() @pytest.fixture def test_metadata(self): """Create test metadata.""" return LMCacheMetadata( model_name="test_model", world_size=1, local_world_size=1, worker_id=0, local_worker_id=0, kv_dtype=torch.bfloat16, kv_shape=(28, 2, 256, 8, 128), ) @pytest.fixture def mock_lmcache_adapter(self, local_cpu_backend, test_metadata, async_loop): """Create a mock LMCacheConnectorV1Impl adapter with real backend.""" adapter = MagicMock() mock_engine = MagicMock(spec=LMCacheEngine) mock_engine.metadata = test_metadata mock_engine.local_cpu_backend = local_cpu_backend # Mock storage_manager with allocator_backend and loop mock_storage_manager = MagicMock() mock_storage_manager.allocator_backend = local_cpu_backend mock_storage_manager.loop = async_loop mock_engine.storage_manager = mock_storage_manager adapter.lmcache_engine = mock_engine return adapter @pytest.fixture def client_with_adapter(self, mock_lmcache_adapter): """Create a test client with mocked adapter.""" app.state.lmcache_adapter = mock_lmcache_adapter client = TestClient(app) yield client client.close() @pytest.fixture def temp_config_file(self, temp_fs_path): """Create a temporary config file for testing.""" config_data = { "chunk_size": 256, "local_cpu": False, "max_local_cpu_size": 2.0, "remote_url": f"fs://host:0/{temp_fs_path}", } with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: yaml.dump(config_data, f) temp_path = f.name yield temp_path if os.path.exists(temp_path): os.unlink(temp_path) def _create_test_key(self, key_id: int) -> CacheEngineKey: """Create a test CacheEngineKey.""" return CacheEngineKey( model_name="test_model", world_size=1, worker_id=0, chunk_hash=key_id, dtype=torch.bfloat16, ) def _prepare_test_data( self, temp_fs_path: str, async_loop: asyncio.AbstractEventLoop, local_cpu_backend: LocalCPUBackend, test_metadata: LMCacheMetadata, num_chunks: int = 3, ): """Prepare test data by putting chunks into FSConnector.""" config = LMCacheEngineConfig.from_defaults( chunk_size=256, remote_url=f"fs://host:0/{temp_fs_path}", remote_serde="naive", ) remote_backend = RemoteBackend( config=config, metadata=test_metadata, loop=async_loop, local_cpu_backend=local_cpu_backend, dst_device="cpu", ) try: for i in range(num_chunks): key = self._create_test_key(i) memory_obj = create_test_memory_obj() future = remote_backend.submit_put_task(key, memory_obj) if future: future.result(timeout=5.0) finally: remote_backend.close() return num_chunks def test_load_fs_chunks_success( self, client_with_adapter, mock_lmcache_adapter, temp_config_file, temp_fs_path, async_loop, local_cpu_backend, test_metadata, ): """Test successful load-fs-chunks operation with real FSConnector.""" num_chunks = 3 self._prepare_test_data( temp_fs_path, async_loop, local_cpu_backend, test_metadata, num_chunks=num_chunks, ) response = client_with_adapter.post( "/cache/load-fs-chunks", json={"config_path": temp_config_file, "max_chunks": 2}, ) assert response.status_code == 200 response_data = json.loads(response.text) assert response_data["status"] == "success" assert response_data["loaded_chunks"] == 2 assert response_data["total_files"] == 3 assert response_data["config_path"] == temp_config_file self._verify_hot_cache(local_cpu_backend, num_chunks, 2) def test_load_fs_chunks_no_engine(self, client_with_adapter, temp_config_file): """Test load-fs-chunks when engine is not configured.""" app.state.lmcache_adapter = None response = client_with_adapter.post( "/cache/load-fs-chunks", json={"config_path": temp_config_file} ) assert response.status_code == 503 response_data = json.loads(response.text) assert response_data["error"] == "/cache/load-fs-chunks API is unavailable" assert response_data["message"] == "LMCache engine not configured." def test_load_fs_chunks_invalid_config( self, client_with_adapter, mock_lmcache_adapter ): """Test load-fs-chunks with invalid config file.""" response = client_with_adapter.post( "/cache/load-fs-chunks", json={"config_path": "/nonexistent/config.json"} ) assert response.status_code == 400 response_data = json.loads(response.text) assert "Invalid configuration file" in response_data["detail"] def test_load_fs_chunks_empty_directory( self, client_with_adapter, mock_lmcache_adapter, temp_config_file, temp_fs_path, ): """Test load-fs-chunks with empty FSConnector directory.""" response = client_with_adapter.post( "/cache/load-fs-chunks", json={"config_path": temp_config_file} ) assert response.status_code == 200 response_data = json.loads(response.text) assert response_data["status"] == "success" assert response_data["loaded_chunks"] == 0 assert response_data["total_files"] == 0 def _verify_hot_cache( self, local_cpu_backend: LocalCPUBackend, num_chunks: int, expected_count: int ): """Verify hot cache contains expected memory objects with ref count 1.""" with local_cpu_backend.cpu_lock: assert len(local_cpu_backend.hot_cache) == expected_count # Check that exactly expected_count keys from total_files # are present in hot cache found_count = 0 for i in range(num_chunks): # total_files is num_chunks key = self._create_test_key(i) if key in local_cpu_backend.hot_cache: found_count += 1 memory_obj = local_cpu_backend.hot_cache[key] assert memory_obj.get_ref_count() == 1 # Verify that we found exactly expected_count keys assert found_count == expected_count def test_load_fs_chunks_all_chunks( self, client_with_adapter, mock_lmcache_adapter, temp_config_file, temp_fs_path, async_loop, local_cpu_backend, test_metadata, ): """Test load-fs-chunks loading all chunks without max_chunks limit.""" num_chunks = 3 self._prepare_test_data( temp_fs_path, async_loop, local_cpu_backend, test_metadata, num_chunks=num_chunks, ) response = client_with_adapter.post( "/cache/load-fs-chunks", json={"config_path": temp_config_file} ) assert response.status_code == 200 response_data = json.loads(response.text) assert response_data["status"] == "success" assert response_data["loaded_chunks"] == 3 assert response_data["total_files"] == 3 assert len(response_data["failed_keys"]) == 0 self._verify_hot_cache(local_cpu_backend, num_chunks, 3)