blob: 0af6693615229d80fd6630b769d79d1e5d42541a [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.
//! Shard OS threads: thread entry, pin, join, and the shutdown plumbing.
use crate::boot::handoff::{BootstrapBarrier, MetadataHandoff};
use crate::boot::shard_main;
use crate::boot::topology::RosterCells;
use crate::server_error::{ServerError, ShardJoinFailure, ShardJoinFailureKind};
use crate::shard_allocator::{ShardAllocator, ShardInfo};
use compio::runtime::ResumeUnwind;
use configs::server::ServerConfig;
use configs::sharding::{
INBOX_CAPACITY_MAX, RECONCILE_PERIODIC_INTERVAL_MAX, SHUTDOWN_DRAIN_TIMEOUT_MAX,
SHUTDOWN_JOIN_TIMEOUT_MAX, SHUTDOWN_POLL_INTERVAL_MAX,
};
use message_bus::{IggyMessageBus, ReplicaOwnerTable};
use metadata::AppliedFrontier;
use partitions::FatalCommit;
use server_common::executor::create_shard_executor;
use shard::metrics::ShardMetrics;
use shard::{Receiver as ShardReceiver, Sender, ShardFrame, TaggedSender};
use std::backtrace::Backtrace;
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, Mutex, OnceLock, PoisonError};
use std::time::{Duration, Instant};
use std::{panic, thread};
use tracing::{error, info, warn};
/// Result of a multi-shard bootstrap.
///
/// Carries the cross-thread shutdown flag, one OS-thread `JoinHandle`
/// per shard, and the first panic `install_panic_hook` recorded. The
/// caller flips the flag via [`Self::install_ctrlc_handler`] and then
/// drains every shard via [`Self::join_all`], bounded by the shared
/// `ShutdownDeadline` (`sharding.shutdown_join_timeout`).
pub struct ShardHandles {
pub(in crate::boot) shutdown_flag: Arc<AtomicBool>,
pub(in crate::boot) shard_threads: Vec<(u16, thread::JoinHandle<Result<(), ServerError>>)>,
pub(in crate::boot) deadline: Arc<ShutdownDeadline>,
pub(in crate::boot) first_panic: Arc<OnceLock<String>>,
}
impl ShardHandles {
/// Install a SIGINT/Ctrl-C handler that flips the shutdown flag on
/// the first signal. A second signal is logged but otherwise
/// ignored so an in-flight WAL fsync or replica drain runs to
/// completion.
///
/// # Errors
///
/// Returns the underlying `ctrlc::Error` if the handler cannot be
/// installed (typically because another handler already owns the
/// signal).
pub fn install_ctrlc_handler(&self) -> Result<(), ctrlc::Error> {
let flag = Arc::clone(&self.shutdown_flag);
ctrlc::set_handler(move || {
if flag.swap(true, Ordering::Relaxed) {
// Second Ctrl-C: leave the shutdown machinery to drain.
// Refusing to abort here keeps the WAL fsync / replica
// drain from being interrupted mid-frame.
warn!("second Ctrl-C ignored; server is already shutting down");
} else {
info!("Ctrl-C received; signalling server shutdown");
}
})
}
/// Drain every shard thread. This is the main thread's park for the
/// server's whole lifetime, so shards are awaited WITHOUT any time
/// bound while the server runs; the `shutdown_join_timeout` clock
/// only starts once the cross-thread shutdown flag flips (Ctrl-C or
/// a shard failure). Each shard's outcome is logged (`info` on clean
/// exit, `error` on Err, panic, or wedge). If any shard failed,
/// returns every failure together as
/// [`ServerError::ShardJoinFailures`] so the operator sees the
/// full set rather than just the first.
///
/// A shard whose thread is still running when the post-shutdown
/// deadline passes is abandoned (its `JoinHandle` dropped, the OS
/// thread left to die with the process) and reported as
/// [`ShardJoinFailureKind::Wedged`]: a wedged pump or listener must
/// not block process exit forever. Shard 0 gets the peer-wait floor
/// as grace on top, because its `PeerExitWait` is allowed to hold the
/// metadata writer that long past a spent budget.
///
/// # Errors
///
/// Returns [`ServerError::ShardJoinFailures`] if any shard
/// returned a `Result::Err`, panicked, or wedged past the deadline.
/// The variant carries every per-shard failure in shard-id order so
/// the caller does not need to read the trace log to discover
/// late-failing shards. Returns [`ServerError::Panicked`] when every
/// thread exited `Ok` but the panic hook recorded a panic: a task
/// compio's `spawn` caught, which no thread result can carry.
pub fn join_all(self) -> Result<(), ServerError> {
let mut failures: Vec<ShardJoinFailure> = Vec::new();
// Shards run thread-per-core with compio's blocking fallback pool
// disabled, so an io_uring opcode the kernel lacks aborts every shard
// with the same panic. Surface the actionable diagnostic once.
let mut io_uring_diagnostic_shown = false;
for (shard_id, handle) in self.shard_threads {
// Shard 0's `PeerExitWait` is allowed to overrun the budget by
// its floor, so its join has to tolerate the same overrun or a
// shutdown that correctly held the writer for a slow peer reports
// the shard that honoured the fence as wedged.
let grace = if shard_id == 0 {
self.deadline.peer_wait_floor
} else {
Duration::ZERO
};
let waited = self.deadline.budget.saturating_add(grace);
let Some(joined) =
join_until_shutdown_deadline(handle, &self.shutdown_flag, &self.deadline, grace)
else {
error!(
shard_id,
?waited,
"shard thread still running at the shutdown join deadline; abandoning it"
);
failures.push(ShardJoinFailure {
shard_id,
kind: ShardJoinFailureKind::Wedged { waited },
});
continue;
};
match joined {
Ok(Ok(())) => {
info!(shard_id, "shard thread exited cleanly");
}
Ok(Err(error)) => {
error!(shard_id, error = %error, "shard thread returned error");
failures.push(ShardJoinFailure {
shard_id,
kind: ShardJoinFailureKind::Error(Box::new(error)),
});
}
Err(panic_payload) => {
let message = panic_payload_to_string(&*panic_payload);
error!(shard_id, message = %message, "shard thread panicked");
if !io_uring_diagnostic_shown
&& message
.contains(server_common::diagnostics::ASYNCIFY_POOL_DISABLED_PANIC_MSG)
{
server_common::diagnostics::print_incomplete_io_uring_ops_info();
io_uring_diagnostic_shown = true;
}
failures.push(ShardJoinFailure {
shard_id,
kind: ShardJoinFailureKind::Panic { message },
});
}
}
}
if !failures.is_empty() {
return Err(ServerError::ShardJoinFailures { failures });
}
self.first_panic.get().map_or_else(
|| Ok(()),
|description| {
Err(ServerError::Panicked {
description: description.clone(),
})
},
)
}
}
/// Install the process-wide panic hook and return the slot it records
/// the first panic into.
///
/// The hook runs on the panicking thread before the unwind, so it sees
/// every panic on a shard: the thread body, whose unwind
/// [`run_shard_thread`] already turns into a join failure, and the tasks
/// compio's `spawn` catches, which nothing observes while the server
/// runs (a dead listener or connection task leaves every thread exiting
/// `Ok`). It logs the panic with its backtrace, records the first one for
/// [`ShardHandles::join_all`] to fail the exit on, and flips the shutdown
/// flag so every shard drains instead of the half-alive state one dead
/// task leaves behind. The previous hook still runs after it, so stderr
/// keeps the standard panic line.
pub(in crate::boot) fn install_panic_hook(shutdown_flag: Arc<AtomicBool>) -> Arc<OnceLock<String>> {
let first_panic = Arc::new(OnceLock::new());
let recorded = Arc::clone(&first_panic);
let previous_hook = panic::take_hook();
panic::set_hook(Box::new(move |info| {
let current = thread::current();
let thread_name = current.name().unwrap_or("<unnamed>");
let location = info
.location()
.map_or_else(|| "<unknown>".to_string(), ToString::to_string);
let message = panic_payload_to_string(info.payload());
let backtrace = Backtrace::force_capture();
error!(thread = thread_name, location = %location, backtrace = %backtrace, "{message}");
let _ = recorded.set(format!(
"thread '{thread_name}' panicked at {location}: {message}"
));
shutdown_flag.store(true, Ordering::Relaxed);
previous_hook(info);
}));
first_panic
}
/// Poll cadence for the bounded shard joins. Coarse enough to cost
/// nothing during a normal drain, fine enough that exit latency past
/// the last shard's return stays imperceptible.
const JOIN_POLL_INTERVAL: Duration = Duration::from_millis(25);
/// Join `handle`, waiting indefinitely while the server runs. The
/// budget clock starts only when `shutdown_flag` is observed set (arming
/// the shared [`ShutdownDeadline`], so every shard join and shard 0's
/// peer wait drain under ONE budget); a running server parked here for
/// hours must never be mistaken for a wedged shard. `grace` extends this
/// caller's share of the budget by an overrun the joined thread is allowed
/// (shard 0's peer-wait floor). `None` means the thread was still running
/// at the post-shutdown deadline and the handle was dropped (the OS thread
/// keeps running detached; process exit reaps it). `JoinHandle` has no
/// timed join, so this polls `is_finished` at [`JOIN_POLL_INTERVAL`]; the
/// closing `join()` on a finished thread returns immediately.
fn join_until_shutdown_deadline(
handle: thread::JoinHandle<Result<(), ServerError>>,
shutdown_flag: &AtomicBool,
deadline: &ShutdownDeadline,
grace: Duration,
) -> Option<thread::Result<Result<(), ServerError>>> {
while !handle.is_finished() {
if shutdown_flag.load(Ordering::Relaxed) && deadline.remaining_with_grace(grace).is_zero() {
return None;
}
thread::sleep(JOIN_POLL_INTERVAL);
}
Some(handle.join())
}
/// Best-effort extraction of the panic message from a
/// `Box<dyn Any + Send>` returned by `JoinHandle::join`. Tries the two
/// payload shapes the standard library guarantees (`&'static str` and
/// `String`) and falls back to a placeholder so the panic still surfaces
/// in the error chain.
fn panic_payload_to_string(payload: &(dyn std::any::Any + Send)) -> String {
if let Some(s) = payload.downcast_ref::<&'static str>() {
return (*s).to_string();
}
if let Some(s) = payload.downcast_ref::<String>() {
return s.clone();
}
"<panic payload not String/&str>".to_string()
}
/// Joins survivor shard threads after a partial-spawn failure, bounded
/// by the same `shutdown_join_timeout` budget as the normal exit path.
///
/// Polls every survivor's `is_finished` in one loop instead of spawning
/// per-survivor joiner threads: the likely OS state on this path is
/// `pthread_create` EAGAIN (the parent spawn just failed with it), so
/// nothing here may create threads, and polling drains all survivors in
/// parallel anyway. A survivor still running at the deadline is
/// abandoned with an error log so the failed bootstrap can surface its
/// spawn error instead of hanging on a wedged shard.
pub(in crate::boot) fn join_partial_shard_survivors(
shard_threads: Vec<(u16, thread::JoinHandle<Result<(), ServerError>>)>,
deadline: &ShutdownDeadline,
) {
let mut remaining = shard_threads;
loop {
let mut still_running = Vec::with_capacity(remaining.len());
for (shard_id, survivor) in remaining {
if survivor.is_finished() {
let _ = survivor.join();
info!(shard_id, "survivor shard thread drained");
} else {
still_running.push((shard_id, survivor));
}
}
remaining = still_running;
if remaining.is_empty() || deadline.remaining().is_zero() {
break;
}
thread::sleep(JOIN_POLL_INTERVAL);
}
for (shard_id, _survivor) in remaining {
error!(
shard_id,
waited = ?deadline.budget,
"survivor shard thread still running at the shutdown join deadline; abandoning it"
);
}
}
/// Flips the cross-thread shutdown flag on `Drop` unless disarmed.
///
/// A shard thread that exits via an error `?` or a panic unwind would
/// otherwise leave sibling shards parked forever on `bus.token().wait()`:
/// their watchdogs never observe the flag and the bus has no
/// `Drop`-triggered shutdown. Arming this for the whole thread body makes
/// every non-clean exit drive sibling-shard teardown. Disarmed only on a
/// clean `Ok(())`.
struct ShutdownOnDrop {
flag: Arc<AtomicBool>,
armed: bool,
}
impl ShutdownOnDrop {
const fn new(flag: Arc<AtomicBool>) -> Self {
Self { flag, armed: true }
}
const fn disarm(&mut self) {
self.armed = false;
}
}
impl Drop for ShutdownOnDrop {
fn drop(&mut self) {
if self.armed {
self.flag.store(true, Ordering::Relaxed);
}
}
}
/// The single post-shutdown budget, shared by the main thread's shard
/// joins and shard 0's peer wait.
///
/// Both waits are bounded by `sharding.shutdown_join_timeout` and
/// they NEST: shard 0 cannot start waiting for its peers until its own
/// drain returned, which is already inside the join budget. Arming one
/// instant on first use, whichever wait gets there first, keeps the two
/// inside one deadline instead of stacking two full budgets, so a
/// correct shutdown cannot report shard 0 as wedged.
///
/// The two waits enforce different things, so the budget is shared but not
/// fungible: the join bounds LIVENESS (process exit must not hang on a
/// wedged shard) while the peer wait enforces SAFETY (the metadata writer
/// must outlive every reader). `peer_wait_floor` is what keeps an exhausted
/// join budget from cancelling the safety fence, and the joins in turn
/// tolerate that floor as grace (see [`ShardHandles::join_all`]).
pub(in crate::boot) struct ShutdownDeadline {
armed: OnceLock<Instant>,
budget: Duration,
peer_wait_floor: Duration,
}
impl ShutdownDeadline {
pub(in crate::boot) const fn new(budget: Duration, peer_wait_floor: Duration) -> Self {
Self {
armed: OnceLock::new(),
budget,
peer_wait_floor,
}
}
/// Time left in the shared budget plus `grace`, arming it on the first
/// call. Callers must only reach this once the shutdown flag is set: a
/// running server would otherwise start the clock.
fn remaining_with_grace(&self, grace: Duration) -> Duration {
self.armed
.get_or_init(|| Instant::now() + self.budget)
.checked_add(grace)
.map_or(Duration::MAX, |limit| {
limit.saturating_duration_since(Instant::now())
})
}
fn remaining(&self) -> Duration {
self.remaining_with_grace(Duration::ZERO)
}
/// How long shard 0 may block on its peers: what is left of the shared
/// budget, floored at a peer's whole exit (one poll interval to observe the
/// shutdown flag plus one drain budget) so a join that already spent the
/// budget cannot release the metadata writer while a peer still reads
/// through it. Only [`PeerExitWait::drop`]'s panic arm skips the fence.
fn remaining_for_peer_wait(&self) -> Duration {
self.remaining().max(self.peer_wait_floor)
}
}
/// Peer shards still running: each [`PeerExitGuard`] counts one out,
/// [`PeerExitWait`] blocks shard 0 until the count is zero.
///
/// Shard 0 owns the metadata state machine's only write handle and every
/// peer reads through handles that stop working the moment it drops, so
/// the shard that owns the writer must outlive every reader on every exit
/// but one: [`PeerExitWait::drop`] skips the wait while shard 0 is
/// panicking, because parking an unwinding thread on a `Condvar` inside
/// `runtime.block_on` would stall the `io_uring` driver, and a panic in
/// shard 0's own pump can be exactly what the peers are blocked on. A
/// peer mid-read then panics too; the first panic is already recorded and
/// the process is going down either way.
pub(in crate::boot) struct PeerExitCountdown {
running: Mutex<usize>,
all_exited: Condvar,
}
impl PeerExitCountdown {
pub(in crate::boot) const fn new(peers: usize) -> Self {
Self {
running: Mutex::new(peers),
all_exited: Condvar::new(),
}
}
/// Block until every peer has counted itself out or `timeout` elapses.
/// `Err` carries the number of peers still running at the deadline.
fn wait(&self, timeout: Duration) -> Result<(), usize> {
let (guard, _) = self
.all_exited
.wait_timeout_while(
self.running.lock().unwrap_or_else(PoisonError::into_inner),
timeout,
|running| *running > 0,
)
.unwrap_or_else(PoisonError::into_inner);
let running = *guard;
drop(guard);
if running == 0 { Ok(()) } else { Err(running) }
}
fn peer_exited(&self) {
let mut running = self.running.lock().unwrap_or_else(PoisonError::into_inner);
*running = running.saturating_sub(1);
if *running == 0 {
self.all_exited.notify_all();
}
}
/// Count out peers that never spawned. The countdown is sized before the
/// spawn loop, so a failed `thread::Builder::spawn` leaves peers whose
/// [`PeerExitGuard`] will never exist: without this shard 0 waits out its
/// whole join budget for threads that were never there.
pub(in crate::boot) fn peers_never_spawned(&self, count: usize) {
for _ in 0..count {
self.peer_exited();
}
}
}
/// Counts one peer shard out of the [`PeerExitCountdown`] on drop.
///
/// Held by `run_shard_thread` from before the runtime exists, so it drops
/// after the runtime and its tasks are gone: past that point nothing on
/// the peer's thread can still read shard 0's metadata. Drop runs on every
/// exit path, the error `?` returns and panic unwinds included.
struct PeerExitGuard {
countdown: Arc<PeerExitCountdown>,
}
impl PeerExitGuard {
const fn new(countdown: Arc<PeerExitCountdown>) -> Self {
Self { countdown }
}
}
impl Drop for PeerExitGuard {
fn drop(&mut self) {
self.countdown.peer_exited();
}
}
/// Shard 0's side of the [`PeerExitCountdown`]: blocks on drop until every
/// peer has exited, so whatever is declared before it outlives every
/// peer's reads.
///
/// Flips the shutdown flag before waiting: a peer parked on its bus token
/// only starts its drain once the flag is set, and the thread-level
/// `ShutdownOnDrop` flips it only after `block_on` returns, which is after
/// this wait. Bounded by [`ShutdownDeadline::remaining_for_peer_wait`] --
/// what is left of the budget [`ShardHandles::join_all`] shares, but never
/// less than one drain -- with the same abandon-and-log policy, so a wedged
/// peer cannot hold shard 0 indefinitely while a spent join budget still
/// cannot cut the wait to nothing.
pub(in crate::boot) struct PeerExitWait {
countdown: Arc<PeerExitCountdown>,
shutdown_flag: Arc<AtomicBool>,
deadline: Arc<ShutdownDeadline>,
}
impl PeerExitWait {
pub(in crate::boot) const fn new(
countdown: Arc<PeerExitCountdown>,
shutdown_flag: Arc<AtomicBool>,
deadline: Arc<ShutdownDeadline>,
) -> Self {
Self {
countdown,
shutdown_flag,
deadline,
}
}
}
impl Drop for PeerExitWait {
fn drop(&mut self) {
// The panic is the fault to report and `ShutdownOnDrop` still
// flips the flag for the peers; blocking an unwinding thread here
// would only delay it.
if thread::panicking() {
return;
}
self.shutdown_flag.store(true, Ordering::Relaxed);
let remaining = self.deadline.remaining_for_peer_wait();
if let Err(peers_running) = self.countdown.wait(remaining) {
warn!(
peers_running,
waited = ?remaining,
budget = ?self.deadline.budget,
"peer shards still running at the shutdown join deadline; \
releasing shard 0 anyway"
);
}
}
}
/// Resolve the operator's `cpu_allocation` into concrete shard
/// assignments plus the checked `u16` shard count.
///
/// Shard ids index `ReplicaOwnerTable` slots as `u16`. `OWNER_NONE`
/// (`u16::MAX`) is reserved as the empty-slot sentinel, so a server
/// configured with `u16::MAX` shards would mint a shard id that
/// collides with the sentinel and an owner-table lookup could never
/// tell that shard apart from an unowned slot. Reject at boot so the
/// invariant is held by the type system, not by hoping the operator
/// never configures 65535 cores worth of shards.
pub(in crate::boot) fn resolve_shard_assignments(
sharding: &configs::sharding::ShardingConfig,
) -> Result<(Vec<ShardInfo>, u16), ServerError> {
let allocator = ShardAllocator::new(&sharding.cpu_allocation, sharding.pin_cores)
.map_err(ServerError::ShardAllocator)?;
let assignments = allocator
.to_shard_assignments()
.map_err(ServerError::ShardAllocator)?;
if assignments.is_empty() {
return Err(ServerError::ShardsCountZero);
}
match u16::try_from(assignments.len()) {
Ok(count) if count < message_bus::OWNER_NONE => Ok((assignments, count)),
_ => Err(ServerError::ShardsCountOverflow {
count: assignments.len(),
}),
}
}
pub(in crate::boot) fn partition_io_limits(
sharding: &configs::sharding::ShardingConfig,
) -> Result<shard::PartitionIoLimits, shard::PartitionIoLimitsError> {
shard::PartitionIoLimits::new(
sharding.partition_io_capacity,
sharding
.partition_io_bytes_max
.map(|bytes| usize::try_from(bytes.as_bytes_u64()))
.transpose()
.map_err(|_| shard::PartitionIoLimitsError::Overflow)?,
)
}
/// Re-validate the runtime sharding knobs that the per-shard runtime
/// consumes directly: the two inbox capacities, the three shutdown
/// durations and their ordering, and the reconcile tick. Mirrors
/// `ShardingConfig::validate` for exactly those, so a caller that built the
/// config without running it (e.g. tests, embedded usage) cannot OOM at
/// boot, starve a core, or wedge process exit with an out-of-range value.
/// `cpu_allocation` is not re-checked here - [`resolve_shard_assignments`]
/// resolves it through the allocator and rejects it there.
pub(in crate::boot) fn validate_sharding_runtime_knobs(
sharding: &configs::sharding::ShardingConfig,
) -> Result<(), ServerError> {
let inbox_capacity = sharding.inbox_capacity;
partition_io_limits(sharding)?;
if inbox_capacity == 0 || inbox_capacity > INBOX_CAPACITY_MAX {
return Err(ServerError::InvalidInboxCapacity {
value: inbox_capacity,
max: INBOX_CAPACITY_MAX,
});
}
let reply_inbox_capacity = sharding.reply_inbox_capacity;
if reply_inbox_capacity == 0 || reply_inbox_capacity > INBOX_CAPACITY_MAX {
return Err(ServerError::InvalidReplyInboxCapacity {
value: reply_inbox_capacity,
max: INBOX_CAPACITY_MAX,
});
}
let poll_completion_capacity = sharding.poll_completion_capacity;
if poll_completion_capacity == 0 || poll_completion_capacity > INBOX_CAPACITY_MAX {
return Err(ServerError::InvalidPollCompletionCapacity {
value: poll_completion_capacity,
max: INBOX_CAPACITY_MAX,
});
}
let drain_timeout = sharding.shutdown_drain_timeout.get_duration();
if drain_timeout.is_zero() || drain_timeout > SHUTDOWN_DRAIN_TIMEOUT_MAX {
return Err(ServerError::InvalidShutdownDrainTimeout {
value: drain_timeout,
max: SHUTDOWN_DRAIN_TIMEOUT_MAX,
});
}
let poll_interval = sharding.shutdown_poll_interval.get_duration();
if poll_interval.is_zero() || poll_interval > SHUTDOWN_POLL_INTERVAL_MAX {
return Err(ServerError::InvalidShutdownPollInterval {
value: poll_interval,
max: SHUTDOWN_POLL_INTERVAL_MAX,
});
}
// Ordering: a poll cadence coarser than the drain budget makes the
// cross-thread shutdown flag effectively unobservable during teardown.
if poll_interval > drain_timeout {
return Err(ServerError::ShutdownPollExceedsDrain {
poll: poll_interval,
drain: drain_timeout,
});
}
let join_timeout = sharding.shutdown_join_timeout.get_duration();
if join_timeout > SHUTDOWN_JOIN_TIMEOUT_MAX {
return Err(ServerError::InvalidShutdownJoinTimeout {
value: join_timeout,
max: SHUTDOWN_JOIN_TIMEOUT_MAX,
});
}
// A join budget under the drain budget abandons shards mid-drain,
// interrupting the WAL fsync / replica drain, and now also cuts shard
// 0's peer wait short of the writer's last reader.
if join_timeout < drain_timeout {
return Err(ServerError::ShutdownJoinBelowDrain {
join: join_timeout,
drain: drain_timeout,
});
}
// Zero feeds `run_reconciler`'s sleep inside an unconditional loop, so the
// tick arm is ready every iteration and `reconcile_once` runs back to back,
// starving the pump on that core.
let reconcile_interval = sharding.reconcile_periodic_interval.get_duration();
if reconcile_interval.is_zero() || reconcile_interval > RECONCILE_PERIODIC_INTERVAL_MAX {
return Err(ServerError::InvalidReconcilePeriodicInterval {
value: reconcile_interval,
max: RECONCILE_PERIODIC_INTERVAL_MAX,
});
}
Ok(())
}
/// Per-shard OS thread entry. Pins CPU + memory, builds the compio
/// runtime, and `block_on`s `shard_main`.
#[allow(clippy::needless_pass_by_value, clippy::too_many_arguments)]
pub(in crate::boot) fn run_shard_thread(
shard_id: u16,
total_shards: u16,
replica_id: Option<u8>,
assignment: ShardInfo,
senders: Vec<TaggedSender>,
inbox: ShardReceiver<ShardFrame>,
reply_inbox: ShardReceiver<ShardFrame>,
config: Arc<ServerConfig>,
shutdown_flag: Arc<AtomicBool>,
metadata_handoff: MetadataHandoff,
barrier: BootstrapBarrier,
owner_table: Arc<ReplicaOwnerTable>,
roster_cells: RosterCells,
metadata_applied_frontier: Arc<AppliedFrontier>,
shard_metrics_all: Vec<ShardMetrics>,
peer_exit: Arc<PeerExitCountdown>,
shutdown_deadline: Arc<ShutdownDeadline>,
) -> Result<(), ServerError> {
// Armed for the whole thread body: a post-spawn error `?` or a panic
// unwind here must flip `shutdown_flag` so sibling watchdogs drive
// their bus shutdown instead of parking forever on `bus.token().wait()`.
let mut shutdown_guard = ShutdownOnDrop::new(Arc::clone(&shutdown_flag));
// Declared before the runtime so it drops after it: a peer counts
// itself out only once no task of its runtime can read shard 0's
// metadata any more.
let _peer_exit_guard = (shard_id != 0).then(|| PeerExitGuard::new(Arc::clone(&peer_exit)));
assignment
.bind_cpu()
.map_err(|source| ServerError::CpuAffinityFailed { shard_id, source })?;
assignment
.bind_memory()
.map_err(|source| ServerError::MemoryAffinityFailed { shard_id, source })?;
// `enrich_runtime_create_error` folds the io_uring remediation (raise
// `ulimit -l`, unblock seccomp, kernel-flag floor) into the error, so the
// guidance survives into the shard-join failure report instead of only
// stderr. Multi-shard boxes exhaust RLIMIT_MEMLOCK on per-shard rings
// before the bootstrap runtime does, so this path needs it most.
let runtime = create_shard_executor().map_err(|source| {
let source = server_common::diagnostics::enrich_runtime_create_error(source);
ServerError::ShardRuntimeCreateFailed { shard_id, source }
})?;
let result = runtime.block_on(async move {
// `shard_main`'s future grows past clippy's `large_futures` cap
// (it ferries the metadata handoff, bus, builders, and inflight
// I/O in one state machine). Heap-pin it so the top-level
// `block_on` future stays small; one allocation per startup buys
// the stack budget back.
Box::pin(shard_main(
shard_id,
total_shards,
replica_id,
senders,
inbox,
reply_inbox,
&config,
shutdown_flag,
metadata_handoff,
barrier,
owner_table,
roster_cells,
metadata_applied_frontier,
shard_metrics_all,
peer_exit,
shutdown_deadline,
))
.await
});
if result.is_ok() {
shutdown_guard.disarm();
}
result
}
/// Await the message pump's completion before the shard returns: its
/// post-loop work includes the final flush of every committed journal to
/// segment storage, and returning first drops the compio runtime, which
/// cancels that flush at its next await point.
///
/// `Err` means the pump was already dead (a panic, or an exit outside the
/// stop protocol), so its final flush never ran and the shard must not
/// report a clean exit. The verdict is the inner `JoinError`; the timeout
/// wrapper alone cannot see it, and a shard that swallows it prints
/// "exited cleanly" over a corpse.
pub(in crate::boot) async fn await_pump_drain(
pump_handle: Option<compio::runtime::JoinHandle<Option<FatalCommit>>>,
config: &ServerConfig,
shard_id: u16,
) -> Result<(), ServerError> {
let Some(pump_handle) = pump_handle else {
return Ok(());
};
let drain_budget = config.sharding.shutdown_drain_timeout.get_duration();
let Ok(join_result) = compio::time::timeout(drain_budget, pump_handle).await else {
error!(
shard = shard_id,
timeout = ?drain_budget,
"message pump did not drain within the shutdown budget; \
committed journal tail may not have flushed"
);
return Err(ServerError::ShardPumpDrainTimedOut {
shard_id,
timeout: drain_budget,
});
};
// `JoinError` renders a panic as the bare "Task has panicked" and the
// type is not re-exported, so the payload -- the only part with
// diagnostic value -- is lifted by re-raising into an immediate catch.
// The panic hook already ran when the task died; `resume_unwind` does
// not run it again, so nothing is printed twice and the message finally
// reaches the tracing sink too.
let reason = match panic::catch_unwind(panic::AssertUnwindSafe(|| join_result.resume_unwind()))
{
Ok(Some(None)) => return Ok(()),
// The pump drained and flushed; it just has nothing left to serve.
// Fail the shard so the process exits non-zero: a node that stopped
// because it could not persist a cluster-committed op must not look
// to an orchestrator like a clean shutdown.
Ok(Some(Some(fault))) => {
error!(
shard = shard_id,
namespace_raw = fault.namespace_raw,
op = fault.op,
operation = ?fault.operation,
"message pump stopped on a partition commit fault; \
the server is shutting down"
);
return Err(ServerError::ShardFatal {
shard_id,
namespace_raw: fault.namespace_raw,
op: fault.op,
});
}
Ok(None) => "task was cancelled".to_string(),
Err(payload) => payload
.downcast_ref::<&str>()
.map(|message| (*message).to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.map_or_else(
|| "task panicked".to_string(),
|message| format!("task panicked: {message}"),
),
};
error!(
shard = shard_id,
"message pump died instead of draining ({reason}); \
committed journal tail may not have flushed"
);
Err(ServerError::ShardPumpDied { shard_id, reason })
}
/// Spawn a per-shard polling task that watches the cross-thread shutdown
/// flag and triggers this shard's bus shutdown on transition. The flag
/// is the only Send signal we have; the bus' shutdown machinery is
/// `!Send` (`Rc<Cell<bool>>` + per-shard `async_channel`), so it must be
/// triggered from within the runtime that owns the bus.
///
/// The caller owns the returned handle and must await it on the exit paths
/// where shutdown is in progress (flag set or bus token triggered):
/// dropping it there cancels the watchdog mid-`bus.shutdown()`, truncating
/// in-flight `ClientForwardFailed` replies (terminal per `SendError` docs).
/// It cannot go through `bus.track_background` instead: the watchdog itself
/// drives `bus.shutdown()`, and the bg-drain loop in `shutdown()` would
/// re-enter awaiting the watchdog's own pending shutdown call
/// (self-deadlock). The await is bounded: once the token fires the loop
/// stands down within one poll interval, and the shutdown call itself is
/// capped by `drain_timeout`.
#[allow(clippy::needless_pass_by_value)]
pub(in crate::boot) fn spawn_shutdown_watchdog(
bus: Rc<IggyMessageBus>,
shutdown_flag: Arc<AtomicBool>,
drain_timeout: Duration,
poll_interval: Duration,
) -> compio::runtime::JoinHandle<()> {
let bus_for_task = Rc::clone(&bus);
let bus_token = bus.token();
compio::runtime::spawn(async move {
loop {
if shutdown_flag.load(Ordering::Relaxed) {
break;
}
if bus_token.is_triggered() {
// Bus shutdown was driven from elsewhere (e.g. internal
// failure path). Watchdog has nothing left to do.
return;
}
compio::time::sleep(poll_interval).await;
}
let _ = bus_for_task.shutdown(drain_timeout).await;
})
}
/// Stop senders of the background loops `shard_main` spawns, fired together
/// on `shard_main`'s bind-failure and normal-shutdown exits.
pub(in crate::boot) struct StopSignals {
pub(in crate::boot) pump: Sender<()>,
pub(in crate::boot) reconciler: Sender<()>,
pub(in crate::boot) heartbeat: Option<Sender<()>>,
pub(in crate::boot) pat_cleaner: Option<Sender<()>>,
pub(in crate::boot) segment_cleaner: Option<Sender<()>>,
pub(in crate::boot) sysinfo_printer: Option<Sender<()>>,
pub(in crate::boot) consumer_group_liveness: Option<Sender<()>>,
}
impl StopSignals {
/// Best-effort: a loop that already exited has dropped its receiver.
pub(in crate::boot) fn fire(&self) {
let _ = self.pump.try_send(());
let _ = self.reconciler.try_send(());
for stop in [
&self.heartbeat,
&self.pat_cleaner,
&self.segment_cleaner,
&self.sysinfo_printer,
&self.consumer_group_liveness,
]
.into_iter()
.flatten()
{
let _ = stop.try_send(());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn given_invalid_poll_completion_capacity_when_boot_validates_should_report_setting() {
for capacity in [0, INBOX_CAPACITY_MAX + 1] {
let sharding = configs::sharding::ShardingConfig {
poll_completion_capacity: capacity,
..configs::sharding::ShardingConfig::default()
};
let error = validate_sharding_runtime_knobs(&sharding)
.expect_err("boot must reject invalid completion capacity before allocation");
assert!(matches!(
error,
ServerError::InvalidPollCompletionCapacity { value, max }
if value == capacity && max == INBOX_CAPACITY_MAX
));
}
}
#[test]
fn given_poll_completion_capacity_at_boundaries_when_boot_validates_should_accept() {
for capacity in [1, INBOX_CAPACITY_MAX] {
let sharding = configs::sharding::ShardingConfig {
poll_completion_capacity: capacity,
..configs::sharding::ShardingConfig::default()
};
assert!(
validate_sharding_runtime_knobs(&sharding).is_ok(),
"boot rejected completion capacity {capacity}"
);
}
}
#[test]
fn shutdown_on_drop_armed_flips_flag() {
let flag = Arc::new(AtomicBool::new(false));
drop(ShutdownOnDrop::new(Arc::clone(&flag)));
assert!(
flag.load(Ordering::Relaxed),
"an armed guard must flip the flag on drop (covers the error `?` \
and panic-unwind exit paths of run_shard_thread)"
);
}
#[test]
fn shutdown_on_drop_disarmed_leaves_flag() {
let flag = Arc::new(AtomicBool::new(false));
let mut guard = ShutdownOnDrop::new(Arc::clone(&flag));
guard.disarm();
drop(guard);
assert!(
!flag.load(Ordering::Relaxed),
"a disarmed guard must not flip the flag (clean `Ok(())` exit)"
);
}
#[test]
fn peer_exit_countdown_releases_the_waiter_once_every_peer_is_out() {
let countdown = Arc::new(PeerExitCountdown::new(2));
let guards: [PeerExitGuard; 2] =
std::array::from_fn(|_| PeerExitGuard::new(Arc::clone(&countdown)));
assert_eq!(
countdown.wait(Duration::from_millis(10)),
Err(2),
"two live peers must hold the waiter past a short deadline"
);
let peers: Vec<_> = guards
.into_iter()
.map(|guard| {
thread::spawn(move || {
thread::sleep(Duration::from_millis(20));
drop(guard);
})
})
.collect();
assert_eq!(
countdown.wait(Duration::from_secs(30)),
Ok(()),
"the last guard drop must release the waiter"
);
for peer in peers {
peer.join()
.expect("peer thread dropped its guard without panicking");
}
}
#[test]
fn peer_exit_guard_counts_out_during_unwind() {
let countdown = Arc::new(PeerExitCountdown::new(1));
let guard = PeerExitGuard::new(Arc::clone(&countdown));
// `resume_unwind` skips the panic hook, so the unwind is silent.
let unwound = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let _guard = guard;
panic::resume_unwind(Box::new("peer shard body panicked"));
}));
assert!(unwound.is_err());
assert_eq!(
countdown.wait(Duration::ZERO),
Ok(()),
"a guard dropped by a panic unwind must still count its peer out"
);
}
/// Stops a parked stand-in thread once the test's binding goes out of
/// scope. The joiner under test drops the `JoinHandle`, so the thread
/// cannot be joined back; flagging it keeps it from parking for the life
/// of the test binary.
struct ThreadStopper(Arc<AtomicBool>);
impl Drop for ThreadStopper {
fn drop(&mut self) {
self.0.store(true, Ordering::Relaxed);
}
}
/// A shard thread that never returns on its own: the stand-in for a
/// wedged pump that the deadline tests need.
fn spawn_wedged_shard() -> (thread::JoinHandle<Result<(), ServerError>>, ThreadStopper) {
let stop = Arc::new(AtomicBool::new(false));
let handle = thread::spawn({
let stop = Arc::clone(&stop);
move || -> Result<(), ServerError> {
while !stop.load(Ordering::Relaxed) {
thread::sleep(JOIN_POLL_INTERVAL);
}
Ok(())
}
});
(handle, ThreadStopper(stop))
}
#[test]
fn peer_exit_wait_flips_the_flag_before_waiting() {
// Stands in for a peer parked on its bus token: it exits only once
// the shutdown flag is set, so a waiter that set the flag after
// waiting would sit out the whole budget.
let countdown = Arc::new(PeerExitCountdown::new(1));
let shutdown_flag = Arc::new(AtomicBool::new(false));
let peer = thread::spawn({
let guard = PeerExitGuard::new(Arc::clone(&countdown));
let shutdown_flag = Arc::clone(&shutdown_flag);
move || {
while !shutdown_flag.load(Ordering::Relaxed) {
thread::sleep(Duration::from_millis(1));
}
drop(guard);
}
});
let started = Instant::now();
drop(PeerExitWait::new(
countdown,
Arc::clone(&shutdown_flag),
Arc::new(ShutdownDeadline::new(
Duration::from_secs(30),
Duration::from_secs(10),
)),
));
assert!(
started.elapsed() < Duration::from_secs(5),
"the waiter must not sit out its budget on a peer that waits for the flag"
);
assert!(shutdown_flag.load(Ordering::Relaxed));
peer.join()
.expect("peer thread dropped its guard without panicking");
}
#[test]
fn peer_exit_wait_gives_up_at_the_deadline() {
let countdown = Arc::new(PeerExitCountdown::new(1));
let _wedged_peer = PeerExitGuard::new(Arc::clone(&countdown));
// Degenerate budget == floor: the floor cannot extend the wait, so
// the abandon is bounded by the budget alone.
let timeout = Duration::from_millis(50);
let started = Instant::now();
drop(PeerExitWait::new(
countdown,
Arc::new(AtomicBool::new(false)),
Arc::new(ShutdownDeadline::new(timeout, timeout)),
));
let waited = started.elapsed();
assert!(
waited >= timeout && waited < Duration::from_secs(5),
"a wedged peer must be abandoned at the deadline, waited {waited:?}"
);
}
/// Regression: the two waits nest (shard 0 cannot start waiting for its
/// peers until its own drain returned, already inside the join budget).
/// With a budget each, a clean shutdown reported shard 0 as wedged and
/// exited non-zero. What is left of the shared budget is what the peer
/// wait gets, whenever that clears the floor.
#[test]
fn peer_wait_inherits_the_shared_budget_above_its_floor() {
const BUDGET: Duration = Duration::from_secs(30);
const FLOOR: Duration = Duration::from_secs(10);
let deadline = ShutdownDeadline::new(BUDGET, FLOOR);
let inherited = deadline.remaining_for_peer_wait();
assert!(
inherited > FLOOR && inherited <= BUDGET,
"an unspent budget must be inherited, not replaced by the floor: {inherited:?}"
);
}
/// The other half of the same nesting: the join budget bounds LIVENESS
/// (process exit must not hang on a wedged shard) while this wait enforces
/// SAFETY (shard 0 owns the metadata write handle every peer reads
/// through). Funding the fence out of the join budget let an exit-latency
/// timeout cancel it: the drain spent the budget, `wait(ZERO)` returned
/// without blocking, and shard 0 dropped the writer under a live reader.
#[test]
fn peer_wait_honours_a_live_peer_past_a_spent_join_budget() {
const BUDGET: Duration = Duration::from_millis(20);
const FLOOR: Duration = Duration::from_millis(200);
let deadline = Arc::new(ShutdownDeadline::new(BUDGET, FLOOR));
let shutdown_flag = AtomicBool::new(true);
// Stands in for the slow drain that arms and then spends the shared
// budget before shard 0 can reach its peer wait.
let (wedged_shard, _stopper) = spawn_wedged_shard();
assert!(
join_until_shutdown_deadline(wedged_shard, &shutdown_flag, &deadline, Duration::ZERO)
.is_none(),
"the join must spend the budget it armed"
);
assert!(deadline.remaining().is_zero(), "the budget must be spent");
let countdown = Arc::new(PeerExitCountdown::new(1));
let _live_peer = PeerExitGuard::new(Arc::clone(&countdown));
let started = Instant::now();
drop(PeerExitWait::new(
countdown,
Arc::new(AtomicBool::new(false)),
Arc::clone(&deadline),
));
let waited = started.elapsed();
assert!(
waited >= FLOOR,
"a spent join budget must not release the metadata writer while a peer still reads \
through it, waited {waited:?}"
);
}
#[test]
fn peer_exit_wait_ignores_peers_that_never_spawned() {
// A failed spawn leaves peers with no guard to count them out; the
// one that did spawn must still be the only thing the waiter waits on.
let countdown = Arc::new(PeerExitCountdown::new(3));
countdown.peers_never_spawned(2);
let spawned = PeerExitGuard::new(Arc::clone(&countdown));
assert_eq!(
countdown.wait(Duration::from_millis(10)),
Err(1),
"the peer that did spawn must still hold the waiter"
);
drop(spawned);
assert_eq!(countdown.wait(Duration::ZERO), Ok(()));
}
#[test]
fn peer_exit_wait_with_no_peers_returns_at_once() {
let started = Instant::now();
drop(PeerExitWait::new(
Arc::new(PeerExitCountdown::new(0)),
Arc::new(AtomicBool::new(false)),
Arc::new(ShutdownDeadline::new(
Duration::from_secs(30),
Duration::from_secs(10),
)),
));
assert!(
started.elapsed() < Duration::from_secs(1),
"a single-shard server has nobody to wait for"
);
}
#[test]
fn peer_exit_wait_never_blocks_an_unwinding_thread() {
let countdown = Arc::new(PeerExitCountdown::new(1));
let _still_running = PeerExitGuard::new(Arc::clone(&countdown));
let wait = PeerExitWait::new(
countdown,
Arc::new(AtomicBool::new(false)),
Arc::new(ShutdownDeadline::new(
Duration::from_secs(30),
Duration::from_secs(10),
)),
);
let started = Instant::now();
let unwound = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let _wait = wait;
panic::resume_unwind(Box::new("shard 0 body panicked"));
}));
assert!(unwound.is_err());
assert!(
started.elapsed() < Duration::from_secs(1),
"a waiter dropped during unwind must return without waiting"
);
}
#[compio::test]
async fn pump_drain_timeout_is_not_reported_as_clean() {
let mut config = ServerConfig::default();
let timeout = Duration::from_millis(1);
config.sharding.shutdown_drain_timeout = iggy_common::IggyDuration::new(timeout);
let pump = compio::runtime::spawn(std::future::pending::<Option<FatalCommit>>());
let error = await_pump_drain(Some(pump), &config, 7)
.await
.expect_err("a live pump past the drain budget is not a clean exit");
assert!(matches!(
error,
ServerError::ShardPumpDrainTimedOut {
shard_id: 7,
timeout: actual,
} if actual == timeout
));
}
#[compio::test]
async fn pump_stopped_by_a_commit_fault_is_not_reported_as_clean() {
// The pump drained and flushed, so the join succeeds. Reporting that
// as a clean exit would hand an orchestrator exit code 0 for a node
// that stopped because it could not persist a cluster-committed op.
let config = ServerConfig::default();
let fault = FatalCommit {
namespace_raw: 42,
op: 7,
operation: Some(iggy_binary_protocol::Operation::SendMessages),
};
let pump = compio::runtime::spawn(async move { Some(fault) });
let error = await_pump_drain(Some(pump), &config, 3)
.await
.expect_err("a pump that stopped on a commit fault is not a clean exit");
assert!(matches!(
error,
ServerError::ShardFatal {
shard_id: 3,
namespace_raw: 42,
op: 7,
}
));
}
/// Regression: the shutdown-join deadline must arm at SHUTDOWN, not
/// at boot. The original bound measured from `join_all` entry, so any
/// healthy server outliving `shutdown_join_timeout` (30s default) was
/// abandoned as "wedged" and the process exited - every BDD run died
/// at t+30s while the test container was still compiling.
#[test]
fn join_waits_unbounded_while_the_server_runs() {
let shutdown_flag = AtomicBool::new(false);
// Thread outlives a deliberately tiny join budget; with the flag
// clear the budget must never even arm.
let handle = thread::spawn(|| -> Result<(), ServerError> {
thread::sleep(Duration::from_millis(300));
Ok(())
});
let deadline = ShutdownDeadline::new(Duration::from_millis(20), Duration::from_millis(20));
let joined =
join_until_shutdown_deadline(handle, &shutdown_flag, &deadline, Duration::ZERO);
assert!(
matches!(joined, Some(Ok(Ok(())))),
"a running server must be awaited indefinitely, not abandoned as wedged"
);
assert!(
deadline.armed.get().is_none(),
"the join deadline must not arm before the shutdown flag flips"
);
}
#[test]
fn join_all_fails_the_exit_on_a_panic_no_thread_surfaced() {
// A listener or connection task panic leaves every shard thread
// exiting Ok; only the hook's record can keep that from reading
// as a clean shutdown to an orchestrator.
let first_panic = Arc::new(OnceLock::new());
first_panic
.set("thread 'shard-0' panicked at listener.rs:1:1: boom".to_string())
.expect("a fresh slot accepts the first record");
let handle = thread::spawn(|| -> Result<(), ServerError> { Ok(()) });
let handles = ShardHandles {
shutdown_flag: Arc::new(AtomicBool::new(true)),
shard_threads: vec![(0, handle)],
deadline: Arc::new(ShutdownDeadline::new(
Duration::from_secs(1),
Duration::from_millis(500),
)),
first_panic,
};
let error = handles
.join_all()
.expect_err("a recorded panic must fail the exit even when every thread exited Ok");
assert!(
matches!(&error, ServerError::Panicked { description } if description.contains("boom")),
"unexpected error: {error}"
);
}
#[test]
fn panic_hook_records_the_panic_and_flips_the_shutdown_flag() {
let shutdown_flag = Arc::new(AtomicBool::new(false));
let first_panic = install_panic_hook(Arc::clone(&shutdown_flag));
let joined = thread::Builder::new()
.name("shard-7".to_string())
.spawn(|| panic!("injected task panic"))
.expect("spawn")
.join();
assert!(joined.is_err(), "the thread must have panicked");
assert!(
shutdown_flag.load(Ordering::Relaxed),
"a panic anywhere must drive the whole server down"
);
let description = first_panic.get().expect("the hook records the first panic");
assert!(
description.starts_with("thread 'shard-7' panicked at ")
&& description.ends_with(": injected task panic"),
"unexpected record: {description}"
);
}
#[test]
fn join_abandons_a_wedged_shard_after_the_shutdown_deadline() {
let shutdown_flag = AtomicBool::new(true);
let (handle, _stopper) = spawn_wedged_shard();
let deadline = ShutdownDeadline::new(Duration::from_millis(100), Duration::from_millis(50));
let joined =
join_until_shutdown_deadline(handle, &shutdown_flag, &deadline, Duration::ZERO);
assert!(
joined.is_none(),
"a shard still running past the post-shutdown budget must be abandoned"
);
assert!(
deadline.armed.get().is_some(),
"the deadline arms once the flag is set"
);
}
}