Skip to content

[serve] Production defaults for HAProxy connect timeout and dead-replica detection - #65729

Open
harshit-anyscale wants to merge 7 commits into
ray-project:masterfrom
harshit-anyscale:serve-production-safe-defaults
Open

[serve] Production defaults for HAProxy connect timeout and dead-replica detection#65729
harshit-anyscale wants to merge 7 commits into
ray-project:masterfrom
harshit-anyscale:serve-production-safe-defaults

Conversation

@harshit-anyscale

@harshit-anyscale harshit-anyscale commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Why this change

Two HAProxy defaults that are reasonable for a single-node dev cluster but poor once replicas come and go under autoscaling. Each is a default change only; both stay overridable through their existing environment variables.

Constant Before After
RAY_SERVE_HAPROXY_TIMEOUT_CONNECT_S unset 5
RAY_SERVE_HAPROXY_OBSERVE_MARK_DOWN_ENABLED 0 1

timeout connect: unset → 5s

Replicas are in-cluster, so a connect that takes seconds means the node is gone rather than busy. Bounding it lets retry-on conn-failure + option redispatch reach another replica while the request still has budget. This is connection-establishment time only — it does not bound request duration.

timeout server is deliberately not set here; see Deliberately not included.

observe layer4 ... on-error mark-down: off → on

Live traffic marks a dead replica DOWN without waiting for a health checker, and redispatch plus the backup fallback take over. A false positive revives in ~0.5s via the existing health check. Backup/fallback servers are never observed.

Not a duplicate

Checked before opening, no overlapping work found:

gh pr list --repo ray-project/ray --state open --search "haproxy default"

The only open PR touching this area is #65698 (mine, direct-ingress listener close), which does not change any of these constants.

Tests

Two environments. (A) nightly ray-3.0.0.dev0 wheel with python/ray/serve and python/ray/tests symlinked in, pytest==7.4.4 per python/requirements/test-requirements.txt — used for the rendering and unit runs. (B) the same, plus a real HAProxy binary (brew 3.4.4) via RAY_SERVE_HAPROXY_BINARY_PATH, running the CI job's env (RAY_SERVE_ENABLE_HA_PROXY=1, RAY_SERVE_DIRECT_INGRESS_MIN_DRAINING_PERIOD_S=0.01, SERVE_SOCKET_REUSE_PORT_ENABLED=1) — used for the integration runs.

End-to-end with a real HAProxy (B) — the tests premerge flagged on earlier revisions of this PR, re-run at this revision's defaults with no env overrides:

test_autoscaling_policy.py::test_e2e_preserve_prev_replicas_rest_api   => passed (x2)
test_streaming_response.py                                             => 23 passed

Both were failing on earlier revisions and pass here. Caveats on this environment: it is HAProxy 3.4.4, while CI ships 2.8 via ray-haproxy; and macOS has no splice(2), so option splice-request / option splice-response had to be dropped from the rendered config for HAProxy to start locally. Those options affect data forwarding, not routing or timing. CI remains the authority.

HAProxy config rendering (A):

RAY_SERVE_ENABLE_HA_PROXY=1 pytest --pyargs ray.serve.tests.test_haproxy_api \
  -k "generate_config_file_internal or escapes_special_characters or close_spread_time
      or generate_backends_in_order or routers_and_targets or ingress_request_router_lua
      or router_servers_without_replica_ids or does_not_leak_into_other_backends
      or router_failure_503 or inherits_global_retry_policy or global_retry_knobs
      or default_data_plane_tuning"
=> 13 passed, 26 deselected

New test test_default_data_plane_tuning_renders pins the shipped values. Every other test in that file passes explicit overrides, so nothing previously caught a silent change to these defaults. It is skipif-guarded on the env vars it asserts about, so overriding any of them skips rather than fails:

RAY_SERVE_HAPROXY_OBSERVE_MARK_DOWN_ENABLED=0      => 1 skipped
RAY_SERVE_HAPROXY_TIMEOUT_SERVER_S=3600            => 1 skipped
RAY_SERVE_HAPROXY_TIMEOUT_CONNECT_S=9              => 1 skipped

The skip does not neuter it — changing a shipped default in source still fails the test.

