Add coordinator module and corresponding unit test (#1465)
Signed-off-by: Jeff Wan <wantszkin2003@gmail.com> Signed-off-by: NumberWan <wantszkin2003@gmail.com>
This commit is contained in:
1 parent
ca37f221a8
commit
617b1d6053
10 files changed
+1353
No files matched your search
@@ -0,0 +1,58 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from time import time
|
||||
|
||||
from vllm_omni.distributed.omni_coordinator import (
|
||||
InstanceInfo,
|
||||
RandomBalancer,
|
||||
StageStatus,
|
||||
)
|
||||
|
||||
|
||||
def test_load_balancer_select_returns_valid_index():
|
||||
"""Verify RandomBalancer.select() returns a valid index for instances."""
|
||||
# Task structure mirrors async_omni; RandomBalancer ignores task contents.
|
||||
task: dict = {
|
||||
"request_id": "test",
|
||||
"engine_inputs": None,
|
||||
"sampling_params": None,
|
||||
}
|
||||
|
||||
now = time()
|
||||
instances = [
|
||||
InstanceInfo(
|
||||
input_addr="tcp://host:10001",
|
||||
output_addr="tcp://host:10001-out",
|
||||
stage_id=0,
|
||||
status=StageStatus.UP,
|
||||
queue_length=0,
|
||||
last_heartbeat=now,
|
||||
registered_at=now,
|
||||
),
|
||||
InstanceInfo(
|
||||
input_addr="tcp://host:10002",
|
||||
output_addr="tcp://host:10002-out",
|
||||
stage_id=0,
|
||||
status=StageStatus.UP,
|
||||
queue_length=1,
|
||||
last_heartbeat=now,
|
||||
registered_at=now,
|
||||
),
|
||||
InstanceInfo(
|
||||
input_addr="tcp://host:10003",
|
||||
output_addr="tcp://host:10003-out",
|
||||
stage_id=1,
|
||||
status=StageStatus.UP,
|
||||
queue_length=2,
|
||||
last_heartbeat=now,
|
||||
registered_at=now,
|
||||
),
|
||||
]
|
||||
|
||||
balancer = RandomBalancer()
|
||||
|
||||
index = balancer.select(task, instances)
|
||||
|
||||
assert isinstance(index, int)
|
||||
assert 0 <= index < len(instances)
|
||||
@@ -0,0 +1,119 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import zmq
|
||||
|
||||
from vllm_omni.distributed.omni_coordinator import (
|
||||
InstanceList,
|
||||
OmniCoordClientForHub,
|
||||
)
|
||||
|
||||
|
||||
def _bind_pub() -> tuple[zmq.Context, zmq.Socket, str]:
|
||||
ctx = zmq.Context.instance()
|
||||
pub = ctx.socket(zmq.PUB)
|
||||
pub.bind("tcp://127.0.0.1:*")
|
||||
endpoint = pub.getsockopt(zmq.LAST_ENDPOINT).decode("ascii")
|
||||
return ctx, pub, endpoint
|
||||
|
||||
|
||||
def _wait_for_condition(cond, timeout: float = 2.0, interval: float = 0.01) -> bool:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
if cond():
|
||||
return True
|
||||
time.sleep(interval)
|
||||
return False
|
||||
|
||||
|
||||
def test_hub_client_caches_instance_list_from_pub():
|
||||
"""Verify OmniCoordClientForHub receives instance list updates from OmniCoordinator and caches for get_instance_list()."""
|
||||
ctx, pub, endpoint = _bind_pub()
|
||||
|
||||
client = OmniCoordClientForHub(endpoint)
|
||||
# ZMQ PUB/SUB slow-joiner: allow SUB to finish connecting before first send
|
||||
time.sleep(0.2)
|
||||
|
||||
now = time.time()
|
||||
instances_payload = [
|
||||
{
|
||||
"input_addr": "tcp://stage:10001",
|
||||
"output_addr": "tcp://stage:10001-out",
|
||||
"stage_id": 0,
|
||||
"status": "up",
|
||||
"queue_length": 0,
|
||||
"last_heartbeat": now,
|
||||
"registered_at": now,
|
||||
},
|
||||
{
|
||||
"input_addr": "tcp://stage:10002",
|
||||
"output_addr": "tcp://stage:10002-out",
|
||||
"stage_id": 0,
|
||||
"status": "up",
|
||||
"queue_length": 1,
|
||||
"last_heartbeat": now,
|
||||
"registered_at": now,
|
||||
},
|
||||
{
|
||||
"input_addr": "tcp://stage:10003",
|
||||
"output_addr": "tcp://stage:10003-out",
|
||||
"stage_id": 1,
|
||||
"status": "error",
|
||||
"queue_length": 5,
|
||||
"last_heartbeat": now,
|
||||
"registered_at": now,
|
||||
},
|
||||
]
|
||||
|
||||
payload = {"instances": instances_payload, "timestamp": now}
|
||||
pub.send(json.dumps(payload).encode("utf-8"))
|
||||
|
||||
assert _wait_for_condition(lambda: len(client.get_instance_list().instances) == 3)
|
||||
|
||||
inst_list = client.get_instance_list()
|
||||
assert isinstance(inst_list, InstanceList)
|
||||
assert len(inst_list.instances) == 3
|
||||
|
||||
for src, inst in zip(instances_payload, inst_list.instances, strict=True):
|
||||
assert inst.input_addr == src["input_addr"]
|
||||
assert inst.output_addr == src["output_addr"]
|
||||
assert inst.stage_id == src["stage_id"]
|
||||
assert inst.status.value == src["status"]
|
||||
|
||||
stage0 = client.get_instances_for_stage(0)
|
||||
stage1 = client.get_instances_for_stage(1)
|
||||
|
||||
assert all(inst.stage_id == 0 for inst in stage0.instances)
|
||||
assert all(inst.stage_id == 1 for inst in stage1.instances)
|
||||
|
||||
# Send an updated list with fewer instances and verify cache refresh.
|
||||
updated_payload = {
|
||||
"instances": instances_payload[:2],
|
||||
"timestamp": now + 1.0,
|
||||
}
|
||||
pub.send(json.dumps(updated_payload).encode("utf-8"))
|
||||
|
||||
assert _wait_for_condition(lambda: len(client.get_instance_list().instances) == 2)
|
||||
updated_list = client.get_instance_list()
|
||||
assert len(updated_list.instances) == 2
|
||||
|
||||
client.close()
|
||||
pub.close(0)
|
||||
ctx.term()
|
||||
|
||||
|
||||
def test_hub_client_close_closes_sub_socket():
|
||||
"""Verify OmniCoordClientForHub.close() marks client as closed; second close raises."""
|
||||
ctx, pub, endpoint = _bind_pub()
|
||||
client = OmniCoordClientForHub(endpoint)
|
||||
client.close()
|
||||
|
||||
with pytest.raises(RuntimeError, match="already closed"):
|
||||
client.close()
|
||||
|
||||
pub.close(0)
|
||||
ctx.term()
|
||||
@@ -0,0 +1,110 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import json
|
||||
|
||||
import zmq
|
||||
|
||||
from vllm_omni.distributed.omni_coordinator import (
|
||||
OmniCoordClientForStage,
|
||||
StageStatus,
|
||||
)
|
||||
|
||||
|
||||
def _bind_router() -> tuple[zmq.Context, zmq.Socket, str]:
|
||||
ctx = zmq.Context.instance()
|
||||
router = ctx.socket(zmq.ROUTER)
|
||||
router.bind("tcp://127.0.0.1:*")
|
||||
endpoint = router.getsockopt(zmq.LAST_ENDPOINT).decode("ascii")
|
||||
return ctx, router, endpoint
|
||||
|
||||
|
||||
def _recv_event(router: zmq.Socket) -> dict:
|
||||
frames = router.recv_multipart()
|
||||
# ROUTER adds identity frame; the last frame is the payload.
|
||||
payload = frames[-1]
|
||||
return json.loads(payload.decode("utf-8"))
|
||||
|
||||
|
||||
def test_stage_client_auto_register_on_init():
|
||||
"""Verify OmniCoordClientForStage automatically sends initial registration/status-up event when created."""
|
||||
ctx, router, endpoint = _bind_router()
|
||||
|
||||
input_addr = "tcp://stage:10001"
|
||||
output_addr = "tcp://stage:10001-out"
|
||||
stage_id = 0
|
||||
|
||||
client = OmniCoordClientForStage(endpoint, input_addr, output_addr, stage_id)
|
||||
|
||||
event = _recv_event(router)
|
||||
|
||||
assert event["event_type"] == "update"
|
||||
assert event["status"] == StageStatus.UP.value
|
||||
assert event["stage_id"] == stage_id
|
||||
assert event["input_addr"] == input_addr
|
||||
assert event["output_addr"] == output_addr
|
||||
|
||||
client.close()
|
||||
router.close(0)
|
||||
ctx.term()
|
||||
|
||||
|
||||
def test_stage_client_update_info_sends_correct_event():
|
||||
"""Verify OmniCoordClientForStage.update_info() sends status/load update events with expected fields."""
|
||||
ctx, router, endpoint = _bind_router()
|
||||
|
||||
input_addr = "tcp://stage:10002"
|
||||
output_addr = "tcp://stage:10002-out"
|
||||
stage_id = 1
|
||||
|
||||
client = OmniCoordClientForStage(endpoint, input_addr, output_addr, stage_id)
|
||||
|
||||
# Discard initial registration event.
|
||||
_recv_event(router)
|
||||
|
||||
client.update_info(status=StageStatus.ERROR)
|
||||
client.update_info(queue_length=10)
|
||||
|
||||
first = _recv_event(router)
|
||||
second = _recv_event(router)
|
||||
|
||||
assert first["status"] == StageStatus.ERROR.value
|
||||
assert first["stage_id"] == stage_id
|
||||
assert first["input_addr"] == input_addr
|
||||
assert first["output_addr"] == output_addr
|
||||
|
||||
assert second["queue_length"] == 10
|
||||
assert second["stage_id"] == stage_id
|
||||
assert second["input_addr"] == input_addr
|
||||
assert second["output_addr"] == output_addr
|
||||
|
||||
client.close()
|
||||
router.close(0)
|
||||
ctx.term()
|
||||
|
||||
|
||||
def test_stage_client_close_sends_down_status():
|
||||
"""Verify close() sends final status-down event before closing underlying socket."""
|
||||
ctx, router, endpoint = _bind_router()
|
||||
|
||||
input_addr = "tcp://stage:10003"
|
||||
output_addr = "tcp://stage:10003-out"
|
||||
stage_id = 2
|
||||
|
||||
client = OmniCoordClientForStage(endpoint, input_addr, output_addr, stage_id)
|
||||
|
||||
# Discard initial registration event.
|
||||
_recv_event(router)
|
||||
|
||||
client.close()
|
||||
|
||||
event = _recv_event(router)
|
||||
assert event["status"] == StageStatus.DOWN.value
|
||||
assert event["stage_id"] == stage_id
|
||||
assert event["input_addr"] == input_addr
|
||||
assert event["output_addr"] == output_addr
|
||||
|
||||
assert client._socket.closed # DEALER socket no longer usable after close
|
||||
|
||||
router.close(0)
|
||||
ctx.term()
|
||||
@@ -0,0 +1,209 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
import zmq
|
||||
from vllm.v1.utils import get_engine_client_zmq_addr
|
||||
|
||||
from vllm_omni.distributed.omni_coordinator import (
|
||||
OmniCoordClientForStage,
|
||||
OmniCoordinator,
|
||||
StageStatus,
|
||||
)
|
||||
|
||||
|
||||
def _recv_instance_list(sub: zmq.Socket, timeout_ms: int = 2000) -> dict | None:
|
||||
"""Receive InstanceList JSON from SUB socket. Returns None on timeout."""
|
||||
sub.setsockopt(zmq.RCVTIMEO, timeout_ms)
|
||||
try:
|
||||
data = sub.recv()
|
||||
return json.loads(data.decode("utf-8"))
|
||||
except zmq.Again:
|
||||
return None
|
||||
|
||||
|
||||
def _wait_for_instance_list(
|
||||
sub: zmq.Socket,
|
||||
expected_count: int,
|
||||
timeout: float = 3.0,
|
||||
) -> dict | None:
|
||||
"""Wait until received InstanceList with expected_count active instances."""
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
msg = _recv_instance_list(sub, timeout_ms=500)
|
||||
if msg is not None and len(msg.get("instances", [])) == expected_count:
|
||||
return msg
|
||||
return None
|
||||
|
||||
|
||||
def test_omni_coordinator_registration_broadcast():
|
||||
"""Verify that after multiple OmniCoordClientForStage instances register,
|
||||
OmniCoordinator publishes an InstanceList containing all registered instances.
|
||||
"""
|
||||
router_addr = get_engine_client_zmq_addr(
|
||||
local_only=False,
|
||||
host="127.0.0.1",
|
||||
port=0,
|
||||
)
|
||||
pub_addr = get_engine_client_zmq_addr(
|
||||
local_only=False,
|
||||
host="127.0.0.1",
|
||||
port=0,
|
||||
)
|
||||
coordinator = OmniCoordinator(
|
||||
router_zmq_addr=router_addr,
|
||||
pub_zmq_addr=pub_addr,
|
||||
heartbeat_timeout=1000.0,
|
||||
)
|
||||
|
||||
sub_ctx = zmq.Context.instance()
|
||||
sub = sub_ctx.socket(zmq.SUB)
|
||||
sub.connect(pub_addr)
|
||||
sub.setsockopt(zmq.SUBSCRIBE, b"")
|
||||
|
||||
# ZMQ PUB/SUB slow-joiner: allow SUB to connect before clients register.
|
||||
time.sleep(0.3)
|
||||
|
||||
# Create 3 stage clients; each auto-registers on init.
|
||||
clients = [
|
||||
OmniCoordClientForStage(router_addr, "tcp://stage:10001", "tcp://stage:10001-out", 0),
|
||||
OmniCoordClientForStage(router_addr, "tcp://stage:10002", "tcp://stage:10002-out", 0),
|
||||
OmniCoordClientForStage(router_addr, "tcp://stage:10003", "tcp://stage:10003-out", 1),
|
||||
]
|
||||
|
||||
msg = _wait_for_instance_list(sub, expected_count=3)
|
||||
assert msg is not None, "Expected InstanceList with 3 instances"
|
||||
assert len(msg["instances"]) == 3
|
||||
assert isinstance(msg["timestamp"], (int, float))
|
||||
|
||||
input_addrs = {inst["input_addr"] for inst in msg["instances"]}
|
||||
assert "tcp://stage:10001" in input_addrs
|
||||
assert "tcp://stage:10002" in input_addrs
|
||||
assert "tcp://stage:10003" in input_addrs
|
||||
|
||||
for c in clients:
|
||||
c.close()
|
||||
coordinator.close()
|
||||
sub.close(0)
|
||||
sub_ctx.term()
|
||||
|
||||
|
||||
def test_omni_coordinator_heartbeat_timeout_handling():
|
||||
"""Verify that when a stage instance stops sending heartbeats,
|
||||
OmniCoordinator marks it as unhealthy and excludes it from the active list.
|
||||
"""
|
||||
router_addr = get_engine_client_zmq_addr(
|
||||
local_only=False,
|
||||
host="127.0.0.1",
|
||||
port=0,
|
||||
)
|
||||
pub_addr = get_engine_client_zmq_addr(
|
||||
local_only=False,
|
||||
host="127.0.0.1",
|
||||
port=0,
|
||||
)
|
||||
coordinator = OmniCoordinator(
|
||||
router_zmq_addr=router_addr,
|
||||
pub_zmq_addr=pub_addr,
|
||||
heartbeat_timeout=5.0,
|
||||
)
|
||||
|
||||
sub_ctx = zmq.Context.instance()
|
||||
sub = sub_ctx.socket(zmq.SUB)
|
||||
sub.connect(pub_addr)
|
||||
sub.setsockopt(zmq.SUBSCRIBE, b"")
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
# A and B: real clients that send heartbeats every 5s.
|
||||
client_a = OmniCoordClientForStage(router_addr, "tcp://stage:a", "tcp://stage:a-out", 0)
|
||||
client_b = OmniCoordClientForStage(router_addr, "tcp://stage:b", "tcp://stage:b-out", 0)
|
||||
|
||||
# C: raw DEALER that sends only registration, no heartbeat.
|
||||
dealer_ctx = zmq.Context.instance()
|
||||
dealer_c = dealer_ctx.socket(zmq.DEALER)
|
||||
dealer_c.connect(router_addr)
|
||||
reg_event = {
|
||||
"input_addr": "tcp://stage:c",
|
||||
"output_addr": "tcp://stage:c-out",
|
||||
"stage_id": 0,
|
||||
"event_type": "update",
|
||||
"status": StageStatus.UP.value,
|
||||
"queue_length": 0,
|
||||
}
|
||||
dealer_c.send(json.dumps(reg_event).encode("utf-8"))
|
||||
|
||||
msg = _wait_for_instance_list(sub, expected_count=3)
|
||||
assert msg is not None, "Expected initial 3 instances"
|
||||
assert len(msg["instances"]) == 3
|
||||
|
||||
# Wait for heartbeat timeout (timeout=5s, check interval ~2.5s).
|
||||
time.sleep(8.0)
|
||||
|
||||
# Receive the update (C should be ERROR and excluded from active list).
|
||||
msg_after_timeout = _wait_for_instance_list(sub, expected_count=2, timeout=5.0)
|
||||
assert msg_after_timeout is not None, "Expected InstanceList with 2 instances after timeout"
|
||||
instances = msg_after_timeout.get("instances", [])
|
||||
input_addrs = {inst["input_addr"] for inst in instances}
|
||||
|
||||
assert "tcp://stage:a" in input_addrs
|
||||
assert "tcp://stage:b" in input_addrs
|
||||
assert "tcp://stage:c" not in input_addrs
|
||||
|
||||
client_a.close()
|
||||
client_b.close()
|
||||
dealer_c.close(0)
|
||||
coordinator.close()
|
||||
sub.close(0)
|
||||
dealer_ctx.term()
|
||||
sub_ctx.term()
|
||||
|
||||
|
||||
def test_omni_coordinator_instance_shutdown_handling():
|
||||
"""Verify that when a stage instance sends status='down',
|
||||
OmniCoordinator removes it from the active list and broadcasts an updated list.
|
||||
"""
|
||||
router_addr = get_engine_client_zmq_addr(
|
||||
local_only=False,
|
||||
host="127.0.0.1",
|
||||
port=0,
|
||||
)
|
||||
pub_addr = get_engine_client_zmq_addr(
|
||||
local_only=False,
|
||||
host="127.0.0.1",
|
||||
port=0,
|
||||
)
|
||||
coordinator = OmniCoordinator(
|
||||
router_zmq_addr=router_addr,
|
||||
pub_zmq_addr=pub_addr,
|
||||
heartbeat_timeout=1000.0,
|
||||
)
|
||||
|
||||
sub_ctx = zmq.Context.instance()
|
||||
sub = sub_ctx.socket(zmq.SUB)
|
||||
sub.connect(pub_addr)
|
||||
sub.setsockopt(zmq.SUBSCRIBE, b"")
|
||||
|
||||
time.sleep(0.3) # PUB/SUB slow-joiner
|
||||
|
||||
client = OmniCoordClientForStage(router_addr, "tcp://stage:shutdown", "tcp://stage:shutdown-out", 0)
|
||||
|
||||
msg = _wait_for_instance_list(sub, expected_count=1)
|
||||
assert msg is not None
|
||||
assert len(msg["instances"]) == 1
|
||||
assert msg["instances"][0]["input_addr"] == "tcp://stage:shutdown"
|
||||
|
||||
# Send down status (simulating graceful shutdown).
|
||||
client.update_info(status=StageStatus.DOWN)
|
||||
|
||||
# Receive updated list (should have 0 active instances).
|
||||
msg = _wait_for_instance_list(sub, expected_count=0)
|
||||
assert msg is not None
|
||||
assert len(msg["instances"]) == 0
|
||||
|
||||
client.close()
|
||||
coordinator.close()
|
||||
sub.close(0)
|
||||
sub_ctx.term()
|
||||
@@ -0,0 +1,27 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from .load_balancer import (
|
||||
LoadBalancer,
|
||||
LoadBalancingPolicy,
|
||||
RandomBalancer,
|
||||
Task,
|
||||
)
|
||||
from .messages import InstanceEvent, InstanceInfo, InstanceList, StageStatus
|
||||
from .omni_coord_client_for_hub import OmniCoordClientForHub
|
||||
from .omni_coord_client_for_stage import OmniCoordClientForStage
|
||||
from .omni_coordinator import OmniCoordinator
|
||||
|
||||
__all__ = [
|
||||
"OmniCoordinator",
|
||||
"StageStatus",
|
||||
"InstanceEvent",
|
||||
"InstanceInfo",
|
||||
"InstanceList",
|
||||
"OmniCoordClientForStage",
|
||||
"OmniCoordClientForHub",
|
||||
"Task",
|
||||
"LoadBalancer",
|
||||
"LoadBalancingPolicy",
|
||||
"RandomBalancer",
|
||||
]
|
||||
@@ -0,0 +1,82 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from .messages import InstanceInfo
|
||||
|
||||
|
||||
class Task(TypedDict, total=False):
|
||||
"""Task structure passed from async_omni (stage.submit(task)).
|
||||
|
||||
Mirrors the dict built in AsyncOmni with request_id, engine_inputs,
|
||||
sampling_params. Future load-balancing policies may use these fields.
|
||||
"""
|
||||
|
||||
request_id: str
|
||||
engine_inputs: Any
|
||||
sampling_params: Any
|
||||
|
||||
|
||||
class LoadBalancingPolicy(str, Enum):
|
||||
"""Enumeration for load balancing policies.
|
||||
|
||||
Only ``RANDOM`` is implemented. Additional policies (e.g. round-robin,
|
||||
least-connections) can be added in the future.
|
||||
"""
|
||||
|
||||
RANDOM = "random"
|
||||
|
||||
|
||||
class LoadBalancer(ABC):
|
||||
"""Abstract base class for load balancers.
|
||||
|
||||
Subclasses implement :meth:`select` to choose an instance for a given task.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def select(self, task: Task, instances: list[InstanceInfo]) -> int:
|
||||
"""Route a task to one of the available instances.
|
||||
|
||||
Args:
|
||||
task: The task to route. Not used by the random policy but reserved
|
||||
for future strategies that may inspect task metadata.
|
||||
instances: List of available instances to choose from.
|
||||
|
||||
Returns:
|
||||
Index of the selected instance in ``instances``.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``instances`` is empty.
|
||||
"""
|
||||
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class RandomBalancer(LoadBalancer):
|
||||
"""Load balancer that selects an instance uniformly at random.
|
||||
|
||||
This is the initial and only policy supported. It intentionally ignores
|
||||
the task payload and chooses a random index from the provided instance
|
||||
list. More sophisticated policies (e.g. round-robin, least-connections)
|
||||
can be implemented as additional subclasses of :class:`LoadBalancer`.
|
||||
"""
|
||||
|
||||
def select(self, task: Task, instances: list[InstanceInfo]) -> int: # noqa: ARG002
|
||||
if not instances:
|
||||
raise ValueError("instances must not be empty")
|
||||
|
||||
return random.randrange(len(instances))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Task",
|
||||
"LoadBalancingPolicy",
|
||||
"LoadBalancer",
|
||||
"RandomBalancer",
|
||||
]
|
||||
@@ -0,0 +1,61 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class StageStatus(str, Enum):
|
||||
"""Enumeration for stage instance status."""
|
||||
|
||||
UP = "up" # Instance is ready and available
|
||||
DOWN = "down" # Instance is shutdown gracefully
|
||||
ERROR = "error" # Instance encountered an error or timeout
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstanceEvent:
|
||||
"""Wire payload from OmniCoordClientForStage to OmniCoordinator.
|
||||
|
||||
Schema for Stage → Coordinator events over ZMQ:
|
||||
input_addr, output_addr, stage_id, status, queue_length, event_type.
|
||||
"""
|
||||
|
||||
input_addr: str # Stage instance input ZMQ address (e.g., "tcp://host:port")
|
||||
output_addr: str # Stage instance output ZMQ address (e.g., "tcp://host:port")
|
||||
stage_id: int # Stage ID
|
||||
event_type: str # "update" | "heartbeat"
|
||||
status: StageStatus # Current status
|
||||
queue_length: int # Current queue length
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstanceInfo:
|
||||
"""Metadata for a single stage instance.
|
||||
|
||||
This type is stored in OmniCoordinator's internal registry and is also
|
||||
published to hubs via :class:`InstanceList`.
|
||||
"""
|
||||
|
||||
input_addr: str # Stage instance input ZMQ address (e.g., "tcp://host:port")
|
||||
output_addr: str # Stage instance output ZMQ address (e.g., "tcp://host:port")
|
||||
stage_id: int # Stage ID of this instance
|
||||
status: StageStatus # Current status of the instance
|
||||
queue_length: int # Current queue length of this instance
|
||||
last_heartbeat: float # Timestamp of the last heartbeat received (seconds)
|
||||
registered_at: float # Timestamp when the instance was registered (seconds)
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstanceList:
|
||||
"""Container for instance list updates.
|
||||
|
||||
OmniCoordinator publishes an :class:`InstanceList` whenever its view of
|
||||
active instances changes. OmniCoordClientForHub caches the latest value
|
||||
and exposes it to AsyncOmni and the load balancer.
|
||||
"""
|
||||
|
||||
instances: list[InstanceInfo]
|
||||
timestamp: float # Time when the list was last updated (seconds)
|
||||
@@ -0,0 +1,164 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from time import sleep, time
|
||||
from typing import Any
|
||||
|
||||
import zmq
|
||||
|
||||
from .messages import InstanceInfo, InstanceList, StageStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OmniCoordClientForHub:
|
||||
"""Client for AsyncOmni side to receive instance list updates.
|
||||
|
||||
This client maintains a SUB socket connected to OmniCoordinator's PUB
|
||||
endpoint and caches the latest :class:`InstanceList` in memory for use by
|
||||
the load balancer and routing logic.
|
||||
"""
|
||||
|
||||
def __init__(self, coord_zmq_addr: str) -> None:
|
||||
"""Initialize client and start receive thread (socket created in thread)."""
|
||||
self._coord_zmq_addr = coord_zmq_addr
|
||||
|
||||
self._ctx = zmq.Context()
|
||||
self._lock = threading.Lock()
|
||||
self._instance_list: InstanceList | None = None
|
||||
self._closed = False
|
||||
self._stop_event = threading.Event()
|
||||
self._init_done = threading.Event()
|
||||
self._init_error: list[BaseException] = []
|
||||
|
||||
self._thread = threading.Thread(target=self._recv_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
self._init_done.wait(timeout=5.0)
|
||||
if self._init_error:
|
||||
raise RuntimeError(f"Failed to connect to coordinator at {self._coord_zmq_addr}") from self._init_error[0]
|
||||
|
||||
def _decode_instance_list(self, payload: dict[str, Any]) -> InstanceList:
|
||||
"""Convert a JSON-decoded dict into an :class:`InstanceList`."""
|
||||
instances_payload = payload.get("instances", [])
|
||||
instances: list[InstanceInfo] = []
|
||||
for inst in instances_payload:
|
||||
instances.append(
|
||||
InstanceInfo(
|
||||
input_addr=inst["input_addr"],
|
||||
output_addr=inst["output_addr"],
|
||||
stage_id=int(inst["stage_id"]),
|
||||
status=StageStatus(inst["status"]),
|
||||
queue_length=int(inst["queue_length"]),
|
||||
last_heartbeat=float(inst["last_heartbeat"]),
|
||||
registered_at=float(inst["registered_at"]),
|
||||
)
|
||||
)
|
||||
|
||||
timestamp = float(payload.get("timestamp", time()))
|
||||
return InstanceList(instances=instances, timestamp=timestamp)
|
||||
|
||||
def _recv_loop(self) -> None:
|
||||
"""Background loop that receives and caches instance lists."""
|
||||
sub = None
|
||||
try:
|
||||
sub = self._ctx.socket(zmq.SUB)
|
||||
sub.setsockopt(zmq.SUBSCRIBE, b"")
|
||||
sub.setsockopt(zmq.RCVTIMEO, 100) # 100ms timeout, avoids busy-wait
|
||||
sub.connect(self._coord_zmq_addr)
|
||||
except (zmq.ZMQError, OSError) as e:
|
||||
self._init_error.append(e)
|
||||
sub = None
|
||||
finally:
|
||||
self._init_done.set()
|
||||
|
||||
try:
|
||||
while not self._stop_event.is_set():
|
||||
# (Re)create and connect SUB socket if needed.
|
||||
if sub is None:
|
||||
try:
|
||||
sub = self._ctx.socket(zmq.SUB)
|
||||
sub.setsockopt(zmq.SUBSCRIBE, b"")
|
||||
sub.setsockopt(zmq.RCVTIMEO, 100) # 100ms timeout, avoids busy-wait
|
||||
sub.connect(self._coord_zmq_addr)
|
||||
except (zmq.ZMQError, OSError) as e:
|
||||
logger.error(
|
||||
"Hub client failed to connect to coordinator at %s, will retry",
|
||||
self._coord_zmq_addr,
|
||||
exc_info=e,
|
||||
)
|
||||
if sub is not None:
|
||||
try:
|
||||
sub.close()
|
||||
except zmq.ZMQError:
|
||||
pass
|
||||
sub = None
|
||||
sleep(1.0)
|
||||
continue
|
||||
|
||||
try:
|
||||
data = sub.recv()
|
||||
except zmq.Again:
|
||||
continue
|
||||
except zmq.ZMQError as e:
|
||||
logger.error("Hub client recv failed, will reconnect", exc_info=e)
|
||||
try:
|
||||
sub.close()
|
||||
except zmq.ZMQError:
|
||||
pass
|
||||
sub = None
|
||||
sleep(1.0)
|
||||
continue
|
||||
|
||||
try:
|
||||
payload = json.loads(data.decode("utf-8"))
|
||||
inst_list = self._decode_instance_list(payload)
|
||||
with self._lock:
|
||||
self._instance_list = inst_list
|
||||
except (
|
||||
json.JSONDecodeError,
|
||||
KeyError,
|
||||
ValueError,
|
||||
TypeError,
|
||||
AttributeError,
|
||||
) as e:
|
||||
logger.warning("Invalid instance list message, skipping: %s", e)
|
||||
finally:
|
||||
try:
|
||||
if sub is not None:
|
||||
sub.close()
|
||||
except zmq.ZMQError:
|
||||
pass
|
||||
try:
|
||||
self._ctx.term()
|
||||
except zmq.ZMQError:
|
||||
pass
|
||||
|
||||
def get_instance_list(self) -> InstanceList:
|
||||
"""Return the latest cached :class:`InstanceList`.
|
||||
|
||||
If no update has been received yet, returns an empty list with
|
||||
``timestamp=0.0``.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._instance_list is None:
|
||||
return InstanceList(instances=[], timestamp=0.0)
|
||||
return self._instance_list
|
||||
|
||||
def get_instances_for_stage(self, stage_id: int) -> InstanceList:
|
||||
"""Return instances filtered by ``stage_id``."""
|
||||
base = self.get_instance_list()
|
||||
filtered = [inst for inst in base.instances if inst.stage_id == stage_id]
|
||||
return InstanceList(instances=filtered, timestamp=base.timestamp)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the SUB socket and stop the background thread."""
|
||||
if self._closed:
|
||||
raise RuntimeError("Client already closed")
|
||||
|
||||
self._closed = True
|
||||
self._stop_event.set()
|
||||
self._thread.join(timeout=1.0)
|
||||
@@ -0,0 +1,193 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
|
||||
import zmq
|
||||
|
||||
from .messages import InstanceEvent, StageStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OmniCoordClientForStage:
|
||||
"""Client used by stage instances to send events to OmniCoordinator.
|
||||
|
||||
This client maintains a DEALER socket connected to OmniCoordinator's
|
||||
ROUTER endpoint and sends JSON-encoded events describing instance status.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coord_zmq_addr: str,
|
||||
input_addr: str,
|
||||
output_addr: str,
|
||||
stage_id: int,
|
||||
) -> None:
|
||||
"""Initialize client and send initial registration / status-up event."""
|
||||
self._coord_zmq_addr = coord_zmq_addr
|
||||
self._input_addr = input_addr
|
||||
self._output_addr = output_addr
|
||||
self._stage_id = stage_id
|
||||
|
||||
self._ctx = zmq.Context()
|
||||
self._socket = self._ctx.socket(zmq.DEALER)
|
||||
try:
|
||||
self._socket.connect(self._coord_zmq_addr)
|
||||
except zmq.ZMQError as e:
|
||||
self._socket.close()
|
||||
raise RuntimeError(f"Failed to connect to coordinator at {self._coord_zmq_addr}: {e}") from e
|
||||
|
||||
self._status = StageStatus.UP
|
||||
self._queue_length = 0
|
||||
self._closed = False
|
||||
self._heartbeat_interval = 5.0
|
||||
self._stop_event = threading.Event()
|
||||
self._send_lock = threading.Lock()
|
||||
|
||||
self._send_event("update")
|
||||
|
||||
self._heartbeat_thread = threading.Thread(
|
||||
target=self._heartbeat_loop,
|
||||
daemon=True,
|
||||
)
|
||||
self._heartbeat_thread.start()
|
||||
|
||||
def _reconnect(self) -> bool:
|
||||
"""Best-effort reconnect with up to ``max_retries`` attempts.
|
||||
|
||||
Each attempt closes the current socket/context, sleeps 5 seconds,
|
||||
then creates a new DEALER socket and reconnects to the coordinator.
|
||||
Caller must hold ``_send_lock``.
|
||||
Returns True on success, False if all attempts fail.
|
||||
"""
|
||||
while not self._stop_event.is_set() and not self._closed:
|
||||
try:
|
||||
self._socket.close(0)
|
||||
except zmq.ZMQError:
|
||||
pass
|
||||
try:
|
||||
self._ctx.term()
|
||||
except zmq.ZMQError:
|
||||
pass
|
||||
|
||||
time.sleep(5.0)
|
||||
|
||||
try:
|
||||
self._ctx = zmq.Context()
|
||||
self._socket = self._ctx.socket(zmq.DEALER)
|
||||
self._socket.connect(self._coord_zmq_addr)
|
||||
return True
|
||||
except zmq.ZMQError as e:
|
||||
logger.error(
|
||||
"Stage client reconnect failed, will retry in 5s (coord=%s)",
|
||||
self._coord_zmq_addr,
|
||||
exc_info=e,
|
||||
)
|
||||
continue
|
||||
return False
|
||||
|
||||
def _send_event(self, event_type: str) -> None:
|
||||
"""Send an InstanceEvent to OmniCoordinator.
|
||||
|
||||
Wire format: input_addr, output_addr, stage_id, status, queue_length, event_type.
|
||||
For "update": includes status and queue_length from instance state.
|
||||
For "heartbeat": status and queue_length are null.
|
||||
|
||||
On send failure (ZMQError / RuntimeError), attempts to reconnect up
|
||||
to 3 times (5s sleep each) and retries the send once after a
|
||||
successful reconnect. Raises if reconnect or the retry send fails.
|
||||
"""
|
||||
if self._closed:
|
||||
raise RuntimeError("Client already closed")
|
||||
|
||||
event = InstanceEvent(
|
||||
input_addr=self._input_addr,
|
||||
output_addr=self._output_addr,
|
||||
stage_id=self._stage_id,
|
||||
event_type=event_type,
|
||||
status=self._status,
|
||||
queue_length=self._queue_length,
|
||||
)
|
||||
data = json.dumps(asdict(event)).encode("utf-8")
|
||||
|
||||
with self._send_lock:
|
||||
try:
|
||||
self._socket.send(data, flags=zmq.NOBLOCK)
|
||||
return
|
||||
except zmq.Again:
|
||||
logger.debug("Send buffer full, dropping event")
|
||||
return
|
||||
except (RuntimeError, zmq.ZMQError) as e:
|
||||
# First send failed; try reconnecting a few times.
|
||||
if not self._reconnect:
|
||||
logger.error("Failed to send event and reconnect to coordinator", exc_info=e)
|
||||
raise
|
||||
|
||||
# Reconnected successfully; try sending once more.
|
||||
try:
|
||||
self._socket.send(data, flags=zmq.NOBLOCK)
|
||||
except zmq.Again:
|
||||
logger.debug("Send buffer full after reconnect, dropping event")
|
||||
except (RuntimeError, zmq.ZMQError) as e2:
|
||||
logger.error("Failed to send event after successful reconnect", exc_info=e2)
|
||||
raise
|
||||
|
||||
def update_info(
|
||||
self,
|
||||
status: StageStatus | None = None,
|
||||
queue_length: int | None = None,
|
||||
) -> None:
|
||||
"""Update instance information and notify OmniCoordinator.
|
||||
|
||||
At least one of ``status`` or ``queue_length`` must be provided.
|
||||
"""
|
||||
if status is None and queue_length is None:
|
||||
raise ValueError("At least one of status or queue_length must be provided")
|
||||
|
||||
if status is not None:
|
||||
self._status = status
|
||||
if queue_length is not None:
|
||||
self._queue_length = queue_length
|
||||
|
||||
self._send_event("update")
|
||||
|
||||
def _heartbeat_loop(self) -> None:
|
||||
"""Periodically send heartbeat events while the client is alive."""
|
||||
while not self._stop_event.wait(timeout=self._heartbeat_interval):
|
||||
if self._closed:
|
||||
break
|
||||
|
||||
try:
|
||||
self._send_event("heartbeat")
|
||||
except (RuntimeError, zmq.ZMQError):
|
||||
break
|
||||
|
||||
def close(self) -> None:
|
||||
"""Send a final down event and close the underlying socket."""
|
||||
if self._closed:
|
||||
raise RuntimeError("Client already closed")
|
||||
|
||||
# Stop heartbeat thread first to avoid concurrent sends during shutdown.
|
||||
self._stop_event.set()
|
||||
if hasattr(self, "_heartbeat_thread"):
|
||||
self._heartbeat_thread.join(timeout=1.0)
|
||||
|
||||
# Mark status as DOWN and send one last update.
|
||||
self._status = StageStatus.DOWN
|
||||
try:
|
||||
self._send_event("update")
|
||||
except zmq.ZMQError:
|
||||
pass # Socket may already be broken, proceed with close
|
||||
|
||||
# Close DEALER socket and terminate this client's context.
|
||||
self._socket.close(0)
|
||||
try:
|
||||
self._ctx.term()
|
||||
except zmq.ZMQError:
|
||||
pass
|
||||
self._closed = True
|
||||
@@ -0,0 +1,330 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import asdict
|
||||
from time import time
|
||||
from typing import Any
|
||||
|
||||
import zmq
|
||||
|
||||
from .messages import InstanceEvent, InstanceInfo, InstanceList, StageStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OmniCoordinator:
|
||||
"""Coordinator for stage instances and hub clients.
|
||||
|
||||
This service receives instance events from :class:`OmniCoordClientForStage`
|
||||
via a ZMQ ROUTER socket and publishes active instance lists to
|
||||
:class:`OmniCoordClientForHub` via a PUB socket.
|
||||
|
||||
The coordinator maintains an in-memory registry of all known instances,
|
||||
including their status, queue length, and heartbeat timestamps. A
|
||||
background thread periodically checks for heartbeat timeouts and marks
|
||||
unhealthy instances as ``StageStatus.ERROR``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
router_zmq_addr: str,
|
||||
pub_zmq_addr: str,
|
||||
heartbeat_timeout: float = 30.0,
|
||||
) -> None:
|
||||
"""Initialize coordinator and start background service loops.
|
||||
|
||||
Args:
|
||||
router_zmq_addr: ZMQ address to bind the ROUTER socket.
|
||||
pub_zmq_addr: ZMQ address to bind the PUB socket.
|
||||
heartbeat_timeout: Seconds before an instance is considered
|
||||
unhealthy if no heartbeat / update is received.
|
||||
"""
|
||||
self._router_zmq_addr = router_zmq_addr
|
||||
self._pub_zmq_addr = pub_zmq_addr
|
||||
self._heartbeat_timeout = heartbeat_timeout
|
||||
|
||||
# Dedicated ZMQ context for this coordinator instance.
|
||||
self._ctx = zmq.Context()
|
||||
self._router = self._ctx.socket(zmq.ROUTER)
|
||||
self._router.bind(self._router_zmq_addr)
|
||||
|
||||
self._pub = self._ctx.socket(zmq.PUB)
|
||||
self._pub.bind(self._pub_zmq_addr)
|
||||
|
||||
self._instances: dict[str, InstanceInfo] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._pub_lock = threading.Lock()
|
||||
|
||||
self._publish_min_interval: float = 0.1 # seconds
|
||||
self._pending_broadcast: bool = False
|
||||
|
||||
self._running = True
|
||||
self._closed = False
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
self._router.setsockopt(zmq.RCVTIMEO, 100)
|
||||
|
||||
self._recv_thread = threading.Thread(target=self._recv_loop, daemon=True)
|
||||
self._recv_thread.start()
|
||||
|
||||
self._periodic_thread = threading.Thread(target=self._periodic_loop, daemon=True)
|
||||
self._periodic_thread.start()
|
||||
|
||||
def get_active_instances(self) -> InstanceList:
|
||||
"""Return an :class:`InstanceList` of active (UP) instances only."""
|
||||
with self._lock:
|
||||
active = [inst for inst in self._instances.values() if inst.status == StageStatus.UP]
|
||||
return InstanceList(instances=active, timestamp=time())
|
||||
|
||||
def add_new_instance(self, event: InstanceEvent) -> None:
|
||||
"""Add a new instance based on an incoming event."""
|
||||
with self._lock:
|
||||
self._add_new_instance_locked(event)
|
||||
self.publish_instance_list_update()
|
||||
|
||||
def update_instance_info(self, event: InstanceEvent) -> None:
|
||||
"""Update an existing instance based on an incoming event."""
|
||||
with self._lock:
|
||||
self._update_instance_info_locked(event)
|
||||
self.publish_instance_list_update()
|
||||
|
||||
def remove_instance(self, event: InstanceEvent) -> None:
|
||||
"""Mark an instance as removed / down based on an incoming event.
|
||||
|
||||
This marks the instance's status as DOWN or ERROR (depending on the
|
||||
event) but keeps it in the internal registry. It is removed from the
|
||||
*active* instance list published to hubs.
|
||||
"""
|
||||
with self._lock:
|
||||
self._remove_instance_locked(event)
|
||||
self.publish_instance_list_update()
|
||||
|
||||
def publish_instance_list_update(self) -> None:
|
||||
"""Publish the current active instance list to all subscribers."""
|
||||
active_list = self.get_active_instances()
|
||||
payload = asdict(active_list)
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
|
||||
with self._pub_lock:
|
||||
try:
|
||||
# PUB socket is best-effort; drop update if not ready.
|
||||
self._pub.send(data, flags=zmq.NOBLOCK)
|
||||
except (zmq.Again, zmq.ZMQError):
|
||||
# Silently ignore send failures; next update will catch up.
|
||||
return
|
||||
|
||||
def _schedule_broadcast(self, force: bool) -> None:
|
||||
"""Schedule a broadcast, optionally bypassing throttling.
|
||||
|
||||
When ``force`` is True, publish immediately. Otherwise, mark a pending
|
||||
broadcast that will be flushed by the periodic loop at most once per
|
||||
``_publish_min_interval``.
|
||||
"""
|
||||
if force:
|
||||
self.publish_instance_list_update()
|
||||
else:
|
||||
self._pending_broadcast = True
|
||||
|
||||
def _mark_instance_error_locked(self, info: InstanceInfo) -> None:
|
||||
"""Mark instance as ERROR (e.g. after heartbeat timeout)."""
|
||||
info.status = StageStatus.ERROR
|
||||
|
||||
def _check_heartbeat_timeouts(self) -> None:
|
||||
"""Mark instances as ERROR if their heartbeat has timed out."""
|
||||
now = time()
|
||||
timed_out = False
|
||||
gc_ttl = 600.0 # 10 minutes
|
||||
|
||||
with self._lock:
|
||||
to_delete: list[str] = []
|
||||
|
||||
for input_addr, info in self._instances.items():
|
||||
if info.status == StageStatus.UP and now - info.last_heartbeat > self._heartbeat_timeout:
|
||||
self._mark_instance_error_locked(info)
|
||||
timed_out = True
|
||||
elif info.status in (StageStatus.DOWN, StageStatus.ERROR) and now - info.last_heartbeat > gc_ttl:
|
||||
to_delete.append(input_addr)
|
||||
|
||||
for input_addr in to_delete:
|
||||
del self._instances[input_addr]
|
||||
if timed_out:
|
||||
# Instance liveness changed; force immediate broadcast.
|
||||
self._schedule_broadcast(force=True)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Shut down background threads and close all ZMQ sockets."""
|
||||
if self._closed:
|
||||
raise RuntimeError("Coordinator already closed")
|
||||
|
||||
self._closed = True
|
||||
self._running = False
|
||||
self._stop_event.set()
|
||||
|
||||
# Wait for threads to exit before closing sockets.
|
||||
for thread in (self._recv_thread, self._periodic_thread):
|
||||
thread.join(timeout=1.0)
|
||||
|
||||
try:
|
||||
self._router.close(0)
|
||||
except zmq.ZMQError:
|
||||
pass
|
||||
|
||||
try:
|
||||
self._pub.close(0)
|
||||
except zmq.ZMQError:
|
||||
pass
|
||||
|
||||
try:
|
||||
self._ctx.term()
|
||||
except zmq.ZMQError:
|
||||
pass
|
||||
|
||||
def _parse_instance_event(self, data: dict[str, Any]) -> InstanceEvent | None:
|
||||
"""Parse wire payload dict into InstanceEvent. Returns None if invalid."""
|
||||
try:
|
||||
return InstanceEvent(
|
||||
input_addr=str(data["input_addr"]),
|
||||
output_addr=str(data["output_addr"]),
|
||||
stage_id=int(data["stage_id"]),
|
||||
event_type=str(data["event_type"]),
|
||||
status=StageStatus(data.get("status")),
|
||||
queue_length=data.get("queue_length"),
|
||||
)
|
||||
except (KeyError, ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def _recv_loop(self) -> None:
|
||||
"""Background loop that receives and processes instance events."""
|
||||
while self._running:
|
||||
try:
|
||||
frames = self._router.recv_multipart()
|
||||
except zmq.Again:
|
||||
# RCVTIMEO expired, loop to recheck _running.
|
||||
continue
|
||||
except zmq.ZMQError:
|
||||
# Socket likely closed or context terminated.
|
||||
break
|
||||
|
||||
if not frames:
|
||||
continue
|
||||
|
||||
payload = frames[-1]
|
||||
try:
|
||||
data = json.loads(payload.decode("utf-8"))
|
||||
event = self._parse_instance_event(data)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning("Invalid JSON in instance event, dropping: %s", e)
|
||||
continue
|
||||
if event is None:
|
||||
logger.warning("Malformed instance event, dropping")
|
||||
continue
|
||||
|
||||
self._handle_event(event)
|
||||
|
||||
def _periodic_loop(self) -> None:
|
||||
"""Periodic loop to check heartbeat timeouts and flush broadcasts.
|
||||
|
||||
Heartbeat timeouts are checked on their original cadence, while
|
||||
queue_length / non-liveness updates are coalesced and flushed at
|
||||
most once per ``_publish_min_interval``.
|
||||
"""
|
||||
heartbeat_interval = max(1.0, min(self._heartbeat_timeout / 2.0, 5.0))
|
||||
loop_interval = self._publish_min_interval
|
||||
|
||||
last_heartbeat_check = 0.0
|
||||
while self._running:
|
||||
now = time()
|
||||
|
||||
if now - last_heartbeat_check >= heartbeat_interval:
|
||||
self._check_heartbeat_timeouts()
|
||||
last_heartbeat_check = now
|
||||
|
||||
if self._pending_broadcast:
|
||||
self.publish_instance_list_update()
|
||||
self._pending_broadcast = False
|
||||
|
||||
if self._stop_event.wait(timeout=loop_interval):
|
||||
break
|
||||
|
||||
def _handle_event(self, event: InstanceEvent) -> None:
|
||||
"""Dispatch an incoming event to the appropriate handler."""
|
||||
try:
|
||||
input_addr = event.input_addr
|
||||
|
||||
# Heartbeat: only update last_heartbeat; if previously ERROR,
|
||||
# promote back to UP and broadcast once.
|
||||
if event.event_type == "heartbeat":
|
||||
promote = False
|
||||
with self._lock:
|
||||
info = self._instances.get(input_addr)
|
||||
if info is not None:
|
||||
info.last_heartbeat = time()
|
||||
if info.status == StageStatus.ERROR:
|
||||
info.status = StageStatus.UP
|
||||
promote = True
|
||||
if promote:
|
||||
self._schedule_broadcast(force=True)
|
||||
return
|
||||
|
||||
# Check-and-act under single lock to avoid TOCTOU race (duplicate
|
||||
# registration when concurrent events arrive for the same instance).
|
||||
with self._lock:
|
||||
force_broadcast = False
|
||||
if input_addr not in self._instances:
|
||||
self._add_new_instance_locked(event)
|
||||
force_broadcast = True
|
||||
else:
|
||||
if event.status == StageStatus.DOWN:
|
||||
self._remove_instance_locked(event)
|
||||
force_broadcast = True
|
||||
else:
|
||||
self._update_instance_info_locked(event)
|
||||
|
||||
# New instances / DOWN events are broadcast immediately; other
|
||||
# updates (e.g. queue_length changes) are throttled via the
|
||||
# periodic loop.
|
||||
self._schedule_broadcast(force=force_broadcast)
|
||||
except (KeyError, ValueError, TypeError) as e:
|
||||
logger.warning("Dropping malformed event: %s", e)
|
||||
|
||||
def _add_new_instance_locked(self, event: InstanceEvent) -> None:
|
||||
input_addr = event.input_addr
|
||||
if not input_addr:
|
||||
raise KeyError("input_addr required")
|
||||
stage_id = event.stage_id
|
||||
if stage_id < 0:
|
||||
raise KeyError("stage_id required and must be non-negative")
|
||||
|
||||
now = time()
|
||||
info = InstanceInfo(
|
||||
input_addr=input_addr,
|
||||
output_addr=event.output_addr,
|
||||
stage_id=stage_id,
|
||||
status=event.status,
|
||||
queue_length=event.queue_length,
|
||||
last_heartbeat=now,
|
||||
registered_at=now,
|
||||
)
|
||||
self._instances[input_addr] = info
|
||||
|
||||
def _update_instance_info_locked(self, event: InstanceEvent) -> None:
|
||||
input_addr = event.input_addr
|
||||
info = self._instances[input_addr]
|
||||
|
||||
if event.status is not None:
|
||||
info.status = event.status
|
||||
|
||||
if event.queue_length is not None:
|
||||
info.queue_length = event.queue_length
|
||||
|
||||
def _remove_instance_locked(self, event: InstanceEvent) -> None:
|
||||
input_addr = event.input_addr
|
||||
info = self._instances.get(input_addr)
|
||||
if info is None:
|
||||
return
|
||||
|
||||
info.status = StageStatus.DOWN
|
||||
Reference in new issue
Block a user