Skip to content

Take the server-zd lease back after a step down with open calls - #1182

Merged
longquanzheng merged 8 commits into
mainfrom
zd-take-back-after-step-down
Nov 2, 2026
Merged

longquanzheng merged 8 commits into
mainfrom
zd-take-back-after-step-down

Conversation

@longquanzheng

@longquanzheng longquanzheng commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • A step down ends the calls still running. Right after the lease release, every FlowService and WorkerTaskService call whose handler is still running ends with UNAVAILABLE ("the server process stopped serving during the call; retry it").
    • Their service clones, and so their local store handles, drop at once.
    • Calls that finish before the release keep their answers.
    • PollWorkerTask response streams that already started stay open until their task ends or the old instance retires. They hold no store handle, and the stopped dispatch hands out no task.
    • AdminService calls, including the step down itself, keep running.
    • After a call, the gate also refuses the answer of a period that ended, so a later period's lease never lets it through.
  • The stepped-down process waits for the old store before it continues. Before it reopens the store or competes for the lease again, it waits up to 4 s for public calls and up to 60 s for the old store to close. Both waits are cancellable.
    • If a stuck task still holds the store after that, the process exits 1 without taking the lease.
    • The step down then answers Internal, unless another process already took over.
  • Only the newest gRPC instance accepts new connections. All instances share one listener. Each accepts through a gate that the next instance closes before it starts.
    • An instance that stopped accepting keeps its open connections. A connection it accepted while it stopped is handed to its successor (InstanceListener, HandedOverConnections), so no new connection meets a GOAWAY before its first call.
    • A serving instance has its own public service gates and serves from its start. A standby or startup instance never serves public calls.
    • When a serving period starts, the replaced standby instance stops accepting, answers public calls on its open connections with a reconnect refusal, and sends GOAWAY at once instead of after 250 ms.
    • A stepped-down serving instance stops accepting without GOAWAY. It retires 250 ms after the step down answer goes out, so that answer still reaches its caller.
    • Together these remove the "the node is starting" refusal that a call could get after readiness reported SERVING.
  • Docs. The ActiveNodeAdministration contract, the server-zd README, and the operations guide (English and Simplified Chinese) describe the new behavior.
  • Shutdown and failure stops are unchanged.

Why

#1174: after a step down, a process that took its own lease back could exit with Internal instead of serving.

  • An open public call kept a clone of the old period's service alive, and so its RocksDB store.
  • The lifecycle waited 4 s and then 5 s, and continued anyway.
  • A cold take-back then opened the store, and the per-process directory registry refused it (WouldBlock).

Reproduced with a 15 s hard-delete Attribute read held across the step down, and no other standby:

  • Cold: the step down answered Internal 9.1 s after the call, and the process exited.
  • Hot: the process held the lease for about 3.9 s while it could not serve. The take-back then re-armed the shared lease validity deadline, so the old period's read answered OK even though that period no longer held the lease.

This PR's first CI run also exposed a startup race from #1168. After the swap, the standby-phase instance kept accepting connections for 250 ms, but its public gates were never activated. A server integration test got "the node is starting" 223 ms after readiness reported SERVING.

A first fix shared the gates between instances. It let a retiring instance cut Worker streams, and a later fix that served public calls only on serving instances made a health Check on a just-accepted connection fail with Internal "h2 protocol error ... GoAway": tonic's accept loop does not prioritize its stop signal, so a replaced instance could still accept a connection and close it at once. The accept gate fixes that.

Design decisions

  • Calls end after the release, not when the stop starts, so the commit drain still finishes in-flight work.
  • 60 s is twice the 30 s object storage request timeout. A handle held longer is stuck, and only a restart clears it, so the process exits rather than following forever.
  • No new ServerError variant. The exit uses ServingTaskFailed.
  • A test-util-only engine API. The engine gains LocalStoreCloseWaiter::for_directory_for_test, enabled through dexzd-server's dev-dependencies.
  • Hand over, not close. A connection accepted while an instance stops accepting goes to its successor through an unbounded channel. Closing it would fail the client's first call.

Known gap, not changed here

Testing

  • standby_failover::step_down::{cold,hot}_take_back_ends_a_read_held_across_the_step_down
    • Before the fix, the cold test failed with Internal/WouldBlock, and the hot test's held read returned OK.
    • After the fix, the held read ends with the new UNAVAILABLE 92–173 ms after the step down, and the process takes the lease back after the 5 s hold-off.
    • Reverting the whole fix fails both tests. Reverting only the cancellation fails both on the message check.
  • step_down_keeps_the_answer_of_a_call_that_ends_before_the_release: a transactional InvokeRPC that its Worker answers during the commit drain keeps its OK answer. Moving the call cancellation to the start of the stop fails this test.
  • startup_calls
    • no_public_call_answers_starting_once_the_process_serves and no_public_call_answers_starting_once_a_take_back_serves send GetServerInfo over new and kept connections across a start or a take-back. No call sent after the first served call may answer "the node is starting", and every call on a connection opened after that point must be served.
    • a_worker_stream_kept_across_the_start_stays_open: a Worker stream is accepted only on an instance where it stays open.
  • public_api_authentication::flow_and_worker_calls_need_valid_credentials, which hit the GoAway failure, passed 20/20 locally.
  • Unit tests in node_lifecycle::local_store_close_tests, with paused time and a store that stays open:
    • The wait reports the store still open after 64 s, and cancellation ends the wait early.
    • A store still open answers a pending step down with Internal and ends the process with an error.
    • Treating "still open" as "continue anyway" fails a test, and so does a 5 s budget.
  • Changed tests:
    • step_down_with_calls_in_flight_leaves_a_hot_standby now proves through the period-end message that its read was still open at the release.
    • A standby connections test retries a describe that races the replaced standby instance's GOAWAY, and records a serving period that may be too short for the harness router to see (NodeGroup::note_serving_period), so the harness EpochStart count allows it.
  • Local runs on the final head: standby_failover three rounds 39/39, startup_calls 5/5. Earlier heads also ran the full serve_process suite and e2e 46/46. cargo fmt --check, make clippy, make rust-rules-check, make copyright-check, and cd docs && npm run check.
  • CI is green on the final head, including the ParadeDB, PostgreSQL, and external S3 tests.