Serve unit suite (A)pytest python/ray/serve/tests/unit:

this branch:            195 failed, 4564 passed, 18 skipped, 2 errors
clean origin/master:    195 failed, 4564 passed, 18 skipped, 2 errors

Identical, so failure-neutral. The 195 are an artifact of running repo Serve code against a slightly older nightly core wheel on macOS; they are in test_deployment_state.py / test_handle_options.py and touch nothing this PR changes. Measured on the six-default revision of this PR; the diff has only shrunk since, so this is a ceiling rather than a current reading.

Lint at the versions pinned in .pre-commit-config.yaml, re-run at this revision:

ruff 0.8.4 check                => All checks passed
ruff 0.8.4 check --select I     => All checks passed
black 22.10.0 --check           => unchanged

mypy 1.7.0 and pyrefly 1.1.1 (both of which have constants.py on their allowlists) were clean on an earlier revision that already contained both remaining changes; they were not re-run at this exact commit, since everything removed since was a revert to master's own text.

One shape change worth calling out: RAY_SERVE_HAPROXY_TIMEOUT_CONNECT_S moves from the hand-rolled int(os.environ.get(...)) if os.environ.get(...) else ... conditional to the file's existing get_env_int_non_negative helper, which drops a # type: ignore[arg-type]. Its default is changing anyway, so the line is touched regardless, and the helper handles =0 correctly — the truthiness form would silently turn an explicit 0 into the default. It does mean an empty-string value now raises instead of being ignored, matching every other get_env_* constant in the file. Say the word if you would rather keep the original shape and just swap the else branch.

AI assistance

This change was drafted with AI assistance (Claude), including the code, the tests, and this description.

Opened as a draft on purpose: per AGENTS.md a human submitter has to review every changed line and run the relevant tests locally before review is requested. I have not finished that pass yet. Marking ready for review is the signal that it is done — please don't spend review time on it before then.

The binary-dependent HAProxy tests (26 deselected above) also still need a run on a machine that has haproxy installed.

@harshit-anyscale harshit-anyscale changed the title [serve] Production defaults for HAProxy routing and replica gRPC limits [serve] Production defaults for HAProxy routing, replica gRPC, and rank errors Aug 26, 2026
@harshit-anyscale harshit-anyscale added the go add ONLY when ready to merge, run all tests label Aug 26, 2026
@harshit-anyscale
harshit-anyscale force-pushed the serve-production-safe-defaults branch from ea2c444 to df64244 Compare August 26, 2026 04:28
@harshit-anyscale harshit-anyscale changed the title [serve] Production defaults for HAProxy routing, replica gRPC, and rank errors [serve] Production defaults for HAProxy routing and replica gRPC limits Aug 26, 2026
@harshit-anyscale
harshit-anyscale force-pushed the serve-production-safe-defaults branch 2 times, most recently from d75b6ca to a5b695c Compare August 26, 2026 07:13
@harshit-anyscale harshit-anyscale changed the title [serve] Production defaults for HAProxy routing and replica gRPC limits [serve] Production defaults for HAProxy routing at fleet scale Aug 26, 2026
Four HAProxy defaults are safe on a single-node dev cluster but wrong once a
cluster runs one HAProxy per node with a shared, fleet-wide backend list.

- balance: leastconn -> random(2). Each node's HAProxy sees the same backend
  list but only its own connection counts, so a global-minimum rule makes N
  proxies converge on the same replica. Power-of-two-choices keeps the local
  signal useful without synchronizing the proxies.
- broadcast coalesce: 0.1s -> 1.0s. Each reload leaves the outgoing worker
  soft-stopping with a frozen backend list until hard-stop-after, so the
  population of stale-routing workers scales with the reload rate.
- timeout connect: unset -> 5s. Replicas are in-cluster, so a slow connect
  means the node is gone; bounding it lets retry-on conn-failure and
  redispatch reach another replica while the request still has budget. This
  bounds connection establishment only, not request duration.
- observe layer4 mark-down: off -> on. Live traffic marks a dead replica DOWN
  without waiting for a health checker; a false positive revives in ~0.5s.

