1553 lines
62 KiB
Python
1553 lines
62 KiB
Python
# Copyright 2023 LiveKit, Inc.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import datetime
|
|
import inspect
|
|
import math
|
|
import multiprocessing as mp
|
|
import os
|
|
import sys
|
|
import threading
|
|
from collections.abc import Awaitable, Callable
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
from typing import Any, Generic, Literal, TypeVar, overload
|
|
from urllib.parse import urljoin, urlparse
|
|
|
|
import aiohttp
|
|
import jwt
|
|
from aiohttp import web
|
|
from google.protobuf.json_format import MessageToDict, MessageToJson
|
|
|
|
from livekit import api, rtc
|
|
from livekit.protocol import agent, agent_worker, models
|
|
|
|
from . import ipc, telemetry, utils
|
|
from ._exceptions import APIStatusError, AssignmentTimeoutError
|
|
from .inference_runner import _InferenceRunner
|
|
from .job import (
|
|
JobAcceptArguments,
|
|
JobContext,
|
|
JobExecutorType,
|
|
JobProcess,
|
|
JobRequest,
|
|
RunningJobInfo,
|
|
)
|
|
from .log import DEV_LEVEL, logger
|
|
from .plugin import Plugin
|
|
from .simulation import SimulationContext
|
|
from .types import ATTRIBUTE_AGENT_NAME, NOT_GIVEN, NotGivenOr
|
|
from .utils import http_context, http_server, is_given
|
|
from .utils.hw import get_cpu_monitor
|
|
from .version import __version__
|
|
|
|
ASSIGNMENT_TIMEOUT = 7.5
|
|
UPDATE_STATUS_INTERVAL = 2.5
|
|
UPDATE_LOAD_INTERVAL = 0.5
|
|
HEARTBEAT_INTERVAL = 30
|
|
WORKER_PROTOCOL_VERSION = 1
|
|
DRAIN_TIMEOUT = 3600 # 1hr
|
|
|
|
|
|
def _default_setup_fnc(proc: JobProcess) -> Any:
|
|
return
|
|
|
|
|
|
async def _default_request_fnc(ctx: JobRequest) -> None:
|
|
await ctx.accept()
|
|
|
|
|
|
class ServerType(Enum):
|
|
ROOM = agent.JobType.JT_ROOM
|
|
PUBLISHER = agent.JobType.JT_PUBLISHER
|
|
|
|
|
|
WorkerType = ServerType
|
|
|
|
|
|
class _DefaultLoadCalc:
|
|
_instance = None
|
|
_instance_lock = threading.Lock()
|
|
|
|
def __init__(self) -> None:
|
|
self._m_avg = utils.MovingAverage(5) # avg over 2.5
|
|
self._cpu_monitor = get_cpu_monitor()
|
|
self._thread = threading.Thread(
|
|
target=self._calc_load, daemon=True, name="worker_cpu_load_monitor"
|
|
)
|
|
self._lock = threading.Lock()
|
|
self._thread.start()
|
|
|
|
def _calc_load(self) -> None:
|
|
while True:
|
|
cpu_p = self._cpu_monitor.cpu_percent(interval=0.5)
|
|
with self._lock:
|
|
self._m_avg.add_sample(cpu_p)
|
|
|
|
def _get_avg(self) -> float:
|
|
with self._lock:
|
|
return self._m_avg.get_avg()
|
|
|
|
@classmethod
|
|
def get_load(cls, worker: AgentServer) -> float:
|
|
if cls._instance is None:
|
|
with cls._instance_lock:
|
|
if cls._instance is None:
|
|
cls._instance = _DefaultLoadCalc()
|
|
|
|
return cls._instance._get_avg()
|
|
|
|
|
|
@dataclass
|
|
class WorkerPermissions:
|
|
can_publish: bool = True
|
|
can_subscribe: bool = True
|
|
can_publish_data: bool = True
|
|
can_update_metadata: bool = True
|
|
can_publish_sources: list[models.TrackSource] = field(default_factory=list)
|
|
hidden: bool = False
|
|
|
|
|
|
if sys.platform.startswith("win"):
|
|
# Some python versions on Windows gets a BrokenPipeError when creating a new process
|
|
_default_job_executor_type = JobExecutorType.THREAD
|
|
else:
|
|
_default_job_executor_type = JobExecutorType.PROCESS
|
|
|
|
|
|
T = TypeVar("T")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ServerEnvOption(Generic[T]):
|
|
dev_default: T
|
|
prod_default: T
|
|
|
|
@staticmethod
|
|
def getvalue(opt: T | ServerEnvOption[T], devmode: bool) -> T:
|
|
if isinstance(opt, ServerEnvOption):
|
|
return opt.dev_default if devmode else opt.prod_default
|
|
return opt
|
|
|
|
|
|
_default_load_threshold = ServerEnvOption(dev_default=math.inf, prod_default=0.7)
|
|
_default_log_level = ServerEnvOption(dev_default="DEBUG", prod_default="INFO")
|
|
_default_permissions = WorkerPermissions()
|
|
|
|
VALID_LOG_LEVELS = frozenset({"TRACE", "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL"})
|
|
|
|
|
|
def _validate_and_normalize_log_level(
|
|
log_level: str | ServerEnvOption[str],
|
|
) -> str | ServerEnvOption[str]:
|
|
if isinstance(log_level, ServerEnvOption):
|
|
levels_to_check = [log_level.dev_default, log_level.prod_default]
|
|
else:
|
|
levels_to_check = [log_level]
|
|
|
|
for level in levels_to_check:
|
|
if level.upper() not in VALID_LOG_LEVELS:
|
|
raise ValueError(
|
|
f"Invalid log level {level!r}. Valid levels: {', '.join(sorted(VALID_LOG_LEVELS))}"
|
|
)
|
|
|
|
if isinstance(log_level, ServerEnvOption):
|
|
return ServerEnvOption(
|
|
dev_default=log_level.dev_default.upper(),
|
|
prod_default=log_level.prod_default.upper(),
|
|
)
|
|
return log_level.upper()
|
|
|
|
|
|
# NOTE: this object must be pickle-able
|
|
@dataclass
|
|
class ServerOptions:
|
|
entrypoint_fnc: Callable[[JobContext], Awaitable[None]]
|
|
"""Entrypoint function that will be called when a job is assigned to this worker."""
|
|
request_fnc: Callable[[JobRequest], Awaitable[None]] = _default_request_fnc
|
|
"""Inspect the request and decide if the current worker should handle it.
|
|
|
|
When left empty, all jobs are accepted."""
|
|
prewarm_fnc: Callable[[JobProcess], Any] = _default_setup_fnc
|
|
"""A function to perform any necessary initialization before the job starts."""
|
|
load_fnc: Callable[[AgentServer], float] | Callable[[], float] = _DefaultLoadCalc.get_load
|
|
"""Called to determine the current load of the worker. Should return a value between 0 and 1."""
|
|
job_executor_type: JobExecutorType = _default_job_executor_type
|
|
"""Which executor to use to run jobs. (currently thread or process are supported)"""
|
|
load_threshold: float | ServerEnvOption[float] = _default_load_threshold
|
|
"""When the load exceeds this threshold, the worker will be marked as unavailable.
|
|
|
|
Defaults to 0.7 on "production" mode, and is disabled in "development" mode.
|
|
"""
|
|
|
|
job_memory_warn_mb: float = 1000
|
|
"""Memory warning threshold in MB. If the job process exceeds this limit, a warning will be logged.""" # noqa: E501
|
|
job_memory_limit_mb: float = 0
|
|
"""Maximum memory usage for a job in MB, the job process will be killed if it exceeds this limit.
|
|
Defaults to 0 (disabled).
|
|
""" # noqa: E501
|
|
|
|
drain_timeout: int = DRAIN_TIMEOUT
|
|
"""Number of seconds to wait for current jobs to finish upon receiving TERM or INT signal."""
|
|
num_idle_processes: int | ServerEnvOption[int] = ServerEnvOption(
|
|
dev_default=0, prod_default=min(math.ceil(get_cpu_monitor().cpu_count()), 4)
|
|
)
|
|
"""Number of idle processes to keep warm."""
|
|
shutdown_process_timeout: float = 10.0
|
|
"""Maximum amount of time to wait for a job to shut down gracefully"""
|
|
session_end_timeout: float = 300.0
|
|
"""Maximum amount of time to wait for on_session_end to complete (default: 5 minutes)."""
|
|
initialize_process_timeout: float = 10.0
|
|
"""Maximum amount of time to wait for a process to initialize/prewarm"""
|
|
permissions: WorkerPermissions = field(default_factory=WorkerPermissions)
|
|
"""Permissions that the agent should join the room with."""
|
|
agent_name: str = ""
|
|
"""Set agent_name to enable explicit dispatch. When explicit dispatch is enabled, jobs will not be dispatched to rooms automatically. Instead, you can either specify the agent(s) to be dispatched in the end-user's token, or use the AgentDispatch.createDispatch API.
|
|
|
|
By default it uses ``LIVEKIT_AGENT_NAME`` from environment""" # noqa: E501
|
|
worker_type: WorkerType = WorkerType.ROOM
|
|
"""Whether to spin up an agent for each room or publisher."""
|
|
max_retry: int = 16
|
|
"""Maximum number of times to retry connecting to LiveKit."""
|
|
ws_url: str | None = None
|
|
"""URL to connect to the LiveKit server.
|
|
|
|
By default it uses ``LIVEKIT_URL`` from environment"""
|
|
api_key: str | None = field(repr=False, default=None)
|
|
"""API key to authenticate with LiveKit.
|
|
|
|
By default it uses ``LIVEKIT_API_KEY`` from environment"""
|
|
api_secret: str | None = field(repr=False, default=None)
|
|
"""API secret to authenticate with LiveKit.
|
|
|
|
By default it uses ``LIVEKIT_API_SECRET`` from environment"""
|
|
|
|
log_level: str | ServerEnvOption[str] = _default_log_level
|
|
"""Log level for the worker.
|
|
|
|
Defaults to ``DEBUG`` in development mode and ``INFO`` in production mode.
|
|
Can also be set via the ``LIVEKIT_LOG_LEVEL`` environment variable or
|
|
the ``--log-level`` CLI argument (CLI takes highest precedence)."""
|
|
|
|
host: str = "" # default to all interfaces
|
|
port: int | ServerEnvOption[int] = ServerEnvOption(dev_default=0, prod_default=8081)
|
|
"""Port for local HTTP server to listen on.
|
|
|
|
The HTTP server is used as a health check endpoint.
|
|
"""
|
|
|
|
http_proxy: NotGivenOr[str | None] = NOT_GIVEN
|
|
"""HTTP proxy used to connect to the LiveKit server.
|
|
|
|
By default it uses ``HTTP_PROXY`` or ``HTTPS_PROXY`` from environment
|
|
"""
|
|
multiprocessing_context: Literal["spawn", "forkserver"] = (
|
|
"spawn" if not sys.platform.startswith("linux") else "forkserver"
|
|
)
|
|
"""The multiprocessing context to use.
|
|
|
|
By default it uses "spawn" on all platforms, but "forkserver" on Linux.
|
|
"""
|
|
prometheus_port: NotGivenOr[int] = NOT_GIVEN
|
|
"""When enabled, will expose prometheus metrics on :{prometheus_port}/metrics"""
|
|
prometheus_multiproc_dir: str | None = None
|
|
"""Directory for prometheus multiprocess mode to enable metrics collection from child job processes.
|
|
When set, the PROMETHEUS_MULTIPROC_DIR environment variable will be configured automatically.
|
|
When None (default), multiprocess mode is disabled and only main process metrics are collected.
|
|
Users can also set PROMETHEUS_MULTIPROC_DIR environment variable directly before starting the worker."""
|
|
|
|
def __post_init__(self) -> None:
|
|
self.log_level = _validate_and_normalize_log_level(self.log_level)
|
|
|
|
def validate_config(self, devmode: bool) -> None:
|
|
load_threshold = ServerEnvOption.getvalue(self.load_threshold, devmode)
|
|
if load_threshold > 1 and not devmode:
|
|
logger.warning(
|
|
f"load_threshold in prod env must be less than 1, current value: {load_threshold}"
|
|
)
|
|
|
|
|
|
WorkerOptions = ServerOptions
|
|
|
|
|
|
@dataclass
|
|
class WorkerInfo:
|
|
http_port: int
|
|
cloud_agents: bool
|
|
|
|
|
|
EventTypes = Literal["worker_started", "worker_registered"]
|
|
|
|
|
|
class AgentServer(utils.EventEmitter[EventTypes]):
|
|
_default_num_idle_processes = ServerEnvOption(
|
|
dev_default=0, prod_default=math.ceil(get_cpu_monitor().cpu_count())
|
|
)
|
|
_default_port = ServerEnvOption(dev_default=0, prod_default=8081)
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
job_executor_type: JobExecutorType = _default_job_executor_type,
|
|
load_threshold: float | ServerEnvOption[float] = _default_load_threshold,
|
|
job_memory_warn_mb: float = 1000,
|
|
job_memory_limit_mb: float = 0,
|
|
drain_timeout: int = DRAIN_TIMEOUT,
|
|
num_idle_processes: int | ServerEnvOption[int] = _default_num_idle_processes,
|
|
shutdown_process_timeout: float = 10.0,
|
|
session_end_timeout: float = 300.0,
|
|
initialize_process_timeout: float = 10.0,
|
|
permissions: WorkerPermissions = _default_permissions,
|
|
max_retry: int = 16,
|
|
ws_url: str | None = None,
|
|
api_key: str | None = None,
|
|
api_secret: str | None = None,
|
|
host: str = "", # default to all interfaces
|
|
port: int | ServerEnvOption[int] = _default_port,
|
|
http_proxy: NotGivenOr[str | None] = NOT_GIVEN,
|
|
multiprocessing_context: Literal["spawn", "forkserver"] = (
|
|
"spawn" if not sys.platform.startswith("linux") else "forkserver"
|
|
),
|
|
setup_fnc: Callable[[JobProcess], Any] | None = None,
|
|
load_fnc: Callable[[AgentServer], float] | Callable[[], float] | None = None,
|
|
prometheus_port: int | None = None,
|
|
prometheus_multiproc_dir: str | None = None,
|
|
log_level: str | ServerEnvOption[str] = _default_log_level,
|
|
) -> None:
|
|
super().__init__()
|
|
self._ws_url = ws_url or os.environ.get("LIVEKIT_URL") or ""
|
|
self._api_key = api_key or os.environ.get("LIVEKIT_API_KEY") or ""
|
|
self._api_secret = api_secret or os.environ.get("LIVEKIT_API_SECRET") or ""
|
|
|
|
self._worker_token = os.environ.get("LIVEKIT_WORKER_TOKEN") or "" # hosted agents
|
|
self._deployment = os.environ.get("LIVEKIT_AGENT_DEPLOYMENT") or "" # hosted agents
|
|
|
|
self._host = host
|
|
self._port = port
|
|
self._job_executor_type = job_executor_type
|
|
self._load_threshold = load_threshold
|
|
self._job_memory_warn_mb = job_memory_warn_mb
|
|
self._job_memory_limit_mb = job_memory_limit_mb
|
|
self._drain_timeout = drain_timeout
|
|
self._num_idle_processes = num_idle_processes
|
|
self._shutdown_process_timeout = shutdown_process_timeout
|
|
self._session_end_timeout = session_end_timeout
|
|
self._initialize_process_timeout = initialize_process_timeout
|
|
self._permissions = permissions
|
|
self._max_retry = max_retry
|
|
self._prometheus_port = prometheus_port
|
|
self._prometheus_multiproc_dir = prometheus_multiproc_dir
|
|
self._mp_ctx_str = multiprocessing_context
|
|
self._mp_ctx = mp.get_context(multiprocessing_context)
|
|
|
|
if not is_given(http_proxy):
|
|
http_proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY")
|
|
|
|
self._http_proxy = http_proxy
|
|
self._log_level = _validate_and_normalize_log_level(log_level)
|
|
# Set by the CLI (--simulation) when the worker runs under an agent
|
|
# simulation: load shedding is disabled so runs can saturate the agent.
|
|
self._simulation = False
|
|
self._agent_name = ""
|
|
self._server_type = ServerType.ROOM
|
|
self._id = "unregistered"
|
|
|
|
# currently only one rtc_session
|
|
self._entrypoint_fnc: Callable[[JobContext], Awaitable[None]] | None = None
|
|
self._request_fnc: Callable[[JobRequest], Awaitable[None]] | None = None
|
|
self._session_end_fnc: Callable[[JobContext], Awaitable[None]] | None = None
|
|
self._simulation_end_fnc: Callable[[SimulationContext], Any] | None = None
|
|
|
|
# worker cb
|
|
self._setup_fnc: Callable[[JobProcess], Any] | None = setup_fnc
|
|
self._load_fnc: Callable[[AgentServer], float] | Callable[[], float] | None = load_fnc
|
|
|
|
self._closed, self._draining, self._connecting, self._connection_failed = (
|
|
True,
|
|
False,
|
|
False,
|
|
False,
|
|
)
|
|
self._http_server: http_server.HttpServer | None = None
|
|
|
|
self._lock = asyncio.Lock()
|
|
|
|
@property
|
|
def log_level(self) -> str | ServerEnvOption[str]:
|
|
return self._log_level
|
|
|
|
@property
|
|
def setup_fnc(self) -> Callable[[JobProcess], Any] | None:
|
|
return self._setup_fnc
|
|
|
|
@setup_fnc.setter
|
|
def setup_fnc(self, value: Callable[[JobProcess], Any] | None) -> None:
|
|
if value is not None and not callable(value):
|
|
raise TypeError("setup_fnc must be a callable or None")
|
|
self._setup_fnc = value
|
|
|
|
@property
|
|
def load_fnc(self) -> Callable[[AgentServer], float] | Callable[[], float] | None:
|
|
return self._load_fnc
|
|
|
|
@load_fnc.setter
|
|
def load_fnc(self, value: Callable[..., float] | None) -> None:
|
|
if value is not None and not callable(value):
|
|
raise TypeError("load_fnc must be a callable or None")
|
|
self._load_fnc = value
|
|
|
|
@classmethod
|
|
def from_server_options(cls, options: ServerOptions) -> AgentServer:
|
|
server = cls(
|
|
job_executor_type=options.job_executor_type,
|
|
load_threshold=options.load_threshold,
|
|
job_memory_limit_mb=options.job_memory_limit_mb,
|
|
job_memory_warn_mb=options.job_memory_warn_mb,
|
|
drain_timeout=options.drain_timeout,
|
|
num_idle_processes=options.num_idle_processes,
|
|
shutdown_process_timeout=options.shutdown_process_timeout,
|
|
session_end_timeout=options.session_end_timeout,
|
|
initialize_process_timeout=options.initialize_process_timeout,
|
|
permissions=options.permissions,
|
|
max_retry=options.max_retry,
|
|
ws_url=options.ws_url,
|
|
api_key=options.api_key,
|
|
api_secret=options.api_secret,
|
|
host=options.host,
|
|
port=options.port,
|
|
http_proxy=options.http_proxy,
|
|
multiprocessing_context=options.multiprocessing_context,
|
|
prometheus_port=options.prometheus_port if is_given(options.prometheus_port) else None,
|
|
prometheus_multiproc_dir=options.prometheus_multiproc_dir,
|
|
setup_fnc=options.prewarm_fnc,
|
|
load_fnc=options.load_fnc,
|
|
log_level=options.log_level,
|
|
)
|
|
server.rtc_session(
|
|
options.entrypoint_fnc,
|
|
agent_name=options.agent_name,
|
|
type=options.worker_type,
|
|
on_request=options.request_fnc,
|
|
)
|
|
return server
|
|
|
|
@overload
|
|
def rtc_session(
|
|
self,
|
|
func: Callable[[JobContext], Awaitable[None]],
|
|
*,
|
|
agent_name: str = "",
|
|
type: ServerType = ServerType.ROOM,
|
|
on_request: Callable[[JobRequest], Any] | None = None,
|
|
on_session_end: Callable[[JobContext], Any] | None = None,
|
|
on_simulation_end: Callable[[SimulationContext], Any] | None = None,
|
|
) -> Callable[[JobContext], Awaitable[None]]: ...
|
|
|
|
@overload
|
|
def rtc_session(
|
|
self,
|
|
*,
|
|
agent_name: str = "",
|
|
type: ServerType = ServerType.ROOM,
|
|
on_request: Callable[[JobRequest], Any] | None = None,
|
|
on_session_end: Callable[[JobContext], Any] | None = None,
|
|
on_simulation_end: Callable[[SimulationContext], Any] | None = None,
|
|
) -> Callable[
|
|
[Callable[[JobContext], Awaitable[None]]], Callable[[JobContext], Awaitable[None]]
|
|
]: ...
|
|
|
|
def rtc_session(
|
|
self,
|
|
func: Callable[[JobContext], Awaitable[None]] | None = None,
|
|
*,
|
|
agent_name: str = "",
|
|
type: ServerType = ServerType.ROOM,
|
|
on_request: Callable[[JobRequest], Any] | None = None,
|
|
on_session_end: Callable[[JobContext], Any] | None = None,
|
|
on_simulation_end: Callable[[SimulationContext], Any] | None = None,
|
|
) -> (
|
|
Callable[[JobContext], Awaitable[None]]
|
|
| Callable[
|
|
[Callable[[JobContext], Awaitable[None]]], Callable[[JobContext], Awaitable[None]]
|
|
]
|
|
):
|
|
"""
|
|
Decorator or direct registrar for the RTC session entrypoint.
|
|
|
|
Usage:
|
|
@server.rtc_session(agent_name="survey_agent")
|
|
async def my_agent(job_ctx: JobContext): ...
|
|
|
|
server.rtc_session(my_agent, agent_name="survey_agent")
|
|
"""
|
|
|
|
def decorator(
|
|
f: Callable[[JobContext], Awaitable[None]],
|
|
) -> Callable[[JobContext], Awaitable[None]]:
|
|
if self._entrypoint_fnc is not None:
|
|
raise RuntimeError(
|
|
"The AgentServer currently only supports registering only one rtc_session"
|
|
)
|
|
self._entrypoint_fnc = f
|
|
self._request_fnc = on_request
|
|
self._session_end_fnc = on_session_end
|
|
self._simulation_end_fnc = on_simulation_end
|
|
# precedence: the LIVEKIT_AGENT_NAME_OVERRIDE env var (a platform-injected
|
|
# force, e.g. from the lk simulation launcher) takes priority, then the
|
|
# explicit agent_name arg, then the LIVEKIT_AGENT_NAME env default.
|
|
if os.environ.get("LIVEKIT_AGENT_NAME_OVERRIDE"):
|
|
self._agent_name = os.environ["LIVEKIT_AGENT_NAME_OVERRIDE"]
|
|
elif agent_name:
|
|
self._agent_name = agent_name
|
|
elif os.environ.get("LIVEKIT_AGENT_NAME"):
|
|
self._agent_name = os.environ["LIVEKIT_AGENT_NAME"]
|
|
else:
|
|
self._agent_name = ""
|
|
self._server_type = type
|
|
return f
|
|
|
|
if func is not None:
|
|
return decorator(func)
|
|
|
|
return decorator
|
|
|
|
@property
|
|
def worker_info(self) -> WorkerInfo:
|
|
return WorkerInfo(
|
|
http_port=self._http_server.port if self._http_server else 0,
|
|
cloud_agents=bool(self._worker_token),
|
|
)
|
|
|
|
async def run(self, *, devmode: bool = False, unregistered: bool = False) -> None:
|
|
"""This method starts the worker's internal event loop, initializes any required
|
|
executors, HTTP servers, and process pools, and optionally registers the worker
|
|
with the LiveKit server.
|
|
|
|
Args:
|
|
devmode (bool, optional):
|
|
If True, the worker runs in development mode.
|
|
This affects certain environment-dependent defaults, such as the
|
|
number of idle processes, logging verbosity, and load thresholds,
|
|
making it easier to test and debug without production constraints.
|
|
|
|
unregistered (bool, optional):
|
|
If True, the worker will start without registering itself with the
|
|
LiveKit server.
|
|
This allows the worker to operate in a partially connected state—
|
|
capable of using other providers or local processing—but invisible
|
|
to the central LiveKit job dispatcher.
|
|
Useful for local testing, isolated jobs, or running without being
|
|
assigned new jobs.
|
|
"""
|
|
async with self._lock:
|
|
if not self._closed:
|
|
raise Exception("worker is already running")
|
|
|
|
if self._entrypoint_fnc is None:
|
|
raise RuntimeError(
|
|
"No RTC session entrypoint has been registered.\n"
|
|
"Define one using the @server.rtc_session() decorator, for example:\n"
|
|
' @server.rtc_session(agent_name="my_agent")\n'
|
|
" async def my_agent(ctx: JobContext):\n"
|
|
" ...\n"
|
|
)
|
|
|
|
if self._request_fnc is None:
|
|
self._request_fnc = _default_request_fnc
|
|
|
|
if self._setup_fnc is None:
|
|
self._setup_fnc = _default_setup_fnc
|
|
|
|
if self._load_fnc is None:
|
|
self._load_fnc = _DefaultLoadCalc.get_load
|
|
|
|
if self.worker_info.cloud_agents:
|
|
if self._load_fnc != _DefaultLoadCalc.get_load:
|
|
logger.warning(
|
|
"custom load_fnc is not supported when hosting on Cloud, reverting to default"
|
|
)
|
|
self._load_fnc = _DefaultLoadCalc.get_load
|
|
if self._load_threshold != _default_load_threshold:
|
|
logger.warning(
|
|
"custom load_threshold is not supported when hosting on Cloud, reverting to default"
|
|
)
|
|
self._load_threshold = _default_load_threshold
|
|
|
|
if self._simulation:
|
|
logger.info("simulation mode enabled: worker load limit disabled")
|
|
|
|
self._loop = asyncio.get_event_loop()
|
|
self._devmode = devmode
|
|
self._job_lifecycle_tasks = set[asyncio.Task[Any]]()
|
|
self._pending_assignments: dict[str, asyncio.Future[agent.JobAssignment]] = {}
|
|
self._close_future: asyncio.Future[None] | None = None
|
|
self._msg_chan = utils.aio.Chan[agent.WorkerMessage](128, loop=self._loop)
|
|
|
|
self._inference_executor: ipc.inference_proc_executor.InferenceProcExecutor | None = (
|
|
None
|
|
)
|
|
if len(_InferenceRunner.registered_runners) > 0:
|
|
self._inference_executor = ipc.inference_proc_executor.InferenceProcExecutor(
|
|
runners=_InferenceRunner.registered_runners,
|
|
initialize_timeout=5 * 60,
|
|
close_timeout=5,
|
|
memory_warn_mb=2000,
|
|
memory_limit_mb=0, # no limit
|
|
ping_interval=5,
|
|
ping_timeout=60,
|
|
high_ping_threshold=2.5,
|
|
mp_ctx=self._mp_ctx,
|
|
loop=self._loop,
|
|
http_proxy=self._http_proxy or None,
|
|
)
|
|
|
|
self._proc_pool = ipc.proc_pool.ProcPool(
|
|
initialize_process_fnc=self._setup_fnc,
|
|
job_entrypoint_fnc=self._entrypoint_fnc,
|
|
session_end_fnc=self._session_end_fnc,
|
|
simulation_end_fnc=self._simulation_end_fnc,
|
|
num_idle_processes=ServerEnvOption.getvalue(self._num_idle_processes, devmode),
|
|
loop=self._loop,
|
|
job_executor_type=self._job_executor_type,
|
|
inference_executor=self._inference_executor,
|
|
mp_ctx=self._mp_ctx,
|
|
initialize_timeout=self._initialize_process_timeout,
|
|
close_timeout=self._shutdown_process_timeout,
|
|
session_end_timeout=self._session_end_timeout,
|
|
memory_warn_mb=self._job_memory_warn_mb,
|
|
memory_limit_mb=self._job_memory_limit_mb,
|
|
http_proxy=self._http_proxy or None,
|
|
)
|
|
|
|
self._previous_status = agent.WorkerStatus.WS_AVAILABLE
|
|
|
|
self._api: api.LiveKitAPI | None = None
|
|
self._http_session: aiohttp.ClientSession | None = None
|
|
self._worker_load: float = 0.0
|
|
self._reserved_slots: int = 0 # jobs we said "available" to but not yet launched
|
|
|
|
# simulations run ephemeral workers side by side; a health
|
|
# endpoint on a fixed port would make concurrent runs collide
|
|
if not self._simulation:
|
|
self._http_server = http_server.HttpServer(
|
|
self._host, ServerEnvOption.getvalue(self._port, devmode)
|
|
)
|
|
|
|
async def health_check(_: Any) -> web.Response:
|
|
if self._inference_executor and not self._inference_executor.is_alive():
|
|
return web.Response(status=503, text="inference process not running")
|
|
|
|
if self._connection_failed:
|
|
return web.Response(status=503, text="failed to connect to livekit")
|
|
|
|
return web.Response(text="OK")
|
|
|
|
async def worker(_: Any) -> web.Response:
|
|
worker_info = agent_worker.WorkerInfo(
|
|
worker_type=agent.JobType.Name(self._server_type.value),
|
|
agent_name=self._agent_name,
|
|
active_jobs=len(self.active_jobs),
|
|
sdk_version=__version__,
|
|
worker_load=self._worker_load,
|
|
protocol_version=WORKER_PROTOCOL_VERSION,
|
|
)
|
|
body = MessageToJson(worker_info, preserving_proto_field_name=True)
|
|
return web.Response(body=body, content_type="application/json")
|
|
|
|
self._http_server.app.add_routes([web.get("/", health_check)])
|
|
self._http_server.app.add_routes([web.get("/worker", worker)])
|
|
|
|
self._conn_task: asyncio.Task[None] | None = None
|
|
self._load_task: asyncio.Task[None] | None = None
|
|
|
|
if not unregistered:
|
|
if not self._ws_url:
|
|
raise ValueError("ws_url is required, or set LIVEKIT_URL environment variable")
|
|
|
|
if not self._api_key:
|
|
raise ValueError(
|
|
"api_key is required, or set LIVEKIT_API_KEY environment variable"
|
|
)
|
|
|
|
if not self._api_secret:
|
|
raise ValueError(
|
|
"api_secret is required, or set LIVEKIT_API_SECRET environment variable"
|
|
)
|
|
|
|
self._prometheus_server: telemetry.http_server.HttpServer | None = None
|
|
if self._prometheus_port is not None:
|
|
self._prometheus_server = telemetry.http_server.HttpServer(
|
|
self._host, self._prometheus_port
|
|
)
|
|
|
|
if self._prometheus_multiproc_dir:
|
|
os.environ["PROMETHEUS_MULTIPROC_DIR"] = self._prometheus_multiproc_dir
|
|
elif "PROMETHEUS_MULTIPROC_DIR" in os.environ:
|
|
self._prometheus_multiproc_dir = os.environ["PROMETHEUS_MULTIPROC_DIR"]
|
|
|
|
if self._prometheus_multiproc_dir:
|
|
os.makedirs(self._prometheus_multiproc_dir, exist_ok=True)
|
|
|
|
if self._prometheus_multiproc_dir and os.path.exists(self._prometheus_multiproc_dir):
|
|
logger.debug(
|
|
"cleaning prometheus multiprocess directory",
|
|
extra={"path": self._prometheus_multiproc_dir},
|
|
)
|
|
for filename in os.listdir(self._prometheus_multiproc_dir):
|
|
file_path = os.path.join(self._prometheus_multiproc_dir, filename)
|
|
try:
|
|
if os.path.isfile(file_path):
|
|
os.unlink(file_path)
|
|
except Exception as e:
|
|
logger.warning(f"failed to remove {file_path}", exc_info=e)
|
|
|
|
if self._ws_url:
|
|
os.environ["LIVEKIT_URL"] = self._ws_url
|
|
if self._api_key:
|
|
os.environ["LIVEKIT_API_KEY"] = self._api_key
|
|
if self._api_secret:
|
|
os.environ["LIVEKIT_API_SECRET"] = self._api_secret
|
|
if self._worker_token:
|
|
os.environ["LIVEKIT_WORKER_TOKEN"] = self._worker_token
|
|
if self._deployment:
|
|
os.environ["LIVEKIT_AGENT_DEPLOYMENT"] = self._deployment
|
|
|
|
# must run before job processes spawn so they inherit the SSL_CERT_FILE fallback
|
|
http_context._set_default_cert_env()
|
|
|
|
logger.info(
|
|
"starting worker",
|
|
extra={"version": __version__, "rtc-version": rtc.__version__},
|
|
)
|
|
|
|
for p in Plugin.registered_plugins:
|
|
logger.info(
|
|
"plugin registered",
|
|
extra={
|
|
"plugin": p.title,
|
|
"version": p.version,
|
|
},
|
|
)
|
|
|
|
if self._mp_ctx_str == "forkserver":
|
|
# `livekit.agents.inference._warmup` is a side-effect module:
|
|
# importing it from the forkserver process calls `init_vad()` and
|
|
# `init_eot()`, paging the native model weights into the
|
|
# forkserver. Forked job processes inherit those pages via COW.
|
|
plugin_packages = [p.package for p in Plugin.registered_plugins] + [
|
|
"av",
|
|
"livekit.agents.inference._warmup",
|
|
]
|
|
logger.info("preloading plugins", extra={"packages": plugin_packages})
|
|
self._mp_ctx.set_forkserver_preload(plugin_packages)
|
|
|
|
if self._inference_executor is not None:
|
|
logger.info("starting inference executor")
|
|
await self._inference_executor.start()
|
|
await self._inference_executor.initialize()
|
|
|
|
self._closed = False
|
|
|
|
def _update_job_status(proc: ipc.job_executor.JobExecutor) -> None:
|
|
t = self._loop.create_task(self._update_job_status(proc))
|
|
self._job_lifecycle_tasks.add(t)
|
|
t.add_done_callback(self._job_lifecycle_tasks.discard)
|
|
|
|
if self._http_server is not None:
|
|
await self._http_server.start()
|
|
logger.info(
|
|
f"HTTP server listening on {self._http_server.host}:{self._http_server.port}"
|
|
)
|
|
|
|
if self._prometheus_server:
|
|
await self._prometheus_server.start()
|
|
logger.info(
|
|
"Prometheus metrics exposed at http://%s:%s/metrics",
|
|
self._prometheus_server.host,
|
|
self._prometheus_server.port,
|
|
)
|
|
|
|
self._proc_pool.on("process_started", _update_job_status)
|
|
self._proc_pool.on("process_closed", _update_job_status)
|
|
self._proc_pool.on("process_job_launched", _update_job_status)
|
|
await self._proc_pool.start()
|
|
|
|
self._http_session = aiohttp.ClientSession(
|
|
proxy=self._http_proxy or None,
|
|
connector=aiohttp.TCPConnector(ssl=http_context._create_ssl_context()),
|
|
)
|
|
if self._ws_url:
|
|
self._api = api.LiveKitAPI(
|
|
self._ws_url, self._api_key, self._api_secret, session=self._http_session
|
|
)
|
|
self._close_future = asyncio.Future(loop=self._loop)
|
|
|
|
@utils.log_exceptions(logger=logger)
|
|
async def _load_task() -> None:
|
|
"""periodically check load"""
|
|
|
|
interval = utils.aio.interval(UPDATE_LOAD_INTERVAL)
|
|
while True:
|
|
await interval.tick()
|
|
|
|
self._worker_load = await self._invoke_load_fnc()
|
|
|
|
telemetry.metrics._update_worker_load(self._worker_load)
|
|
if self._prometheus_multiproc_dir:
|
|
await asyncio.get_event_loop().run_in_executor(
|
|
None, telemetry.metrics._update_child_proc_count
|
|
)
|
|
|
|
load_threshold = ServerEnvOption.getvalue(self._load_threshold, devmode)
|
|
default_num_idle_processes = ServerEnvOption.getvalue(
|
|
self._num_idle_processes, devmode
|
|
)
|
|
|
|
if not math.isinf(load_threshold):
|
|
active_jobs = len(self.active_jobs)
|
|
if active_jobs > 0:
|
|
job_load = self._worker_load / len(self.active_jobs)
|
|
if job_load > 0.0:
|
|
available_load = max(load_threshold - self._worker_load, 0.0)
|
|
available_job = min(
|
|
math.ceil(available_load / job_load), default_num_idle_processes
|
|
)
|
|
self._proc_pool.set_target_idle_processes(available_job)
|
|
else:
|
|
self._proc_pool.set_target_idle_processes(default_num_idle_processes)
|
|
|
|
tasks = []
|
|
self._load_task = asyncio.create_task(_load_task(), name="load_task")
|
|
tasks.append(self._load_task)
|
|
|
|
if not unregistered:
|
|
self._conn_task = asyncio.create_task(
|
|
self._connection_task(), name="worker_conn_task"
|
|
)
|
|
tasks.append(self._conn_task)
|
|
|
|
# propagate a terminal connection failure out of run()
|
|
def _on_conn_task_done(task: asyncio.Task[None]) -> None:
|
|
if task.cancelled() or self._close_future is None or self._close_future.done():
|
|
return
|
|
if (exc := task.exception()) is not None:
|
|
self._close_future.set_exception(exc)
|
|
|
|
self._conn_task.add_done_callback(_on_conn_task_done)
|
|
|
|
self.emit("worker_started")
|
|
|
|
await self._close_future
|
|
|
|
def update_options(
|
|
self,
|
|
*,
|
|
ws_url: NotGivenOr[str] = NOT_GIVEN,
|
|
api_key: NotGivenOr[str] = NOT_GIVEN,
|
|
api_secret: NotGivenOr[str] = NOT_GIVEN,
|
|
max_retry: NotGivenOr[int] = NOT_GIVEN,
|
|
job_executor_type: NotGivenOr[JobExecutorType] = NOT_GIVEN,
|
|
load_threshold: NotGivenOr[float] = NOT_GIVEN,
|
|
job_memory_warn_mb: NotGivenOr[float] = NOT_GIVEN,
|
|
job_memory_limit_mb: NotGivenOr[float] = NOT_GIVEN,
|
|
drain_timeout: NotGivenOr[int] = NOT_GIVEN,
|
|
num_idle_processes: NotGivenOr[int] = NOT_GIVEN,
|
|
shutdown_process_timeout: NotGivenOr[float] = NOT_GIVEN,
|
|
session_end_timeout: NotGivenOr[float] = NOT_GIVEN,
|
|
initialize_process_timeout: NotGivenOr[float] = NOT_GIVEN,
|
|
) -> None:
|
|
if not self._closed:
|
|
raise RuntimeError("cannot update options after starting the server")
|
|
|
|
if is_given(ws_url):
|
|
self._ws_url = ws_url
|
|
|
|
if is_given(api_key):
|
|
self._api_key = api_key
|
|
|
|
if is_given(api_secret):
|
|
self._api_secret = api_secret
|
|
|
|
if is_given(max_retry):
|
|
self._max_retry = max_retry
|
|
|
|
if is_given(job_executor_type):
|
|
self._job_executor_type = job_executor_type
|
|
|
|
if is_given(load_threshold):
|
|
self._load_threshold = load_threshold
|
|
|
|
if is_given(job_memory_warn_mb):
|
|
self._job_memory_warn_mb = job_memory_warn_mb
|
|
|
|
if is_given(job_memory_limit_mb):
|
|
self._job_memory_limit_mb = job_memory_limit_mb
|
|
|
|
if is_given(drain_timeout):
|
|
self._drain_timeout = drain_timeout
|
|
|
|
if is_given(num_idle_processes):
|
|
self._num_idle_processes = num_idle_processes
|
|
|
|
if is_given(shutdown_process_timeout):
|
|
self._shutdown_process_timeout = shutdown_process_timeout
|
|
|
|
if is_given(session_end_timeout):
|
|
self._session_end_timeout = session_end_timeout
|
|
|
|
if is_given(initialize_process_timeout):
|
|
self._initialize_process_timeout = initialize_process_timeout
|
|
|
|
@property
|
|
def id(self) -> str:
|
|
return self._id
|
|
|
|
@property
|
|
def active_jobs(self) -> list[RunningJobInfo]:
|
|
return [proc.running_job for proc in self._proc_pool.processes if proc.running_job]
|
|
|
|
@property
|
|
def draining(self) -> bool:
|
|
return self._draining
|
|
|
|
async def drain(self, timeout: NotGivenOr[int | None] = NOT_GIVEN) -> None:
|
|
"""When timeout isn't None, it will raise asyncio.TimeoutError if the processes didn't finish in time.""" # noqa: E501
|
|
|
|
timeout = timeout if is_given(timeout) else self._drain_timeout
|
|
|
|
async with self._lock:
|
|
if self._draining:
|
|
return
|
|
|
|
logger.info("draining worker", extra={"id": self.id, "timeout": timeout})
|
|
self._draining = True
|
|
await self._update_worker_status()
|
|
|
|
async def _drain() -> None:
|
|
# wait for in-flight availability tasks to finish launching their jobs
|
|
await asyncio.gather(*self._job_lifecycle_tasks, return_exceptions=True)
|
|
|
|
# then wait for the launched jobs to complete
|
|
while True:
|
|
procs = [p for p in self._proc_pool.processes if p.running_job]
|
|
if not procs:
|
|
break
|
|
for proc in procs:
|
|
await proc.join()
|
|
|
|
if timeout:
|
|
await asyncio.wait_for(_drain(), timeout) # raises asyncio.TimeoutError on timeout
|
|
else:
|
|
await _drain()
|
|
|
|
@utils.log_exceptions(logger=logger)
|
|
async def simulate_job(
|
|
self,
|
|
room: str,
|
|
*,
|
|
fake_job: bool = False,
|
|
agent_identity: str | None = None,
|
|
room_info: models.Room | None = None,
|
|
token: str | None = None,
|
|
) -> None:
|
|
async with self._lock:
|
|
if token is not None:
|
|
# read identity from token if provided
|
|
agent_identity = api.TokenVerifier().verify(token, verify_signature=False).identity
|
|
|
|
if agent_identity is None:
|
|
if not fake_job:
|
|
raise ValueError("agent_identity is None but fake_job is False")
|
|
|
|
agent_identity = utils.shortuuid("fake-agent-")
|
|
|
|
if room_info is None:
|
|
if not fake_job:
|
|
raise ValueError("room_info is None but fake_job is False")
|
|
|
|
room_info = models.Room(sid=utils.shortuuid("SRM_"), name=room)
|
|
|
|
# room_info = await self._api.room.create_room(api.CreateRoomRequest(name=room))
|
|
|
|
job = agent.Job(
|
|
id=utils.shortuuid("job-") if not fake_job else utils.shortuuid("mock-job-"),
|
|
room=room_info,
|
|
type=agent.JobType.JT_ROOM,
|
|
participant=None,
|
|
)
|
|
|
|
if not token:
|
|
if fake_job and (not self._api_key or not self._api_secret):
|
|
token = ""
|
|
else:
|
|
token = (
|
|
api.AccessToken(self._api_key, self._api_secret)
|
|
.with_identity(agent_identity)
|
|
.with_kind("agent")
|
|
.with_grants(api.VideoGrants(room_join=True, room=room, agent=True))
|
|
.to_jwt()
|
|
)
|
|
running_info = RunningJobInfo(
|
|
worker_id=self._id,
|
|
accept_arguments=JobAcceptArguments(identity=agent_identity, name="", metadata=""),
|
|
job=job,
|
|
url=self._ws_url,
|
|
token=token,
|
|
fake_job=fake_job,
|
|
)
|
|
|
|
await self._proc_pool.launch_job(running_info)
|
|
|
|
async def aclose(self) -> None:
|
|
async with self._lock:
|
|
if self._closed:
|
|
if self._close_future is not None:
|
|
# _close_future may hold run()'s error; keep aclose() a no-op
|
|
with contextlib.suppress(Exception):
|
|
await self._close_future
|
|
return
|
|
|
|
self._closed = True
|
|
|
|
# Worker never fully started (e.g. interrupted during init, or
|
|
# running unregistered without a LiveKit connection)
|
|
if self._close_future is None:
|
|
return
|
|
|
|
logger.info("shutting down worker", extra={"id": self.id})
|
|
|
|
if self._conn_task is not None:
|
|
await utils.aio.cancel_and_wait(self._conn_task)
|
|
|
|
if self._load_task is not None:
|
|
await utils.aio.cancel_and_wait(self._load_task)
|
|
|
|
# let in-flight availability tasks finish launching their jobs
|
|
# before closing the proc pool (they accepted before shutdown)
|
|
await asyncio.gather(*self._job_lifecycle_tasks, return_exceptions=True)
|
|
|
|
await self._proc_pool.aclose()
|
|
|
|
if self._inference_executor is not None:
|
|
await self._inference_executor.aclose()
|
|
|
|
if self._http_session is not None:
|
|
await self._http_session.close()
|
|
|
|
if self._http_server is not None:
|
|
await self._http_server.aclose()
|
|
|
|
if self._prometheus_server:
|
|
await self._prometheus_server.aclose()
|
|
|
|
if self._api is not None:
|
|
await self._api.aclose() # type: ignore[no-untyped-call, unused-ignore]
|
|
|
|
# await asyncio.sleep(0.25) # see https://github.com/aio-libs/aiohttp/issues/1925
|
|
self._msg_chan.close()
|
|
|
|
if not self._close_future.done():
|
|
self._close_future.set_result(None)
|
|
|
|
async def _queue_msg(self, msg: agent.WorkerMessage) -> None:
|
|
"""_queue_msg raises aio.ChanClosed when the worker is closing/closed"""
|
|
if self._connecting:
|
|
which = msg.WhichOneof("message")
|
|
if which == "update_worker":
|
|
return
|
|
elif which == "ping":
|
|
return
|
|
|
|
await self._msg_chan.send(msg)
|
|
|
|
@utils.log_exceptions(logger=logger)
|
|
async def _connection_task(self) -> None:
|
|
assert self._http_session is not None
|
|
|
|
retry_count = 0
|
|
ws: aiohttp.ClientWebSocketResponse | None = None
|
|
while not self._closed:
|
|
try:
|
|
self._connecting = True
|
|
join_jwt = (
|
|
api.AccessToken(self._api_key, self._api_secret)
|
|
.with_grants(api.VideoGrants(agent=True))
|
|
.to_jwt()
|
|
)
|
|
|
|
headers = {"Authorization": f"Bearer {join_jwt}"}
|
|
|
|
parse = urlparse(self._ws_url)
|
|
scheme = parse.scheme
|
|
if scheme.startswith("http"):
|
|
scheme = scheme.replace("http", "ws")
|
|
|
|
base = f"{scheme}://{parse.netloc}{parse.path}".rstrip("/") + "/"
|
|
agent_url = urljoin(base, "agent")
|
|
|
|
params = {}
|
|
if self._worker_token:
|
|
params["worker_token"] = self._worker_token
|
|
|
|
ws = await self._http_session.ws_connect(
|
|
agent_url,
|
|
headers=headers,
|
|
params=params,
|
|
autoping=True,
|
|
proxy=self._http_proxy or None,
|
|
heartbeat=HEARTBEAT_INTERVAL,
|
|
)
|
|
|
|
retry_count = 0
|
|
|
|
# register the worker
|
|
req = agent.WorkerMessage()
|
|
req.register.type = self._server_type.value
|
|
req.register.allowed_permissions.CopyFrom(
|
|
models.ParticipantPermission(
|
|
can_publish=self._permissions.can_publish,
|
|
can_subscribe=self._permissions.can_subscribe,
|
|
can_publish_data=self._permissions.can_publish_data,
|
|
can_update_metadata=self._permissions.can_update_metadata,
|
|
can_publish_sources=self._permissions.can_publish_sources,
|
|
hidden=self._permissions.hidden,
|
|
agent=True,
|
|
)
|
|
)
|
|
req.register.agent_name = self._agent_name
|
|
req.register.deployment = self._deployment
|
|
req.register.version = __version__
|
|
await ws.send_bytes(req.SerializeToString())
|
|
|
|
# wait for the register response before running this connection
|
|
first_msg_b = await ws.receive_bytes()
|
|
msg = agent.ServerMessage()
|
|
msg.ParseFromString(first_msg_b)
|
|
|
|
if not msg.HasField("register"):
|
|
raise Exception("expected register response as first message")
|
|
|
|
self._handle_register(msg.register)
|
|
self._connecting = False
|
|
|
|
# report all active jobs to the server after registration
|
|
await self._report_active_jobs()
|
|
|
|
await self._run_ws(ws)
|
|
except Exception as e:
|
|
if self._closed:
|
|
break
|
|
|
|
if retry_count >= self._max_retry:
|
|
self._connection_failed = True
|
|
raise RuntimeError(
|
|
f"failed to connect to livekit after {retry_count} attempts",
|
|
) from None
|
|
|
|
retry_delay = min(retry_count * 2, 10)
|
|
retry_count += 1
|
|
|
|
logger.warning(
|
|
f"failed to connect to livekit, retrying in {retry_delay}s",
|
|
extra={"retry_count": retry_count, "max_retry": self._max_retry, "error": e},
|
|
)
|
|
await asyncio.sleep(retry_delay)
|
|
finally:
|
|
if ws is not None:
|
|
await ws.close()
|
|
|
|
async def _run_ws(self, ws: aiohttp.ClientWebSocketResponse) -> None:
|
|
closing_ws = False
|
|
|
|
async def _load_task() -> None:
|
|
"""periodically update worker status"""
|
|
interval = utils.aio.interval(UPDATE_STATUS_INTERVAL)
|
|
while True:
|
|
await interval.tick()
|
|
await self._update_worker_status()
|
|
|
|
async def _send_task() -> None:
|
|
nonlocal closing_ws
|
|
while True:
|
|
try:
|
|
msg = await self._msg_chan.recv()
|
|
await ws.send_bytes(msg.SerializeToString())
|
|
except utils.aio.ChanClosed:
|
|
closing_ws = True
|
|
return
|
|
|
|
async def _recv_task() -> None:
|
|
nonlocal closing_ws
|
|
while True:
|
|
msg = await ws.receive()
|
|
if msg.type in (
|
|
aiohttp.WSMsgType.CLOSE,
|
|
aiohttp.WSMsgType.CLOSED,
|
|
aiohttp.WSMsgType.CLOSING,
|
|
):
|
|
if closing_ws:
|
|
return
|
|
|
|
raise APIStatusError(
|
|
message="worker connection closed unexpectedly",
|
|
status_code=ws.close_code or -1,
|
|
body=f"{msg.data=} {msg.extra=}",
|
|
)
|
|
|
|
if msg.type != aiohttp.WSMsgType.BINARY:
|
|
ws_data = str(msg.data)
|
|
if len(ws_data) > 128:
|
|
ws_data = ws_data[:128] + f"...(+{len(ws_data) - 128} more)"
|
|
logger.warning(
|
|
"unexpected message type: %s",
|
|
msg.type,
|
|
extra={
|
|
"type": msg.type.name,
|
|
"ws_data": ws_data,
|
|
},
|
|
)
|
|
continue
|
|
|
|
data = msg.data
|
|
server_msg = agent.ServerMessage()
|
|
server_msg.ParseFromString(data)
|
|
which = server_msg.WhichOneof("message")
|
|
if which == "availability":
|
|
self._handle_availability(server_msg.availability)
|
|
elif which == "assignment":
|
|
self._handle_assignment(server_msg.assignment)
|
|
elif which == "termination":
|
|
user_task = self._loop.create_task(
|
|
self._handle_termination(server_msg.termination),
|
|
name="agent_job_termination",
|
|
)
|
|
self._job_lifecycle_tasks.add(user_task)
|
|
user_task.add_done_callback(self._job_lifecycle_tasks.discard)
|
|
|
|
tasks = [
|
|
asyncio.create_task(_load_task()),
|
|
asyncio.create_task(_send_task()),
|
|
asyncio.create_task(_recv_task()),
|
|
]
|
|
try:
|
|
await asyncio.gather(*tasks)
|
|
finally:
|
|
await utils.aio.cancel_and_wait(*tasks)
|
|
|
|
async def _reload_jobs(self, jobs: list[RunningJobInfo]) -> None:
|
|
if not self._api_secret:
|
|
raise RuntimeError("api_secret is required to reload jobs")
|
|
|
|
for aj in jobs:
|
|
logger.log(
|
|
DEV_LEVEL,
|
|
"reloading job",
|
|
extra={"job_id": aj.job.id, "agent_name": aj.job.agent_name},
|
|
)
|
|
|
|
# take the original jwt token and extend it while keeping all the same data that was generated # noqa: E501
|
|
# by the SFU for the original join token.
|
|
original_token = aj.token
|
|
decoded = jwt.decode(original_token, self._api_secret, algorithms=["HS256"])
|
|
decoded["exp"] = int(datetime.datetime.now(datetime.timezone.utc).timestamp()) + 3600
|
|
running_info = RunningJobInfo(
|
|
accept_arguments=aj.accept_arguments,
|
|
job=aj.job,
|
|
url=self._ws_url,
|
|
token=jwt.encode(decoded, self._api_secret, algorithm="HS256"),
|
|
worker_id=aj.worker_id,
|
|
fake_job=aj.fake_job,
|
|
)
|
|
await self._proc_pool.launch_job(running_info)
|
|
|
|
def _handle_register(self, reg: agent.RegisterWorkerResponse) -> None:
|
|
self._id = reg.worker_id
|
|
logger.info(
|
|
"registered worker",
|
|
extra={
|
|
"agent_name": self._agent_name,
|
|
"deployment": self._deployment,
|
|
"id": reg.worker_id,
|
|
"url": self._ws_url,
|
|
"region": reg.server_info.region,
|
|
"protocol": reg.server_info.protocol,
|
|
},
|
|
)
|
|
self.emit("worker_registered", reg.worker_id, reg.server_info)
|
|
|
|
def _handle_availability(self, msg: agent.AvailabilityRequest) -> None:
|
|
task = self._loop.create_task(self._answer_availability(msg))
|
|
self._job_lifecycle_tasks.add(task)
|
|
task.add_done_callback(self._job_lifecycle_tasks.discard)
|
|
|
|
async def _invoke_load_fnc(self) -> float:
|
|
"""Run load_fnc in executor. Uses signature to call with or without self."""
|
|
|
|
def load_fnc() -> float:
|
|
assert self._load_fnc is not None
|
|
signature = inspect.signature(self._load_fnc)
|
|
parameters = list(signature.parameters.values())
|
|
if len(parameters) == 0:
|
|
return self._load_fnc() # type: ignore
|
|
return self._load_fnc(self) # type: ignore
|
|
|
|
return await asyncio.get_event_loop().run_in_executor(None, load_fnc)
|
|
|
|
async def _refresh_worker_load(self) -> None:
|
|
"""Refresh _worker_load by running load_fnc. Used before availability checks
|
|
so concurrent job requests see up-to-date load (fixes race with periodic interval).
|
|
"""
|
|
if self._load_fnc is None:
|
|
return
|
|
|
|
self._worker_load = await self._invoke_load_fnc()
|
|
telemetry.metrics._update_worker_load(self._worker_load)
|
|
|
|
def _get_effective_load(self) -> float:
|
|
"""Current load including reserved slots (accepted but not yet launched)."""
|
|
active_jobs = self.active_jobs
|
|
load_threshold = ServerEnvOption.getvalue(self._load_threshold, self._devmode)
|
|
if active_jobs:
|
|
job_load_estimate = self._worker_load / len(active_jobs)
|
|
elif math.isinf(load_threshold):
|
|
job_load_estimate = 0.0
|
|
else:
|
|
default_idle = ServerEnvOption.getvalue(self._num_idle_processes, self._devmode)
|
|
job_load_estimate = load_threshold / max(default_idle, 1)
|
|
return self._worker_load + self._reserved_slots * job_load_estimate
|
|
|
|
def _is_available(self) -> bool:
|
|
if self._draining:
|
|
return False
|
|
|
|
if self._simulation:
|
|
return True
|
|
|
|
load_threshold = ServerEnvOption.getvalue(self._load_threshold, self._devmode)
|
|
if math.isinf(load_threshold):
|
|
return True
|
|
|
|
# Use effective load so we don't over-accept when two requests arrive
|
|
# before either job appears in active_jobs.
|
|
return self._get_effective_load() < load_threshold
|
|
|
|
async def _answer_availability(self, msg: agent.AvailabilityRequest) -> None:
|
|
"""Ask the user if they want to accept this job and forward the answer to the server.
|
|
If we get the job assigned, we start a new process."""
|
|
|
|
await self._refresh_worker_load()
|
|
if not self._is_available():
|
|
availability_resp = agent.WorkerMessage()
|
|
availability_resp.availability.job_id = msg.job.id
|
|
availability_resp.availability.available = False
|
|
await self._queue_msg(availability_resp)
|
|
return
|
|
|
|
# Reserve a slot immediately so concurrent availability checks see updated
|
|
# load before the user's request_fnc runs or calls accept().
|
|
self._reserved_slots += 1
|
|
answered = False
|
|
|
|
async def _on_reject(terminate: bool) -> None:
|
|
nonlocal answered
|
|
answered = True
|
|
|
|
availability_resp = agent.WorkerMessage()
|
|
availability_resp.availability.job_id = msg.job.id
|
|
availability_resp.availability.available = False
|
|
availability_resp.availability.terminate = terminate
|
|
await self._queue_msg(availability_resp)
|
|
|
|
async def _on_accept(args: JobAcceptArguments) -> None:
|
|
nonlocal answered
|
|
answered = True
|
|
|
|
availability_resp = agent.WorkerMessage()
|
|
availability_resp.availability.job_id = msg.job.id
|
|
availability_resp.availability.available = True
|
|
availability_resp.availability.participant_identity = args.identity
|
|
availability_resp.availability.participant_name = args.name
|
|
availability_resp.availability.participant_metadata = args.metadata
|
|
availability_resp.availability.participant_attributes[ATTRIBUTE_AGENT_NAME] = (
|
|
self._agent_name
|
|
)
|
|
if args.attributes:
|
|
availability_resp.availability.participant_attributes.update(args.attributes)
|
|
|
|
wait_assignment = asyncio.Future[agent.JobAssignment]()
|
|
self._pending_assignments[job_req.id] = wait_assignment
|
|
await self._queue_msg(availability_resp)
|
|
|
|
# the job was accepted by the user, wait for the server assignment
|
|
try:
|
|
await asyncio.wait_for(wait_assignment, ASSIGNMENT_TIMEOUT)
|
|
except asyncio.TimeoutError:
|
|
self._pending_assignments.pop(job_req.id, None)
|
|
logger.warning(
|
|
f"assignment for job {job_req.id} timed out",
|
|
extra={"job_request": job_req, "agent_name": self._agent_name},
|
|
)
|
|
raise AssignmentTimeoutError() from None
|
|
|
|
job_assign = wait_assignment.result()
|
|
running_info = RunningJobInfo(
|
|
accept_arguments=args,
|
|
job=msg.job,
|
|
url=job_assign.url or self._ws_url,
|
|
token=job_assign.token,
|
|
worker_id=self._id,
|
|
fake_job=False,
|
|
)
|
|
|
|
await self._proc_pool.launch_job(running_info)
|
|
|
|
job_req = JobRequest(job=msg.job, on_reject=_on_reject, on_accept=_on_accept)
|
|
|
|
logger.info(
|
|
"received job request",
|
|
extra={
|
|
"job_id": msg.job.id,
|
|
"dispatch_id": msg.job.dispatch_id,
|
|
"room": msg.job.room.name,
|
|
"room_id": msg.job.room.sid,
|
|
"agent_name": self._agent_name,
|
|
"resuming": msg.resuming,
|
|
"enable_recording": msg.job.enable_recording,
|
|
},
|
|
)
|
|
|
|
@utils.log_exceptions(logger=logger)
|
|
async def _job_request_task() -> None:
|
|
assert self._request_fnc is not None
|
|
try:
|
|
await self._request_fnc(job_req)
|
|
except Exception:
|
|
logger.exception(
|
|
"job_request_fnc failed",
|
|
extra={"job_request": job_req, "agent_name": self._agent_name},
|
|
)
|
|
|
|
if not answered:
|
|
logger.warning(
|
|
"no answer was given inside the job_request_fnc, automatically rejecting the job", # noqa: E501
|
|
extra={"job_request": job_req, "agent_name": self._agent_name},
|
|
)
|
|
await _on_reject(terminate=False)
|
|
|
|
def _on_job_request_done(task: asyncio.Task[Any]) -> None:
|
|
self._reserved_slots -= 1
|
|
self._job_lifecycle_tasks.discard(task)
|
|
|
|
user_task = self._loop.create_task(_job_request_task(), name="job_request")
|
|
self._job_lifecycle_tasks.add(user_task)
|
|
user_task.add_done_callback(_on_job_request_done)
|
|
|
|
def _handle_assignment(self, assignment: agent.JobAssignment) -> None:
|
|
logger.debug(
|
|
"received assignment",
|
|
extra={
|
|
"agent_name": self._agent_name,
|
|
"room_id": assignment.job.room.sid,
|
|
"room": assignment.job.room.name,
|
|
"job_id": assignment.job.id,
|
|
"dispatch_id": assignment.job.dispatch_id,
|
|
"enable_recording": assignment.job.enable_recording,
|
|
},
|
|
)
|
|
if assignment.job.id in self._pending_assignments:
|
|
with contextlib.suppress(asyncio.InvalidStateError):
|
|
fut = self._pending_assignments.pop(assignment.job.id)
|
|
fut.set_result(assignment)
|
|
else:
|
|
logger.warning(
|
|
"received assignment for an unknown job",
|
|
extra={"job": MessageToDict(assignment.job), "agent_name": self._agent_name},
|
|
)
|
|
|
|
async def _handle_termination(self, msg: agent.JobTermination) -> None:
|
|
proc = self._proc_pool.get_by_job_id(msg.job_id)
|
|
if not proc:
|
|
# safe to ignore
|
|
return
|
|
await proc.aclose()
|
|
|
|
async def _update_worker_status(self) -> None:
|
|
job_cnt = len(self.active_jobs)
|
|
|
|
if self._draining:
|
|
update = agent.UpdateWorkerStatus(status=agent.WorkerStatus.WS_FULL, job_count=job_cnt)
|
|
msg = agent.WorkerMessage(update_worker=update)
|
|
await self._queue_msg(msg)
|
|
return
|
|
|
|
load_threshold = ServerEnvOption.getvalue(self._load_threshold, self._devmode)
|
|
effective_load = self._get_effective_load()
|
|
is_full = not self._simulation and effective_load >= load_threshold
|
|
currently_available = not is_full and not self._draining
|
|
|
|
status = (
|
|
agent.WorkerStatus.WS_AVAILABLE if currently_available else agent.WorkerStatus.WS_FULL
|
|
)
|
|
|
|
update = agent.UpdateWorkerStatus(load=self._worker_load, status=status, job_count=job_cnt)
|
|
|
|
# only log if status has changed
|
|
if self._previous_status != status and not self._draining:
|
|
self._previous_status = status
|
|
extra = {"load": effective_load, "threshold": load_threshold}
|
|
if is_full:
|
|
logger.info("worker is at full capacity, marking as unavailable", extra=extra)
|
|
else:
|
|
logger.info("worker is below capacity, marking as available", extra=extra)
|
|
|
|
msg = agent.WorkerMessage(update_worker=update)
|
|
with contextlib.suppress(utils.aio.ChanClosed):
|
|
await self._queue_msg(msg)
|
|
|
|
async def _update_job_status(self, proc: ipc.job_executor.JobExecutor) -> None:
|
|
job_info = proc.running_job
|
|
if job_info is None:
|
|
return
|
|
|
|
status: agent.JobStatus = agent.JobStatus.JS_RUNNING
|
|
if proc.status == ipc.job_executor.JobStatus.FAILED:
|
|
status = agent.JobStatus.JS_FAILED
|
|
elif proc.status == ipc.job_executor.JobStatus.SUCCESS:
|
|
status = agent.JobStatus.JS_SUCCESS
|
|
elif proc.status == ipc.job_executor.JobStatus.RUNNING:
|
|
status = agent.JobStatus.JS_RUNNING
|
|
|
|
update = agent.UpdateJobStatus(job_id=job_info.job.id, status=status, error="")
|
|
msg = agent.WorkerMessage(update_job=update)
|
|
await self._queue_msg(msg)
|
|
|
|
async def _report_active_jobs(self) -> None:
|
|
active_jobs = self.active_jobs
|
|
if not active_jobs:
|
|
return
|
|
|
|
job_ids = [job_info.job.id for job_info in active_jobs]
|
|
migrate_req = agent.MigrateJobRequest(job_ids=job_ids)
|
|
msg = agent.WorkerMessage(migrate_job=migrate_req)
|
|
await self._queue_msg(msg)
|
|
|
|
logger.debug(
|
|
"reported active jobs after registration",
|
|
extra={"job_count": len(active_jobs), "job_ids": job_ids},
|
|
)
|