feat(migration): add support for back migration without restart#3550
feat(migration): add support for back migration without restart#3550barakeinav1 wants to merge 3 commits into
Conversation
The migration web server's `import_keyshares` channel was owned by the `onboard()` loop and dropped when the loop returned on `OnboardingJob::Done`. For any node that was already an active participant at process start, this happened immediately, leaving the web server with a dangling sender. Subsequent `PUT /set_keyshares` calls (e.g. a back-migration targeting that node) returned `500 keyshares receiver channel is closed`, forcing a process restart to recreate the channel. Make `onboard()` a long-lived state machine. `Done` becomes a resting state (same shape as `WaitForStateChange`) rather than a terminal one. The first time the loop reaches `Done`, it fires a oneshot so the caller can unblock startup -- preserving the pre-existing gating that the coordinator only starts once keyshares are present. After that, the loop keeps watching contract + migration state. A future `start_node_migration` naming this node as destination flips `OnboardingJob` to `Onboard(keyset)`, the loop re-enters `execute_onboarding`, and the existing protocol path handles both sub-cases without branching: - epoch unchanged: storage check passes, no PUT required, conclude tx sent - epoch advanced: backup-CLI PUT lands, import_backup runs, conclude tx sent Design: docs/design/migration-onboarding-reentry.md. Tests: - Unit: onboard__should_fire_first_done_signal_and_keep_looping_past_done asserts the loop fires first_done_signal once and keeps running. - E2E: migration_service__should_handle_back_migration_a_to_b_to_a_no_restart back-migration A -> B -> A with A0 staying alive between directions; fails on main, passes here.
066b3e1 to
b72e64a
Compare
| @@ -51,8 +52,11 @@ pub(crate) async fn onboard( | |||
| match job { | |||
| OnboardingJob::Done => { | |||
There was a problem hiding this comment.
maybe a better name is "active_participant"?
There was a problem hiding this comment.
Pull request overview
This PR makes the migration onboarding loop re-entrant so nodes that start as active participants keep their keyshare receiver alive and can later accept back-migration keyshares without requiring a process restart (fixes #3406).
Changes:
- Turn
onboard()into a long-lived state machine:Donebecomes a resting state, and a one-shot signal is used to unblock startup on firstDone. - Spawn
onboard()as a background task fromspawn_recovery_server_and_run_onboardingand await the one-shot instead of awaiting the whole loop. - Add unit + e2e coverage, including a new “no restart” back-migration round-trip test variant and a new port seed constant.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/design/migration-onboarding-reentry.md | Design doc describing the re-entrant onboarding approach and operational motivation. |
| crates/node/src/migration_service/onboarding.rs | Makes onboard() non-terminating on Done and adds a unit test verifying the new behavior. |
| crates/node/src/migration_service.rs | Spawns onboard() and waits for a first-Done one-shot before returning. |
| crates/e2e-tests/tests/migration_service.rs | Refactors back-migration e2e test to support both restart and no-restart variants; adds new no-restart test. |
| crates/e2e-tests/tests/common.rs | Adds a new port seed for the no-restart back-migration test. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let (first_done_tx, first_done_rx) = oneshot::channel(); | ||
| let tls_public_key = migration_secrets.p2p_private_key.verifying_key(); | ||
| tokio::spawn(async move { | ||
| if let Err(err) = onboard( | ||
| contract_state_receiver, | ||
| my_migration_info_receiver, | ||
| my_near_account_id, | ||
| tls_public_key, | ||
| tx_sender, | ||
| keyshare_storage, | ||
| import_keyshares_receiver, | ||
| Some(first_done_tx), | ||
| ) | ||
| .await | ||
| { | ||
| tracing::error!(?err, "onboarding state machine exited unexpectedly"); | ||
| } | ||
| }); | ||
|
|
||
| // Err here = the spawned task dropped the sender; the panic/error is | ||
| // already logged above. Either way, startup proceeds. | ||
| let _ = first_done_rx.await; | ||
| Ok(()) |
There was a problem hiding this comment.
Good catch — agreed. Fixed in 9781816: first_done_rx.await now propagates with .context(...) so startup fails fast if the spawned onboarding task dies before signaling.
| // Keep `contract_state_sender` alive until after the assertion so the | ||
| // loop's receiver channel doesn't close (which would make it exit). | ||
| drop(contract_state_sender); | ||
| onboard_handle.abort(); |
There was a problem hiding this comment.
Agreed — abort() is what actually kills the task; the drop was meaningless and the comment was misleading. Fixed in 9781816: removed the drop + comment, renamed the binding to _contract_state_sender.
|
@claude review |
Pull request overviewMakes the migration Changes:
Reviewed changesPer-file summary
FindingsBlocking (must fix before merge):
Non-blocking (nits, follow-ups, suggestions):
|
Two Copilot review findings on #3550: 1. spawn_recovery_server_and_run_onboarding ignored `first_done_rx.await` errors, so a panicking onboarding task would still return Ok(()) at startup -- silently reintroducing the #3406 symptom (no live receiver on the keyshare channel). Propagate the RecvError with context so startup fails fast, matching the pre-PR semantics. 2. Test cleanup in onboard__should_fire_first_done_signal... had a misleading comment claiming `drop(contract_state_sender)` made the loop exit. It doesn't -- abort() does. Remove the drop and the comment; rename the unused binding to `_contract_state_sender`.
Pull request overviewMakes Changes:
Reviewed changesPer-file summary
FindingsBlocking (must fix before merge):
Non-blocking (nits, follow-ups, suggestions):
|
1. spawn_recovery_server_and_run_onboarding now propagates errors from first_done_rx.await with context. Previously the error was discarded, so a panicking onboarding task could let startup return Ok(()) with no live listener on the keyshare channel. 2. Test cleanup in onboard__should_fire_first_done_signal... had a misleading comment claiming drop(contract_state_sender) made the loop exit. It doesn't -- abort() does. Drop the line and the comment; rename the unused binding to _contract_state_sender.
2ebacaf to
9781816
Compare
|
Thanks for the reviews — going through the unique findings. Most of the "blocking" items are stale (fixed in 9781816 in response to Copilot's earlier pass on the same code).
Already fixed in 9781816.
Already fixed in 9781816. Removed the
This was the blocking finding addressed by 9781816. Same propagation now covers panics — they drop the sender,
Good catch, real concern. Pre-existing path whose operational severity changed under the long-lived loop. If Filed as #3552 as a follow-up. Not addressing in this PR — it's a separate failure mode (web server task died) that needs its own observability/shutdown story.
That's by design. The e2e test (
Done in 2cd0db0.
Real observability gap, but it's a broader codebase pattern (the migration web server task in
Low priority. Leaving as-is unless it actually flakes in CI.
Agreed (two AI reviewers + @barakeinav1 independently). Deferred to Kevin's review — flagged as an open suggestion, didn't want to rename without his input. |
|
Superseded by #3601 — implementing per @kevindeforth's feedback (outer state-driven dispatcher, mutually exclusive coordinator/onboarding) instead of the parallel-loop approach here. |
…ding (#3406) Replaces the linear startup ('start migration web server -> run onboarding to first Done -> run coordinator forever') in run.rs with a state-driven dispatcher loop. The two subsystems are now mutually exclusive at the run.rs level: - Migration web server starts once and stays alive for the process lifetime; the keyshare-import receiver is cloned into every onboarding cycle. - Each iteration classifies the role via OnboardingJob::new(contract_state, migration_info, my_id, tls_pub_key): Done -> run coordinator until the role changes away from Done, then cancel and re-enter the loop. Onboard(_) | WaitForStateChange -> run onboarding (returns on first Done), then re-enter the loop. Also splits migration_service.rs so callers can start the web server independently and pass the receiver to onboard() repeatedly: - start_migration_web_server(...) -> watch::Receiver<Vec<Keyshare>> - wait_until_role_change(...) -> OnboardingJob This is the back-migration-without-restart fix from #3406 in the structural shape requested by @kevindeforth in #3550's review (mutual exclusion rather than parallel loops with shared keyshare-storage invariants). Implements the design in docs/design/migration-onboarding-dispatcher.md.
Captures the outer state-driven dispatcher approach agreed with @kevindeforth in the PR #3550 review: mutually exclusive coordinator vs onboarding subsystems driven by a single dispatcher loop, instead of running them in parallel with implicit storage-access invariants. Implementation will follow on this branch.
…ding (#3406) Replaces the linear startup ('start migration web server -> run onboarding to first Done -> run coordinator forever') in run.rs with a state-driven dispatcher loop. The two subsystems are now mutually exclusive at the run.rs level: - Migration web server starts once and stays alive for the process lifetime; the keyshare-import receiver is cloned into every onboarding cycle. - Each iteration classifies the role via OnboardingJob::new(contract_state, migration_info, my_id, tls_pub_key): Done -> run coordinator until the role changes away from Done, then cancel and re-enter the loop. Onboard(_) | WaitForStateChange -> run onboarding (returns on first Done), then re-enter the loop. Also splits migration_service.rs so callers can start the web server independently and pass the receiver to onboard() repeatedly: - start_migration_web_server(...) -> watch::Receiver<Vec<Keyshare>> - wait_until_role_change(...) -> OnboardingJob This is the back-migration-without-restart fix from #3406 in the structural shape requested by @kevindeforth in #3550's review (mutual exclusion rather than parallel loops with shared keyshare-storage invariants). Implements the design in docs/design/migration-onboarding-dispatcher.md.
Design doc (original):
docs/design/migration-onboarding-reentry.md.Test plan (kept for reference)
New tests added:
onboard__should_fire_first_done_signal_and_keep_looping_past_done(crates/node/src/migration_service/onboarding.rs) — asserts the loop firesfirst_done_signalonce on firstDoneand keeps running past it.migration_service__should_handle_back_migration_a_to_b_to_a_no_restart(crates/e2e-tests/tests/migration_service.rs) — back-migration A → B → A with A0 staying alive between the two directions. Fails onmain; passes here.