fix: don't hang coordinator Close while metadata status writes fail - #1323
Merged
Merged
Conversation
GrpcServer.Close closed the runtime before the metadata. The metadata status writers retry forever on the metadata context, which only the metadata Close canceled. So when the status writes keep failing (e.g. after losing the coordinator leadership, or with an unreachable metadata store) while an election is stuck persisting its new term, the shard controller never exits and runtime.Close waits for it forever. Derive the metadata context from the server context, so that Close stops the retries before closing the runtime. Since a status write can now give up, the writers report it and the callers stop instead of acting on state that was not persisted: - an election does not fence the ensemble with a term it failed to persist, nor report a leader it failed to record; - the split controller stops, and resumes from its persisted state after a restart, instead of going on from state it failed to record; - CreateNamespace and InitiateSplit fail before starting controllers for shards whose ids or status were not persisted, and CreateNamespaceStatus no longer reports a namespace as created when the write failed. Signed-off-by: Matteo Merli <mmerli@apache.org>
merlimat
requested review from
RobertIndie,
coderzc and
mattisonchao
as code owners
September 24, 2026 00:10
Contributor
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Unresolved persistence and split-recovery defects can leave metadata inconsistent or allow unsafe progression.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 4
Open (6)
Reject split initiation when the namespace is missing · New Treat missing namespace or shard as an election update error · New Only apply backoff.Permanent to non-nil update errors · New Prevent controllers from starting after metadata deletion · New Recover when fencing succeeds but parent persistence fails · New Make child cleanup atomic to prevent partial split deletion · New
What changed in this PR
This PR prevents coordinator shutdown hangs by canceling metadata retries and propagating persistence failures through elections, namespace operations, shard deletion, and splits.
Changes:
- Derives metadata operations from the server cancellation context.
- Stops unsafe controller actions after failed persistence.
- Adds shutdown and metadata failure tests.
| File | Summary |
|---|---|
tests/coordinator/coordinator_test.go |
Checks status-write errors. |
tests/coordinator/close_e2e_test.go |
Adds shutdown regression coverage. |
oxiad/coordinator/runtime/runtime.go |
Handles namespace and split persistence failures. |
oxiad/coordinator/runtime/controller/shard/shard_split_controller.go |
Propagates split metadata errors. |
oxiad/coordinator/runtime/controller/shard/shard_split_controller_test.go |
Updates split persistence assertions. |
oxiad/coordinator/runtime/controller/shard/shard_controller.go |
Handles deletion persistence failures. |
oxiad/coordinator/runtime/controller/shard/shard_controller_test.go |
Tests election behavior on failed writes. |
oxiad/coordinator/runtime/controller/shard/shard_controller_election.go |
Requires term and leader persistence. |
oxiad/coordinator/runtime/balancer/scheduler_test.go |
Updates metadata mocks. |
oxiad/coordinator/runtime/autosplit/monitor_test.go |
Checks update errors. |
oxiad/coordinator/reconciler/namespace_reconciler_test.go |
Updates metadata mocks and assertions. |
oxiad/coordinator/metadata/metadata.go |
Returns status-write errors. |
oxiad/coordinator/metadata/metadata_test.go |
Tests canceled writers. |
oxiad/coordinator/grpc_server.go |
Cancels metadata retries during close. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
UpdateNamespaceStatus and UpdateShardStatus silently did nothing when the namespace was gone, and UpdateShardStatus re-created a shard that had been deleted. The callers now take a nil error as proof that the write was persisted (InitiateSplit, the election, the split controller), so return ErrNotFound instead. Also make the nil check around backoff.Permanent explicit in the split controller, and describe what happens to a partially persisted split abort. Signed-off-by: Matteo Merli <mmerli@apache.org>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Motivation
GrpcServer.Close()closes the runtime before the metadata. The metadata status writers (UpdateShardStatus,DeleteShardStatus,ReserveShardIDs, ...) retry forever on the metadata context (NewBackOffsetsMaxElapsedTime: 0), and onlycoordinatorMetadata.Close()cancels that context. Suppose the status writes keep failing while an election is in flight: after the coordinator lost its leadership (raftVerifyLeader, a configmap conflict), or with an unreachable Kubernetes API. The shard controller's run goroutine then stays inElection.start → UpdateShardStatus.shard.(*controller).Close()waits for it, soruntime.Close()never returns, and neither doesClose():GrpcServer.Close → runtime.Close → shard.(*controller).Close → sync.(*WaitGroup).Waitbackoff.RetryNotify → coordinatorMetadata.UpdateShardStatus → Election.start → Election.Start → controller.onElectLeader → controller.handleDataServerFailure → controller.runWith the CLI this only delays shutdown until SIGKILL. An embedder that closes the coordinator after losing the leadership, as the
WithOnLeadershipLostoption in #1281 documents, hangs instead. The same wait also affects a split controller writing its state, and the reconciler orInitiateSplit(which holds the runtime lock) while they are inReserveShardIDs.Changes
NewGrpcServercreates the server context before the metadata and derives the metadata context from it. ThectxCancel()at the top ofClose()now stops the retries before the runtime is closed. Theruntime.Close()ordering is unchanged, and the metadata is still closed after the runtime.ReserveShardIDs,UpdateNamespaceStatus,UpdateShardStatusandDeleteShardStatusreturn an error.CreateNamespaceStatusreturns false, andDeleteNamespaceStatusreturns an empty status. Before,CreateNamespaceStatusreported a failed write as a created namespace, and the runtime would have started controllers that panic on the missing shard status.UpdateNamespaceStatusandUpdateShardStatusalso fail withErrNotFoundwhen the namespace or the shard is gone. Before, they silently did nothing, andUpdateShardStatusre-created a deleted shard, so a nil error now always means the write was persisted.SplitComplete, whoseDeleteShard(at the persisted, fenced term) deletes the parent while the new leader coordinator still has the split in progress. Retrying in process isn't safe either, since re-enteringrunCutoverafter a partial update can reset the split to Bootstrap. A split abort that fails to persist is also left to resume.CreateNamespaceandInitiateSplitfail before creating any controllers. A shard deletion whose status removal fails resumes after a restart. The periodic pending-delete update stays best-effort.runCutover's final metadata updates move todetachChildren, to keep it under the cyclomatic complexity limit.Testing
TestCoordinator_CloseWhileStatusWritesFail(tests/coordinator): 3 data servers and the file metadata provider. Once the shard is up, the test makescluster-status.jsonread-only and closes the data servers, so an election gets stuck persisting its new term.Close()must then return. On main it is still blocked when the test gives up. The test is skipped when file permissions are not enforced (root).TestController_ElectionDoesNotFenceWithUnpersistedTerm: no NewTerm is sent when the term cannot be persisted. The test fails if the election ignores the write error.TestMetadataStatusWritersGiveUpOnceCanceled: once the context is canceled, every writer reports the failure and nothing is persisted. The test fails with the oldCreateNamespaceStatusresult.TestMetadataStatusUpdatesFailWhenTargetIsGone: the status updates returnErrNotFoundfor a missing namespace or shard, and a deleted shard is not re-created.go test -racepasses onoxiad/coordinator/...and on the wholetestsmodule.Note:
Close()still takes about 15s in the new test. The data-server controllers' health retry loops useConcurrentBackOff, which hides the backoff context frombackoff.RetryNotify, so their backoff sleep (10s initially, up to 60s) ignores the cancellation. That is left for a separate change.