Skip to content

fix: send the first shard assignments right after the data server handshake - #1326

Merged
merlimat merged 3 commits into
oxia-db:mainfrom
merlimat:fix-first-assignments-after-handshake
Sep 24, 2026
Merged

merlimat merged 3 commits into
oxia-db:mainfrom
merlimat:fix-first-assignments-after-handshake

Conversation

@merlimat

Copy link
Copy Markdown
Collaborator

Motivation

On a fresh cluster, and for any data server that has not bound the coordinator instance id yet, data servers get their first shard assignments 5-15s late. Until then, clients connected to them keep retrying with Failed receiving shard assignments, retrying later ... error="oxia: server not initialized yet".

The data server rejects every coordinator RPC except Handshake and the health checks with ErrNotInitialized until the handshake binds it (NewGrpcInsIDVerifyInterceptors). The data server controller starts the assignments dispatcher together with the health watch and ping goroutines that perform the handshake, so the dispatcher usually opens PushShardAssignments first. The stream is rejected: with gRPC, the rejection reaches the drain goroutine from #1216, and the dispatch loop sees the stream end as context.Canceled. The dispatcher then waits for dispatchAssignmentsBackoff before retrying: 10s initial, ±50% jitter.

This is the assignments half of the startup handshake race. #1196 proposed gating on Running || Draining, but it returned an error from the retry function, so it still waited for the backoff before the first retry.

Changes

In oxiad/coordinator/runtime/controller/dataserver/dataserver_controller.go:

  • openAssignmentsStream opens the stream only once the data server is Running or Draining. It waits on a statusChanged channel instead of polling, so the first assignments go out right after the handshake and nothing is sent before it.
  • advanceStatusEpochLocked replaces the three statusEpoch++ sites and closes and replaces statusChanged, so every status transition wakes the waiters.
  • Failures on a Running data server still go through the regular 10s backoff.

Behavior change: a data server that failed its health checks is NotRunning too. The dispatcher now waits for it to come back and be handshaken again before reopening the stream, instead of retrying it on every backoff round. An unreachable node could not take the stream anyway. After a long outage, the next push now follows the recovery right away, instead of waiting for a backoff that has grown to its 60s cap (30-90s with jitter). A Draining node is never re-handshaken, so it is not gated.

Test support: the mock RPC provider counts PushShardAssignments attempts (PushShardAssignmentsCount).

In tests/control: TestControlRequestFeatureEnabled and TestOrderedWrites read the shard leader from the metadata right after creating the client. That only worked because client creation blocked on the late first assignments. They now wait for the shard's steady state first.

This relies on #1325. The first snapshot the reconciler publishes has no leaders yet, because the first elections are still running, and main only hid that behind the delay: NewShardManager blocks until the first snapshot arrives. Without #1325, a client created right after startup would get that snapshot and fail its first requests to the empty leader with a non-retryable Unavailable. Before #1325, TestOIDCWithPerIssuerConfig, TestOIDCWithStaticKeyFile and TestControlRequestRecordChecksum failed every run with this change. On top of #1325 they pass, and much faster than before. Go clients released before #1325 can still fail requests sent in the sub-second window before the first election on a fresh cluster. Main already exposes empty leaders the same way for namespaces created at runtime (per the code, not tested).

Testing

  • New TestDataServerController_SendsAssignmentsRightAfterHandshake: the mock rejects the stream with ErrNotInitialized while the handshake is held in flight, with a 1-minute retry backoff. It fails on main (no assignments within 10s, because the retry is 30-90s away). With the fix it passes in ~0.2s, with exactly one stream opened, after the handshake. 50× repeat of the four dispatcher tests with -race: 200/200 pass.
  • TestCoordinator_ShrinkCluster with -race: 12.6-22.9s on main (5 runs), 0.33-0.56s with the fix (10 runs). Client server not initialized yet retries in the logs: 54 on main, 0 with the fix.
  • Rebased on fix: retry transport Unavailable errors on shard requests in the Go client #1325: go test -race ./oxiad/coordinator/... and every tests/ package pass.
  • On top of fix: retry transport Unavailable errors on shard requests in the Go client #1325, with -race, 3 runs each: both OIDC tests pass in 0.5-0.7s (6-14s before), and the three tests/control tests pass in 0.5-5.4s (10-20s before). The test commit also passes on the old main, 5/5 per test.
  • golangci-lint v2.13.2: 0 issues on the oxiad and tests modules.

…dshake

Until the handshake binds a data server to the coordinator instance id,
the data server rejects every coordinator RPC except the handshake and
the health checks. The assignments dispatcher starts together with the
health checks that perform the handshake, so it usually opened the
stream first and got it rejected. It then retried only after the
dispatch backoff: 10s initial, with jitter. On a fresh cluster, and for
any data server not bound yet, the first assignments arrived 5-15s late,
and clients spun on "server not initialized yet" in the meantime.

Open the assignments stream only once the data server is Running or
Draining. Status transitions now wake up the dispatcher, so it sends the
first assignments right after the handshake, without retrying before it.
A data server that failed its health checks is NotRunning too: the
dispatcher now waits for it to come back and be handshaken again before
reopening the stream, instead of retrying it on every backoff round.
Failures on a Running data server still go through the regular backoff.

With -race, TestCoordinator_ShrinkCluster drops from 12-23s to about
0.4s.

Signed-off-by: Matteo Merli <mmerli@apache.org>
TestControlRequestFeatureEnabled and TestOrderedWrites read the shard
leader from the coordinator metadata right after creating the client.
Creating the client blocks until it receives the first shard
assignments, which used to reach the data servers 5-15s after startup,
long after the first leader election. Now that the assignments are sent
right after the handshake, the client can connect before the election
completes, and the tests read a nil leader. Wait for the shard to reach
the steady state before looking up its leader.

Signed-off-by: Matteo Merli <mmerli@apache.org>
Signed-off-by: Matteo Merli <mmerli@apache.org>
Copilot AI lite review requested due to automatic review settings September 24, 2026 06:23

Copilot AI 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.

Copilot review overview

🔵 Needs a closer look

Recovery after a stream ends while the server is not running can still be delayed by the backoff interval.

Review effort: Lite
Findings: None

What changed in this PR

This PR prevents delayed initial shard assignments by waiting for data-server handshake readiness.

Changes:

  • Gates assignment streams on server readiness.
  • Adds status-transition signaling and regression coverage.
  • Tracks stream attempts in mocks.
  • Updates control tests to wait for shard steady state.
File Summary
tests/​control/​ordered_writes_test.go Waits for shard steady state.
tests/​control/​control_request_test.go Waits for shard steady state.
oxiad/​coordinator/​runtime/​controller/​mockutils/​mock.go Tracks assignment stream attempts.
oxiad/​coordinator/​runtime/​controller/​dataserver/​dataserver_controller.go Gates streams on data-server readiness.
oxiad/​coordinator/​runtime/​controller/​dataserver/​dataserver_controller_test.go Tests immediate post-handshake assignment delivery.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@merlimat
merlimat merged commit 2362977 into oxia-db:main Sep 24, 2026
5 checks passed
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.

2 participants