timeout server is deliberately left unset. It is a server-side inactivity
limit applied to every connection on the live process, not only on workers
draining after a reload, and hard-stop-after starts counting only on
soft-stop; the two are unrelated clocks. Serve's own request_timeout_s
defaults to None, so any value here would silently cap request duration.

Every one of these remains overridable by its existing environment variable.
The new defaults test is skipif-guarded on the variables it asserts about, so
overriding them skips rather than fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: harshit-anyscale <harshit@anyscale.com>
@harshit-anyscale
harshit-anyscale force-pushed the serve-production-safe-defaults branch from a5b695c to 5ce6006 Compare August 26, 2026 07:19
@harshit-anyscale harshit-anyscale self-assigned this Aug 26, 2026
@harshit-anyscale
harshit-anyscale marked this pull request as ready for review August 26, 2026 07:24
@harshit-anyscale
harshit-anyscale requested a review from a team as a code owner August 26, 2026 07:24

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates several HAProxy default configurations in Ray Serve to optimize data plane tuning, including increasing the broadcast coalesce time, setting a default connection timeout of 5 seconds, enabling observe-mark-down by default, and switching the load balancing algorithm from leastconn to random(2). It also adds a new test to verify these default configurations. Feedback suggests simplifying the new test by removing a redundant mock.patch block, as the configuration file path is already explicitly passed to the HAProxyApi constructor.

Comment thread python/ray/serve/tests/test_haproxy_api.py Outdated
harshit-anyscale and others added 2 commits August 26, 2026 13:07
The comment claimed N proxies read near-identical state and converge on one
replica. That is wrong in steady state: each HAProxy counts only its own
connections, so the per-proxy views are independent, each proxy spreads its
own share evenly, and the sum is even too.

The real failure mode is the transition. A replica that appears or returns
from DOWN reads as zero connections on every proxy at the same moment, and
leastconn is deterministic on that observation, so they all prefer it until
local counts catch up. That is the herd HAProxy documents slowstart for, and
Serve sets no slowstart; autoscaling makes the window recur rather than being
a one-off at startup.

random(2) ranks by the same counts but only across two sampled servers, so
identical observations stop producing identical choices. No behavior change,
comment only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: harshit-anyscale <harshit@anyscale.com>
The test patched RAY_SERVE_HAPROXY_CONFIG_FILE_LOC while also passing
config_file_path to the HAProxyApi constructor. The patch could not have had
any effect either way: haproxy.py binds the name into its own module
namespace at import, so patching it on the constants module does not rebind
it, and its only use there is as a parameter default evaluated at def time.
Config generation reads self.config_file_path throughout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: harshit-anyscale <harshit@anyscale.com>
@ray-gardener ray-gardener Bot added the serve Ray Serve Related Issue label Aug 26, 2026
harshit-anyscale and others added 2 commits August 29, 2026 17:21
CI (premerge 72475, serve HAProxy tests) failed
test_responses_actually_streamed[use_multiple_replicas=True] on all four
attempts: two concurrent requests to a two-replica deployment landed on the
same replica, which leastconn makes impossible.

HAProxy's get_server_rnd (2.8 src/backend.c) draws each candidate from an
independent statistical_prng(), so with `random(<draws>)` the draws are taken
with replacement -- the `prev != curr` guard in the comparison exists exactly
because a draw can repeat. On a repeat the load comparison is skipped and the
single drawn server is used regardless of load. The chance of that is 1/n, so
at two replicas half of all selections carry no load signal at all and the
busier replica is picked about a quarter of the time. leastconn picks the
less-loaded server every time.

Power-of-two-choices earns its reputation against *random* selection, and
against leastconn only when balancers are numerous or their load view is
stale -- the regime where leastconn's determinism synchronizes them. A
default has to hold for the two-replica deployment too, and there the source
shows it strictly loses. Reverting until there is an A/B to weigh the
large-fleet gain against this measured small-fleet cost.

The remaining defaults in this PR are unaffected; the defaults test drops its
balance assertion accordingly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: harshit-anyscale <harshit@anyscale.com>
CI (premerge 72546) failed test_e2e_preserve_prev_replicas_rest_api across
all four autoscaling_policy targets, on both shards, surviving per-test
retry. Reproduced locally against a real HAProxy with only this env var
toggled and everything else held constant:

  RAY_SERVE_HAPROXY_BROADCAST_COALESCE_S=1.0  -> failed (x3)
  RAY_SERVE_HAPROXY_BROADCAST_COALESCE_S=0.1  -> passed (x3)

