| // Licensed to the Apache Software Foundation (ASF) under one | |
| // or more contributor license agreements. See the NOTICE file | |
| // distributed with this work for additional information | |
| // regarding copyright ownership. The ASF licenses this file | |
| // to you under the Apache License, Version 2.0 (the | |
| // "License"); you may not use this file except in compliance | |
| // with the License. You may obtain a copy of the License at | |
| // | |
| // http://www.apache.org/licenses/LICENSE-2.0 | |
| // | |
| // Unless required by applicable law or agreed to in writing, | |
| // software distributed under the License is distributed on an | |
| // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| // KIND, either express or implied. See the License for the | |
| // specific language governing permissions and limitations | |
| // under the License. | |
| //! Cross-shard boot handoff: the metadata bundle broadcast and the | |
| //! listener barrier. | |
| use crate::server_error::ServerError; | |
| use crate::shell::ServerMetadataBundle; | |
| // `try_send` / `try_recv` resolve through these traits on `MAsyncTx` / | |
| // `MAsyncRx`; the metadata-handoff loops below depend on the | |
| // non-blocking variants for cancel-safe shutdown polling. | |
| use crossfire::{AsyncRxTrait, AsyncTxTrait}; | |
| use std::sync::Arc; | |
| use std::sync::atomic::{AtomicBool, Ordering}; | |
| use std::time::Duration; | |
| /// Shard-local end of the metadata bundle handoff. | |
| /// | |
| /// Shard 0 owns the WAL writer and runs `recover()` to build the only | |
| /// `WriteHandle`-bearing [`crate::shell::ServerMuxStateMachine`]. It then mints a | |
| /// [`ServerMetadataBundle`] (a tuple of `Send + Sync` | |
| /// `ReadHandleFactory`s) and pushes one clone per peer onto `bundle_tx`. | |
| /// Every other shard receives the bundle and rebuilds a reader-mode | |
| /// `MuxStateMachine` on its own runtime - no WAL access, no replay, no | |
| /// `RecoverySync` two-phase fence. The old phase-2 WAL fence is gone | |
| /// because peers no longer scan the WAL. They do still scan live shared | |
| /// metadata to load their on-disk partitions, so a separate listener | |
| /// fence is still required - see [`BootstrapBarrier`]. | |
| /// | |
| /// The channel is bounded to the peer count so shard 0's `send` never | |
| /// blocks beyond a peer drain. A peer that dies before recv drops its | |
| /// `bundle_rx`, so shard 0's `send` eventually sees a disconnected | |
| /// channel; the cross-thread shutdown flag drives every waiter out of | |
| /// its `recv` loop if shard 0 panics before broadcasting. | |
| pub(in crate::boot) enum MetadataHandoff { | |
| Owner { | |
| bundle_tx: crossfire::MAsyncTx<crossfire::mpmc::Array<ServerMetadataBundle>>, | |
| }, | |
| Waiter { | |
| bundle_rx: crossfire::MAsyncRx<crossfire::mpmc::Array<ServerMetadataBundle>>, | |
| }, | |
| } | |
| /// Reverse handshake to [`MetadataHandoff`]: gates shard 0's client | |
| /// listeners until every peer has loaded its on-disk partitions. | |
| /// | |
| /// Peers build their owned-partition set from live shared metadata and | |
| /// load each segment from disk in `build_shard_for_thread`. If shard 0 | |
| /// opened listeners the instant `broadcast_metadata_bundle` returned | |
| /// (peers have only *received* the bundle, not *loaded* partitions), a | |
| /// client could create a partition before a peer's load scan finished. | |
| /// That freshly committed partition would surface in the peer's scan | |
| /// with no segment dir on disk yet, and `load_partition`'s `walk_dir` | |
| /// would fail with `CannotReadPartitions`, aborting the whole node. A | |
| /// partition created after boot must take the runtime reconciler path | |
| /// (which creates its dir), never the bootstrap load path. | |
| /// | |
| /// Shard 0 (`Owner`) drains one signal per peer before binding | |
| /// listeners; each peer (`Waiter`) sends one once its load completes. | |
| /// The cross-thread shutdown flag drives both sides out of their poll | |
| /// loop if any shard dies mid-boot. | |
| pub(in crate::boot) enum BootstrapBarrier { | |
| Owner { | |
| ready_rx: crossfire::MAsyncRx<crossfire::mpmc::Array<u16>>, | |
| }, | |
| Waiter { | |
| ready_tx: crossfire::MAsyncTx<crossfire::mpmc::Array<u16>>, | |
| }, | |
| } | |
| /// Block until shard 0 broadcasts the metadata factory bundle, or the | |
| /// cross-thread shutdown flag flips. Polled in a `poll_interval` loop | |
| /// so a shard 0 that panics before it broadcasts cannot strand peer | |
| /// shards: the shutdown path flips the flag, every waiter observes it | |
| /// on the next tick, and the server tears down instead of hanging. | |
| /// | |
| /// Uses `try_recv` + sleep rather than `timeout(recv())`. Crossfire 3.x | |
| /// documents `recv()` as cancellation-safe (no leak/deadlock) but does | |
| /// not guarantee atomicity for the dropped future's result; `try_recv` | |
| /// keeps each tick fully synchronous and side-effect-free, so the | |
| /// shutdown poll cadence cannot ambiguously consume a bundle. | |
| pub(in crate::boot) async fn await_metadata_bundle( | |
| shard_id: u16, | |
| bundle_rx: &crossfire::MAsyncRx<crossfire::mpmc::Array<ServerMetadataBundle>>, | |
| shutdown_flag: &Arc<AtomicBool>, | |
| poll_interval: Duration, | |
| ) -> Result<ServerMetadataBundle, ServerError> { | |
| loop { | |
| match bundle_rx.try_recv() { | |
| Ok(bundle) => return Ok(bundle), | |
| Err(crossfire::TryRecvError::Disconnected) => { | |
| return Err(ServerError::MetadataHandoffAborted { shard_id }); | |
| } | |
| Err(crossfire::TryRecvError::Empty) => { | |
| if shutdown_flag.load(Ordering::Relaxed) { | |
| return Err(ServerError::MetadataHandoffAborted { shard_id }); | |
| } | |
| compio::time::sleep(poll_interval).await; | |
| } | |
| } | |
| } | |
| } | |
| /// Push `peers` cloned bundles onto `bundle_tx`, polling each send in a | |
| /// `poll_interval` loop so the cross-thread shutdown flag can interrupt | |
| /// a stalled handoff. Symmetric to [`await_metadata_bundle`]: shutdown | |
| /// observed mid-handshake aborts cleanly rather than stalling on a | |
| /// `send` future that can no longer make progress. | |
| /// | |
| /// Uses `try_send` + sleep rather than `timeout(send())`. Crossfire 3.x | |
| /// documents `send()` as cancellation-safe in the leak/deadlock sense | |
| /// but explicitly warns the true result is unknown when `SendFuture` is | |
| /// dropped on cancellation. For a retry loop that re-clones on every | |
| /// tick that would risk publishing the same bundle twice, stuffing the | |
| /// bounded channel past `peers` and stranding a follow-up `send`. | |
| /// `try_send` returns the bundle back inside `TrySendError::Full`, so | |
| /// the loop reuses it instead of re-cloning when the channel is full. | |
| pub(in crate::boot) async fn broadcast_metadata_bundle( | |
| shard_id: u16, | |
| bundle_tx: &crossfire::MAsyncTx<crossfire::mpmc::Array<ServerMetadataBundle>>, | |
| bundle: ServerMetadataBundle, | |
| peers: u16, | |
| shutdown_flag: &Arc<AtomicBool>, | |
| poll_interval: Duration, | |
| ) -> Result<(), ServerError> { | |
| for _ in 0..peers { | |
| let mut pending = bundle.clone(); | |
| loop { | |
| match bundle_tx.try_send(pending) { | |
| Ok(()) => break, | |
| Err(crossfire::TrySendError::Disconnected(_)) => { | |
| // Every peer dropped its `bundle_rx` before recv. Shard | |
| // 0 must not silently continue past handoff: it would | |
| // bind listeners and commit consensus state for a | |
| // cluster whose peers are gone. Propagate the abort so | |
| // `shard_main` short-circuits before further side | |
| // effects; `shutdown_flag` will flip via the normal | |
| // teardown path. | |
| return Err(ServerError::MetadataHandoffAborted { shard_id }); | |
| } | |
| Err(crossfire::TrySendError::Full(returned)) => { | |
| if shutdown_flag.load(Ordering::Relaxed) { | |
| return Err(ServerError::MetadataHandoffAborted { shard_id }); | |
| } | |
| pending = returned; | |
| compio::time::sleep(poll_interval).await; | |
| } | |
| } | |
| } | |
| } | |
| Ok(()) | |
| } | |
| /// Peer side of [`BootstrapBarrier`]: tell shard 0 this shard finished | |
| /// loading its on-disk partitions. Mirrors [`broadcast_metadata_bundle`]'s | |
| /// `try_send`-or-shutdown poll loop so a sibling failure (which flips the | |
| /// shutdown flag) drives this out instead of stranding it on a full | |
| /// channel. The channel is sized to the peer count and each peer sends | |
| /// exactly once, so `Full` is not expected; the branch only keeps the | |
| /// loop interruptible. | |
| pub(in crate::boot) async fn signal_bootstrap_complete( | |
| shard_id: u16, | |
| ready_tx: &crossfire::MAsyncTx<crossfire::mpmc::Array<u16>>, | |
| shutdown_flag: &Arc<AtomicBool>, | |
| poll_interval: Duration, | |
| ) -> Result<(), ServerError> { | |
| let mut pending = shard_id; | |
| loop { | |
| match ready_tx.try_send(pending) { | |
| Ok(()) => return Ok(()), | |
| Err(crossfire::TrySendError::Disconnected(_)) => { | |
| // Shard 0 dropped its `ready_rx` before draining (it | |
| // aborted before binding listeners). Propagate so this | |
| // shard short-circuits; the shutdown flag flips via the | |
| // normal teardown path. | |
| return Err(ServerError::MetadataHandoffAborted { shard_id }); | |
| } | |
| Err(crossfire::TrySendError::Full(returned)) => { | |
| if shutdown_flag.load(Ordering::Relaxed) { | |
| return Err(ServerError::MetadataHandoffAborted { shard_id }); | |
| } | |
| pending = returned; | |
| compio::time::sleep(poll_interval).await; | |
| } | |
| } | |
| } | |
| } | |
| /// Owner side of [`BootstrapBarrier`]: drain one ready signal per peer | |
| /// before shard 0 binds listeners. Polls the shutdown flag so a peer that | |
| /// dies mid-load (flipping the flag) aborts the wait instead of hanging on | |
| /// a signal that will never arrive. A single shard (`peers == 0`) returns | |
| /// immediately. | |
| pub(in crate::boot) async fn await_bootstrap_complete( | |
| ready_rx: &crossfire::MAsyncRx<crossfire::mpmc::Array<u16>>, | |
| peers: usize, | |
| shutdown_flag: &Arc<AtomicBool>, | |
| poll_interval: Duration, | |
| ) -> Result<(), ServerError> { | |
| let mut remaining = peers; | |
| while remaining > 0 { | |
| match ready_rx.try_recv() { | |
| Ok(_shard_id) => remaining -= 1, | |
| Err(crossfire::TryRecvError::Disconnected) => { | |
| return Err(ServerError::ShardBootstrapBarrierAborted { remaining }); | |
| } | |
| Err(crossfire::TryRecvError::Empty) => { | |
| if shutdown_flag.load(Ordering::Relaxed) { | |
| return Err(ServerError::ShardBootstrapBarrierAborted { remaining }); | |
| } | |
| compio::time::sleep(poll_interval).await; | |
| } | |
| } | |
| } | |
| Ok(()) | |
| } | |
| #[cfg(test)] | |
| mod tests { | |
| use super::*; | |
| use crate::shell::ServerMuxStateMachine; | |
| const TEST_POLL_INTERVAL: Duration = Duration::from_millis(50); | |
| #[compio::test] | |
| async fn broadcast_metadata_bundle_returns_immediately_with_no_peers() { | |
| // Single-shard deployment: shard 0 has no peers to fan out to, | |
| // so the handoff must complete without ever calling `send`. | |
| let (bundle_tx, _bundle_rx) = crossfire::mpmc::bounded_async::<ServerMetadataBundle>(0); | |
| let flag = Arc::new(AtomicBool::new(false)); | |
| let mux = ServerMuxStateMachine::default(); | |
| broadcast_metadata_bundle( | |
| 0, | |
| &bundle_tx, | |
| mux.factory_bundle(), | |
| 0, | |
| &flag, | |
| TEST_POLL_INTERVAL, | |
| ) | |
| .await | |
| .expect("zero peers must not block shard 0"); | |
| } | |
| #[compio::test] | |
| async fn metadata_bundle_round_trips_through_channel() { | |
| // End-to-end: shard 0 mints a bundle, a peer receives it on | |
| // another runtime, and `from_factory_bundle` constructs a | |
| // reader-mode mux that observes shard 0's writes via the same | |
| // LeftRight pair. | |
| let peers = 1u16; | |
| let (bundle_tx, bundle_rx) = | |
| crossfire::mpmc::bounded_async::<ServerMetadataBundle>(usize::from(peers)); | |
| let flag = Arc::new(AtomicBool::new(false)); | |
| let owner = ServerMuxStateMachine::default(); | |
| let bundle = owner.factory_bundle(); | |
| broadcast_metadata_bundle(0, &bundle_tx, bundle, peers, &flag, TEST_POLL_INTERVAL) | |
| .await | |
| .expect("broadcast must succeed with one peer drained"); | |
| let received = await_metadata_bundle(1, &bundle_rx, &flag, TEST_POLL_INTERVAL) | |
| .await | |
| .expect("peer must receive the broadcast bundle"); | |
| let _peer_mux = ServerMuxStateMachine::from_factory_bundle(received); | |
| } | |
| #[compio::test] | |
| async fn broadcast_metadata_bundle_aborts_when_peers_drop_rx() { | |
| // Shard 0 drives handoff but every peer's `bundle_rx` was dropped | |
| // before recv. Silently returning Ok would commit listener binds | |
| // and consensus init for a cluster whose peers are gone; the | |
| // broadcast must surface the disconnect so `shard_main` aborts. | |
| let (bundle_tx, bundle_rx) = crossfire::mpmc::bounded_async::<ServerMetadataBundle>(0); | |
| drop(bundle_rx); | |
| let flag = Arc::new(AtomicBool::new(false)); | |
| let mux = ServerMuxStateMachine::default(); | |
| let err = broadcast_metadata_bundle( | |
| 0, | |
| &bundle_tx, | |
| mux.factory_bundle(), | |
| 3, | |
| &flag, | |
| TEST_POLL_INTERVAL, | |
| ) | |
| .await | |
| .expect_err("dropped rx must surface as MetadataHandoffAborted"); | |
| assert!( | |
| matches!(err, ServerError::MetadataHandoffAborted { shard_id: 0 }), | |
| "expected MetadataHandoffAborted, got {err:?}" | |
| ); | |
| } | |
| #[compio::test] | |
| async fn await_metadata_bundle_aborts_when_owner_drops_without_sending() { | |
| let (bundle_tx, bundle_rx) = crossfire::mpmc::bounded_async::<ServerMetadataBundle>(1); | |
| let flag = Arc::new(AtomicBool::new(false)); | |
| // Shard 0 dies before broadcasting; the peer must observe the | |
| // disconnect and abort instead of hanging forever. | |
| drop(bundle_tx); | |
| let err = await_metadata_bundle(1, &bundle_rx, &flag, TEST_POLL_INTERVAL) | |
| .await | |
| .expect_err("a peer whose owner never sends must abort"); | |
| assert!( | |
| matches!(err, ServerError::MetadataHandoffAborted { shard_id: 1 }), | |
| "expected MetadataHandoffAborted, got {err:?}" | |
| ); | |
| } | |
| #[compio::test] | |
| async fn await_metadata_bundle_aborts_on_shutdown_flag() { | |
| // compio 0.19 `JoinHandle` yields `Result<T, JoinError>`; the | |
| // `ResumeUnwind` impl re-raises a task panic and maps cancellation | |
| // to `None`. | |
| use compio::runtime::ResumeUnwind; | |
| let (_bundle_tx, bundle_rx) = crossfire::mpmc::bounded_async::<ServerMetadataBundle>(1); | |
| let flag = Arc::new(AtomicBool::new(false)); | |
| let waiter = compio::runtime::spawn({ | |
| let flag = Arc::clone(&flag); | |
| async move { await_metadata_bundle(1, &bundle_rx, &flag, TEST_POLL_INTERVAL).await } | |
| }); | |
| // Owner has not sent yet, but shutdown was requested; the peer | |
| // must exit via the flag poll instead of hanging. | |
| compio::time::sleep(TEST_POLL_INTERVAL / 2).await; | |
| flag.store(true, Ordering::Relaxed); | |
| let err = waiter | |
| .await | |
| .resume_unwind() | |
| .expect("waiter task was cancelled") | |
| .expect_err("shutdown flag must abort the bundle wait"); | |
| assert!( | |
| matches!(err, ServerError::MetadataHandoffAborted { shard_id: 1 }), | |
| "expected MetadataHandoffAborted on shutdown, got {err:?}" | |
| ); | |
| } | |
| #[compio::test] | |
| async fn await_bootstrap_complete_returns_immediately_for_single_shard() { | |
| // A single-shard server has no peers to wait on; the owner barrier | |
| // must not block when `peers == 0`. | |
| let (_ready_tx, ready_rx) = crossfire::mpmc::bounded_async::<u16>(1); | |
| let flag = Arc::new(AtomicBool::new(false)); | |
| await_bootstrap_complete(&ready_rx, 0, &flag, TEST_POLL_INTERVAL) | |
| .await | |
| .expect("single-shard server must not block on the barrier"); | |
| } | |
| #[compio::test] | |
| async fn await_bootstrap_complete_drains_every_peer_signal() { | |
| // Two peers report load-complete; shard 0 drains both, then proceeds | |
| // to bind listeners. | |
| let (ready_tx, ready_rx) = crossfire::mpmc::bounded_async::<u16>(2); | |
| let flag = Arc::new(AtomicBool::new(false)); | |
| signal_bootstrap_complete(1, &ready_tx, &flag, TEST_POLL_INTERVAL) | |
| .await | |
| .expect("peer 1 must signal load-complete"); | |
| signal_bootstrap_complete(2, &ready_tx, &flag, TEST_POLL_INTERVAL) | |
| .await | |
| .expect("peer 2 must signal load-complete"); | |
| await_bootstrap_complete(&ready_rx, 2, &flag, TEST_POLL_INTERVAL) | |
| .await | |
| .expect("owner must drain both peer signals"); | |
| } | |
| #[compio::test] | |
| async fn await_bootstrap_complete_aborts_on_shutdown_flag() { | |
| use compio::runtime::ResumeUnwind; | |
| // `_ready_tx` is held so the channel is not disconnected: the owner | |
| // must exit via the shutdown flag, not a dropped sender. | |
| let (_ready_tx, ready_rx) = crossfire::mpmc::bounded_async::<u16>(1); | |
| let flag = Arc::new(AtomicBool::new(false)); | |
| let owner = compio::runtime::spawn({ | |
| let flag = Arc::clone(&flag); | |
| async move { await_bootstrap_complete(&ready_rx, 1, &flag, TEST_POLL_INTERVAL).await } | |
| }); | |
| // The peer never signals, but a sibling failure flips the flag; the | |
| // owner must abort instead of hanging before listeners. | |
| compio::time::sleep(TEST_POLL_INTERVAL / 2).await; | |
| flag.store(true, Ordering::Relaxed); | |
| let err = owner | |
| .await | |
| .resume_unwind() | |
| .expect("owner task was cancelled") | |
| .expect_err("shutdown flag must abort the barrier wait"); | |
| assert!( | |
| matches!( | |
| err, | |
| ServerError::ShardBootstrapBarrierAborted { remaining: 1 } | |
| ), | |
| "expected ShardBootstrapBarrierAborted, got {err:?}" | |
| ); | |
| } | |
| #[compio::test] | |
| async fn signal_bootstrap_complete_aborts_when_owner_drops_rx() { | |
| // Shard 0 aborted before draining and dropped its receiver; a peer's | |
| // signal must surface the disconnect instead of stranding. | |
| let (ready_tx, ready_rx) = crossfire::mpmc::bounded_async::<u16>(1); | |
| let flag = Arc::new(AtomicBool::new(false)); | |
| drop(ready_rx); | |
| let err = signal_bootstrap_complete(2, &ready_tx, &flag, TEST_POLL_INTERVAL) | |
| .await | |
| .expect_err("dropped rx must surface as an abort"); | |
| assert!( | |
| matches!(err, ServerError::MetadataHandoffAborted { shard_id: 2 }), | |
| "expected MetadataHandoffAborted, got {err:?}" | |
| ); | |
| } | |
| } |