Skip to content

[12/N][HAProxy stability] - Close direct-ingress listeners once the deregistration window ends - #65698

Open
harshit-anyscale wants to merge 5 commits into
ray-project:masterfrom
harshit-anyscale:serve-close-ingress-listeners-after-deregistration-window
Open

[12/N][HAProxy stability] - Close direct-ingress listeners once the deregistration window ends#65698
harshit-anyscale wants to merge 5 commits into
ray-project:masterfrom
harshit-anyscale:serve-close-ingress-listeners-after-deregistration-window

Conversation

@harshit-anyscale

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

Copy link
Copy Markdown
Contributor

Description

What happens today when a direct-ingress replica behind HAProxy (RAY_SERVE_ENABLE_HA_PROXY=1) shuts down, in sequence:

  1. A replica starts draining (scale-down, upgrade, or its node is being removed).
  2. Every node's HAProxy receives the updated backend list and reloads.
  3. A reload starts a new HAProxy process. The old process keeps running until its connections finish (bounded by hard-stop-after, minutes) — and it still routes with the old backend list. It cannot be corrected: config reloads and runtime commands only reach the new process.
  4. So a stale worker can keep sending brand-new requests to the draining replica for minutes.
  5. Today the replica accepts them: it keeps serving for as long as requests arrive, not just for the deregistration window (RAY_SERVE_DIRECT_INGRESS_MIN_DRAINING_PERIOD_S, default 30s).
  6. The replica/node is only guaranteed to live through that window. When the node then goes away (e.g. spot interruption), the late-admitted requests are cut mid-flight — the client sees an error nothing can safely retry.

We measured ~40 such failures per spot-interruption event in a staging fleet.

The fix — behind HAProxy, drain in two phases:

  1. Stay fully reachable for the deregistration window, exactly as today.
  2. Then close the direct-ingress HTTP listener and wait for in-flight requests.

A late request from a stale worker is now refused at connect time instead of admitted. HAProxy retries refused connections on another replica (retry-on conn-failure + option redispatch), so the client sees no error.

Without HAProxy nothing changes: there is no retrying proxy in front, a refusal would reach the client, so that path keeps the existing drain.

Related issues

