chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
-- HAProxy Lua action that picks a backend replica via the ingress request
|
||||
-- router. Templated at config-reload time; placeholders are filled in by
|
||||
-- _write_ingress_request_router_lua in haproxy.py.
|
||||
--
|
||||
-- Bodies exceeding tune.bufsize are truncated; we forward what we have with
|
||||
-- X-Body-Truncated since prefix-routing only needs the head of the body.
|
||||
-- See RAY_SERVE_INGRESS_REQUEST_ROUTER_FORWARD_BODY in constants.py.
|
||||
|
||||
local ROUTER_REQUEST_TIMEOUT_S = ${TIMEOUT_S}
|
||||
local FORWARD_BODY = ${FORWARD_BODY}
|
||||
-- Name of the client header that carries the session id Serve should
|
||||
-- pin on. Filled from SERVE_SESSION_ID (set via the env var
|
||||
-- RAY_SERVE_SESSION_ID_HEADER_KEY) in lowercase form. Forwarded on the
|
||||
-- same name to /internal/route so session-aware routers (e.g.
|
||||
-- ConsistentHashRouter) can use it.
|
||||
local SESSION_HEADER = "${SESSION_HEADER}"
|
||||
|
||||
-- Per-app state. The frontend sets txn.ingress_request_router_app to the
|
||||
-- backend name whose path prefix matched, and we look up that app's
|
||||
-- router pool and replica map here. Each app routes through its own
|
||||
-- router; replica IDs are scoped to the app to avoid cross-app pinning.
|
||||
local ROUTERS = ${ROUTERS}
|
||||
local REPLICA_TARGETS = ${REPLICA_TARGETS}
|
||||
|
||||
local function call_router(router, body, truncated_length, session_id)
|
||||
local sock = core.tcp()
|
||||
sock:settimeout(ROUTER_REQUEST_TIMEOUT_S)
|
||||
|
||||
if not sock:connect(router.host, router.port) then
|
||||
return nil
|
||||
end
|
||||
|
||||
local truncation_header = ""
|
||||
if truncated_length then
|
||||
truncation_header = "X-Body-Truncated: "
|
||||
.. #body .. "/" .. truncated_length .. "\r\n"
|
||||
end
|
||||
|
||||
local session_header = ""
|
||||
if session_id and session_id ~= "" then
|
||||
session_header = SESSION_HEADER .. ": " .. session_id .. "\r\n"
|
||||
end
|
||||
|
||||
-- Connection: close so sock:receive("*a") terminates on EOF.
|
||||
local req = "POST /internal/route HTTP/1.0\r\n"
|
||||
.. "Host: " .. router.host_header .. "\r\n"
|
||||
.. "Connection: close\r\n"
|
||||
.. "Content-Type: application/json\r\n"
|
||||
.. "Content-Length: " .. #body .. "\r\n"
|
||||
.. truncation_header
|
||||
.. session_header
|
||||
.. "\r\n"
|
||||
.. body
|
||||
|
||||
if not sock:send(req) then
|
||||
sock:close()
|
||||
return nil
|
||||
end
|
||||
|
||||
local response = sock:receive("*a")
|
||||
sock:close()
|
||||
return response
|
||||
end
|
||||
|
||||
local function is_http_200(response)
|
||||
return response:match("^HTTP/[%d%.]+ 200") ~= nil
|
||||
end
|
||||
|
||||
local function http_response_body(response)
|
||||
local _, separator_end = response:find("\r\n\r\n", 1, true)
|
||||
if not separator_end then
|
||||
return ""
|
||||
end
|
||||
return response:sub(separator_end + 1)
|
||||
end
|
||||
|
||||
-- Router contract: response MUST be a flat JSON object with `replica_id` as a
|
||||
-- plain string actor name (no escaped quotes, no nested objects). The regex
|
||||
-- relies on this; broaden the parser if the contract grows.
|
||||
local function extract_replica_id(json_body)
|
||||
return json_body:match('"replica_id"%s*:%s*"([^"]+)"')
|
||||
end
|
||||
|
||||
-- Returns the original Content-Length when `body` is shorter than the
|
||||
-- advertised length (truncated by tune.bufsize), or nil otherwise.
|
||||
local function truncated_full_length(txn, body)
|
||||
local cl = tonumber(txn.sf:hdr("content-length"))
|
||||
if cl and #body < cl then
|
||||
return cl
|
||||
end
|
||||
end
|
||||
|
||||
-- Core routing decision. Sets either txn.ingress_request_router_target +
|
||||
-- txn.via_ingress_request_router (success) or txn.ingress_request_router_failed
|
||||
-- (failure). Both outcomes are timed by the caller; only the silent early
|
||||
-- returns in the action handler skip timing because no routing was attempted.
|
||||
local function _route_via_ingress_request_router(txn, router, replica_map)
|
||||
-- FORWARD_BODY=false: don't read or forward the body; call the router
|
||||
-- with body="" so a Content-Length: 0 POST still goes through routing
|
||||
-- (any policy that needs the body must opt in via FORWARD_BODY=true).
|
||||
-- Empty body in FORWARD_BODY=true mode is treated the same -- a
|
||||
-- legitimate input the router can accept or reject on its own terms;
|
||||
-- we don't synthesize a sentinel for it here.
|
||||
local body = ""
|
||||
local truncated = nil
|
||||
if FORWARD_BODY then
|
||||
body = txn.sf:req_body() or ""
|
||||
if body ~= "" then
|
||||
truncated = truncated_full_length(txn, body)
|
||||
if truncated then
|
||||
core.log(core.warning,
|
||||
"ingress_request_router: forwarding truncated body to router ("
|
||||
.. #body .. "/" .. truncated .. " bytes)")
|
||||
${METRICS_SET_TRUNCATED}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Look up the configured session-id header from the client request.
|
||||
-- req_get_headers() returns a table keyed by lowercase header name
|
||||
-- with array values (header can appear multiple times); take the
|
||||
-- first occurrence. Missing header -> nil -> call_router skips the
|
||||
-- forwarded header line entirely.
|
||||
--
|
||||
-- Also probe the `-`/`_` variant so an intermediate proxy that rewrites
|
||||
-- the separator doesn't silently drop session affinity, matching the
|
||||
-- Python-side `_matches_session_id_header` behavior in http_util.py.
|
||||
local session_id = nil
|
||||
if SESSION_HEADER ~= "" then
|
||||
local hdrs = txn.http:req_get_headers()
|
||||
local entry = hdrs and hdrs[SESSION_HEADER]
|
||||
if hdrs and not entry then
|
||||
local alt = SESSION_HEADER:gsub("-", "_")
|
||||
if alt == SESSION_HEADER then
|
||||
alt = SESSION_HEADER:gsub("_", "-")
|
||||
end
|
||||
entry = hdrs[alt]
|
||||
end
|
||||
if entry then
|
||||
session_id = entry[0]
|
||||
end
|
||||
end
|
||||
|
||||
local response = call_router(router, body, truncated, session_id)
|
||||
if not response then
|
||||
txn:set_var("txn.ingress_request_router_failed", "router_unreachable")
|
||||
return
|
||||
end
|
||||
if not is_http_200(response) then
|
||||
txn:set_var("txn.ingress_request_router_failed", "router_non_200")
|
||||
return
|
||||
end
|
||||
|
||||
local replica_id = extract_replica_id(http_response_body(response))
|
||||
if not replica_id then
|
||||
txn:set_var("txn.ingress_request_router_failed", "unparseable_replica_id")
|
||||
return
|
||||
end
|
||||
|
||||
local server_name = replica_map[replica_id]
|
||||
if not server_name then
|
||||
-- Pin-miss: router named a replica not in HAProxy's server map, a
|
||||
-- transient gap between the router view and HAProxy's config snapshot.
|
||||
-- Arm `failed`. The frontend recovers it via the fallback proxy.
|
||||
txn:set_var("txn.ingress_request_router_failed", "unknown_replica_id")
|
||||
return
|
||||
end
|
||||
|
||||
txn:set_var("txn.ingress_request_router_target", server_name)
|
||||
txn:set_var("txn.via_ingress_request_router", true)
|
||||
end
|
||||
|
||||
-- Failure semantics: every path that reaches a router decision but cannot pin
|
||||
-- a replica must arm txn.ingress_request_router_failed so the frontend's 503
|
||||
-- rule fires instead of letting the request silently fall through to the
|
||||
-- primary backend. The product invariant is: requests to a router-bearing app
|
||||
-- are served via the router or fail; there is no quiet alternative path.
|
||||
-- Exception: `unknown_replica_id` is recovered via the fallback proxy when the
|
||||
-- app has one, otherwise 503ed. The fallback re-pins via its own router so the
|
||||
-- policy is not bypassed. While the fallback is DOWN the request load-balances
|
||||
-- onto a primary replica in the router backend instead of 503ing, so affinity
|
||||
-- lapses for that window. Still counted as a failure.
|
||||
-- Two silent returns are correct: (1) the request didn't target a
|
||||
-- router-bearing app (no txn var set, no app entry in our maps), and
|
||||
-- (2) the controller hasn't pushed router/replica state for this app yet.
|
||||
-- Failure-mode bucketing belongs in observability (response header label,
|
||||
-- metric label), not in the data plane.
|
||||
core.register_action("route_via_ingress_request_router", {"http-req"}, function(txn)
|
||||
-- Time the routing attempt regardless of outcome: both successful pins
|
||||
-- and explicit failures (router_unreachable, router_non_200,
|
||||
-- unparseable_replica_id, unknown_replica_id) record latency_us so
|
||||
-- failure paths are visible in the metrics stream.
|
||||
${METRICS_PRE_CALL_ROUTER}
|
||||
|
||||
local app = txn:get_var("txn.ingress_request_router_app")
|
||||
if not app then
|
||||
return
|
||||
end
|
||||
local router = ROUTERS[app]
|
||||
local replica_map = REPLICA_TARGETS[app]
|
||||
if not router or not replica_map then
|
||||
return
|
||||
end
|
||||
|
||||
_route_via_ingress_request_router(txn, router, replica_map)
|
||||
${METRICS_POST_CALL_ROUTER}
|
||||
end, 0)
|
||||
Reference in New Issue
Block a user