629 lines
22 KiB
Python
629 lines
22 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""Unit tests for RegistrationController."""
|
|
|
|
# Standard
|
|
from unittest.mock import Mock, patch
|
|
import time
|
|
|
|
# Third Party
|
|
import pytest
|
|
|
|
# First Party
|
|
from lmcache.v1.cache_controller.controllers.registration_controller import (
|
|
RegistrationController,
|
|
)
|
|
from lmcache.v1.cache_controller.message import (
|
|
DeRegisterMsg,
|
|
HealthMsg,
|
|
HeartbeatMsg,
|
|
QueryInstMsg,
|
|
QueryWorkerInfoMsg,
|
|
RegisterMsg,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def reg_controller(mock_kv_controller, mock_cluster_executor):
|
|
"""Create a RegistrationController instance for testing."""
|
|
controller = RegistrationController()
|
|
controller.post_init(
|
|
kv_controller=mock_kv_controller,
|
|
cluster_executor=mock_cluster_executor,
|
|
)
|
|
return controller
|
|
|
|
|
|
class TestRegistrationControllerRegister:
|
|
"""Test RegistrationController register operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_new_worker(self, reg_controller, mock_zmq_socket):
|
|
"""Test registering a new worker."""
|
|
with patch(
|
|
"lmcache.v1.cache_controller.controllers.registration_controller.get_zmq_socket"
|
|
) as mock_get_socket:
|
|
mock_get_socket.return_value = mock_zmq_socket
|
|
|
|
msg = RegisterMsg(
|
|
instance_id="test_instance",
|
|
worker_id=0,
|
|
ip="127.0.0.1",
|
|
port=5000,
|
|
peer_init_url="tcp://127.0.0.1:6000",
|
|
)
|
|
|
|
await reg_controller.register(msg)
|
|
|
|
# Verify worker is registered
|
|
worker_ids = reg_controller.registry.get_worker_ids("test_instance")
|
|
assert 0 in worker_ids
|
|
|
|
# Verify socket is created
|
|
worker_node = reg_controller.registry.get_worker("test_instance", 0)
|
|
assert worker_node is not None
|
|
assert worker_node.socket is not None
|
|
assert worker_node.peer_init_url == "tcp://127.0.0.1:6000"
|
|
|
|
# Verify instance mapping
|
|
instance_node = reg_controller.registry.get_instance_by_ip("127.0.0.1")
|
|
assert instance_node is not None
|
|
assert instance_node.instance_id == "test_instance"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_without_peer_url(self, reg_controller, mock_zmq_socket):
|
|
"""Test registering a worker without peer_init_url."""
|
|
with patch(
|
|
"lmcache.v1.cache_controller.controllers.registration_controller.get_zmq_socket"
|
|
) as mock_get_socket:
|
|
mock_get_socket.return_value = mock_zmq_socket
|
|
|
|
msg = RegisterMsg(
|
|
instance_id="test_instance",
|
|
worker_id=0,
|
|
ip="127.0.0.1",
|
|
port=5000,
|
|
peer_init_url=None,
|
|
)
|
|
|
|
await reg_controller.register(msg)
|
|
|
|
# Verify worker is registered
|
|
worker_node = reg_controller.registry.get_worker("test_instance", 0)
|
|
assert worker_node is not None
|
|
assert worker_node.socket is not None
|
|
|
|
# Peer URL should not be registered
|
|
assert worker_node.peer_init_url is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_duplicate_worker(self, reg_controller, mock_zmq_socket):
|
|
"""Test duplicate registration is prevented."""
|
|
with patch(
|
|
"lmcache.v1.cache_controller.controllers.registration_controller.get_zmq_socket"
|
|
) as mock_get_socket:
|
|
mock_get_socket.return_value = mock_zmq_socket
|
|
|
|
msg = RegisterMsg(
|
|
instance_id="test_instance",
|
|
worker_id=0,
|
|
ip="127.0.0.1",
|
|
port=5000,
|
|
peer_init_url="tcp://127.0.0.1:6000",
|
|
)
|
|
|
|
# Register once
|
|
await reg_controller.register(msg)
|
|
|
|
# Try to register again
|
|
await reg_controller.register(msg)
|
|
|
|
# Should still have only one entry
|
|
worker_ids = reg_controller.registry.get_worker_ids("test_instance")
|
|
assert len(worker_ids) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_multiple_workers(self, reg_controller, mock_zmq_socket):
|
|
"""Test registering multiple workers for same instance."""
|
|
with patch(
|
|
"lmcache.v1.cache_controller.controllers.registration_controller.get_zmq_socket"
|
|
) as mock_get_socket:
|
|
mock_get_socket.return_value = mock_zmq_socket
|
|
|
|
for worker_id in range(3):
|
|
msg = RegisterMsg(
|
|
instance_id="test_instance",
|
|
worker_id=worker_id,
|
|
ip="127.0.0.1",
|
|
port=5000 + worker_id,
|
|
peer_init_url=f"tcp://127.0.0.1:{6000 + worker_id}",
|
|
)
|
|
await reg_controller.register(msg)
|
|
|
|
# Verify all workers are registered
|
|
worker_ids = reg_controller.registry.get_worker_ids("test_instance")
|
|
assert len(worker_ids) == 3
|
|
|
|
# Verify workers are sorted
|
|
assert worker_ids == [0, 1, 2]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_worker_info_timestamps(
|
|
self, reg_controller, mock_zmq_socket
|
|
):
|
|
"""Test worker info contains correct timestamps."""
|
|
with patch(
|
|
"lmcache.v1.cache_controller.controllers.registration_controller.get_zmq_socket"
|
|
) as mock_get_socket:
|
|
mock_get_socket.return_value = mock_zmq_socket
|
|
|
|
before_time = time.time()
|
|
|
|
msg = RegisterMsg(
|
|
instance_id="test_instance",
|
|
worker_id=0,
|
|
ip="127.0.0.1",
|
|
port=5000,
|
|
peer_init_url="tcp://127.0.0.1:6000",
|
|
)
|
|
|
|
await reg_controller.register(msg)
|
|
|
|
after_time = time.time()
|
|
|
|
worker_node = reg_controller.registry.get_worker("test_instance", 0)
|
|
assert worker_node is not None
|
|
|
|
assert before_time <= worker_node.registration_time <= after_time
|
|
assert before_time <= worker_node.last_heartbeat_time <= after_time
|
|
|
|
|
|
class TestRegistrationControllerDeregister:
|
|
"""Test RegistrationController deregister operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deregister_worker(self, reg_controller, mock_zmq_socket):
|
|
"""Test deregistering a worker."""
|
|
with patch(
|
|
"lmcache.v1.cache_controller.controllers.registration_controller.get_zmq_socket"
|
|
) as mock_get_socket:
|
|
mock_get_socket.return_value = mock_zmq_socket
|
|
|
|
# First register
|
|
register_msg = RegisterMsg(
|
|
instance_id="test_instance",
|
|
worker_id=0,
|
|
ip="127.0.0.1",
|
|
port=5000,
|
|
peer_init_url="tcp://127.0.0.1:6000",
|
|
)
|
|
await reg_controller.register(register_msg)
|
|
|
|
# Then deregister
|
|
deregister_msg = DeRegisterMsg(
|
|
instance_id="test_instance",
|
|
worker_id=0,
|
|
ip="127.0.0.1",
|
|
port=5000,
|
|
)
|
|
await reg_controller.deregister(deregister_msg)
|
|
|
|
# Verify cleanup
|
|
worker_node = reg_controller.registry.get_worker("test_instance", 0)
|
|
assert worker_node is None
|
|
|
|
instance_node = reg_controller.registry.get_instance("test_instance")
|
|
assert instance_node is None
|
|
|
|
# Note: KV cleanup is now handled directly by the registry's
|
|
# deregister_worker method
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deregister_nonexistent_worker(self, reg_controller):
|
|
"""Test deregistering a non-existent worker."""
|
|
deregister_msg = DeRegisterMsg(
|
|
instance_id="nonexistent_instance",
|
|
worker_id=0,
|
|
ip="127.0.0.1",
|
|
port=5000,
|
|
)
|
|
|
|
# Should not raise an error
|
|
await reg_controller.deregister(deregister_msg)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deregister_partial_workers(self, reg_controller, mock_zmq_socket):
|
|
"""Test deregistering one of multiple workers."""
|
|
with patch(
|
|
"lmcache.v1.cache_controller.controllers.registration_controller.get_zmq_socket"
|
|
) as mock_get_socket:
|
|
mock_get_socket.return_value = mock_zmq_socket
|
|
|
|
# Register two workers
|
|
for worker_id in range(2):
|
|
msg = RegisterMsg(
|
|
instance_id="test_instance",
|
|
worker_id=worker_id,
|
|
ip="127.0.0.1",
|
|
port=5000 + worker_id,
|
|
peer_init_url=f"tcp://127.0.0.1:{6000 + worker_id}",
|
|
)
|
|
await reg_controller.register(msg)
|
|
|
|
# Deregister only worker 0
|
|
deregister_msg = DeRegisterMsg(
|
|
instance_id="test_instance",
|
|
worker_id=0,
|
|
ip="127.0.0.1",
|
|
port=5000,
|
|
)
|
|
await reg_controller.deregister(deregister_msg)
|
|
|
|
# Worker 1 should still be registered
|
|
worker_ids = reg_controller.registry.get_worker_ids("test_instance")
|
|
assert worker_ids == [1]
|
|
|
|
worker_node = reg_controller.registry.get_worker("test_instance", 1)
|
|
assert worker_node is not None
|
|
assert worker_node.socket is not None
|
|
|
|
|
|
class TestRegistrationControllerGetters:
|
|
"""Test RegistrationController getter methods."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_socket(self, reg_controller, mock_zmq_socket):
|
|
"""Test getting socket for a worker."""
|
|
with patch(
|
|
"lmcache.v1.cache_controller.controllers.registration_controller.get_zmq_socket"
|
|
) as mock_get_socket:
|
|
mock_get_socket.return_value = mock_zmq_socket
|
|
|
|
msg = RegisterMsg(
|
|
instance_id="test_instance",
|
|
worker_id=0,
|
|
ip="127.0.0.1",
|
|
port=5000,
|
|
peer_init_url="tcp://127.0.0.1:6000",
|
|
)
|
|
await reg_controller.register(msg)
|
|
|
|
socket = reg_controller.get_socket("test_instance", 0)
|
|
|
|
assert socket is not None
|
|
assert socket == mock_zmq_socket
|
|
|
|
def test_get_socket_nonexistent(self, reg_controller):
|
|
"""Test getting socket for non-existent worker."""
|
|
socket = reg_controller.get_socket("nonexistent", 0)
|
|
|
|
assert socket is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_peer_init_url(self, reg_controller, mock_zmq_socket):
|
|
"""Test getting peer init URL for a worker."""
|
|
with patch(
|
|
"lmcache.v1.cache_controller.controllers.registration_controller.get_zmq_socket"
|
|
) as mock_get_socket:
|
|
mock_get_socket.return_value = mock_zmq_socket
|
|
|
|
msg = RegisterMsg(
|
|
instance_id="test_instance",
|
|
worker_id=0,
|
|
ip="127.0.0.1",
|
|
port=5000,
|
|
peer_init_url="tcp://127.0.0.1:6000",
|
|
)
|
|
await reg_controller.register(msg)
|
|
|
|
url = reg_controller.get_peer_init_url("test_instance", 0)
|
|
|
|
assert url == "tcp://127.0.0.1:6000"
|
|
|
|
def test_get_peer_init_url_nonexistent(self, reg_controller):
|
|
"""Test getting peer init URL for non-existent worker."""
|
|
url = reg_controller.get_peer_init_url("nonexistent", 0)
|
|
|
|
assert url is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_workers(self, reg_controller, mock_zmq_socket):
|
|
"""Test getting workers for an instance."""
|
|
with patch(
|
|
"lmcache.v1.cache_controller.controllers.registration_controller.get_zmq_socket"
|
|
) as mock_get_socket:
|
|
mock_get_socket.return_value = mock_zmq_socket
|
|
|
|
for worker_id in [2, 0, 1]: # Register out of order
|
|
msg = RegisterMsg(
|
|
instance_id="test_instance",
|
|
worker_id=worker_id,
|
|
ip="127.0.0.1",
|
|
port=5000 + worker_id,
|
|
peer_init_url=f"tcp://127.0.0.1:{6000 + worker_id}",
|
|
)
|
|
await reg_controller.register(msg)
|
|
|
|
workers = reg_controller.get_workers("test_instance")
|
|
|
|
# Should be sorted
|
|
assert workers == [0, 1, 2]
|
|
|
|
def test_get_workers_nonexistent(self, reg_controller):
|
|
"""Test getting workers for non-existent instance."""
|
|
workers = reg_controller.get_workers("nonexistent")
|
|
|
|
assert workers == []
|
|
|
|
|
|
class TestRegistrationControllerInstanceMapping:
|
|
"""Test RegistrationController instance ID mapping."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_instance_id(self, reg_controller, mock_zmq_socket):
|
|
"""Test getting instance ID from IP."""
|
|
with patch(
|
|
"lmcache.v1.cache_controller.controllers.registration_controller.get_zmq_socket"
|
|
) as mock_get_socket:
|
|
mock_get_socket.return_value = mock_zmq_socket
|
|
|
|
msg = RegisterMsg(
|
|
instance_id="test_instance",
|
|
worker_id=0,
|
|
ip="127.0.0.1",
|
|
port=5000,
|
|
peer_init_url="tcp://127.0.0.1:6000",
|
|
)
|
|
await reg_controller.register(msg)
|
|
|
|
query_msg = QueryInstMsg(
|
|
event_id="event_123",
|
|
ip="127.0.0.1",
|
|
)
|
|
|
|
result = await reg_controller.get_instance_id(query_msg)
|
|
|
|
assert result.event_id == "event_123"
|
|
assert result.instance_id == "test_instance"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_instance_id_not_found(self, reg_controller):
|
|
"""Test getting instance ID for unregistered IP."""
|
|
query_msg = QueryInstMsg(
|
|
event_id="event_123",
|
|
ip="192.168.1.1",
|
|
)
|
|
|
|
result = await reg_controller.get_instance_id(query_msg)
|
|
|
|
assert result.event_id == "event_123"
|
|
assert result.instance_id is None
|
|
|
|
|
|
class TestRegistrationControllerHeartbeat:
|
|
"""Test RegistrationController heartbeat operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_heartbeat_existing_worker(self, reg_controller, mock_zmq_socket):
|
|
"""Test heartbeat updates timestamp for existing worker."""
|
|
with patch(
|
|
"lmcache.v1.cache_controller.controllers.registration_controller.get_zmq_socket"
|
|
) as mock_get_socket:
|
|
mock_get_socket.return_value = mock_zmq_socket
|
|
|
|
# Register worker
|
|
register_msg = RegisterMsg(
|
|
instance_id="test_instance",
|
|
worker_id=0,
|
|
ip="127.0.0.1",
|
|
port=5000,
|
|
peer_init_url="tcp://127.0.0.1:6000",
|
|
)
|
|
await reg_controller.register(register_msg)
|
|
|
|
worker_node = reg_controller.registry.get_worker("test_instance", 0)
|
|
assert worker_node is not None
|
|
original_time = worker_node.last_heartbeat_time
|
|
|
|
# Wait a bit
|
|
time.sleep(0.1)
|
|
|
|
# Send heartbeat
|
|
heartbeat_msg = HeartbeatMsg(
|
|
instance_id="test_instance",
|
|
worker_id=0,
|
|
ip="127.0.0.1",
|
|
port=5000,
|
|
peer_init_url="tcp://127.0.0.1:6000",
|
|
)
|
|
await reg_controller.heartbeat(heartbeat_msg)
|
|
|
|
# Verify timestamp was updated
|
|
worker_node = reg_controller.registry.get_worker("test_instance", 0)
|
|
assert worker_node is not None
|
|
new_time = worker_node.last_heartbeat_time
|
|
assert new_time > original_time
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_heartbeat_unregistered_worker(self, reg_controller, mock_zmq_socket):
|
|
"""Test heartbeat triggers re-registration for unregistered worker."""
|
|
with patch(
|
|
"lmcache.v1.cache_controller.controllers.registration_controller.get_zmq_socket"
|
|
) as mock_get_socket:
|
|
mock_get_socket.return_value = mock_zmq_socket
|
|
|
|
heartbeat_msg = HeartbeatMsg(
|
|
instance_id="test_instance",
|
|
worker_id=0,
|
|
ip="127.0.0.1",
|
|
port=5000,
|
|
peer_init_url="tcp://127.0.0.1:6000",
|
|
)
|
|
|
|
await reg_controller.heartbeat(heartbeat_msg)
|
|
|
|
# Worker should now be registered
|
|
worker_node = reg_controller.registry.get_worker("test_instance", 0)
|
|
assert worker_node is not None
|
|
|
|
|
|
class TestRegistrationControllerQueryWorkerInfo:
|
|
"""Test RegistrationController query worker info operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_query_worker_info(self, reg_controller, mock_zmq_socket):
|
|
"""Test querying worker information."""
|
|
with patch(
|
|
"lmcache.v1.cache_controller.controllers.registration_controller.get_zmq_socket"
|
|
) as mock_get_socket:
|
|
mock_get_socket.return_value = mock_zmq_socket
|
|
|
|
# Register workers
|
|
for worker_id in range(3):
|
|
msg = RegisterMsg(
|
|
instance_id="test_instance",
|
|
worker_id=worker_id,
|
|
ip="127.0.0.1",
|
|
port=5000 + worker_id,
|
|
peer_init_url=f"tcp://127.0.0.1:{6000 + worker_id}",
|
|
)
|
|
await reg_controller.register(msg)
|
|
|
|
# Query all workers
|
|
query_msg = QueryWorkerInfoMsg(
|
|
event_id="event_123",
|
|
instance_id="test_instance",
|
|
worker_ids=None,
|
|
)
|
|
|
|
result = await reg_controller.query_worker_info(query_msg)
|
|
|
|
assert result.event_id == "event_123"
|
|
assert len(result.worker_infos) == 3
|
|
assert result.worker_infos[0].worker_id == 0
|
|
assert result.worker_infos[1].worker_id == 1
|
|
assert result.worker_infos[2].worker_id == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_query_worker_info_specific_workers(
|
|
self, reg_controller, mock_zmq_socket
|
|
):
|
|
"""Test querying specific workers."""
|
|
with patch(
|
|
"lmcache.v1.cache_controller.controllers.registration_controller.get_zmq_socket"
|
|
) as mock_get_socket:
|
|
mock_get_socket.return_value = mock_zmq_socket
|
|
|
|
# Register workers
|
|
for worker_id in range(3):
|
|
msg = RegisterMsg(
|
|
instance_id="test_instance",
|
|
worker_id=worker_id,
|
|
ip="127.0.0.1",
|
|
port=5000 + worker_id,
|
|
peer_init_url=f"tcp://127.0.0.1:{6000 + worker_id}",
|
|
)
|
|
await reg_controller.register(msg)
|
|
|
|
# Query specific workers
|
|
query_msg = QueryWorkerInfoMsg(
|
|
event_id="event_123",
|
|
instance_id="test_instance",
|
|
worker_ids=[0, 2],
|
|
)
|
|
|
|
result = await reg_controller.query_worker_info(query_msg)
|
|
|
|
assert len(result.worker_infos) == 2
|
|
assert result.worker_infos[0].worker_id == 0
|
|
assert result.worker_infos[1].worker_id == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_query_worker_info_nonexistent_instance(self, reg_controller):
|
|
"""Test querying worker info for non-existent instance."""
|
|
query_msg = QueryWorkerInfoMsg(
|
|
event_id="event_123",
|
|
instance_id="nonexistent",
|
|
worker_ids=None,
|
|
)
|
|
|
|
result = await reg_controller.query_worker_info(query_msg)
|
|
|
|
assert result.event_id == "event_123"
|
|
assert len(result.worker_infos) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_query_worker_info_empty_list(self, reg_controller, mock_zmq_socket):
|
|
"""Test querying with empty worker_ids list returns all workers."""
|
|
with patch(
|
|
"lmcache.v1.cache_controller.controllers.registration_controller.get_zmq_socket"
|
|
) as mock_get_socket:
|
|
mock_get_socket.return_value = mock_zmq_socket
|
|
|
|
# Register workers
|
|
for worker_id in range(2):
|
|
msg = RegisterMsg(
|
|
instance_id="test_instance",
|
|
worker_id=worker_id,
|
|
ip="127.0.0.1",
|
|
port=5000 + worker_id,
|
|
peer_init_url=f"tcp://127.0.0.1:{6000 + worker_id}",
|
|
)
|
|
await reg_controller.register(msg)
|
|
|
|
# Query with empty list
|
|
query_msg = QueryWorkerInfoMsg(
|
|
event_id="event_123",
|
|
instance_id="test_instance",
|
|
worker_ids=[],
|
|
)
|
|
|
|
result = await reg_controller.query_worker_info(query_msg)
|
|
|
|
# Should return all workers
|
|
assert len(result.worker_infos) == 2
|
|
|
|
|
|
class TestRegistrationControllerHealth:
|
|
"""Test RegistrationController health operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_delegates_to_executor(self, reg_controller):
|
|
"""Test health check delegates to cluster executor."""
|
|
msg = HealthMsg(
|
|
event_id="event_123",
|
|
instance_id="test_instance",
|
|
)
|
|
|
|
await reg_controller.health(msg)
|
|
|
|
reg_controller.cluster_executor.execute.assert_called_once_with("health", msg)
|
|
|
|
|
|
class TestRegistrationControllerMetrics:
|
|
"""Test RegistrationController metrics setup."""
|
|
|
|
@patch(
|
|
"lmcache.v1.cache_controller.controllers.registration_controller.PrometheusLogger"
|
|
)
|
|
def test_metrics_setup(self, mock_prometheus_logger):
|
|
"""Test metrics are properly set up."""
|
|
mock_logger_instance = Mock()
|
|
mock_prometheus_logger.GetInstanceOrNone.return_value = mock_logger_instance
|
|
|
|
_controller = RegistrationController()
|
|
|
|
# Verify metrics function was set
|
|
mock_logger_instance.registered_workers_count.set_function.assert_called_once()
|
|
|
|
@patch(
|
|
"lmcache.v1.cache_controller.controllers.registration_controller.PrometheusLogger"
|
|
)
|
|
def test_metrics_setup_no_logger(self, mock_prometheus_logger):
|
|
"""Test metrics setup when no logger is available."""
|
|
mock_prometheus_logger.GetInstanceOrNone.return_value = None
|
|
|
|
# Should not raise an error
|
|
controller = RegistrationController()
|
|
|
|
assert controller is not None
|