N/A (follow-up to the HAProxy stability series, most recently #63996).

Additional information

  • Adds a Replica._is_direct_ingress property (mirrors the one on ReplicaMetricsManager), used to pick the drain path.
  • Only the HTTP listener closes early; direct-ingress gRPC keeps the existing behavior until the post-drain quiesce. Candidate follow-up.
  • Gated on RAY_SERVE_ENABLE_HA_PROXY because the early refusal is only safe with a retrying proxy in front.
  • A live-cluster reproduction with keep-alive connections against a stale HAProxy worker is a planned follow-up.

Not a duplicate

Checked open PRs before and after opening this one (gh pr list --search "haproxy", --search "drain replica graceful shutdown"): no open PR or issue touches replica drain or direct-ingress listener shutdown. The nearest neighbours are unrelated (#65502 draining-node accessor on the controller, #65542 lazy jinja2 import).

Tests run

pytest python/ray/serve/tests/unit/test_replica_quiesce.py -v

12 passed — the 7 pre-existing tests plus 5 new ones covering: drain-path selection with and without HAProxy, phase ordering, the deregistration window being served in full before the listener closes, the minimum period not being applied twice, and the no-HTTP-server case.

pre-commit run --files python/ray/serve/_private/replica.py python/ray/serve/tests/unit/test_replica_quiesce.py

All hooks passed, including mypy (ray serve) and pyrefly (ray serve).

AI assistance

This change was developed with AI assistance (Claude Code). A human author reviewed every changed line and ran the tests above locally before requesting review.

@harshit-anyscale
harshit-anyscale requested a review from a team as a code owner August 24, 2026 11:08

@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 introduces a two-phase draining mechanism for Ray Serve replicas running behind HAProxy. When HAProxy is enabled, the replica stays reachable during the deregistration window and then stops accepting new direct ingress HTTP connections before waiting for in-flight requests to complete. This prevents client-visible errors from late-arriving requests on a shutting-down replica. If HAProxy is not enabled, the replica continues to serve requests normally during the draining period. Unit tests have been added to verify these behaviors. I have no additional feedback to provide.

… ends

Behind HAProxy, a draining replica today keeps accepting new requests for
as long as they arrive. An HAProxy reload leaves the old process running
with a frozen backend list (it cannot be updated, only awaited), so a
stale worker can keep sending fresh requests to the draining replica for
minutes. When the node then goes away, those late requests are severed
mid-flight and the client sees an unretryable error.

Fix: behind HAProxy, drain in two phases -- serve the deregistration
window in full, then close the direct-ingress HTTP listener and wait for
in-flight requests. Late arrivals are refused at connect time, which
HAProxy retries on another replica (retry-on conn-failure + option
redispatch).

Without HAProxy nothing changes: there is no retrying party in front, so
a refusal would reach the client.

Signed-off-by: harshit-anyscale <harshit@anyscale.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@harshit-anyscale
harshit-anyscale force-pushed the serve-close-ingress-listeners-after-deregistration-window branch from 73ed903 to 4f969b7 Compare August 24, 2026 11:22
@harshit-anyscale harshit-anyscale self-assigned this Aug 24, 2026
@harshit-anyscale harshit-anyscale added the go add ONLY when ready to merge, run all tests label Aug 24, 2026
@ray-gardener ray-gardener Bot added the serve Ray Serve Related Issue label Aug 24, 2026
@harshit-anyscale harshit-anyscale changed the title [serve] Close direct-ingress listeners once the deregistration window ends [12/N][HAProxy stability] - Close direct-ingress listeners once the deregistration window ends Aug 26, 2026
…roperty>

Signed-off-by: harshit-anyscale <harshit@anyscale.com>

@eicherseiji eicherseiji 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.

This addresses the immediate spot-preemption case, but the underlying complexity comes from Ray manually managing HAProxy worker generations. A cleaner follow-up could be:

  1. Run HAProxy in master-worker mode and reload through the master CLI.
  2. Enumerate current and old workers and mark removed replicas maint in each before shutdown.
  3. Optionally use Runtime API membership updates later.

This would let HAProxy manage worker lifecycles, remove Ray’s manual PID/process handling, prevent stale-worker routing at the source, and return replicas to the normal drain path. It can be implemented incrementally while this PR remains a targeted mitigation.


@pytest.mark.asyncio
async def test_without_haproxy_keeps_serving_until_drained(self):
"""Without a retrying party in front, the replica keeps accepting for

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.

Could we do a manual rewrite on these comments

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.

have re-written them, ptal again, TIA :)

Comment thread python/ray/serve/_private/replica.py Outdated
self._stop_accepting_direct_ingress()

# Phase 3: wait for in-flight requests (window already served).
await self._drain_ongoing_requests()

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.

_drain_ongoing_requests() begins by sleeping for graceful_shutdown_wait_loop_s before checking the request count. That delay is redundant here because this path has already waited for deregistration and closed HTTP ingress.

With graceful_shutdown_wait_loop_s=10:

  1. t=0–30: Wait for deregistration.
  2. t=30: Close HTTP ingress and call this method.
  3. t=30–40: Sleep before checking the request count.
  4. t=35: The controller force-kills the replica before cleanup.

Could we pass check_immediately=True here so the request count is checked before entering the polling loop?

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.

thanks for pointing this out @eicherseiji , pushed the changes.

Review follow-up. `_drain_ongoing_requests` sleeps
`graceful_shutdown_wait_loop_s` before it first counts ongoing requests.
That is fine when it also owns the draining period, but in the two-phase
drain the window is already spent, so an idle replica waits one more loop
for nothing.

With `graceful_shutdown_wait_loop_s=10` that overruns the shutdown
budget: the deregistration window ends at t=30, the drain then sleeps
until t=40, and the controller force-kills at t=35 (the ingress floor of
`RAY_SERVE_DIRECT_INGRESS_MIN_DRAINING_PERIOD_S` +
`RAY_SERVE_DIRECT_INGRESS_SHUTDOWN_BUFFER_S`), so the quiesce and the
graceful server shutdown never run.

Add `check_immediately` to count before the first sleep, and pass it from
the two-phase drain. The default path is unchanged.

Co-Authored-By: Claude Opus 5 <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