blob: 8796077f6e11a267ffb8239cd088a426fc1d51ac [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.
//! Stopping the process on an environmental failure that has no in-process answer.
use iggy_common::IggyTimestamp;
use nix::errno::Errno;
use nix::sys::resource::{Resource, getrlimit};
use std::io::{self, Write};
use std::sync::OnceLock;
/// Why the process is stopping. The discriminant is the exit status, one per
/// condition; `1` stays the binary's generic startup failure.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum FatalReason {
/// A prepare's WAL append failed and the op it had claimed could not be handed
/// back. The durable log is intact up to the previous op, so recovery re-derives
/// the frontier and restarting is the repair.
UnreconcilableLogFrontier = 2,
/// The superblock stayed unwritable past the configured fail-stop window.
/// The replica was already fenced quorum-invisible, so exiting hands the
/// wedge to a supervisor instead of a log reader.
SuperblockWedged = 3,
/// The server stopped on an error after a storage open for a write, a sync
/// or a partition load failed because the process (`EMFILE`) or the host
/// (`ENFILE`) had no free file descriptor. The exit comes after the
/// ordinary shutdown, so the shards flushed what they could. See
/// [`NoteDescriptorExhaustion`].
///
/// The other reasons keep their own status after such a failure, for
/// example a superblock that stays unwritable for lack of a descriptor
/// exits 3. Their exit line then gives the time of the first one.
DescriptorsExhausted = 4,
}
impl FatalReason {
#[must_use]
pub const fn exit_status(self) -> u8 {
self as u8
}
}
/// The target that 0.9.0 shipped for the fatal log line, so existing log
/// filters keep matching.
const FATAL_LOG_TARGET: &str = "iggy.consensus.diag";
/// Log `message` and terminate the process.
///
/// For an environmental failure where stopping IS the answer, rather than an error
/// threaded up a stack whose top knows less than this leaf does. Not for bugs in
/// this process, which are `assert!` / `panic!` and say so.
///
/// `exit`, not `panic!`: a panic unwinds one shard of a thread-per-core runtime and
/// leaves its siblings serving, which is the half-alive state this exists to avoid.
/// Skipping destructors is wanted here, since the reason for stopping is that
/// further writes cannot be trusted.
///
/// The reason also goes straight to stderr. The tracing appenders are
/// non-blocking workers, and `exit` stops them before they flush, so without
/// this write a supervisor's journal never learns why the process stopped. The
/// write result is ignored because `eprintln!` would panic on a broken stderr.
///
/// If a storage open found no free file descriptor earlier, the line gives the
/// time of the first one, whatever the reason. See [`NoteDescriptorExhaustion`].
pub fn fatal(reason: FatalReason, message: &str) -> ! {
fatal_with_log_flush(reason, message, || {});
}
/// [`fatal`] for the owner of the log appenders. `flush_logs` runs after the
/// log event and before the exit, so the event reaches the log files too.
pub fn fatal_with_log_flush(reason: FatalReason, message: &str, flush_logs: impl FnOnce()) -> ! {
let exhaustion = first_descriptor_exhaustion()
.map(|at| format!("; a storage open first found no free file descriptor at {at} UTC"))
.unwrap_or_default();
tracing::error!(
target: FATAL_LOG_TARGET,
reason = ?reason,
exit_status = reason.exit_status(),
"{message}{exhaustion}"
);
flush_logs();
let _ = writeln!(
std::io::stderr().lock(),
"iggy fatal: reason={reason:?} exit_status={}: {message}{exhaustion}",
reason.exit_status()
);
std::process::exit(i32::from(reason.exit_status()));
}
/// Storage results that record a missing file descriptor for the exit status.
///
/// Only for opens that write, create, truncate or sync, and for read-only
/// opens that are one step of such a write or of a partition load: the
/// existence probes of segment setup, an open that exists only to sync, and
/// the reads of partition recovery. The error still goes to the caller, whose
/// own handling decides what happens. A failed partition write fences the
/// partition and stops the server through the shutdown flush. Stopping at the
/// open instead would skip that flush and lose committed messages that are not
/// yet in a segment. A failed partition load stops the server at boot, and at
/// run time the reconciler retries it on a later pass.
///
/// If the server then stops on an error, it exits with
/// [`FatalReason::DescriptorsExhausted`] instead of 1. A stop through
/// [`fatal`] keeps the status of its own reason. Any other failed read fails
/// only its own request, so it does not record, and accept loops do not
/// either, see `message_bus::accept`.
///
/// The record lasts for the life of the process. An exhaustion that the server
/// recovers from, such as a superblock write that succeeds on a retry, therefore
/// also sets the status of a later stop on an unrelated error. The exit line
/// gives the time of the first exhaustion, so the two can be told apart.
pub trait NoteDescriptorExhaustion: Sized {
/// Pass the result through. If it failed with `EMFILE` or `ENFILE`, record
/// that for [`descriptors_exhausted`], and log the first one with the
/// limits. `operation` names what was being opened, for that log line.
#[must_use]
fn note_descriptor_exhaustion(self, operation: impl FnOnce() -> String) -> Self;
}
/// The time of the first noted exhaustion.
static FIRST_DESCRIPTOR_EXHAUSTION: OnceLock<IggyTimestamp> = OnceLock::new();
impl<T> NoteDescriptorExhaustion for io::Result<T> {
fn note_descriptor_exhaustion(self, operation: impl FnOnce() -> String) -> Self {
if let Err(error) = &self
&& is_descriptor_exhaustion(error)
&& FIRST_DESCRIPTOR_EXHAUSTION
.set(IggyTimestamp::now())
.is_ok()
{
tracing::error!(
target: FATAL_LOG_TARGET,
"no free file descriptor while {}: {error} ({}); if the server stops \
on an error, it exits with status {}, and a fail-stop with its own \
status keeps that status",
operation(),
open_file_limits(),
FatalReason::DescriptorsExhausted.exit_status()
);
}
self
}
}
/// Whether a recorded storage open of this process failed with `EMFILE` or
/// `ENFILE`. See [`NoteDescriptorExhaustion`].
#[must_use]
pub fn descriptors_exhausted() -> bool {
first_descriptor_exhaustion().is_some()
}
fn first_descriptor_exhaustion() -> Option<IggyTimestamp> {
FIRST_DESCRIPTOR_EXHAUSTION.get().copied()
}
/// `EMFILE` (this process is at its `RLIMIT_NOFILE`) or `ENFILE` (the host is
/// at its system-wide file table limit).
#[must_use]
pub fn is_descriptor_exhaustion(error: &io::Error) -> bool {
error
.raw_os_error()
.is_some_and(|code| code == Errno::EMFILE as i32 || code == Errno::ENFILE as i32)
}
fn open_file_limits() -> String {
getrlimit(Resource::RLIMIT_NOFILE).map_or_else(
|errno| format!("RLIMIT_NOFILE unreadable: {errno}"),
|(soft, hard)| format!("RLIMIT_NOFILE soft={soft} hard={hard}"),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn given_emfile_or_enfile_when_classifying_should_report_exhaustion() {
for errno in [Errno::EMFILE, Errno::ENFILE] {
assert!(is_descriptor_exhaustion(&io::Error::from_raw_os_error(
errno as i32
)));
}
}
#[test]
fn given_other_error_when_classifying_should_not_report_exhaustion() {
assert!(!is_descriptor_exhaustion(&io::Error::from_raw_os_error(
Errno::ENOENT as i32
)));
assert!(!is_descriptor_exhaustion(&io::Error::other(
"Too many open files"
)));
}
#[test]
fn given_exhaustion_error_when_noting_result_should_record_it_and_pass_it_through() {
let other: io::Result<()> = Err(io::Error::from_raw_os_error(Errno::ENOENT as i32));
let noted = other.note_descriptor_exhaustion(|| "opening a test file".to_owned());
assert_eq!(
noted.map_err(|error| error.kind()),
Err(io::ErrorKind::NotFound)
);
assert!(!descriptors_exhausted());
let exhausted: io::Result<()> = Err(io::Error::from_raw_os_error(Errno::EMFILE as i32));
let noted = exhausted.note_descriptor_exhaustion(|| "opening a test file".to_owned());
assert_eq!(
noted.map_err(|error| error.raw_os_error()),
Err(Some(Errno::EMFILE as i32))
);
assert!(descriptors_exhausted());
let first = first_descriptor_exhaustion().expect("the first exhaustion has a time");
let again: io::Result<()> = Err(io::Error::from_raw_os_error(Errno::ENFILE as i32));
let _ = again.note_descriptor_exhaustion(|| "opening a test file".to_owned());
assert_eq!(
first_descriptor_exhaustion(),
Some(first),
"a later exhaustion must keep the time of the first one"
);
}
}