Fixes #1174

A step down now ends the FlowService and WorkerTaskService calls still
open at the stopping serving period with UNAVAILABLE right after the
lease release. Their service clones, and so their local store handles,
drop at once, and AdminService calls keep running.

Before the stepped-down process reopens the store or competes for the
lease again, it waits up to 60 s for the old store to close instead of
continuing after 9 s. A store still held then means a stuck task: the
process answers the step down with Internal and exits without taking
the lease, so a restart releases the store. The ActiveNodeAdministration
contract states this exception to the step down duration bound.

Process tests step down the only process, cold and hot, while a 15 s
hard-delete Attribute read is open, and check that the read ends and
the process takes the lease back. See issue #1174.
@codecov

codecov Bot commented Nov 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

When a serving period starts, the standby-phase gRPC instance keeps
accepting connections for 250 ms before it retires. Its FlowService
and WorkerTaskService gates were never activated, so calls on those
connections answered UNAVAILABLE "the node is starting" after the
process already reported SERVING, as an integration test saw 223 ms
after startup.

The period now activates its public services on the standby
instance's gates before the swap, and the serving instance shares
those gates. Every call either instance accepts after the activation
is served, and AdminService stays as it was on both instances.

A process test calls GetServerInfo across a start over new and kept
connections and checks that no call sent after the first served one
answers "the node is starting".
A process test holds a transactional InvokeRPC at its Worker across a
step down and answers it during the commit drain. The call must keep
its answer, so ending calls at the start of the stop fails the test.

Unit tests drive the wait for the stepped-down store on paused time
with a store that stays open: the wait reports it still open after the
documented 4 s plus 60 s, and cancellation ends it early. The decision
after the wait is now a function: a store still open answers the step
down Internal and ends the process with an error, without competing
for the lease. The error log moves there from the wait.

After a call, the gate also refuses the answer of a period that ended,
so a later period's lease never lets it through. Comments, the README,
and the operations guide in English and Simplified Chinese now say
which calls end at the release, that PollWorkerTask streams already
started run until their task ends, and that a stuck store ends the
step down with INTERNAL and an exit after about 64 s.
A step down whose old local store stays open answers Internal only
when no other process took over during the wait. When one did, the
call already answered that it was taken over, and the process just
exits 1. The operations guide in English and Simplified Chinese, the
README, and the ActiveNodeAdministration contract now say so.
Sharing the gates let the retiring standby instance serve public
calls, but that instance closes each connection after its 3 s age and
1 s grace, and its retirement aborts it 4 s after GOAWAY. A Worker
stream registered there was cut with Unknown within 4 s, and its task
result was lost; a long poll there was cut the same way.

A serving instance now starts with its period's public services
already active, and the standby instance it replaces never serves a
public call. That instance sends GOAWAY at once, since it owes no step
down answer, and a call that still reaches it answers UNAVAILABLE
"this connection is closing; reconnect and retry the call". After a
step down, the old serving instance answers UNAVAILABLE "this server
process stepped down; retry the call" instead of "the node is
starting".

Process tests keep a Worker stream registered across a start open for
6 s and check that no public call sent after a take-back serves
answers "the node is starting". The operations guide now says that a
task result that arrives after the release is not recorded.
The replaced standby instance now sends GOAWAY at the takeover, so the
step down test's describe calls can race that GOAWAY, and its step
down nearly always goes through the serving instance. That instance
then serves for only milliseconds before it steps down, which the
readiness router can miss.

The describe loop retries a call that raced the GOAWAY. When the step
down went through the serving instance, the test records that serving
period with the node group: the slot's standby write check restarts at
its next confirmed standby, and the EpochStart bound allows the period
the router missed.
On Linux a describe that races the replaced standby instance's GOAWAY
fails with Internal "h2 protocol error" rather than Cancelled, which
the step down test's describe loop did not retry. The loop now retries
any call whose error comes from the transport, since the server never
processed such a call, and still fails on any other status.
tonic's accept loop does not prioritize its stop signal, so a standby
instance that a serving instance replaced could still accept a new
connection and then close it at once with GOAWAY. A client's first call
on that connection failed with Internal "h2 protocol error ... GoAway",
as a public API test saw when it connected right after the dev server
started. A stepped-down serving instance likewise took new connections
until 250 ms after the take-back.

Each instance now accepts through a gate that a newer instance closes
before it starts: the old instance stops accepting at once and keeps
its open connections, which then get the graceful two-phase GOAWAY of
their retirement. A connection that the old instance accepted while it
stopped is handed to its successor rather than served and closed. A
stepped-down instance stops accepting without GOAWAY, so the step down
answer still reaches its caller.

The startup and take-back process tests now also check that every call
on a connection opened after the process served is served.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

server-zd: a take-back after a step down can exit when an old serving-period handle still holds the store

1 participant