and every probed value at or above 0.15 fails, so no smaller value is
viable either.

The failure is a scale-from-zero request returning 404. An application is
reported RUNNING by the controller without waiting for the proxies to apply
the corresponding HAProxy config, so for roughly a second after the deploy
completes there is no backend for the route at all -- not an empty backend
that would fall through to the fallback server, but no backend, which means
default_backend and a 404. A request arriving in that window registers no
queued demand, so the deployment never scales up:

  [1.20s] status=RUNNING
  [1.20s] GET / -> 404 "Path '/' not found."
  [2.26s] GET / -> 200

That gap exists at 0.1 too; raising the coalesce window simply moves the
reload past the point where RUNNING is reported. Fixing it belongs with the
readiness reporting rather than here, so this reverts to the previous
default and leaves the window alone until then.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: harshit-anyscale <harshit@anyscale.com>
@harshit-anyscale harshit-anyscale changed the title [serve] Production defaults for HAProxy routing at fleet scale [serve] Production defaults for HAProxy connect timeout and dead-replica detection Aug 30, 2026

@akyang-anyscale akyang-anyscale left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one comment on timeout connect. other change lgtm

Comment on lines +857 to 859
RAY_SERVE_HAPROXY_TIMEOUT_CONNECT_S = get_env_int_non_negative(
"RAY_SERVE_HAPROXY_TIMEOUT_CONNECT_S", 5
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does one disable this?

@harshit-anyscale harshit-anyscale Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Set it to 0. Documented in the comment as of fe769b1.


Details

get_env_int_non_negative accepts 0 (it validates >= 0), and the template gates on is not none, so RAY_SERVE_HAPROXY_TIMEOUT_CONNECT_S=0 renders timeout connect 0s. HAProxy stores an unset timeout as 0 internally, so that is indistinguishable from omitting the directive — in 2.8:

  • proxy_parse_timeout accepts a literal 0 (only sub-millisecond non-zero values are rejected, as PARSE_TIME_UNDER: "minimum non-null value is 1 ms"), then assigns *tv = MS_TO_TICKS(0) == 0.
  • The missing-timeouts warning tests !curproxy->timeout.connect (cfgparse.c), so 0 and unset take the same branch.
  • The tarpit/queue inheritance is if (!timeout.queue) timeout.queue = timeout.connect, which copies the same 0 either way — so no divergent side effect from the fact that Serve leaves timeout queue unset.

Confirmed against a running HAProxy with a blackholed backend, config identical apart from this one line:

timeout connect 2s  ->  HTTP/1.1 503 Service Unavailable   after 2.00s
timeout connect 0s  ->  no response                        after 12.00s (connect still pending)

So =0 restores the previous behaviour exactly: infinite connect timeout, and the same missing timeouts for backend ... startup warning that omitting it produced.

Caveat on the runtime check: that was HAProxy 3.4.4 locally, since that is what I can run on macOS. The source references above are from 2.8, which is what ray-haproxy ships.


🤖 Investigated and drafted with Claude Code

Review question on the new default: with the constant no longer Optional,
how does one turn the timeout off?

Setting it to 0 does that. HAProxy stores an unset timeout as 0 internally,
so `timeout connect 0s` is indistinguishable from omitting the directive:
proxy_parse_timeout accepts a literal 0 (only sub-millisecond non-zero values
are rejected as PARSE_TIME_UNDER) and assigns MS_TO_TICKS(0) == 0; the
missing-timeouts warning tests `!curproxy->timeout.connect`, so 0 and unset
take the same branch; and the queue/tarpit inheritance copies the same 0
either way. Verified against a running HAProxy: with `timeout connect 2s` a
blackholed backend returns 503 after 2.00s, while with `0s` the connect is
still pending after 12s.

Comment only, no behavior change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: harshit-anyscale <harshit@anyscale.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

go add ONLY when ready to merge, run all tests serve Ray Serve Related Issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants