chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,999 @@
|
||||
"""Unit tests for `HAProxyMetricsCollector`.
|
||||
|
||||
The collector is the parser + recorder that sits behind the Unix dgram
|
||||
socket HAProxy writes RFC 5424 log lines to. These tests drive the pure
|
||||
parsing and record paths directly (no socket, no HAProxy, no asyncio).
|
||||
End-to-end coverage of the whole pipeline -- HAProxy emits a log line,
|
||||
asyncio reader picks it up, metric increments visible on /metrics --
|
||||
lives in the integration suite alongside `test_haproxy_api.py`.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import socket as stdlib_socket
|
||||
import tempfile
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from ray.serve._private.haproxy_metrics import (
|
||||
HAProxyMetricsCollector,
|
||||
ParsedMetrics,
|
||||
_DatagramHandler,
|
||||
)
|
||||
|
||||
# Sample syslog line shaped the way HAProxy emits when rendered through
|
||||
# the rfc5424 log target. The PRI / timestamp / app-name fields are
|
||||
# intentionally noisy because the parser must locate the SD section by
|
||||
# anchor, not by position.
|
||||
_SAMPLE_PREFIX = "<134>1 2026-05-15T12:34:56.789Z host haproxy 12345 - "
|
||||
|
||||
|
||||
def _line(sd_body: str) -> bytes:
|
||||
"""Build a fake RFC 5424 datagram with the given SD body."""
|
||||
return f"{_SAMPLE_PREFIX}[serve@1 {sd_body}] - normal log message".encode()
|
||||
|
||||
|
||||
class _FakeHAProxyApi:
|
||||
"""Minimal HAProxyApi stand-in. Required by the collector constructor; the
|
||||
push-metric tests pass it as an inert dummy, while the node-metrics tests
|
||||
drive its backend_configs / stats."""
|
||||
|
||||
def __init__(
|
||||
self, backend_configs: Optional[dict] = None, stats: Optional[dict] = None
|
||||
):
|
||||
self.backend_configs = backend_configs or {}
|
||||
self._stats = stats or {}
|
||||
|
||||
async def get_all_stats(self) -> dict:
|
||||
return self._stats
|
||||
|
||||
def count_haproxy_processes(self) -> int:
|
||||
return 0
|
||||
|
||||
async def compute_target_mismatch(self) -> int:
|
||||
# Inert: the real symmetric-difference logic is covered by
|
||||
# test_compute_target_mismatch against a real HAProxyApi.
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_line: pure parser tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sd_body, expected",
|
||||
[
|
||||
pytest.param(
|
||||
'app="llm" intended="replica-1" actual="replica-1" '
|
||||
'router_latency_us="1234" body_truncated_full_length="" '
|
||||
'via_router="1" failed=""',
|
||||
ParsedMetrics(
|
||||
app="llm",
|
||||
ingress_request_intended_server="replica-1",
|
||||
ingress_request_actual_server="replica-1",
|
||||
ingress_request_router_latency_us=1234,
|
||||
ingress_request_body_truncated_full_length=None,
|
||||
ingress_request_via_router=True,
|
||||
ingress_request_failed=None,
|
||||
),
|
||||
id="success-path-matched-pin",
|
||||
),
|
||||
pytest.param(
|
||||
'app="llm" intended="replica-1" actual="replica-2" '
|
||||
'router_latency_us="9000" body_truncated_full_length="" '
|
||||
'via_router="1" failed=""',
|
||||
ParsedMetrics(
|
||||
app="llm",
|
||||
ingress_request_intended_server="replica-1",
|
||||
ingress_request_actual_server="replica-2",
|
||||
ingress_request_router_latency_us=9000,
|
||||
ingress_request_body_truncated_full_length=None,
|
||||
ingress_request_via_router=True,
|
||||
ingress_request_failed=None,
|
||||
),
|
||||
id="mismatch-redispatch",
|
||||
),
|
||||
pytest.param(
|
||||
'app="llm" intended="replica-1" actual="replica-1" '
|
||||
'router_latency_us="2000" body_truncated_full_length="500000" '
|
||||
'via_router="1" failed=""',
|
||||
ParsedMetrics(
|
||||
app="llm",
|
||||
ingress_request_intended_server="replica-1",
|
||||
ingress_request_actual_server="replica-1",
|
||||
ingress_request_router_latency_us=2000,
|
||||
ingress_request_body_truncated_full_length=500000,
|
||||
ingress_request_via_router=True,
|
||||
ingress_request_failed=None,
|
||||
),
|
||||
id="truncated-body",
|
||||
),
|
||||
pytest.param(
|
||||
'app="llm" intended="" actual="<NOSRV>" '
|
||||
'router_latency_us="" body_truncated_full_length="" '
|
||||
'via_router="" failed="router_unreachable"',
|
||||
ParsedMetrics(
|
||||
app="llm",
|
||||
ingress_request_intended_server=None,
|
||||
ingress_request_actual_server="<NOSRV>",
|
||||
ingress_request_router_latency_us=None,
|
||||
ingress_request_body_truncated_full_length=None,
|
||||
ingress_request_via_router=False,
|
||||
ingress_request_failed="router_unreachable",
|
||||
),
|
||||
id="failure-router-unreachable",
|
||||
),
|
||||
pytest.param(
|
||||
'app="" intended="" actual="<NOSRV>" router_latency_us="" '
|
||||
'body_truncated_full_length="" via_router="" failed=""',
|
||||
ParsedMetrics(
|
||||
app=None,
|
||||
ingress_request_intended_server=None,
|
||||
ingress_request_actual_server="<NOSRV>",
|
||||
ingress_request_router_latency_us=None,
|
||||
ingress_request_body_truncated_full_length=None,
|
||||
ingress_request_via_router=False,
|
||||
ingress_request_failed=None,
|
||||
),
|
||||
id="all-unset-not-routed",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_parse_line_extracts_expected_fields(
|
||||
sd_body: str, expected: ParsedMetrics
|
||||
) -> None:
|
||||
parsed = HAProxyMetricsCollector.parse_line(_line(sd_body))
|
||||
assert parsed == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw, why",
|
||||
[
|
||||
pytest.param(b"", "empty"),
|
||||
pytest.param(b"not a syslog line", "no-sd-section"),
|
||||
pytest.param(
|
||||
f'{_SAMPLE_PREFIX}[other@1 foo="bar"] msg'.encode(),
|
||||
"different-sd-id",
|
||||
id="wrong-sd-id",
|
||||
),
|
||||
# Garbage bytes mid-line — the parser should still return None or a
|
||||
# best-effort ParsedMetrics rather than crashing.
|
||||
pytest.param(b"\xff\xfe\x00", "binary-garbage"),
|
||||
],
|
||||
)
|
||||
def test_parse_line_returns_none_when_sd_missing(raw: bytes, why: str) -> None:
|
||||
assert HAProxyMetricsCollector.parse_line(raw) is None, why
|
||||
|
||||
|
||||
def test_parse_line_handles_unknown_int_value() -> None:
|
||||
# router_latency_us isn't a number — parser should map it to None
|
||||
# rather than raise.
|
||||
line = _line(
|
||||
'app="llm" intended="r" actual="r" router_latency_us="notanint" '
|
||||
'body_truncated_full_length="" via_router="1" failed=""'
|
||||
)
|
||||
parsed = HAProxyMetricsCollector.parse_line(line)
|
||||
assert parsed is not None
|
||||
assert parsed.ingress_request_router_latency_us is None
|
||||
|
||||
|
||||
def test_parse_line_extracts_general_http_fields() -> None:
|
||||
"""A real HAProxy line carries the general request fields with %ST/%Ta
|
||||
rendered unquoted (bare), alongside the quoted var-based fields."""
|
||||
line = _line(
|
||||
'app="llm" route="/llm" method="POST" status=200 latency_ms=42 '
|
||||
'deployment="LLMDeployment" term_state=--'
|
||||
)
|
||||
parsed = HAProxyMetricsCollector.parse_line(line)
|
||||
assert parsed.app == "llm"
|
||||
assert parsed.route == "/llm"
|
||||
assert parsed.method == "POST"
|
||||
assert parsed.status_code == "200"
|
||||
assert parsed.latency_ms == 42
|
||||
assert parsed.deployment == "LLMDeployment"
|
||||
# %ts renders unquoted, like %ST/%Ta.
|
||||
assert parsed.termination_state == "--"
|
||||
# Router fields are absent on a non-router line.
|
||||
assert parsed.ingress_request_via_router is False
|
||||
assert parsed.ingress_request_router_latency_us is None
|
||||
|
||||
|
||||
def test_parse_line_unescapes_plus_e_escaping() -> None:
|
||||
"""HAProxy's `+E` log-format flag escapes `"`, `\\` and `]` inside a quoted
|
||||
value. The parser undoes that so the metric tag holds the original name --
|
||||
and a value containing `]` must not truncate the SD section at the wrong
|
||||
bracket."""
|
||||
# deployment name `a]b"c\d` -> on the wire: a\]b\"c\\d
|
||||
line = _line(
|
||||
'app="app" route="/" method="GET" status=200 latency_ms=1 '
|
||||
'deployment="a\\]b\\"c\\\\d" term_state=--'
|
||||
)
|
||||
parsed = HAProxyMetricsCollector.parse_line(line)
|
||||
assert parsed is not None
|
||||
# Section wasn't truncated at the escaped `]`; status still parsed.
|
||||
assert parsed.status_code == "200"
|
||||
assert parsed.deployment == 'a]b"c\\d'
|
||||
|
||||
|
||||
def test_parse_line_general_fields_empty_for_system_endpoints() -> None:
|
||||
"""A 404 / system-endpoint line has empty app & deployment (-> None) but a
|
||||
real status; the route may be empty (404) or the system path."""
|
||||
line = _line('app="" route="" method="GET" status=404 latency_ms=3 deployment=""')
|
||||
parsed = HAProxyMetricsCollector.parse_line(line)
|
||||
assert parsed.app is None
|
||||
assert parsed.route is None
|
||||
assert parsed.status_code == "404"
|
||||
assert parsed.latency_ms == 3
|
||||
assert parsed.deployment is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# record: each metric path, driven from a constructed ParsedMetrics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _RecordingMetric:
|
||||
"""Stub Counter / Histogram that captures calls in-memory.
|
||||
|
||||
Lets us assert against record() without depending on ray.util.metrics's
|
||||
Prometheus export wiring. Each call records (kind, tags, value).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list = []
|
||||
|
||||
def inc(self, value: float = 1.0, tags: Optional[dict] = None) -> None:
|
||||
self.calls.append(("inc", dict(tags or {}), value))
|
||||
|
||||
def observe(self, value: float, tags: Optional[dict] = None) -> None:
|
||||
self.calls.append(("observe", dict(tags or {}), value))
|
||||
|
||||
|
||||
class _RecordingIngressMetrics:
|
||||
"""Stub for RequestIngressMetrics that captures record_request kwargs."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list = []
|
||||
|
||||
def record_request(self, **kwargs) -> None:
|
||||
self.calls.append(kwargs)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def collector() -> HAProxyMetricsCollector:
|
||||
"""Build a collector with real metric constructors but stubbed inc/observe.
|
||||
|
||||
The collector's metric attributes are replaced post-init with
|
||||
`_RecordingMetric` so tests can assert against captured calls.
|
||||
"""
|
||||
c = HAProxyMetricsCollector(haproxy_api=_FakeHAProxyApi(), node_id="test-node")
|
||||
c.truncated_bodies_counter = _RecordingMetric()
|
||||
c.latency_histogram = _RecordingMetric()
|
||||
c.replica_mismatches_counter = _RecordingMetric()
|
||||
c.failures_counter = _RecordingMetric()
|
||||
c.requests_counter = _RecordingMetric()
|
||||
c.request_ingress_metrics = _RecordingIngressMetrics()
|
||||
return c
|
||||
|
||||
|
||||
def _ok(
|
||||
*,
|
||||
app: str = "llm",
|
||||
intended: str = "replica-A",
|
||||
actual: str = "replica-A",
|
||||
latency_us: Optional[int] = 1500,
|
||||
truncated: Optional[int] = None,
|
||||
) -> ParsedMetrics:
|
||||
return ParsedMetrics(
|
||||
app=app,
|
||||
ingress_request_intended_server=intended,
|
||||
ingress_request_actual_server=actual,
|
||||
ingress_request_router_latency_us=latency_us,
|
||||
ingress_request_body_truncated_full_length=truncated,
|
||||
ingress_request_via_router=True,
|
||||
ingress_request_failed=None,
|
||||
)
|
||||
|
||||
|
||||
def test_record_success_path_observes_latency(collector) -> None:
|
||||
collector.record(_ok(latency_us=2500))
|
||||
assert collector.latency_histogram.calls == [
|
||||
(
|
||||
"observe",
|
||||
{"application": "llm", "outcome": "success"},
|
||||
2.5,
|
||||
) # us → ms conversion
|
||||
]
|
||||
assert collector.truncated_bodies_counter.calls == []
|
||||
assert collector.replica_mismatches_counter.calls == []
|
||||
assert collector.failures_counter.calls == []
|
||||
|
||||
|
||||
def test_record_truncation_increments_counter(collector) -> None:
|
||||
collector.record(_ok(truncated=500_000))
|
||||
assert collector.truncated_bodies_counter.calls == [
|
||||
("inc", {"application": "llm"}, 1.0)
|
||||
]
|
||||
|
||||
|
||||
def test_record_mismatch_when_intended_and_actual_differ(collector) -> None:
|
||||
collector.record(_ok(intended="replica-A", actual="replica-B"))
|
||||
assert collector.replica_mismatches_counter.calls == [
|
||||
("inc", {"application": "llm"}, 1.0)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"intended, actual, why",
|
||||
[
|
||||
("replica-A", "replica-A", "matched-pin"),
|
||||
("replica-A", "<NOSRV>", "no-server-reached"),
|
||||
("replica-A", None, "actual-missing"),
|
||||
(None, "replica-A", "intended-missing"),
|
||||
],
|
||||
)
|
||||
def test_record_does_not_count_mismatch(collector, intended, actual, why) -> None:
|
||||
collector.record(_ok(intended=intended, actual=actual))
|
||||
assert collector.replica_mismatches_counter.calls == [], why
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reason",
|
||||
[
|
||||
"router_unreachable",
|
||||
"router_non_200",
|
||||
"unparseable_replica_id",
|
||||
"unknown_replica_id",
|
||||
],
|
||||
)
|
||||
def test_record_failure_increments_failures_counter_with_reason(
|
||||
collector, reason
|
||||
) -> None:
|
||||
parsed = ParsedMetrics(
|
||||
app="llm",
|
||||
ingress_request_intended_server=None,
|
||||
ingress_request_actual_server="<NOSRV>",
|
||||
ingress_request_router_latency_us=None,
|
||||
ingress_request_body_truncated_full_length=None,
|
||||
ingress_request_via_router=False,
|
||||
ingress_request_failed=reason,
|
||||
)
|
||||
collector.record(parsed)
|
||||
assert collector.failures_counter.calls == [
|
||||
("inc", {"application": "llm", "reason": reason}, 1.0)
|
||||
]
|
||||
# router_latency_us=None means the Lua timer wasn't set (e.g. metrics
|
||||
# disabled in the rendered Lua, or earliest-stage failure). The
|
||||
# histogram should stay quiet; failures with a real latency value are
|
||||
# covered by the test below.
|
||||
assert collector.latency_histogram.calls == []
|
||||
assert collector.truncated_bodies_counter.calls == []
|
||||
assert collector.replica_mismatches_counter.calls == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reason",
|
||||
[
|
||||
"router_unreachable",
|
||||
"router_non_200",
|
||||
"unparseable_replica_id",
|
||||
"unknown_replica_id",
|
||||
],
|
||||
)
|
||||
def test_record_failure_with_latency_observes_outcome_failure(
|
||||
collector, reason
|
||||
) -> None:
|
||||
"""The Lua action now wraps the routing call with the timer, so failure
|
||||
paths carry a real latency_us. record() must tag the observation with
|
||||
`outcome="failure"` so success vs failure latency can be split in PromQL."""
|
||||
parsed = ParsedMetrics(
|
||||
app="llm",
|
||||
ingress_request_intended_server=None,
|
||||
ingress_request_actual_server="<NOSRV>",
|
||||
ingress_request_router_latency_us=4200,
|
||||
ingress_request_body_truncated_full_length=None,
|
||||
ingress_request_via_router=False,
|
||||
ingress_request_failed=reason,
|
||||
)
|
||||
collector.record(parsed)
|
||||
assert collector.latency_histogram.calls == [
|
||||
("observe", {"application": "llm", "outcome": "failure"}, 4.2)
|
||||
]
|
||||
# Failure path still bumps the failure + requests counters.
|
||||
assert collector.failures_counter.calls == [
|
||||
("inc", {"application": "llm", "reason": reason}, 1.0)
|
||||
]
|
||||
assert collector.requests_counter.calls == [("inc", {"application": "llm"}, 1.0)]
|
||||
|
||||
|
||||
def test_record_skips_when_not_via_router_and_not_failed(collector) -> None:
|
||||
"""Request didn't hit the router path at all (no router-bearing app, or
|
||||
router state not yet pushed). Nothing should be recorded."""
|
||||
parsed = ParsedMetrics(
|
||||
app=None,
|
||||
ingress_request_intended_server=None,
|
||||
ingress_request_actual_server="<NOSRV>",
|
||||
ingress_request_router_latency_us=None,
|
||||
ingress_request_body_truncated_full_length=None,
|
||||
ingress_request_via_router=False,
|
||||
ingress_request_failed=None,
|
||||
)
|
||||
collector.record(parsed)
|
||||
assert collector.failures_counter.calls == []
|
||||
assert collector.latency_histogram.calls == []
|
||||
assert collector.truncated_bodies_counter.calls == []
|
||||
assert collector.replica_mismatches_counter.calls == []
|
||||
assert collector.requests_counter.calls == []
|
||||
|
||||
|
||||
def test_record_success_increments_requests_counter(collector) -> None:
|
||||
"""Every successful router consultation bumps the requests counter
|
||||
with just the `application` tag."""
|
||||
collector.record(_ok())
|
||||
assert collector.requests_counter.calls == [("inc", {"application": "llm"}, 1.0)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reason",
|
||||
[
|
||||
"router_unreachable",
|
||||
"router_non_200",
|
||||
"unparseable_replica_id",
|
||||
"unknown_replica_id",
|
||||
],
|
||||
)
|
||||
def test_record_failure_increments_requests_counter(collector, reason) -> None:
|
||||
"""Failed consultations are still consultations: requests_counter must
|
||||
bump on every failure reason so failure_total / requests_total yields
|
||||
the failure ratio."""
|
||||
parsed = ParsedMetrics(
|
||||
app="llm",
|
||||
ingress_request_intended_server=None,
|
||||
ingress_request_actual_server="<NOSRV>",
|
||||
ingress_request_router_latency_us=None,
|
||||
ingress_request_body_truncated_full_length=None,
|
||||
ingress_request_via_router=False,
|
||||
ingress_request_failed=reason,
|
||||
)
|
||||
collector.record(parsed)
|
||||
assert collector.requests_counter.calls == [("inc", {"application": "llm"}, 1.0)]
|
||||
|
||||
|
||||
def test_record_requests_counter_uses_unknown_app_tag_when_app_missing(
|
||||
collector,
|
||||
) -> None:
|
||||
"""Missing `app` is reported as "unknown" instead of dropping the
|
||||
observation, matching the rest of the record() paths."""
|
||||
parsed = ParsedMetrics(
|
||||
app=None,
|
||||
ingress_request_intended_server="replica-A",
|
||||
ingress_request_actual_server="replica-A",
|
||||
ingress_request_router_latency_us=1000,
|
||||
ingress_request_body_truncated_full_length=None,
|
||||
ingress_request_via_router=True,
|
||||
ingress_request_failed=None,
|
||||
)
|
||||
collector.record(parsed)
|
||||
assert collector.requests_counter.calls == [
|
||||
("inc", {"application": "unknown"}, 1.0)
|
||||
]
|
||||
|
||||
|
||||
def test_record_uses_unknown_app_tag_when_app_missing(collector) -> None:
|
||||
"""`application` is a required tag; missing app → 'unknown' rather than
|
||||
dropping the observation. Misconfigured frontends should still surface."""
|
||||
parsed = ParsedMetrics(
|
||||
app=None,
|
||||
ingress_request_intended_server="replica-A",
|
||||
ingress_request_actual_server="replica-B",
|
||||
ingress_request_router_latency_us=1000,
|
||||
ingress_request_body_truncated_full_length=None,
|
||||
ingress_request_via_router=True,
|
||||
ingress_request_failed=None,
|
||||
)
|
||||
collector.record(parsed)
|
||||
assert collector.replica_mismatches_counter.calls == [
|
||||
("inc", {"application": "unknown"}, 1.0)
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# record: RequestIngressMetrics (serve_num_http_*) path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _http(
|
||||
*,
|
||||
app: Optional[str] = "llm",
|
||||
route: Optional[str] = "/llm",
|
||||
method: Optional[str] = "GET",
|
||||
status: Optional[str] = "200",
|
||||
latency_ms: Optional[int] = 42,
|
||||
deployment: Optional[str] = "D",
|
||||
via_router: bool = False,
|
||||
termination_state: Optional[str] = None,
|
||||
) -> ParsedMetrics:
|
||||
return ParsedMetrics(
|
||||
app=app,
|
||||
ingress_request_via_router=via_router,
|
||||
route=route,
|
||||
method=method,
|
||||
status_code=status,
|
||||
latency_ms=latency_ms,
|
||||
deployment=deployment,
|
||||
termination_state=termination_state,
|
||||
)
|
||||
|
||||
|
||||
def test_record_emits_http_ingress_metrics(collector) -> None:
|
||||
collector.record(_http())
|
||||
assert collector.request_ingress_metrics.calls == [
|
||||
{
|
||||
"route": "/llm",
|
||||
"method": "GET",
|
||||
"application": "llm",
|
||||
"status_code": "200",
|
||||
"latency_ms": 42.0,
|
||||
"is_error": False,
|
||||
"deployment_name": "D",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_record_http_ingress_marks_4xx_5xx_as_error(collector) -> None:
|
||||
collector.record(_http(status="503"))
|
||||
assert collector.request_ingress_metrics.calls[0]["is_error"] is True
|
||||
collector.record(_http(status="404"))
|
||||
assert collector.request_ingress_metrics.calls[1]["is_error"] is True
|
||||
|
||||
|
||||
def test_record_http_ingress_empty_app_for_system_endpoints(collector) -> None:
|
||||
"""404 / health / routes carry no app context (parsed as None); they are
|
||||
still recorded, with empty application/route/deployment -- matching the
|
||||
proxy, which records them with empty tags."""
|
||||
collector.record(_http(app=None, route="", deployment=None, status="404"))
|
||||
call = collector.request_ingress_metrics.calls[0]
|
||||
assert call["application"] == ""
|
||||
assert call["route"] == ""
|
||||
assert call["deployment_name"] == ""
|
||||
assert call["is_error"] is True
|
||||
|
||||
|
||||
def test_record_http_ingress_client_abort_recorded_as_499(collector) -> None:
|
||||
"""A client abort (HAProxy term_state starting with "C") is recorded as
|
||||
status 499, matching the Python proxy's client-disconnect convention -- even
|
||||
though HAProxy logged its own status (here 400)."""
|
||||
collector.record(_http(status="400", termination_state="CH"))
|
||||
call = collector.request_ingress_metrics.calls[0]
|
||||
assert call["status_code"] == "499"
|
||||
assert call["is_error"] is True
|
||||
|
||||
|
||||
def test_record_http_ingress_non_client_term_state_keeps_status(collector) -> None:
|
||||
"""A genuine 400 (no client abort) keeps its status; only leading-"C"
|
||||
termination states are remapped to 499."""
|
||||
collector.record(_http(status="400", termination_state="--"))
|
||||
assert collector.request_ingress_metrics.calls[0]["status_code"] == "400"
|
||||
# Server-side termination ("S...") is also left untouched.
|
||||
collector.record(_http(status="502", termination_state="SH"))
|
||||
assert collector.request_ingress_metrics.calls[1]["status_code"] == "502"
|
||||
|
||||
|
||||
def test_record_http_ingress_skipped_without_status(collector) -> None:
|
||||
"""A router-only line (no status) is not a per-request HTTP observation."""
|
||||
collector.record(_ok()) # status_code defaults to None
|
||||
assert collector.request_ingress_metrics.calls == []
|
||||
|
||||
|
||||
def test_record_http_ingress_recorded_even_via_router(collector) -> None:
|
||||
"""A router request is still an HTTP request: both the ingress metric and
|
||||
the router requests counter fire."""
|
||||
collector.record(_http(via_router=True))
|
||||
assert len(collector.request_ingress_metrics.calls) == 1
|
||||
assert collector.requests_counter.calls == [("inc", {"application": "llm"}, 1.0)]
|
||||
|
||||
|
||||
def test_record_http_ingress_defaults_missing_latency_to_zero(collector) -> None:
|
||||
collector.record(_http(latency_ms=None))
|
||||
assert collector.request_ingress_metrics.calls[0]["latency_ms"] == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DatagramHandler: end-to-end through the asyncio protocol layer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_datagram_handler_dispatches_to_record(collector) -> None:
|
||||
"""Handler should parse the bytes and call record(); a malformed line
|
||||
should not raise out of datagram_received()."""
|
||||
handler = _DatagramHandler(collector)
|
||||
handler.datagram_received(
|
||||
_line(
|
||||
'app="llm" intended="X" actual="X" router_latency_us="100" '
|
||||
'body_truncated_full_length="" via_router="1" failed=""'
|
||||
),
|
||||
("addr", 0),
|
||||
)
|
||||
assert collector.latency_histogram.calls == [
|
||||
("observe", {"application": "llm", "outcome": "success"}, 0.1)
|
||||
]
|
||||
|
||||
|
||||
def test_datagram_handler_swallows_malformed_lines(collector) -> None:
|
||||
handler = _DatagramHandler(collector)
|
||||
# Should not raise.
|
||||
handler.datagram_received(b"\xff junk \x00", ("addr", 0))
|
||||
assert collector.latency_histogram.calls == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# bind_and_attach / close: real socket round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bind_and_attach_receives_datagram_then_close_unlinks(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""End-to-end on the asyncio path: bind a dgram socket, send a real
|
||||
syslog line to it from another socket, assert the metric was recorded,
|
||||
then close and verify the socket file is gone."""
|
||||
collector = HAProxyMetricsCollector(
|
||||
haproxy_api=_FakeHAProxyApi(), node_id="test-node"
|
||||
)
|
||||
# Replace the metric objects so we can assert on them without depending
|
||||
# on Ray's Prometheus registry.
|
||||
collector.latency_histogram = _RecordingMetric()
|
||||
collector.truncated_bodies_counter = _RecordingMetric()
|
||||
collector.replica_mismatches_counter = _RecordingMetric()
|
||||
collector.failures_counter = _RecordingMetric()
|
||||
|
||||
sock_path = str(tmp_path / "metrics.sock")
|
||||
try:
|
||||
await collector.bind_and_attach(sock_path, loop=asyncio.get_event_loop())
|
||||
assert os.path.exists(sock_path)
|
||||
|
||||
# Send one valid syslog line from a fresh client socket.
|
||||
client = stdlib_socket.socket(stdlib_socket.AF_UNIX, stdlib_socket.SOCK_DGRAM)
|
||||
try:
|
||||
line = _line(
|
||||
'app="llm" intended="r" actual="r" router_latency_us="500" '
|
||||
'body_truncated_full_length="" via_router="1" failed=""'
|
||||
)
|
||||
client.sendto(line, sock_path)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
# Give asyncio a tick to drain.
|
||||
for _ in range(50):
|
||||
if collector.latency_histogram.calls:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
assert collector.latency_histogram.calls == [
|
||||
("observe", {"application": "llm", "outcome": "success"}, 0.5)
|
||||
]
|
||||
finally:
|
||||
collector.close()
|
||||
|
||||
# close() should both close the transport and unlink the socket file.
|
||||
assert not os.path.exists(sock_path)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_is_idempotent_and_safe_without_bind() -> None:
|
||||
"""close() should never raise -- pre-bind, post-bind, or called twice."""
|
||||
collector = HAProxyMetricsCollector(
|
||||
haproxy_api=_FakeHAProxyApi(), node_id="test-node"
|
||||
)
|
||||
# never bound
|
||||
collector.close()
|
||||
collector.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bind_replaces_existing_socket_file(tmp_path) -> None:
|
||||
"""Stale socket file from a crashed predecessor should not block bind."""
|
||||
sock_path = tmp_path / "metrics.sock"
|
||||
sock_path.write_bytes(b"") # touch a stale file
|
||||
assert sock_path.exists()
|
||||
|
||||
collector = HAProxyMetricsCollector(
|
||||
haproxy_api=_FakeHAProxyApi(), node_id="test-node"
|
||||
)
|
||||
try:
|
||||
await collector.bind_and_attach(str(sock_path), loop=asyncio.get_event_loop())
|
||||
assert sock_path.exists()
|
||||
finally:
|
||||
collector.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Node-level poll metrics: target mismatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _backend(name: str, server_names, fallback: Optional[str] = None):
|
||||
from ray.serve._private.haproxy import BackendConfig, ServerConfig
|
||||
|
||||
return BackendConfig(
|
||||
name=name,
|
||||
path_prefix="/",
|
||||
servers=[
|
||||
ServerConfig(name=s, host="127.0.0.1", port=9000 + i)
|
||||
for i, s in enumerate(server_names)
|
||||
],
|
||||
fallback_server=(
|
||||
ServerConfig(name=fallback, host="127.0.0.1", port=8999)
|
||||
if fallback is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"broadcasted, reported, expected_mismatch",
|
||||
[
|
||||
# Fully converged: every broadcasted server is reported, nothing extra.
|
||||
({"s1", "s2"}, {"s1", "s2"}, 0),
|
||||
# A broadcasted server hasn't been applied to HAProxy yet.
|
||||
({"s1", "s2", "s3"}, {"s1", "s2"}, 1),
|
||||
# HAProxy still reports a stale server we no longer broadcast.
|
||||
({"s1", "s2"}, {"s1", "s2", "stale"}, 1),
|
||||
# Divergence in both directions counts each side.
|
||||
({"s1", "s2"}, {"s1", "stale"}, 2),
|
||||
],
|
||||
)
|
||||
def test_compute_target_mismatch(broadcasted, reported, expected_mismatch) -> None:
|
||||
# compute_target_mismatch lives on HAProxyApi (it reads backend_configs and
|
||||
# get_all_stats), so exercise the real method with a stubbed stats source.
|
||||
from ray.serve._private.haproxy import HAProxyApi, HAProxyConfig
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
api = HAProxyApi(
|
||||
cfg=HAProxyConfig(socket_path=os.path.join(td, "admin.sock")),
|
||||
backend_configs={"http-app": _backend("http-app", broadcasted)},
|
||||
config_file_path=os.path.join(td, "haproxy.cfg"),
|
||||
)
|
||||
|
||||
async def fake_get_all_stats():
|
||||
return {"http-app": {name: object() for name in reported}}
|
||||
|
||||
api.get_all_stats = fake_get_all_stats
|
||||
assert asyncio.run(api.compute_target_mismatch()) == expected_mismatch
|
||||
|
||||
|
||||
def test_compute_target_mismatch_treats_fallback_server_as_expected() -> None:
|
||||
"""The generated config renders the fallback server as a real backup
|
||||
`server` line, so HAProxy reports it in stats. It must count as expected,
|
||||
or the gauge would never converge to zero for backends with a fallback."""
|
||||
from ray.serve._private.haproxy import HAProxyApi, HAProxyConfig
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
api = HAProxyApi(
|
||||
cfg=HAProxyConfig(socket_path=os.path.join(td, "admin.sock")),
|
||||
backend_configs={
|
||||
"http-app": _backend("http-app", {"s1", "s2"}, fallback="fb")
|
||||
},
|
||||
config_file_path=os.path.join(td, "haproxy.cfg"),
|
||||
)
|
||||
|
||||
async def fake_get_all_stats():
|
||||
# HAProxy reports the two servers plus the fallback backup server.
|
||||
return {"http-app": {"s1": object(), "s2": object(), "fb": object()}}
|
||||
|
||||
api.get_all_stats = fake_get_all_stats
|
||||
assert asyncio.run(api.compute_target_mismatch()) == 0
|
||||
|
||||
|
||||
def test_count_ongoing_http_requests_sums_http_backend_scur() -> None:
|
||||
"""num_ongoing is sampled from each HTTP backend's aggregate `scur`,
|
||||
including the `-via-ingress-request-router` backend; gRPC and internal
|
||||
backends are excluded."""
|
||||
from ray.serve._private.common import RequestProtocol
|
||||
from ray.serve._private.haproxy import BackendConfig, HAProxyApi, HAProxyConfig
|
||||
|
||||
# `show stat` CSV: an HTTP app backend, its via-router variant, a gRPC app
|
||||
# backend, and internal rows (frontend / stats). scur is what's summed.
|
||||
show_stat = (
|
||||
"# pxname,svname,scur,qcur,status\n"
|
||||
"http_frontend,FRONTEND,9,0,OPEN\n"
|
||||
"http-app,s1,3,0,UP\n"
|
||||
"http-app,s2,2,0,UP\n"
|
||||
"http-app,BACKEND,5,0,UP\n"
|
||||
"http-app-via-ingress-request-router,r1,2,0,UP\n"
|
||||
"http-app-via-ingress-request-router,BACKEND,2,0,UP\n"
|
||||
"grpc-app,g1,3,0,UP\n"
|
||||
"grpc-app,BACKEND,3,0,UP\n"
|
||||
"stats,BACKEND,1,0,UP\n"
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
api = HAProxyApi(
|
||||
cfg=HAProxyConfig(socket_path=os.path.join(td, "admin.sock")),
|
||||
backend_configs={
|
||||
"http-app": BackendConfig(
|
||||
name="http-app", path_prefix="/", protocol=RequestProtocol.HTTP
|
||||
),
|
||||
"grpc-app": BackendConfig(
|
||||
name="grpc-app", path_prefix="/", protocol=RequestProtocol.GRPC
|
||||
),
|
||||
},
|
||||
config_file_path=os.path.join(td, "haproxy.cfg"),
|
||||
)
|
||||
|
||||
async def fake_send(command):
|
||||
assert command == "show stat"
|
||||
return show_stat
|
||||
|
||||
api._send_socket_command = fake_send
|
||||
# http-app BACKEND (5) + via-router BACKEND (2); gRPC (3) and the stats
|
||||
# listener are excluded.
|
||||
assert asyncio.run(api.count_ongoing_http_requests()) == 7
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_polls_and_binds_dgram_reader(tmp_path) -> None:
|
||||
"""start() begins node polling and always binds the per-request dgram
|
||||
reader, creating the socket's parent directory and returning its bind
|
||||
task."""
|
||||
api = _FakeHAProxyApi(backend_configs={}, stats={})
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
sock_path = tmp_path / "subdir" / "metrics.sock"
|
||||
collector = HAProxyMetricsCollector(haproxy_api=api, node_id="test-node")
|
||||
attach_task = collector.start(
|
||||
loop,
|
||||
poll_interval_s=10.0,
|
||||
metrics_socket_path=str(sock_path),
|
||||
)
|
||||
try:
|
||||
assert attach_task is not None
|
||||
await attach_task # bind completes; makedirs created the parent dir
|
||||
assert sock_path.exists()
|
||||
assert collector._node_metrics_task is not None
|
||||
finally:
|
||||
collector.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config rendering: metrics-enabled vs metrics-disabled
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _render_with_metrics(enabled: bool) -> str:
|
||||
"""Render the HAProxy config with metrics on or off; return the text.
|
||||
|
||||
Imports inside the function so the module-level test discovery doesn't
|
||||
drag in HAProxy template rendering for tests that don't need it.
|
||||
"""
|
||||
|
||||
from ray.serve._private.haproxy import (
|
||||
BackendConfig,
|
||||
HAProxyApi,
|
||||
HAProxyConfig,
|
||||
ServerConfig,
|
||||
)
|
||||
from ray.serve.config import HTTPOptions
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
cfg = HAProxyConfig(
|
||||
http_options=HTTPOptions(host="127.0.0.1", port=8000),
|
||||
socket_path=os.path.join(td, "admin.sock"),
|
||||
# `metrics_enabled` gates the per-request SD log line + socket;
|
||||
# `ingress_request_router_metrics_enabled` gates the router-specific
|
||||
# fields appended to it. These tests toggle both together (all
|
||||
# metrics on vs all off).
|
||||
metrics_enabled=enabled,
|
||||
ingress_request_router_metrics_enabled=enabled,
|
||||
metrics_socket_path=os.path.join(td, "metrics.sock"),
|
||||
has_received_routes=True,
|
||||
has_received_servers=True,
|
||||
)
|
||||
backend = BackendConfig(
|
||||
name="llm",
|
||||
path_prefix="/",
|
||||
app_name="llm",
|
||||
servers=[
|
||||
ServerConfig(
|
||||
name="A", host="127.0.0.1", port=9001, replica_id="actor-A"
|
||||
),
|
||||
],
|
||||
ingress_request_router_servers=[
|
||||
ServerConfig(name="router", host="127.0.0.1", port=9100),
|
||||
],
|
||||
)
|
||||
api = HAProxyApi(
|
||||
cfg=cfg,
|
||||
backend_configs={"llm": backend},
|
||||
config_file_path=os.path.join(td, "haproxy.cfg"),
|
||||
)
|
||||
api._generate_config_file_internal()
|
||||
with open(os.path.join(td, "haproxy.cfg")) as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def test_rendered_config_contains_metrics_directives_when_enabled() -> None:
|
||||
rendered = _render_with_metrics(enabled=True)
|
||||
assert "log-format-sd" in rendered
|
||||
assert "[serve@1" in rendered
|
||||
assert "format rfc5424" in rendered
|
||||
assert "router_latency_us" in rendered
|
||||
|
||||
|
||||
def test_rendered_config_omits_metrics_directives_when_disabled() -> None:
|
||||
rendered = _render_with_metrics(enabled=False)
|
||||
assert "log-format-sd" not in rendered
|
||||
assert "[serve@1" not in rendered
|
||||
# Match the directive, not the bare token: an explanatory comment in the
|
||||
# global block mentions "rfc5424" unconditionally; the `format rfc5424` log
|
||||
# target is what's actually gated on metrics being enabled.
|
||||
assert "format rfc5424" not in rendered
|
||||
|
||||
|
||||
def _render_lua_with_metrics(enabled: bool) -> str:
|
||||
"""Render the ingress-request-router Lua and return its text."""
|
||||
|
||||
from ray.serve._private.haproxy import (
|
||||
BackendConfig,
|
||||
HAProxyApi,
|
||||
HAProxyConfig,
|
||||
ServerConfig,
|
||||
)
|
||||
from ray.serve.config import HTTPOptions
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
cfg = HAProxyConfig(
|
||||
http_options=HTTPOptions(host="127.0.0.1", port=8000),
|
||||
socket_path=os.path.join(td, "admin.sock"),
|
||||
ingress_request_router_metrics_enabled=enabled,
|
||||
metrics_socket_path=os.path.join(td, "metrics.sock"),
|
||||
has_received_routes=True,
|
||||
has_received_servers=True,
|
||||
)
|
||||
backend = BackendConfig(
|
||||
name="llm",
|
||||
path_prefix="/",
|
||||
app_name="llm",
|
||||
servers=[
|
||||
ServerConfig(
|
||||
name="A", host="127.0.0.1", port=9001, replica_id="actor-A"
|
||||
),
|
||||
],
|
||||
ingress_request_router_servers=[
|
||||
ServerConfig(name="router", host="127.0.0.1", port=9100),
|
||||
],
|
||||
)
|
||||
api = HAProxyApi(
|
||||
cfg=cfg,
|
||||
backend_configs={"llm": backend},
|
||||
config_file_path=os.path.join(td, "haproxy.cfg"),
|
||||
)
|
||||
lua_path = api._write_ingress_request_router_lua([backend])
|
||||
assert lua_path is not None
|
||||
with open(lua_path) as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def test_rendered_lua_has_timing_calls_when_metrics_enabled() -> None:
|
||||
lua = _render_lua_with_metrics(enabled=True)
|
||||
assert "core.now()" in lua
|
||||
assert "ingress_request_router_latency_us" in lua
|
||||
assert "ingress_request_router_truncated_full_length" in lua
|
||||
|
||||
|
||||
def test_rendered_lua_has_no_timing_calls_when_metrics_disabled() -> None:
|
||||
lua = _render_lua_with_metrics(enabled=False)
|
||||
# When metrics are off, the substitutions become empty strings -- the
|
||||
# Lua should have no `core.now()` calls and no metric-only set_var
|
||||
# references at all.
|
||||
assert "core.now()" not in lua
|
||||
assert "ingress_request_router_latency_us" not in lua
|
||||
assert "ingress_request_router_truncated_full_length" not in lua
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
Reference in New Issue
Block a user