Files
2026-07-13 13:17:40 +08:00

2219 lines
89 KiB
Python

import asyncio
import csv
import functools
import io
import json
import logging
import os
import re
import shutil
import signal
import string
import time
from abc import ABC, abstractmethod
from collections import deque
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
from jinja2 import Environment
import ray
from ray._common.network_utils import get_localhost_ip
from ray._common.utils import get_or_create_event_loop
from ray.serve._private.common import (
NodeId,
ReplicaID,
RequestMetadata,
RequestProtocol,
)
from ray.serve._private.constants import (
DRAINING_MESSAGE,
HEALTHY_MESSAGE,
NO_REPLICAS_MESSAGE,
NO_ROUTES_MESSAGE,
PROXY_MIN_DRAINING_PERIOD_S,
RAY_SERVE_ENABLE_HAPROXY_OPTIMIZED_CONFIG,
RAY_SERVE_HAPROXY_BALANCE_ALGORITHM,
RAY_SERVE_HAPROXY_BINARY_PATH,
RAY_SERVE_HAPROXY_BROADCAST_COALESCE_S,
RAY_SERVE_HAPROXY_CONFIG_FILE_LOC,
RAY_SERVE_HAPROXY_H2_BE_INITIAL_WINDOW_SIZE,
RAY_SERVE_HAPROXY_H2_BE_MAX_CONCURRENT_STREAMS,
RAY_SERVE_HAPROXY_H2_FE_INITIAL_WINDOW_SIZE,
RAY_SERVE_HAPROXY_H2_FE_MAX_CONCURRENT_STREAMS,
RAY_SERVE_HAPROXY_H2_MAX_FRAME_SIZE,
RAY_SERVE_HAPROXY_HARD_STOP_AFTER_S,
RAY_SERVE_HAPROXY_HEALTH_CHECK_DOWNINTER,
RAY_SERVE_HAPROXY_HEALTH_CHECK_FALL,
RAY_SERVE_HAPROXY_HEALTH_CHECK_FASTINTER,
RAY_SERVE_HAPROXY_HEALTH_CHECK_INTER,
RAY_SERVE_HAPROXY_HEALTH_CHECK_RISE,
RAY_SERVE_HAPROXY_INGRESS_REQUEST_ROUTER_BUFSIZE,
RAY_SERVE_HAPROXY_INGRESS_REQUEST_ROUTER_TIMEOUT_S,
RAY_SERVE_HAPROXY_INGRESS_RETRIES,
RAY_SERVE_HAPROXY_INGRESS_RETRY_ON,
RAY_SERVE_HAPROXY_INGRESS_TIMEOUT_SERVER_S,
RAY_SERVE_HAPROXY_LOG_TARGET,
RAY_SERVE_HAPROXY_MAXCONN,
RAY_SERVE_HAPROXY_METRICS_ENABLED,
RAY_SERVE_HAPROXY_METRICS_PORT,
RAY_SERVE_HAPROXY_METRICS_REPORT_INTERVAL_S,
RAY_SERVE_HAPROXY_METRICS_SOCKET_PATH,
RAY_SERVE_HAPROXY_NBTHREAD,
RAY_SERVE_HAPROXY_RETRIES,
RAY_SERVE_HAPROXY_RETRY_ON,
RAY_SERVE_HAPROXY_SERVER_STATE_BASE,
RAY_SERVE_HAPROXY_SERVER_STATE_FILE,
RAY_SERVE_HAPROXY_SOCKET_PATH,
RAY_SERVE_HAPROXY_STARTUP_TIMEOUT_S,
RAY_SERVE_HAPROXY_STATS_PORT,
RAY_SERVE_HAPROXY_TCP_NODELAY,
RAY_SERVE_HAPROXY_TIMEOUT_CLIENT_S,
RAY_SERVE_HAPROXY_TIMEOUT_CONNECT_S,
RAY_SERVE_HAPROXY_TIMEOUT_SERVER_S,
RAY_SERVE_HAPROXY_TUNE_BUFSIZE,
RAY_SERVE_HAPROXY_UPDATE_LATENCY_BUCKETS_S,
RAY_SERVE_INGRESS_REQUEST_ROUTER_FORWARD_BODY,
RAY_SERVE_INGRESS_REQUEST_ROUTER_METRICS_ENABLED,
SERVE_CONTROLLER_NAME,
SERVE_LOGGER_NAME,
SERVE_NAMESPACE,
SERVE_SESSION_ID,
)
from ray.serve._private.haproxy_templates import (
HAPROXY_CONFIG_TEMPLATE,
HAPROXY_GRPC_HEALTHZ_RULES_TEMPLATE,
HAPROXY_HEALTHZ_RULES_TEMPLATE,
)
from ray.serve._private.logging_utils import get_component_logger_file_path
from ray.serve._private.long_poll import LongPollClient, LongPollNamespace
from ray.serve._private.proxy import (
ProxyActorInterface,
apply_per_node_port_overrides,
)
from ray.serve._private.utils import get_head_node_id, is_grpc_enabled
from ray.serve.config import HTTPOptions, gRPCOptions
from ray.serve.schema import (
LoggingConfig,
Target,
TargetGroup,
)
from ray.util import metrics
logger = logging.getLogger(SERVE_LOGGER_NAME)
@functools.cache
def _haproxy_fmt_literal(value: Any) -> str:
r"""Format a string as a double-quoted HAProxy log-format literal for a
`set-var-fmt` rule.
App, route, and deployment names can contain any character, so the value is
quoted and the characters that are special inside a HAProxy double-quoted
log-format string are escaped: `\` (escape), `"` (delimiter), `%`
(directive), and `$` (env var). HAProxy removes the escaping when it reads
the config, so the stored value is the original string.
Example: `a"b%c` becomes `"a\"b%%c"`.
"""
s = str(value)
s = s.replace("\\", "\\\\")
s = s.replace('"', '\\"')
s = s.replace("%", "%%")
s = s.replace("$", "\\$")
return '"' + s + '"'
def _load_lua_template() -> string.Template:
path = Path(__file__).parent / "ingress_request_router.lua.tmpl"
try:
return string.Template(path.read_text())
except FileNotFoundError as e:
raise FileNotFoundError(
f"{path.name} is missing. Ray Serve installation is incomplete."
) from e
def _routers_and_targets_by_backend(
backends: "List[BackendConfig]",
) -> "Tuple[Dict[str, ServerConfig], Dict[str, List[Tuple[str, str]]]]":
"""Per-backend router and replica map, restricted to backends with both.
Pick deterministically within each router pool to avoid the first-response
latency regression from cycling routers across requests.
"""
routers: Dict[str, ServerConfig] = {}
targets: Dict[str, List[Tuple[str, str]]] = {}
for backend in backends:
if not backend.ingress_request_router_servers:
continue
entries = [
(s.replica_id, s.name) for s in backend.servers if s.replica_id is not None
]
if not entries:
continue
# Host-first so co-located routers sort adjacent in debug output.
routers[backend.name] = min(
backend.ingress_request_router_servers, key=lambda s: (s.host, s.port)
)
targets[backend.name] = entries
return routers, targets
def _format_routers_lua(routers: "Dict[str, ServerConfig]") -> str:
"""Render {backend_name: ServerConfig} as a Lua table literal."""
body = ",\n".join(
f" [{json.dumps(name)}] = "
f"{{ host = {json.dumps(s.host)}, port = {s.port}, "
f"host_header = {json.dumps(f'{s.host}:{s.port}')} }}"
for name, s in routers.items()
)
return "{\n" + body + "\n}"
def _format_replica_targets_lua(
targets: "Dict[str, List[Tuple[str, str]]]",
) -> str:
"""Render {backend_name: {replica_id: server_name}} as nested Lua tables."""
backends_lua = []
for backend_name, entries in targets.items():
inner = ",\n".join(
f" [{json.dumps(rid)}] = {json.dumps(sname)}"
for rid, sname in entries
)
backends_lua.append(
f" [{json.dumps(backend_name)}] = " + "{\n" + inner + "\n }"
)
return "{\n" + ",\n".join(backends_lua) + "\n}"
def _write_if_changed(path: str, content: str) -> bool:
"""Write content to path only if it differs from what's there."""
try:
with open(path) as f:
if f.read() == content:
return False
except FileNotFoundError:
pass
with open(path, "w") as f:
f.write(content)
return True
def _tail_file(path: str, n_bytes: int = 4096) -> str:
"""Return up to the last ``n_bytes`` of ``path``, or "" on any error."""
try:
with open(path, "rb") as f:
f.seek(0, 2)
f.seek(max(0, f.tell() - n_bytes))
return f.read().decode("utf-8", errors="ignore").strip()
except OSError:
return ""
def get_haproxy_binary() -> str:
"""Return the path to the HAProxy binary.
Resolution order:
1. ``RAY_SERVE_HAPROXY_BINARY_PATH`` if explicitly set to an absolute path.
2. The binary bundled in the ``ray-haproxy`` package.
3. ``haproxy`` on the system PATH (fallback).
Raises ``FileNotFoundError`` if no usable binary is found.
"""
binary = _resolve_haproxy_binary()
logger.info(f"Using HAProxy binary: {binary}")
return binary
def _resolve_haproxy_binary() -> str:
# 1. Explicit RAY_SERVE_HAPROXY_BINARY_PATH override. An operator who sets
# this wants that specific binary, so it takes precedence over the bundled
# package.
if RAY_SERVE_HAPROXY_BINARY_PATH:
if os.path.isfile(RAY_SERVE_HAPROXY_BINARY_PATH) and os.access(
RAY_SERVE_HAPROXY_BINARY_PATH, os.X_OK
):
return RAY_SERVE_HAPROXY_BINARY_PATH
raise FileNotFoundError(
f"RAY_SERVE_HAPROXY_BINARY_PATH={RAY_SERVE_HAPROXY_BINARY_PATH!r} "
"does not point to an executable file."
)
# 2. Bundled binary from the ray-haproxy package, so ray[serve] installs work
# without extra configuration. Falls through if the package is missing or its
# binary is unusable.
try:
from ray_haproxy import get_haproxy_binary as _pip_binary
return _pip_binary()
except ImportError:
pass
except OSError:
pass
# 3. System PATH fallback.
system_haproxy = shutil.which("haproxy")
if system_haproxy:
return system_haproxy
raise FileNotFoundError(
"Could not find an HAProxy binary. "
"Install 'ray[serve]' on Linux for the bundled binary, "
"set RAY_SERVE_HAPROXY_BINARY_PATH to an executable, "
"or ensure 'haproxy' is on PATH."
)
@dataclass
class ServerStats:
"""Server statistics from HAProxy."""
backend: str # Which backend this server belongs to
server: str # Server name within the backend
status: str # Current status: "UP", "DOWN", "DRAIN", etc.
current_sessions: int # Active sessions (HAProxy 'scur')
queued: int # Queued requests (HAProxy 'qcur')
@property
def is_up(self) -> bool:
return self.status == "UP"
@property
def is_draining(self) -> bool:
return self.status in ["DRAIN", "NOLB"]
@property
def can_drain_safely(self) -> bool:
"""
Return True if the server can be drained safely based on the current load.
Safe to drain when:
- No current active sessions (0)
- No queued requests waiting
This ensures no active user sessions are disrupted during draining.
"""
return self.current_sessions == 0 and self.queued == 0
@dataclass
class HAProxyStats:
"""Complete HAProxy statistics with both individual server data and aggregate metrics."""
# Individual server statistics by backend and server name
backend_to_servers: Dict[str, Dict[str, ServerStats]] = field(default_factory=dict)
# Computed aggregate metrics (calculated from server data)
@property
def total_backends(self) -> int:
"""Total number of backends."""
return len(self.backend_to_servers)
@property
def total_servers(self) -> int:
"""Total number of servers across all backends."""
return sum(
len(backend_servers) for backend_servers in self.backend_to_servers.values()
)
@property
def active_servers(self) -> int:
"""Number of servers currently UP."""
return sum(
1
for backend_servers in self.backend_to_servers.values()
for server in backend_servers.values()
if server.is_up
)
@property
def draining_servers(self) -> int:
"""Number of servers currently draining."""
return sum(
1
for backend_servers in self.backend_to_servers.values()
for server in backend_servers.values()
if server.is_draining
)
@property
def total_active_sessions(self) -> int:
"""Sum of all active sessions across all servers."""
return sum(
server.current_sessions
for backend_servers in self.backend_to_servers.values()
for server in backend_servers.values()
)
@property
def total_queued_requests(self) -> int:
"""Sum of all queued requests across all servers."""
return sum(
server.queued
for backend_servers in self.backend_to_servers.values()
for server in backend_servers.values()
)
@property
def is_system_idle(self) -> bool:
"""Return True if the entire system has no active load."""
return self.total_active_sessions == 0 and self.total_queued_requests == 0
@property
def draining_progress_pct(self) -> float:
"""Return percentage of servers currently draining (0.0 to 100.0)."""
if self.total_servers == 0:
return 0.0
return (self.draining_servers / self.total_servers) * 100.0
@dataclass
class HealthRouteInfo:
"""Information regarding how proxy should respond to health and routes requests."""
healthy: bool = True
status: int = 200
health_message: str = HEALTHY_MESSAGE
routes_message: str = "{}"
routes_content_type: str = "application/json"
def build_grpc_healthcheck_request_hex(path: str) -> str:
"""Build the raw HTTP/2 bytes for a unary gRPC health-check request.
HAProxy's `http-check` cannot health-check a gRPC server: `http-check send`
truncates its body at the first NUL byte, and a gRPC length-prefixed message
always begins with the NUL compression-flag byte. With no message frame, the
server receives a message-less unary call and stalls until the check times
out. We therefore drive the check via `tcp-check send-binary`, which emits
exact bytes (NUL included), replaying a complete gRPC request: the HTTP/2
connection preface, an empty SETTINGS frame, a HEADERS frame (HPACK literal,
no Huffman/dynamic table), and a DATA frame carrying an empty message
(`00 00 00 00 00`) with END_STREAM. The peer responds with the real
`Healthz` body, which `tcp-check expect binary` matches against the healthy
message. Returns the request as a hex string for `tcp-check send-binary`.
"""
def hpack_str(s: str) -> bytes:
b = s.encode()
# Literal string, no Huffman; lengths here are always < 127.
assert len(b) < 127, f"header value too long for simple HPACK: {s!r}"
return bytes([len(b)]) + b
def hpack_header(name: str, value: str) -> bytes:
# 0x00 = literal header field without indexing, new name.
return b"\x00" + hpack_str(name) + hpack_str(value)
def h2_frame(frame_type: int, flags: int, stream_id: int, payload: bytes) -> bytes:
return (
len(payload).to_bytes(3, "big")
+ bytes([frame_type, flags])
+ stream_id.to_bytes(4, "big")
+ payload
)
preface = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
settings = h2_frame(0x4, 0x0, 0, b"") # empty SETTINGS
headers_block = b"".join(
[
hpack_header(":method", "POST"),
hpack_header(":scheme", "http"),
hpack_header(":path", path),
hpack_header(":authority", "ray-serve-grpc"),
hpack_header("te", "trailers"),
hpack_header("content-type", "application/grpc"),
]
)
headers = h2_frame(0x1, 0x4, 1, headers_block) # END_HEADERS (not END_STREAM)
data = h2_frame(0x0, 0x1, 1, b"\x00\x00\x00\x00\x00") # empty msg, END_STREAM
return (preface + settings + headers + data).hex()
@dataclass
class ServerConfig:
"""Configuration for a single server."""
name: str # Server identifier for HAProxy config
host: str # IP/hostname to connect to
port: int # Port to connect to
# Replica identifier returned by /internal/route.
replica_id: Optional[str] = None
def __str__(self) -> str:
return f"ServerConfig(name='{self.name}', host='{self.host}', port={self.port})"
def __repr__(self) -> str:
return str(self)
@dataclass
class BackendConfig:
"""Configuration for a single application backend."""
# Name of the target group.
name: str
# Path prefix for the target group. This will be used to route requests to the target group.
path_prefix: str
# Maximum time HAProxy will wait for a successful TCP connection to be established with the backend server.
timeout_connect_s: Optional[int] = None
# Maximum time that the backend server can be inactive while sending data back to HAProxy.
# This is also active during the initial response phase.
timeout_server_s: Optional[int] = None
# Maximum time that the client can be inactive while sending data to HAProxy.
# This is active during the initial request phase.
timeout_client_s: Optional[int] = None
timeout_http_request_s: Optional[int] = None
# Maximum time HAProxy will wait for a request in the queue.
timeout_queue_s: Optional[int] = None
# Maximum time HAProxy will keep the connection alive.
# This has to be the same or greater than the client side keep-alive timeout.
timeout_http_keep_alive_s: Optional[int] = None
# Control the inactivity timeout for established WebSocket connections.
# Without this setting, a WebSocket connection could be prematurely terminated by other,
# more general timeout settings like timeout client or timeout server,
# which are intended for the initial phases of a connection.
timeout_tunnel_s: Optional[int] = None
# The number of consecutive failed health checks that must occur before a service instance is marked as unhealthy
health_check_fall: Optional[int] = None
# Number of consecutive successful health checks required to mark an unhealthy service instance as healthy again
health_check_rise: Optional[int] = None
# Interval, or the amount of time, between each health check attempt
health_check_inter: Optional[str] = None
# The interval between two consecutive health checks when the server is in any of the transition states: UP - transitionally DOWN or DOWN - transitionally UP
health_check_fastinter: Optional[str] = None
# The interval between two consecutive health checks when the server is in the DOWN state
health_check_downinter: Optional[str] = None
# Endpoint path that the health check mechanism will send a request to.
http_health_check_path: Optional[str] = "/-/healthz"
grpc_health_check_path: Optional[str] = "/ray.serve.RayServeAPIService/Healthz"
# List of servers in this backend
servers: List[ServerConfig] = field(default_factory=list)
# Ingress request router servers. When populated, HAProxy Lua calls
# /internal/route on one of these to pick a data-plane replica.
ingress_request_router_servers: List[ServerConfig] = field(default_factory=list)
# The fallback server for this backend.
fallback_server: Optional[ServerConfig] = None
# The app name for this backend.
app_name: str = field(default_factory=str)
# Name of the app's ingress deployment. Rendered into the per-request HTTP
# metrics log line so HAProxy-emitted ingress metrics carry the deployment
# tag (matching what the Python proxy records). Empty when unknown.
ingress_deployment_name: str = field(default_factory=str)
# Protocol of this backend. HTTP backends are path-routed and use HTTP/1.1;
# gRPC backends are header-routed (by the `application` gRPC metadata) and
# speak HTTP/2 cleartext to replica direct-ingress gRPC servers.
protocol: RequestProtocol = RequestProtocol.HTTP
def build_health_check_config(self, global_config: "HAProxyConfig") -> dict:
"""Build health check configuration for HAProxy backend.
Returns a dict with:
- health_path: path for HTTP health checks (or None)
- default_server_directive: complete "default-server" line with all params
"""
# Resolve values: backend-specific overrides global defaults
fall = (
self.health_check_fall
if self.health_check_fall is not None
else global_config.health_check_fall
)
rise = (
self.health_check_rise
if self.health_check_rise is not None
else global_config.health_check_rise
)
inter = (
self.health_check_inter
if self.health_check_inter is not None
else global_config.health_check_inter
)
fastinter = (
self.health_check_fastinter
if self.health_check_fastinter is not None
else global_config.health_check_fastinter
)
downinter = (
self.health_check_downinter
if self.health_check_downinter is not None
else global_config.health_check_downinter
)
health_path = (
self.http_health_check_path
if self.http_health_check_path is not None
else global_config.http_health_check_path
)
if self.protocol == RequestProtocol.GRPC:
health_path = (
self.grpc_health_check_path
if self.grpc_health_check_path is not None
else global_config.grpc_health_check_path
)
# Build default-server directive
parts = []
# Add optional fastinter/downinter only if provided
if fastinter is not None:
parts.append(f"fastinter {fastinter}")
if downinter is not None:
parts.append(f"downinter {downinter}")
# Add required fall/rise/inter if any are set
if fall is not None:
parts.append(f"fall {fall}")
if rise is not None:
parts.append(f"rise {rise}")
if inter is not None:
parts.append(f"inter {inter}")
# Always add check at the end
parts.append("check")
default_server_directive = "default-server " + " ".join(parts)
result = {
"health_path": health_path,
"default_server_directive": default_server_directive,
}
# gRPC backends are health-checked via `tcp-check send-binary` (see
# build_grpc_healthcheck_request_hex for why `http-check` can't be used).
# Precompute the request bytes and the expected response marker so the
# template just emits them.
if self.protocol == RequestProtocol.GRPC:
result["grpc_healthcheck_request_hex"] = build_grpc_healthcheck_request_hex(
health_path
)
result["grpc_healthcheck_expect_hex"] = HEALTHY_MESSAGE.encode().hex()
return result
def __str__(self) -> str:
return f"BackendConfig(app_name='{self.app_name}', name='{self.name}', path_prefix='{self.path_prefix}', servers={self.servers}, ingress_request_router_servers={self.ingress_request_router_servers}, fallback_server={self.fallback_server}, protocol={self.protocol.value})"
def __repr__(self) -> str:
return str(self)
@dataclass
class HAProxyConfig:
"""Configuration for HAProxy."""
socket_path: str = RAY_SERVE_HAPROXY_SOCKET_PATH
server_state_base: str = RAY_SERVE_HAPROXY_SERVER_STATE_BASE
server_state_file: str = RAY_SERVE_HAPROXY_SERVER_STATE_FILE
# Enable HAProxy optimizations (server state persistence, etc.)
# Disabled by default to prevent test suite interference
enable_hap_optimization: bool = RAY_SERVE_ENABLE_HAPROXY_OPTIMIZED_CONFIG
maxconn: int = RAY_SERVE_HAPROXY_MAXCONN
nbthread: int = RAY_SERVE_HAPROXY_NBTHREAD
stats_port: int = RAY_SERVE_HAPROXY_STATS_PORT
stats_uri: str = "/stats"
metrics_port: int = RAY_SERVE_HAPROXY_METRICS_PORT
metrics_uri: str = "/metrics"
# All timeout values are in seconds
timeout_queue_s: Optional[int] = None
timeout_connect_s: Optional[int] = RAY_SERVE_HAPROXY_TIMEOUT_CONNECT_S
timeout_client_s: Optional[int] = RAY_SERVE_HAPROXY_TIMEOUT_CLIENT_S
timeout_server_s: Optional[int] = RAY_SERVE_HAPROXY_TIMEOUT_SERVER_S
timeout_http_request_s: Optional[int] = None
hard_stop_after_s: Optional[int] = RAY_SERVE_HAPROXY_HARD_STOP_AFTER_S
custom_global: Dict[str, str] = field(default_factory=dict)
custom_defaults: Dict[str, str] = field(default_factory=dict)
inject_process_id_header: bool = False
reload_id: Optional[str] = None # Unique ID for each reload
tcp_nodelay: bool = RAY_SERVE_HAPROXY_TCP_NODELAY
enable_so_reuseport: bool = (
os.environ.get("SERVE_SOCKET_REUSE_PORT_ENABLED", "0") == "1"
)
has_received_routes: bool = False
has_received_servers: bool = False
pass_health_checks: bool = True
health_check_endpoint: str = "/-/healthz"
# Global health check parameters (used as defaults for backends)
# Number of consecutive failed health checks that must occur before a service instance is marked as unhealthy
health_check_fall: Optional[int] = RAY_SERVE_HAPROXY_HEALTH_CHECK_FALL
# Number of consecutive successful health checks required to mark an unhealthy service instance as healthy again
health_check_rise: Optional[int] = RAY_SERVE_HAPROXY_HEALTH_CHECK_RISE
# Interval, or the amount of time, between each health check attempt
health_check_inter: Optional[str] = RAY_SERVE_HAPROXY_HEALTH_CHECK_INTER
# The interval between two consecutive health checks when the server is in any of the transition states: UP - transitionally DOWN or DOWN - transitionally UP
health_check_fastinter: Optional[str] = RAY_SERVE_HAPROXY_HEALTH_CHECK_FASTINTER
# The interval between two consecutive health checks when the server is in the DOWN state
health_check_downinter: Optional[str] = RAY_SERVE_HAPROXY_HEALTH_CHECK_DOWNINTER
http_health_check_path: Optional[str] = "/-/healthz" # For HTTP health checks
grpc_health_check_path: Optional[
str
] = "/ray.serve.RayServeAPIService/Healthz" # For gRPC health checks
http_options: HTTPOptions = field(default_factory=HTTPOptions)
grpc_options: gRPCOptions = field(default_factory=gRPCOptions)
log_target: str = RAY_SERVE_HAPROXY_LOG_TARGET
# Per-request metrics for the ingress request router data path.
# When metrics are disabled, there is no additional overhead:
# 1) no metric log target / log-format-sd is rendered,
# 2) the Lua template skips the timing+truncation set_vars, and
# 3) HAProxyManager does not bind the dgram socket.
ingress_request_router_metrics_enabled: bool = (
RAY_SERVE_INGRESS_REQUEST_ROUTER_METRICS_ENABLED
)
# Per-request HTTP ingress metrics (serve_num_http_requests, latency,
# errors) emitted from HAProxy log datagrams -- the metrics the Python proxy
# emits in non-HAProxy mode. On by default whenever HAProxy metrics are
# enabled. When set, the http_frontend renders a per-request RFC 5424 log
# line to metrics_socket_path. gRPC is intentionally excluded due to
# limitations in parsing trailers.
metrics_enabled: bool = RAY_SERVE_HAPROXY_METRICS_ENABLED
metrics_socket_path: str = RAY_SERVE_HAPROXY_METRICS_SOCKET_PATH
balance_algorithm: str = RAY_SERVE_HAPROXY_BALANCE_ALGORITHM
# Global retry policy for the defaults block (inherited by every backend).
retry_on: str = RAY_SERVE_HAPROXY_RETRY_ON
retries: Optional[int] = RAY_SERVE_HAPROXY_RETRIES
# Per-ingress-router overrides; when None the ingress backend inherits the
# global policy above.
ingress_retry_on: Optional[str] = RAY_SERVE_HAPROXY_INGRESS_RETRY_ON
ingress_retries: Optional[int] = RAY_SERVE_HAPROXY_INGRESS_RETRIES
ingress_timeout_server_s: Optional[int] = RAY_SERVE_HAPROXY_INGRESS_TIMEOUT_SERVER_S
is_head: bool = False
# Tuning flags
bufsize: int = RAY_SERVE_HAPROXY_TUNE_BUFSIZE
h2_max_frame_size: int = RAY_SERVE_HAPROXY_H2_MAX_FRAME_SIZE
h2_be_initial_window_size: int = RAY_SERVE_HAPROXY_H2_BE_INITIAL_WINDOW_SIZE
h2_be_max_concurrent_streams: int = RAY_SERVE_HAPROXY_H2_BE_MAX_CONCURRENT_STREAMS
h2_fe_initial_window_size: int = RAY_SERVE_HAPROXY_H2_FE_INITIAL_WINDOW_SIZE
h2_fe_max_concurrent_streams: int = RAY_SERVE_HAPROXY_H2_FE_MAX_CONCURRENT_STREAMS
@property
def frontend_host(self) -> str:
if self.http_options.host is None or self.http_options.host in (
"0.0.0.0",
"::",
):
return "*"
return self.http_options.host
@property
def frontend_port(self) -> int:
return self.http_options.port
@property
def grpc_enabled(self) -> bool:
return is_grpc_enabled(self.grpc_options)
@property
def grpc_frontend_host(self) -> str:
# gRPCOptions has no host field; reuse the HTTP host so both proxies
# bind on the same interface.
return self.frontend_host
@property
def grpc_frontend_port(self) -> int:
return self.grpc_options.port
@property
def root_path(self) -> str:
"""Global root_path prefix, normalized without a trailing slash.
Empty when unset so the config template omits root_path handling.
"""
return (self.http_options.root_path or "").rstrip("/")
@property
def timeout_http_keep_alive_s(self) -> int:
return self.http_options.keep_alive_timeout_s
def build_health_route_info(self, backends: List[BackendConfig]) -> HealthRouteInfo:
if not self.has_received_routes:
router_ready_for_traffic = False
router_message = NO_ROUTES_MESSAGE
elif self.is_head or self.has_received_servers:
router_ready_for_traffic = True
router_message = ""
else:
router_ready_for_traffic = False
router_message = NO_REPLICAS_MESSAGE
if not self.pass_health_checks:
healthy = False
message = DRAINING_MESSAGE
elif not router_ready_for_traffic:
healthy = False
message = router_message
else:
healthy = True
message = HEALTHY_MESSAGE
if healthy:
# Build routes JSON mapping: {"<path_prefix>": "<app_name>", ...}
routes = {
be.path_prefix: be.app_name
for be in backends
if be.app_name and be.path_prefix
}
routes_json = json.dumps(routes, separators=(",", ":"), ensure_ascii=False)
# Escape for haproxy double-quoted string literal
routes_message = routes_json.replace("\\", "\\\\").replace('"', '\\"')
else:
routes_message = message
return HealthRouteInfo(
healthy=healthy,
status=200 if healthy else 503,
health_message=message,
routes_message=routes_message,
routes_content_type="application/json" if healthy else "text/plain",
)
class ProxyApi(ABC):
"""Generic interface for load balancer management operations."""
@abstractmethod
async def start(self) -> None:
"""Initializes proxy configuration files."""
pass
@abstractmethod
async def get_all_stats(self) -> Dict[str, Dict[str, ServerStats]]:
"""Get statistics for all servers in all backends."""
pass
@abstractmethod
async def stop(self) -> None:
"""Stop the proxy."""
pass
@abstractmethod
async def disable(self) -> None:
"""Disables the proxy from receiving any HTTP requests"""
pass
@abstractmethod
async def enable(self) -> None:
"""Enables the proxy from receiving any HTTP requests"""
pass
@abstractmethod
async def reload(self) -> None:
"""Gracefully reload the service."""
pass
class HAProxyApi(ProxyApi):
"""ProxyApi implementation for HAProxy."""
def __init__(
self,
cfg: HAProxyConfig,
backend_configs: Dict[str, BackendConfig] = None,
config_file_path: str = RAY_SERVE_HAPROXY_CONFIG_FILE_LOC,
):
self.cfg = cfg
self.backend_configs = backend_configs or {}
# Standalone gRPC fallback server used for requests (e.g. ListApplications)
# that can only be answered by the Serve proxy.
self.grpc_fallback_server: Optional[ServerConfig] = None
self.grpc_fallback_backend: Optional[BackendConfig] = None
self.config_file_path = config_file_path
# Lock to prevent concurrent config modifications
self._config_lock = asyncio.Lock()
self._proc = None
# Track old processes from graceful reloads that may still be draining
self._old_procs: List[asyncio.subprocess.Process] = []
# Per-spawn counter for stdout/stderr file names.
self._spawn_seq: int = 0
# Unique per actor incarnation. A restart resets _spawn_seq to 0, so
# without this a new incarnation would reuse — and (wb) truncate — the
# previous one's log files. The actor PID changes on every restart.
self._log_id: int = os.getpid()
# Debug ring of (stdout_path, stderr_path) for the most recently
# exited workers. Files for still-alive workers are never touched
# (their fds are still writing); once a worker exits its files are
# retired here and the oldest beyond the cap are deleted, so the
# file count stays bounded instead of growing with every reload.
self._retired_logs: "deque[Tuple[str, str]]" = deque()
self._max_retained_logs: int = 10
# Ensure required directories exist during initialization
self._initialize_directories_and_error_files()
def _initialize_directories_and_error_files(self) -> None:
"""
Ensures all required directories exist, creates a unified 500 error file,
and assigns its path to self.cfg.error_file_path. Called once during initialization.
"""
# Create a config file directory
config_dir = os.path.dirname(self.config_file_path)
os.makedirs(config_dir, exist_ok=True)
# Create a socket directory
socket_dir = os.path.dirname(self.cfg.socket_path)
os.makedirs(socket_dir, exist_ok=True)
# Sweep std-stream logs left by a previous incarnation on this node.
# No worker has spawned yet, so any match belongs to a dead actor.
self._sweep_stale_logs()
# Create a server state directory only if optimization is enabled
if self.cfg.enable_hap_optimization:
server_state_dir = os.path.dirname(self.cfg.server_state_file)
os.makedirs(server_state_dir, exist_ok=True)
# Create a single error file for both 502 and 504 errors
# Both will be normalized to 500 Internal Server Error
error_file_path = os.path.join(config_dir, "500.http")
with open(error_file_path, "w") as ef:
ef.write("HTTP/1.1 500 Internal Server Error\r\n")
ef.write("Content-Type: text/plain\r\n")
ef.write("Content-Length: 21\r\n")
ef.write("\r\n")
ef.write("Internal Server Error")
self.cfg.error_file_path = error_file_path
def _sweep_stale_logs(self) -> None:
"""Delete std-stream log files left by a previous actor incarnation.
Called at init before any worker spawns, so every match belongs to a
dead actor (a crash-restart can't run the normal prune on its way out).
"""
socket_path = Path(self.cfg.socket_path)
for pattern in (
f"{socket_path.name}.*.stdout.log",
f"{socket_path.name}.*.stderr.log",
):
for stale in socket_path.parent.glob(pattern):
try:
stale.unlink()
except OSError:
pass
def _prune_old_procs(self) -> None:
"""Drop exited workers from ``_old_procs`` and bound their log files.
A still-alive (draining) worker is kept untouched — its fd is still
writing to its file. When a worker has exited, its (stdout, stderr)
files are retired into a fixed-size ring kept for post-mortem
debugging; files that fall off the end of the ring are deleted, so the
file count stays bounded instead of growing with every reload.
"""
still_alive = []
for p in self._old_procs:
if p.returncode is None:
still_alive.append(p)
continue
self._retire_log_files(p)
self._old_procs = still_alive
def _soft_stop_old_procs(self) -> None:
"""Send SIGUSR1 to displaced workers so they close their listeners and
drain.
HAProxy's `-sf` flag already requests this, but its delivery can be
lost, so the manager sends the signal itself where it is reliable.
"""
self._prune_old_procs()
for proc in self._old_procs:
if not self._is_our_haproxy(proc.pid):
continue
try:
# This is a no-op if the worker is already draining
os.kill(proc.pid, signal.SIGUSR1)
except OSError:
pass
def _is_our_haproxy(self, pid: int) -> bool:
"""Whether `pid` is currently one of our haproxy workers.
Reads /proc to guard against signaling a recycled pid: a reaped or
recycled pid won't have our config_file_path in its cmdline and drops
out. Returns False on any non-Linux / no-/proc platform (fail-safe:
skips re-signaling rather than risk hitting the wrong process).
"""
try:
with open(f"/proc/{pid}/cmdline", "rb") as f:
return self.config_file_path.encode() in f.read().split(b"\0")
except OSError:
return False
def count_haproxy_processes(self) -> int:
"""Number of HAProxy processes on the node belonging to this manager.
Scans /proc for processes whose cmdline references our config file, so
the count spans the live worker, draining workers from prior reloads,
and any leaked/orphaned workers — making it a signal for process leaks.
Returns 0 on non-Linux / no-/proc platforms where /proc is unavailable.
"""
try:
entries = os.listdir("/proc")
except OSError:
return 0
processes = {
entry
for entry in entries
if entry.isdigit() and self._is_our_haproxy(int(entry))
}
return len(processes)
async def compute_target_mismatch(self) -> int:
"""Cardinality of the mismatch between the controller's broadcasted
targets and the targets HAProxy actually reports in its stats.
Broadcasted targets are the (backend, server) pairs the controller asked
this proxy to configure; reported targets are the (backend, server) pairs
present in HAProxy's live stats. The result is the symmetric difference,
so it counts both targets not yet applied to HAProxy and stale targets
HAProxy still reports.
"""
expected = set()
for backend_name, backend_config in self.backend_configs.items():
for server in backend_config.servers:
expected.add((backend_name, server.name))
# The generated config also renders the fallback server as a real
# `server ... backup` line in the same backend, so it appears in
# get_all_stats; count it as expected or the gauge never converges
# to zero for backends that have a fallback.
if backend_config.fallback_server is not None:
expected.add((backend_name, backend_config.fallback_server.name))
# Note that if an entire backend scaled down (when an app is removed),
# get_all_stats will filter out that entire backend, so there could be
# stale servers in that haproxy backend but not reported by this metric.
# This case is rare and not really something we worry about since requests
# shouldn't be hitting that backend or should return errors anyways.
stats = await self.get_all_stats()
reported = {
(backend_name, server_name)
for backend_name, servers in stats.items()
for server_name in servers
}
return len(expected ^ reported)
async def count_ongoing_http_requests(self) -> int:
"""Total in-flight HTTP requests across this node's HTTP app backends.
Sums each HTTP backend's aggregate `scur` (current sessions), which
counts request streams actively being served. Unlike the frontend scur
it does not count idle keep-alive connections, and unlike server-connection
counts it is unaffected by `http-reuse`. The aggregate row also includes
requests queued waiting for a server. The `-via-ingress-request-router`
backends are included (a router request lives in exactly one backend).
"""
suffix = "-via-ingress-request-router"
stats = self._parse_haproxy_csv_stats(
await self._send_socket_command("show stat")
)
total = 0
for backend_name, servers in stats.items():
# The via-router backend is rendered from its app's BackendConfig but
# under a suffixed name; strip it to look the config (and protocol) up.
base = (
backend_name[: -len(suffix)]
if backend_name.endswith(suffix)
else backend_name
)
backend_config = self.backend_configs.get(base)
if (
backend_config is None
or backend_config.protocol != RequestProtocol.HTTP
):
continue
backend_row = servers.get("BACKEND")
if backend_row is not None:
total += backend_row.current_sessions
return total
def _retire_log_files(self, proc: asyncio.subprocess.Process) -> None:
"""Move an exited proc's std-stream logs into the bounded debug ring,
deleting the oldest pair once the ring exceeds its cap. Only call this
for procs that have exited — their fds must be closed."""
self._retired_logs.append((proc._stdout_path, proc._stderr_path))
while len(self._retired_logs) > self._max_retained_logs:
for path in self._retired_logs.popleft():
try:
os.remove(path)
except OSError:
pass
def _is_running(self) -> bool:
"""Check if the HAProxy process is still running."""
return self._proc is not None and self._proc.returncode is None
async def _start_and_wait_for_haproxy(
self,
*extra_args: str,
timeout_s: int = RAY_SERVE_HAPROXY_STARTUP_TIMEOUT_S,
) -> asyncio.subprocess.Process:
# Build command args
haproxy_bin = get_haproxy_binary()
args = [haproxy_bin, "-db", "-f", self.config_file_path]
if not self.cfg.enable_so_reuseport:
args.append("-dR")
# Add any extra args (like -sf for graceful reload)
args.extend(extra_args)
# Redirect both std streams to files → no 64KB pipe buffer to deadlock on.
self._spawn_seq += 1
base = f"{self.cfg.socket_path}.{self._log_id}.{self._spawn_seq}"
stdout_path = f"{base}.stdout.log"
stderr_path = f"{base}.stderr.log"
with open(stdout_path, "wb", buffering=0) as stdout_file, open(
stderr_path, "wb", buffering=0
) as stderr_file:
proc = await asyncio.create_subprocess_exec(
*args,
stdout=stdout_file,
stderr=stderr_file,
)
proc._stdout_path = stdout_path
proc._stderr_path = stderr_path
logger.info(
f"Starting HAProxy (spawn #{self._spawn_seq}, pid={proc.pid}, "
f"stdout={stdout_path}, stderr={stderr_path}); args={args}"
)
try:
await self._wait_for_hap_availability(proc, timeout_s=timeout_s)
except Exception:
# If startup fails, ensure the process is killed to avoid orphaned processes
if proc.returncode is None:
proc.kill()
await proc.wait()
# The proc has exited; retire its log files so a failed start/reload
# doesn't orphan them outside the bounded ring.
self._retire_log_files(proc)
raise
return proc
async def _save_server_state(self) -> None:
"""Save the server state to the file."""
server_state = await self._send_socket_command("show servers state")
with open(self.cfg.server_state_file, "w") as f:
f.write(server_state)
async def _graceful_reload(self) -> None:
"""Perform a graceful reload of HAProxy by starting a new process with -sf."""
try:
old_proc = self._proc
await self._wait_for_hap_availability(old_proc)
# Save server state if optimization is enabled
if self.cfg.enable_hap_optimization:
await self._save_server_state()
# Start new HAProxy process with -sf flag to gracefully take over from old process
# Use -x socket transfer for seamless reloads if optimization is enabled
# Also re-signal still-alive displaced workers: heals any worker
# whose earlier -sf signal was lost (re-signaling a draining one is
# a no-op). The new process delivers SIGUSR1 only after startup, so
# filter to pids whose /proc cmdline is still one of our haproxy
# workers: a worker that exited and had its pid recycled in the
# meantime drops out, so the signal can't land on an unrelated
# process.
self._prune_old_procs()
live_old_pids = [
str(p.pid) for p in self._old_procs if self._is_our_haproxy(p.pid)
]
reload_args = ["-sf", str(old_proc.pid), *live_old_pids]
if self.cfg.enable_hap_optimization:
reload_args.extend(["-x", self.cfg.socket_path])
self._proc = await self._start_and_wait_for_haproxy(*reload_args)
# Track for shutdown cleanup; pruned at the next reload.
if old_proc is not None:
self._old_procs.append(old_proc)
# `-sf` only re-signals stranded workers on the next reload, and
# there is none after the final one. Re-send SIGUSR1 directly so a
# worker left serving stale config stops now instead of racing the
# client's next request.
self._soft_stop_old_procs()
logger.info(
"Successfully performed graceful HAProxy reload with process restart."
)
except Exception as e:
logger.error(f"HAProxy graceful reload failed: {e}")
raise
async def _wait_for_hap_availability(
self,
proc: asyncio.subprocess.Process,
timeout_s: int = RAY_SERVE_HAPROXY_STARTUP_TIMEOUT_S,
) -> None:
start_time = time.time()
# TODO: update this to use health checks
while time.time() - start_time < timeout_s:
if proc.returncode is not None:
# Both streams were redirected to files at spawn; tail them.
stderr_text = _tail_file(proc._stderr_path)
stdout_text = _tail_file(proc._stdout_path)
output = stderr_text or stdout_text
raise RuntimeError(
f"HAProxy crashed during startup: {output or f'exit code {proc.returncode}'}"
)
# The socket path answers through its previous owner until the
# new process rebinds it, so require the answer to come from
# `proc` itself. Otherwise a spawn that dies before taking over is
# declared ready and its -sf target is stranded forever.
if await self._get_running_pid() == proc.pid:
return
await asyncio.sleep(0.5)
raise RuntimeError(
f"HAProxy (pid={proc.pid}) did not take over the admin socket within "
f"{timeout_s} seconds. stderr: {_tail_file(proc._stderr_path)}"
)
def _write_ingress_request_router_lua(
self,
backends: List[BackendConfig],
) -> Optional[str]:
"""Render the ingress-request-router Lua action and write it to disk.
Returns the script path, or None if no backend has both ingress
request routers AND replicas with replica IDs.
"""
routers, targets = _routers_and_targets_by_backend(backends)
if not routers:
return None
# When metrics are enabled, render the two timing hooks and the
# truncation set_var; when disabled, all three substitute to empty
# strings to avoid any additional overhead.
if self.cfg.ingress_request_router_metrics_enabled:
metrics_pre = "local _metrics_t0 = core.now()"
metrics_post = (
"local _metrics_t1 = core.now(); "
'txn:set_var("txn.ingress_request_router_latency_us", '
"(_metrics_t1.sec - _metrics_t0.sec) * 1000000 "
"+ (_metrics_t1.usec - _metrics_t0.usec))"
)
metrics_set_truncated = (
'txn:set_var("txn.ingress_request_router_truncated_full_length", '
"truncated)"
)
else:
metrics_pre = ""
metrics_post = ""
metrics_set_truncated = ""
content = _load_lua_template().substitute(
TIMEOUT_S=RAY_SERVE_HAPROXY_INGRESS_REQUEST_ROUTER_TIMEOUT_S,
FORWARD_BODY=str(RAY_SERVE_INGRESS_REQUEST_ROUTER_FORWARD_BODY).lower(),
# HAProxy's req_get_headers() returns lowercase header keys,
# so lowercase here for the Lua lookup. Empty string disables
# forwarding entirely.
SESSION_HEADER=SERVE_SESSION_ID.lower(),
ROUTERS=_format_routers_lua(routers),
REPLICA_TARGETS=_format_replica_targets_lua(targets),
METRICS_PRE_CALL_ROUTER=metrics_pre,
METRICS_POST_CALL_ROUTER=metrics_post,
METRICS_SET_TRUNCATED=metrics_set_truncated,
)
lua_path = os.path.join(
os.path.dirname(self.config_file_path), "ingress_request_router.lua"
)
if _write_if_changed(lua_path, content):
logger.debug(f"Wrote Lua routing script to {lua_path}")
return lua_path
def _generate_config_file_internal(self) -> bool:
"""Internal config generation without locking (for use within locked sections)."""
try:
env = Environment()
# Escapes names before they are rendered into set-var-fmt values.
env.filters["haproxy_fmt"] = _haproxy_fmt_literal
# Backends are sorted in decreasing order of length of path prefix
# to ensure that the longest path prefix match is taken first.
# Equal lengthed prefixes are then sorted alphabetically.
backends = sorted(
self.backend_configs.values(),
key=lambda be: (-len(be.path_prefix), be.path_prefix),
)
# HTTP backends are path-routed; gRPC backends are header-routed
# (by the `application` gRPC metadata) and need a different
# frontend / backend rendering. Split them up here so the template
# can iterate each set independently.
http_backends = [b for b in backends if b.protocol == RequestProtocol.HTTP]
grpc_backends = [b for b in backends if b.protocol == RequestProtocol.GRPC]
# Derive from the write result: returns None when no backend has
# both routers and replicas with IDs (transient during scaling).
# The ingress request router is HTTP-only.
ingress_request_router_lua_path = self._write_ingress_request_router_lua(
http_backends
)
has_ingress_request_router = ingress_request_router_lua_path is not None
# Enrich HTTP backends with precomputed health check configuration strings
http_backends_with_health_config = [
{
"backend": backend,
"health_config": backend.build_health_check_config(self.cfg),
}
for backend in http_backends
]
# Enrich gRPC backends with precomputed health check configuration strings
grpc_backends_with_health_config = [
{
"backend": backend,
"health_config": backend.build_health_check_config(self.cfg),
}
for backend in grpc_backends
]
# Healthz / routes only consider HTTP backends. The HAProxy `/-/healthz`
# and `/-/routes` endpoints are HTTP-facing.
health_route_info = self.cfg.build_health_route_info(http_backends)
# Render healthz rules separately for readability/reuse
healthz_template = env.from_string(HAPROXY_HEALTHZ_RULES_TEMPLATE)
healthz_rules = healthz_template.render(
{
"config": self.cfg,
"backends": http_backends,
"health_info": health_route_info,
}
)
grpc_healthz_template = env.from_string(HAPROXY_GRPC_HEALTHZ_RULES_TEMPLATE)
grpc_healthz_rules = grpc_healthz_template.render(
{
"config": self.cfg,
"backends": grpc_backends,
"health_info": health_route_info,
}
)
# Generate the gRPC fallback backend with health check configuration, which only
# contains the head node serve proxy. This is only used for ListApplication requests
# since HAProxy can't construct the gRPC response itself, so it relies on the serve
# proxy to do so.
grpc_fallback_backend_with_health_config = None
if (
self.grpc_fallback_server is not None
and self.grpc_fallback_backend is not None
):
grpc_fallback_backend_with_health_config = {
"backend": self.grpc_fallback_backend,
"health_config": self.grpc_fallback_backend.build_health_check_config(
self.cfg
),
}
config_template = env.from_string(HAPROXY_CONFIG_TEMPLATE)
config_content = config_template.render(
{
"config": self.cfg,
"backends": http_backends,
"grpc_backends": grpc_backends,
"backends_with_health_config": http_backends_with_health_config,
"grpc_backends_with_health_config": (
grpc_backends_with_health_config
),
"healthz_rules": healthz_rules,
"grpc_healthz_rules": grpc_healthz_rules,
"route_info": health_route_info,
"has_ingress_request_router": has_ingress_request_router,
"ingress_request_router_lua_path": ingress_request_router_lua_path,
"ingress_request_router_timeout_s": (
RAY_SERVE_HAPROXY_INGRESS_REQUEST_ROUTER_TIMEOUT_S
),
"ingress_request_router_bufsize": (
RAY_SERVE_HAPROXY_INGRESS_REQUEST_ROUTER_BUFSIZE
),
"ingress_request_router_forward_body": (
RAY_SERVE_INGRESS_REQUEST_ROUTER_FORWARD_BODY
),
"ingress_request_router_metrics_enabled": self.cfg.ingress_request_router_metrics_enabled,
"metrics_enabled": self.cfg.metrics_enabled,
"metrics_socket_path": self.cfg.metrics_socket_path,
"grpc_fallback_backend_with_health_config": grpc_fallback_backend_with_health_config,
"healthy_message": HEALTHY_MESSAGE, # the message in the response body for healthy replicas
}
)
# Ensure the config ends with a newline
if not config_content.endswith("\n"):
config_content += "\n"
# Use file locking to prevent concurrent writes from multiple processes
# This is important in test environments where multiple nodes may run
# on the same machine
import fcntl
lock_file_path = self.config_file_path + ".lock"
with open(lock_file_path, "w") as lock_f:
fcntl.flock(lock_f.fileno(), fcntl.LOCK_EX)
try:
with open(self.config_file_path, "w") as f:
f.write(config_content)
finally:
fcntl.flock(lock_f.fileno(), fcntl.LOCK_UN)
logger.debug(
f"Succesfully generated HAProxy configuration: {self.config_file_path}."
)
except Exception as e:
logger.error(f"Failed to create HAProxy configuration files: {e}")
raise
async def start(self) -> None:
"""
Generate HAProxy configuration files and start the HAProxy server process.
This method creates the necessary configuration files and launches the HAProxy
process in foreground mode, ensuring that the proxy is running with the latest
configuration and that the parent retains control of the subprocess handle.
"""
try:
async with self._config_lock:
# Set initial reload ID if header injection is enabled and ID is not set
if self.cfg.inject_process_id_header and self.cfg.reload_id is None:
self.cfg.reload_id = f"initial-{int(time.time() * 1000)}"
self._generate_config_file_internal()
logger.info("Successfully generated HAProxy config file.")
self._proc = await self._start_and_wait_for_haproxy()
logger.info("HAProxy started successfully.")
except Exception as e:
logger.error(f"Failed to initialize and start HAProxy configuration: {e}")
raise
async def get_all_stats(self) -> Dict[str, Dict[str, ServerStats]]:
"""Get statistics for all servers in all backends (implements abstract method).
Returns only application backends configured in self.backend_configs,
excluding HAProxy internal components (frontends, default_backend, stats).
Also excludes BACKEND aggregate entries, returning only individual servers.
"""
try:
stats_output = await self._send_socket_command("show stat")
all_stats = self._parse_haproxy_csv_stats(stats_output)
# Filter to only return application backends (ones in backend_configs)
# Exclude HAProxy internal components like frontends, default_backend, stats
# Also exclude BACKEND aggregate entries, keep only individual servers
return {
backend_name: {
server_name: stats
for server_name, stats in servers.items()
if server_name != "BACKEND"
}
for backend_name, servers in all_stats.items()
if backend_name in self.backend_configs
}
except Exception as e:
logger.error(f"Failed to get HAProxy stats: {e}")
return {}
async def get_haproxy_stats(self) -> HAProxyStats:
"""Get complete HAProxy statistics including both individual and aggregate data."""
server_stats = await self.get_all_stats()
return HAProxyStats(backend_to_servers=server_stats)
async def _send_socket_command(self, command: str) -> str:
"""Send a command to the HAProxy stats socket via Unix domain socket."""
try:
if not os.path.exists(self.cfg.socket_path):
raise RuntimeError(
f"HAProxy socket file does not exist: {self.cfg.socket_path}."
)
try:
reader, writer = await asyncio.wait_for(
asyncio.open_unix_connection(self.cfg.socket_path),
timeout=5.0,
)
except asyncio.TimeoutError:
raise RuntimeError(
f"Timeout connecting to HAProxy socket: {self.cfg.socket_path}"
)
try:
writer.write(f"{command}\n".encode("utf-8"))
await writer.drain()
# Read until EOF (HAProxy closes connection after response)
try:
result_bytes = await asyncio.wait_for(reader.read(), timeout=5.0)
except asyncio.TimeoutError:
raise RuntimeError(
f"Timeout while sending command '{command}' to HAProxy socket"
)
finally:
writer.close()
await writer.wait_closed()
result = result_bytes.decode("utf-8", errors="ignore")
logger.debug(f"Socket command '{command}' returned {len(result)} chars.")
return result
except Exception as e:
raise RuntimeError(f"Failed to send socket command '{command}': {e}")
@staticmethod
def _parse_haproxy_csv_stats(
stats_output: str,
) -> Dict[str, Dict[str, ServerStats]]:
"""Parse HAProxy stats CSV output into structured data."""
if not stats_output or not stats_output.strip():
return {}
# HAProxy stats start with '#' comment - replace with nothing for CSV parsing
csv_data = stats_output.replace("# ", "", 1)
backend_stats: Dict[str, Dict[str, ServerStats]] = {}
def safe_int(v):
try:
return int(v)
except (TypeError, ValueError):
return 0
for row in csv.DictReader(io.StringIO(csv_data)):
backend = row.get("pxname", "").strip()
server = row.get("svname", "").strip()
status = row.get("status", "").strip() or "UNKNOWN"
if not backend or not server:
continue
backend_stats.setdefault(backend, {})
backend_stats[backend][server] = ServerStats(
backend=backend,
server=server,
status=status,
current_sessions=safe_int(row.get("scur")),
queued=safe_int(row.get("qcur")),
)
return backend_stats
async def stop(self) -> None:
proc = self._proc
if proc is None:
logger.info("HAProxy process not running, skipping shutdown.")
return
try:
# Kill the current process
if proc.returncode is None:
proc.kill()
await proc.wait()
self._proc = None
# Also kill any old processes from graceful reloads that might still be running
for old_proc in self._old_procs:
try:
if old_proc.returncode is None:
old_proc.kill()
await old_proc.wait()
logger.info(f"Killed old HAProxy process (PID: {old_proc.pid})")
except Exception as e:
logger.warning(f"Error killing old HAProxy process: {e}")
self._old_procs.clear()
logger.info("Stopped HAProxy process.")
except RuntimeError as e:
logger.error(f"Error during HAProxy shutdown: {e}")
async def reload(self) -> None:
try:
self._generate_config_file_internal()
await self._graceful_reload()
except Exception as e:
raise RuntimeError(f"Failed to update and reload HAProxy: {e}")
def has_alive_old_procs(self) -> bool:
"""True if any soft-stopping HAProxy workers from prior reloads remain."""
self._prune_old_procs()
return len(self._old_procs) > 0
async def disable(self) -> None:
"""Force haproxy health checks to fail."""
try:
# Disable health checks (set to fail)
self.cfg.pass_health_checks = False
# Regenerate the config file with the deny rule
self._generate_config_file_internal()
# Perform a graceful reload to apply changes
await self._graceful_reload()
logger.info("Successfully disabled health checks.")
except Exception as e:
logger.error(f"Failed to disable health checks: {e}")
raise
async def enable(self) -> None:
"""Force haproxy health checks to pass."""
try:
self.cfg.pass_health_checks = True
self._generate_config_file_internal()
# Perform a graceful reload to apply changes
await self._graceful_reload()
logger.info("Successfully enabled health checks.")
except Exception as e:
logger.error(f"Failed to disable health checks: {e}")
raise
def set_backend_configs(
self,
backend_configs: Dict[str, BackendConfig],
) -> None:
if backend_configs:
self.cfg.has_received_routes = True
self.backend_configs = backend_configs
self.cfg.has_received_servers = self.cfg.has_received_servers or any(
len(bc.servers) > 0 for bc in backend_configs.values()
)
def set_grpc_fallback_server(self, server: Optional[ServerConfig]) -> None:
self.grpc_fallback_server = server
if server is not None:
self.grpc_fallback_backend = BackendConfig(
name="grpc_fallback_backend",
path_prefix="/", # this is unused for grpc
protocol=RequestProtocol.GRPC,
servers=[server],
)
else:
self.grpc_fallback_backend = None
async def is_grpc_fallback_ready(self) -> bool:
"""True iff the running HAProxy has an UP server in grpc_fallback_backend."""
if self.grpc_fallback_backend is None:
return False
try:
stats = self._parse_haproxy_csv_stats(
await self._send_socket_command("show stat")
)
except Exception:
return False
servers = stats.get("grpc_fallback_backend", {})
return any(server.is_up for server in servers.values())
async def _get_running_pid(self) -> Optional[int]:
"""Pid of the HAProxy process currently answering the admin socket,
or None if the socket is unavailable or the response is unparseable.
"""
try:
info = await self._send_socket_command("show info")
except Exception:
return None
for line in info.splitlines():
if line.startswith("Pid:"):
try:
return int(line.split(":", 1)[1])
except ValueError:
return None
return None
async def is_running(self) -> bool:
try:
await self._send_socket_command("show info")
return True
except Exception:
# During reload or shutdown, socket can be temporarily unavailable.
# Treat as unhealthy instead of raising.
return False
@ray.remote(num_cpus=0)
class HAProxyManager(ProxyActorInterface):
def __init__(
self,
http_options: HTTPOptions,
grpc_options: gRPCOptions,
*,
node_id: NodeId,
node_ip_address: str,
logging_config: LoggingConfig,
long_poll_client: Optional[LongPollClient] = None,
): # noqa: F821
super().__init__(
node_id=node_id,
node_ip_address=node_ip_address,
logging_config=logging_config,
# HAProxyManager is not on the request path, so we can disable
# the buffer to ensure logs are immediately flushed.
log_buffer_size=1,
)
self._grpc_options = grpc_options
self._http_options = http_options
# The time when the node starts to drain.
# The node is not draining if it's None.
self._draining_start_time: Optional[float] = None
self.event_loop = get_or_create_event_loop()
self._target_groups: List[TargetGroup] = []
self._received_target_groups_broadcast = False
# Fallback targets.
self._http_fallback_target: Optional[Target] = None
self._grpc_fallback_target: Optional[Target] = None
# Lock to serialize HAProxy reloads and prevent concurrent reload operations
# which can cause race conditions with SO_REUSEPORT
self._reload_lock = asyncio.Lock()
# Coalescing state: each broadcast sets _update_pending=True and
# (re)arms _coalesce_task, which sleeps one window then applies.
# _coalesce_armed_at is the monotonic time of the first broadcast
# since the last apply, used to report end-to-end update latency.
self._update_pending: bool = False
self._coalesce_task: Optional[asyncio.Task] = None
self._coalesce_armed_at: Optional[float] = None
self._haproxy_update_latency = metrics.Histogram(
"serve_haproxy_update_latency_s",
description=(
"Seconds from the first coalesced controller broadcast to the "
"HAProxy reload completing. Includes the coalesce window, time "
"queued behind an in-flight reload, and the reload itself."
),
boundaries=RAY_SERVE_HAPROXY_UPDATE_LATENCY_BUCKETS_S,
tag_keys=("node_id",),
).set_default_tags({"node_id": node_id})
self.long_poll_client = long_poll_client or LongPollClient(
ray.get_actor(SERVE_CONTROLLER_NAME, namespace=SERVE_NAMESPACE),
{
LongPollNamespace.GLOBAL_LOGGING_CONFIG: self._update_logging_config,
LongPollNamespace.TARGET_GROUPS: self.update_target_groups,
LongPollNamespace.FALLBACK_TARGETS: self.update_fallback_targets,
},
call_in_event_loop=self.event_loop,
client_id=f"{type(self).__name__}:{ray.get_runtime_context().get_actor_id()}",
)
is_head = self._node_id == get_head_node_id()
apply_per_node_port_overrides(self._http_options, self._grpc_options, is_head)
startup_msg = f"HAProxy starting on node {self._node_id} (HTTP port: {self._http_options.port}"
if is_grpc_enabled(self._grpc_options):
startup_msg += f", gRPC port: {self._grpc_options.port})."
else:
startup_msg += ")."
logger.info(startup_msg)
logger.debug(
f"Configure HAProxyManager actor {ray.get_runtime_context().get_actor_id()} "
f"logger with logging config: {logging_config}"
)
# Scope paths under node_id so co-located managers (multi-raylet
# test clusters on one host) don't overwrite each other's files.
def _per_node(path: str) -> str:
d, f = os.path.split(path)
return os.path.join(d, self._node_id, f)
self._haproxy = HAProxyApi(
cfg=HAProxyConfig(
http_options=http_options,
grpc_options=grpc_options,
is_head=is_head,
socket_path=_per_node(RAY_SERVE_HAPROXY_SOCKET_PATH),
server_state_base=os.path.join(
RAY_SERVE_HAPROXY_SERVER_STATE_BASE, self._node_id
),
server_state_file=_per_node(RAY_SERVE_HAPROXY_SERVER_STATE_FILE),
metrics_socket_path=_per_node(RAY_SERVE_HAPROXY_METRICS_SOCKET_PATH),
),
config_file_path=_per_node(RAY_SERVE_HAPROXY_CONFIG_FILE_LOC),
)
self._metrics_collector = None
self._metrics_attach_task: Optional[asyncio.Task] = None
if RAY_SERVE_HAPROXY_METRICS_ENABLED:
from ray.serve._private.haproxy_metrics import HAProxyMetricsCollector
# The metrics collector owns all serve_haproxy_* metrics for this proxy.
# It is constructed if haproxy metrics are enabled. start() always begins
# node-level polling and binds the per-request dgram reader (where
# HAProxy writes one line per request). It returns its bind task (which
# ready() awaits to surface bind failures).
self._metrics_collector = HAProxyMetricsCollector(
haproxy_api=self._haproxy,
node_id=self._node_id,
node_ip_address=self._node_ip_address,
)
try:
self._metrics_attach_task = self._metrics_collector.start(
self.event_loop,
poll_interval_s=RAY_SERVE_HAPROXY_METRICS_REPORT_INTERVAL_S,
metrics_socket_path=self._haproxy.cfg.metrics_socket_path,
)
except Exception:
logger.exception(
"Failed to start the per-request metrics datagram reader; "
"per-request metrics will not be emitted. Node-level metrics "
"and HAProxy continue normally."
)
self._haproxy_start_task = self.event_loop.create_task(self._haproxy.start())
async def shutdown(self) -> None:
"""Shutdown the HAProxyManager and clean up the HAProxy process.
This method should be called before the actor is killed to ensure
the HAProxy subprocess is properly terminated.
"""
try:
logger.info(
f"Shutting down HAProxyManager on node {self._node_id}.",
extra={"log_to_stderr": False},
)
await self._haproxy.stop()
logger.info(
f"Successfully stopped HAProxy process on node {self._node_id}.",
extra={"log_to_stderr": False},
)
except Exception as e:
raise RuntimeError(f"Error stopping HAProxy during shutdown: {e}")
finally:
if self._metrics_collector is not None:
self._metrics_collector.close()
self._metrics_collector = None
async def ready(self) -> str:
try:
# Wait for haproxy to start. Internally, this starts the process and
# waits for it to be running by querying the stats socket.
await self._haproxy_start_task
# Surface metrics bind failures here. HAProxy startup is much
# slower than the bind (subprocess vs. one syscall), so by the
# time we get here the attach task has either succeeded or
# raised; awaiting it is just collecting the result.
if self._metrics_attach_task is not None:
try:
await self._metrics_attach_task
except Exception:
# Only the dgram reader failed. Leave the collector intact
# so node-level gauges keep polling; the socket is left
# unbound (bind_and_attach is safe to have failed).
logger.exception(
"Failed to bind/attach metrics dgram reader; "
"per-request router metrics will not be drained."
)
except Exception as e:
logger.exception("Failed to start HAProxy.")
raise e from None
# Return proxy metadata used by the controller.
# NOTE(zcin): We need to convert the metadata to a json string because
# of cross-language scenarios. Java can't deserialize a Python tuple.
return json.dumps(
[
ray.get_runtime_context().get_worker_id(),
get_component_logger_file_path(),
]
)
async def serving(self, wait_for_applications_running: bool = True) -> None:
"""Wait for the HAProxy process to be ready to serve requests."""
if not wait_for_applications_running:
return
ready_to_serve = False
while not ready_to_serve:
if self._is_draining():
return
# We have not received an update from the controller yet. We always
# get at least one broadcast even if target groups is empty (e.g.
# when the user sets route_prefix=None).
if not self._received_target_groups_broadcast:
await asyncio.sleep(0.2)
continue
# When gRPC is used, haproxy relies on the fallback serve proxy to
# handle ListApplications requests, so we block until HAProxy reprots
# an UP server in grpc_fallback_backend.
if (
is_grpc_enabled(self._grpc_options)
and not await self._haproxy.is_grpc_fallback_ready()
):
await asyncio.sleep(0.2)
continue
desired_backend_servers = {
self._generate_backend_name(tg): {
self._generate_server_name(target) for target in tg.targets
}
for tg in self._target_groups
}
fallback_servers = {
self._generate_server_name(target)
for target in (
self._http_fallback_target,
self._grpc_fallback_target,
)
if target is not None
}
try:
stats = await self._haproxy.get_all_stats()
ready_backends = set()
for backend, servers in stats.items():
desired_servers = desired_backend_servers.get(backend, set())
desired_servers.update(fallback_servers)
for server_name, server in servers.items():
if not server.is_up:
continue
if server_name in desired_servers:
ready_backends.add(backend)
break
ready_to_serve = set(desired_backend_servers) == ready_backends
except Exception:
pass
if not ready_to_serve:
await asyncio.sleep(0.2)
def _is_draining(self) -> bool:
"""Whether is haproxy is in the draining status or not."""
return self._draining_start_time is not None
async def update_draining(
self, draining: bool, _after: Optional[Any] = None
) -> None:
"""Update the draining status of the proxy.
This is called by the proxy state manager
to drain or un-drain the haproxy.
"""
if draining and (not self._is_draining()):
logger.info(
f"Start to drain the HAProxy on node {self._node_id}.",
extra={"log_to_stderr": False},
)
# Use the reload lock to serialize with other HAProxy reload operations
async with self._reload_lock:
await self._haproxy.disable()
self._draining_start_time = time.time()
if (not draining) and self._is_draining():
logger.info(
f"Stop draining the HAProxy on node {self._node_id}.",
extra={"log_to_stderr": False},
)
# Use the reload lock to serialize with other HAProxy reload operations
async with self._reload_lock:
await self._haproxy.enable()
self._draining_start_time = None
async def is_drained(self, _after: Optional[Any] = None) -> bool:
"""Drained iff past min drain period AND no old workers still serving
AND current worker reports idle.
The admin socket only sees the current (post-reload) worker; old
soft-stopping workers can still hold in-flight long-running requests
invisible to ``is_system_idle``. Gating on ``has_alive_old_procs``
prevents SIGKILL from severing those connections.
"""
if not self._is_draining():
return False
if (time.time() - self._draining_start_time) <= PROXY_MIN_DRAINING_PERIOD_S:
return False
if self._haproxy.has_alive_old_procs():
return False
haproxy_stats = await self._haproxy.get_haproxy_stats()
return haproxy_stats.is_system_idle
async def check_health(self) -> bool:
# If haproxy is already shutdown, return False.
if not self._haproxy or not self._haproxy._proc:
return False
logger.debug("Received health check.", extra={"log_to_stderr": False})
return await self._haproxy.is_running()
def pong(self) -> str:
pass
async def receive_asgi_messages(self, request_metadata: RequestMetadata) -> bytes:
raise NotImplementedError("Receive is handled by the ingress replicas.")
async def receive_grpc_messages(
self, session_id: str
) -> Tuple[bool, Optional[Any], bool]:
raise NotImplementedError("Receive is handled by the ingress replicas.")
def _get_http_options(self) -> HTTPOptions:
return self._http_options
def _get_logging_config(self) -> Optional[str]:
"""Get the logging configuration (for testing purposes)."""
log_file_path = None
for handler in logger.handlers:
if isinstance(handler, logging.handlers.MemoryHandler):
log_file_path = handler.target.baseFilename
return log_file_path
def _generate_server_name(self, target: Target) -> str:
# The server name is derived from the replica's actor name, with the
# format `SERVE_REPLICA::<app>#<deployment>#<replica_id>`, or the
# proxy's actor name, with the format `SERVE_PROXY_ACTOR-<node_id>`.
# Special characters in the names are converted to comply with haproxy
# config's allowed characters, e.g. `#` -> `-`.
return self.get_safe_name(target.name)
def _target_to_server(self, target: Target) -> ServerConfig:
"""Convert a target to a server."""
# Use localhost if target is on the same node as HAProxy
host = get_localhost_ip() if target.ip == self._node_ip_address else target.ip
return ServerConfig(
name=self._generate_server_name(target),
host=host,
port=target.port,
# Unsanitized actor name; matches what /internal/route returns.
replica_id=target.name,
)
def _generate_backend_name(self, target_group: TargetGroup) -> str:
# The name is lowercased and formatted as <protocol>-<app_name>. Special
# characters in the name are converted to comply with haproxy config's
# allowed characters, e.g. `#` -> `-`.
return self.get_safe_name(
f"{target_group.protocol.value.lower()}-{target_group.app_name}"
)
def _create_backend_config(
self,
target_group: TargetGroup,
fallback_target: Optional[Target],
) -> BackendConfig:
"""Create a backend configuration from a target group and fallback target."""
servers = [self._target_to_server(target) for target in target_group.targets]
ingress_request_router_servers = [
self._target_to_server(target)
for target in target_group.ingress_request_router_targets
]
fallback_server = None
if fallback_target is not None:
fallback_server = self._target_to_server(fallback_target)
return BackendConfig(
name=self._generate_backend_name(target_group),
path_prefix=target_group.route_prefix,
servers=servers,
ingress_request_router_servers=ingress_request_router_servers,
app_name=target_group.app_name,
ingress_deployment_name=target_group.ingress_deployment_name,
fallback_server=fallback_server,
protocol=target_group.protocol,
)
async def _reload_haproxy(self) -> None:
# To avoid dropping updates from a long poll, we wait until HAProxy
# is up and running before attempting to generate config and reload.
# Use lock to serialize reloads and prevent race conditions with SO_REUSEPORT
async with self._reload_lock:
await self._haproxy_start_task
await self._haproxy.reload()
async def _update_haproxy_backends(self) -> None:
backend_configs = []
for target_group in self._target_groups:
fallback_target = None
if target_group.protocol == RequestProtocol.HTTP:
fallback_target = self._http_fallback_target
elif target_group.protocol == RequestProtocol.GRPC:
fallback_target = self._grpc_fallback_target
backend_config = self._create_backend_config(target_group, fallback_target)
backend_configs.append(backend_config)
logger.info(
f"Got updated backend configs: {backend_configs}.",
extra={"log_to_stderr": True},
)
name_to_backend_configs = {
backend_config.name: backend_config for backend_config in backend_configs
}
self._haproxy.set_backend_configs(name_to_backend_configs)
grpc_fallback_server = (
self._target_to_server(self._grpc_fallback_target)
if self._grpc_fallback_target is not None
else None
)
self._haproxy.set_grpc_fallback_server(grpc_fallback_server)
await self._reload_haproxy()
def update_target_groups(self, target_groups: List[TargetGroup]) -> None:
self._target_groups = target_groups
self._received_target_groups_broadcast = True
self._schedule_haproxy_update()
def update_fallback_targets(
self,
fallback_targets: Dict[RequestProtocol, Target],
) -> None:
# Reset so that fallbacks are repopulated from LongPoll.
self._http_fallback_target = None
self._grpc_fallback_target = None
for protocol, target in fallback_targets.items():
if protocol == RequestProtocol.HTTP:
self._http_fallback_target = target
elif protocol == RequestProtocol.GRPC:
self._grpc_fallback_target = target
self._schedule_haproxy_update()
def _schedule_haproxy_update(self) -> None:
"""Mark state dirty and ensure the apply task is running.
Broadcasts within RAY_SERVE_HAPROXY_BROADCAST_COALESCE_S collapse
into a single _update_haproxy_backends call. A window of 0 disables
the coalesce delay (each broadcast applies immediately) but still
runs through the same task, so update latency is always recorded.
"""
self._update_pending = True
if self._coalesce_armed_at is None:
self._coalesce_armed_at = time.monotonic()
if self._coalesce_task is None or self._coalesce_task.done():
self._coalesce_task = self.event_loop.create_task(
self._coalesce_and_apply()
)
async def _coalesce_and_apply(self) -> None:
# Awaits the reload so failures propagate here and so broadcasts
# arriving during a slow reload coalesce into the next cycle
# instead of queueing behind the reload lock. On failure, re-arm
# for up to 2 retries; past that, wait for the next broadcast.
consecutive_failures = 0
while self._update_pending:
if RAY_SERVE_HAPROXY_BROADCAST_COALESCE_S > 0:
await asyncio.sleep(RAY_SERVE_HAPROXY_BROADCAST_COALESCE_S)
self._update_pending = False
armed_at = self._coalesce_armed_at
self._coalesce_armed_at = None
try:
await self._update_haproxy_backends()
if armed_at is not None:
self._haproxy_update_latency.observe(time.monotonic() - armed_at)
consecutive_failures = 0
except Exception:
logger.exception("Coalesced HAProxy update failed.")
consecutive_failures += 1
if consecutive_failures < 3:
self._update_pending = True
# Keep measuring from the original broadcast so a retry's
# latency still reflects the full delay.
self._coalesce_armed_at = armed_at
def get_target_groups(self) -> List[TargetGroup]:
"""Get current target groups."""
return self._target_groups
@staticmethod
def get_safe_name(name: str) -> str:
"""Get a safe label name for the haproxy config."""
name = name.replace("#", "-").replace("/", ".")
# replace all remaining non-alphanumeric and non-{".", "_", "-"} with "_"
return re.sub(r"[^A-Za-z0-9._-]+", "_", name)
def _dump_ingress_replicas_for_testing(self, route: str) -> Set[ReplicaID]:
"""Return the set of replica IDs for targets matching the given route.
Args:
route: The route prefix to match against target groups.
Returns:
Set of ReplicaID objects for targets in the matching target group.
"""
replica_ids = set()
if self._target_groups is None:
return replica_ids
for target_group in self._target_groups:
if target_group.route_prefix == route:
for target in target_group.targets:
# Target names are in the format "SERVE_REPLICA::<app>#<deployment>#<replica_id>"
if ReplicaID.is_full_id_str(target.name):
replica_id = ReplicaID.from_full_id_str(target.name)
replica_ids.add(replica_id)
return replica_ids
def _dump_ingress_cache_for_testing(self, route: str) -> Set[ReplicaID]:
"""Return replica IDs that are cached/ready for the given route (for testing).
For HAProxy, all registered replicas are immediately ready for routing
(no warm-up cache like the internal router), so this returns the same
set as _dump_ingress_replicas_for_testing.
Args:
route: The route prefix to match against target groups.
Returns:
Set of ReplicaID objects for targets in the matching target group.
"""
return self._dump_ingress_replicas_for_testing(route)