blob: 10e8daf5ae027a13cc01128b50e7952e429c6b52 [file]
// 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:?}"
);
}
}