Files
seaweedfs/seaweed-volume/src/storage/io_error.rs
T
bd953b0f84 volume server: group-commit fsync writes in the write queue (#11543)
* volume: split the write path into reusable steps

do_write_request ran its pre-append checks, the append, the sync
rollback, the index publish and the post-write bookkeeping inline, so a
batched write could only reuse it one needle at a time. Pull the steps
out (check_writable, prepare_write, undo_unsynced_append, publish_write,
finish_write) and the store's volume lookup plus disk-space check
(writable_volume_mut). do_write_request composes them in the same order
with the same early returns; no behaviour change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* volume server: group-commit fsync writes in the write queue

The write queue holds one store lock for a batch of up to 128 needles
but wrote them one at a time, so every fsync needle paid its own .dat
sync and its own .idx sync: 2N syncs per batch.

Add Volume::write_needles_grouped, after Go's processBatch. A volume's
entries are split into runs of distinct needle ids (a repeated id starts
a new run, so its dedup and cookie checks see the earlier write). A run
with a durable entry appends everything with append_at_ns chained
through a local, syncs the .dat once, and only then publishes the
entries and syncs the .idx once. A failed .dat sync truncates the .dat
back to the run start (marking the volume unavailable if that fails),
leaves last_append_at_ns and last_modified untouched, and fails every
entry of the run. Runs with no durable entry go through the unchanged
per-needle path. Store::write_volume_needles is the queue's entry point;
the handlers' non-queue path is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* volume server: fail closed when a failed append's rollback fails

append_needle discarded the truncate-back result, so a partial write
that could not be rolled back left unindexed bytes on the .dat while
the volume stayed writable; the next append would bury them mid-file,
past the load-time tail check. Route the rollback through
undo_unsynced_append, which marks the volume unavailable when the
truncate fails, so nothing more is appended over an unverified tail.

* volume server: keep a grouped run's I/O error streak from later appends

A synced run stages every append before any entry finishes, so the
success reset in finish_write ran after the failed appends queued
behind the last write to land and erased their media-error streak.
Sent one at a time, those errors would have counted and quarantined
the volume. Skip the reset when an append after the last landed write
added to the streak.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* volume server: replay a grouped run's I/O error streak in queue order

Skipping the run's success reset whenever an append after the last
landed write failed kept the errors from before that write as well,
so a run like [EIO, EIO, landed, EIO] reached the quarantine count
that the same writes one at a time (one error) do not. Mark the
streak where each entry is staged and record the run's success at
the last landed write's mark: errors before it are cleared, the ones
after it still count.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* volume server: replay a run's I/O error streak in one atomic step

Reads record their outcomes on the tracker without the volume's write
lock, so record_success_at's separate load and store could drop an
error a read counted in between, or restore errors a read had just
cleared. Keep the count and the clear counter in one atomic word and
apply the replay with a single fetch_update.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-10-01 20:59:40 +08:00

355 lines
12 KiB
Rust

//! Consecutive storage-media error tracking shared by `Volume` and
//! `EcVolume`. Mirrors Go's `weed/storage/io_error.go`.
use std::io;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
/// Consecutive storage-media errors allowed before the volume is quarantined.
pub(crate) const IO_ERROR_TOLERANCE: i32 = 3;
/// Returns true for I/O errors that indicate faulty storage media, not
/// transient/network failures. On Unix this is EIO; on Windows it covers
/// ERROR_CRC and ERROR_IO_DEVICE, which the kernel returns for failing disks.
pub(crate) fn is_storage_io_error(e: &io::Error) -> bool {
#[cfg(unix)]
{
e.raw_os_error() == Some(libc::EIO)
}
#[cfg(windows)]
{
const ERROR_CRC: i32 = 23;
const ERROR_IO_DEVICE: i32 = 1117;
return e.raw_os_error() == Some(ERROR_CRC) || e.raw_os_error() == Some(ERROR_IO_DEVICE);
}
#[cfg(not(any(unix, windows)))]
{
false
}
}
/// Consecutive storage-media error state for one volume. `quarantined` is
/// sticky: once set it survives later successful I/O and is lifted only by
/// `reset_io_error_state`.
#[derive(Default)]
pub(crate) struct IoErrorTracker {
last: Mutex<Option<String>>,
/// The consecutive error count in the low 32 bits and, in the high 32,
/// how many times it has been cleared. They share one word so that
/// `record_success_at` updates both in one step: reads record their
/// outcomes here without the volume's write lock.
streak: AtomicU64,
quarantined: AtomicBool,
}
const STREAK_COUNT_BITS: u64 = 0xffff_ffff;
fn streak_count(streak: u64) -> i32 {
(streak & STREAK_COUNT_BITS) as i32
}
/// `streak` with its count cleared and one more clear on record.
fn streak_cleared(streak: u64) -> u64 {
(streak >> 32).wrapping_add(1) << 32
}
/// A point in the error streak, taken where a write landed whose success
/// is only recorded later. See `IoErrorTracker::record_success_at`.
#[derive(Clone, Copy)]
pub(crate) struct StreakMark(u64);
impl IoErrorTracker {
/// `Some(e)` records a failure, `None` a success. Only storage-media
/// failures count; every other outcome clears the count and last error.
pub(crate) fn check_read_write_error(&self, err: Option<&io::Error>) {
if let Some(e) = err
&& is_storage_io_error(e)
{
self.streak.fetch_add(1, Ordering::Relaxed);
if let Ok(mut guard) = self.last.lock() {
*guard = Some(e.to_string());
}
crate::metrics::STORAGE_IO_ERROR_COUNTER.inc();
return;
}
self.clear_count();
self.clear_last();
}
fn clear_count(&self) {
self.update_streak(|streak| Some(streak_cleared(streak)));
}
fn clear_last(&self) {
if let Ok(mut guard) = self.last.lock()
&& guard.is_some()
{
*guard = None;
}
}
/// Apply `f` to the streak atomically; `None` leaves it as it is.
/// Returns the streak `f` produced, if any.
fn update_streak(&self, mut f: impl FnMut(u64) -> Option<u64>) -> Option<u64> {
let mut updated = None;
let _ = self
.streak
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |streak| {
updated = f(streak);
updated
});
updated
}
pub(crate) fn mark(&self) -> StreakMark {
StreakMark(self.streak.load(Ordering::Relaxed))
}
/// Record a success as if it had come at `mark`: the errors counted
/// before the mark are cleared and the ones counted since still stand,
/// as they would had each outcome been recorded in order. A streak
/// cleared since the mark is left as it is.
pub(crate) fn record_success_at(&self, mark: StreakMark) {
let before = streak_count(mark.0);
let updated = self.update_streak(|streak| {
if streak >> 32 != mark.0 >> 32 {
return None;
}
if streak_count(streak) <= before {
return Some(streak_cleared(streak));
}
Some(streak - before as u64)
});
// The last error stays when one counted since the mark is left.
if updated.is_some_and(|streak| streak_count(streak) == 0) {
self.clear_last();
}
}
/// The last recorded error, the consecutive count, and the quarantine flag.
pub(crate) fn get_io_error_state(&self) -> (Option<String>, i32, bool) {
let err = self.last.lock().ok().and_then(|g| g.clone());
let count = self.count();
let quarantined = self.quarantined.load(Ordering::Relaxed);
(err, count, quarantined)
}
fn count(&self) -> i32 {
streak_count(self.streak.load(Ordering::Relaxed))
}
pub(crate) fn should_quarantine(&self) -> bool {
self.quarantined.load(Ordering::Relaxed) || self.count() >= IO_ERROR_TOLERANCE
}
pub(crate) fn mark_io_quarantined(&self) {
self.quarantined.store(true, Ordering::Relaxed);
}
pub(crate) fn reset_io_error_state(&self) {
self.clear_count();
self.quarantined.store(false, Ordering::Relaxed);
if let Ok(mut guard) = self.last.lock() {
*guard = None;
}
}
#[cfg(test)]
pub(crate) fn set_last_io_error_for_test(&self, err: Option<&str>) {
if let Ok(mut guard) = self.last.lock() {
*guard = err.map(|value| value.to_string());
}
if err.is_some() {
self.update_streak(|streak| {
Some((streak & !STREAK_COUNT_BITS) | IO_ERROR_TOLERANCE as u64)
});
} else {
self.clear_count();
}
}
}
// The tracker only reacts to errors `is_storage_io_error` recognises, which is
// nothing at all on a platform that is neither Unix nor Windows.
#[cfg(all(test, any(unix, windows)))]
mod tests {
use super::*;
/// An OS error the platform reports for failing storage media.
#[cfg(unix)]
fn media_error() -> io::Error {
io::Error::from_raw_os_error(libc::EIO)
}
/// An OS error the platform reports for failing storage media.
#[cfg(windows)]
fn media_error() -> io::Error {
const ERROR_IO_DEVICE: i32 = 1117;
io::Error::from_raw_os_error(ERROR_IO_DEVICE)
}
#[test]
fn check_read_write_error_counts_consecutive_media_errors() {
let tracker = IoErrorTracker::default();
tracker.check_read_write_error(Some(&media_error()));
tracker.check_read_write_error(Some(&media_error()));
let (last, count, quarantined) = tracker.get_io_error_state();
assert_eq!(last, Some(media_error().to_string()));
assert_eq!(count, 2);
assert!(!quarantined);
}
#[test]
fn success_clears_the_count_and_the_last_error() {
let tracker = IoErrorTracker::default();
tracker.check_read_write_error(Some(&media_error()));
tracker.check_read_write_error(None);
assert_eq!(tracker.get_io_error_state(), (None, 0, false));
}
#[test]
fn non_media_error_clears_the_count() {
let tracker = IoErrorTracker::default();
tracker.check_read_write_error(Some(&media_error()));
tracker.check_read_write_error(Some(&io::Error::new(
io::ErrorKind::NotFound,
"no such file",
)));
assert_eq!(tracker.get_io_error_state(), (None, 0, false));
}
#[test]
fn should_quarantine_only_once_the_tolerance_is_reached() {
let tracker = IoErrorTracker::default();
for _ in 1..IO_ERROR_TOLERANCE {
tracker.check_read_write_error(Some(&media_error()));
assert!(!tracker.should_quarantine());
}
tracker.check_read_write_error(Some(&media_error()));
assert!(tracker.should_quarantine());
}
#[test]
fn quarantine_survives_later_successful_io() {
let tracker = IoErrorTracker::default();
tracker.mark_io_quarantined();
tracker.check_read_write_error(None);
assert_eq!(tracker.get_io_error_state(), (None, 0, true));
assert!(tracker.should_quarantine());
}
#[test]
fn success_at_a_mark_keeps_only_the_errors_after_it() {
let tracker = IoErrorTracker::default();
tracker.check_read_write_error(Some(&media_error()));
tracker.check_read_write_error(Some(&media_error()));
let mark = tracker.mark();
tracker.check_read_write_error(Some(&media_error()));
tracker.record_success_at(mark);
assert_eq!(
tracker.get_io_error_state(),
(Some(media_error().to_string()), 1, false)
);
}
#[test]
fn success_at_a_mark_with_nothing_after_it_clears_the_streak() {
let tracker = IoErrorTracker::default();
tracker.check_read_write_error(Some(&media_error()));
let mark = tracker.mark();
tracker.record_success_at(mark);
assert_eq!(tracker.get_io_error_state(), (None, 0, false));
}
#[test]
fn success_at_a_mark_leaves_a_streak_cleared_since() {
let tracker = IoErrorTracker::default();
tracker.check_read_write_error(Some(&media_error()));
tracker.check_read_write_error(Some(&media_error()));
let mark = tracker.mark();
tracker.check_read_write_error(None);
tracker.check_read_write_error(Some(&media_error()));
tracker.record_success_at(mark);
assert_eq!(
tracker.get_io_error_state(),
(Some(media_error().to_string()), 1, false)
);
}
/// Reads update the tracker without the volume's write lock, so a
/// success replayed at a mark must not lose the errors they record
/// while it runs.
#[test]
fn success_at_a_mark_keeps_concurrent_errors() {
use std::sync::{Arc, Barrier};
const READERS: i32 = 4;
const ERRORS: i32 = 200;
for _ in 0..500 {
let tracker = Arc::new(IoErrorTracker::default());
tracker.check_read_write_error(Some(&media_error()));
tracker.check_read_write_error(Some(&media_error()));
let mark = tracker.mark();
let start = Arc::new(Barrier::new(READERS as usize + 1));
let readers: Vec<_> = (0..READERS)
.map(|_| {
let (tracker, start) = (tracker.clone(), start.clone());
std::thread::spawn(move || {
start.wait();
for _ in 0..ERRORS {
tracker.check_read_write_error(Some(&media_error()));
}
})
})
.collect();
start.wait();
while tracker.get_io_error_state().1 < 2 + READERS * ERRORS / 2 {
std::hint::spin_loop();
}
tracker.record_success_at(mark);
for reader in readers {
reader.join().unwrap();
}
// The two errors before the mark are cleared; every error the
// readers recorded after it stands.
assert_eq!(tracker.get_io_error_state().1, READERS * ERRORS);
}
}
#[test]
fn reset_io_error_state_lifts_the_quarantine() {
let tracker = IoErrorTracker::default();
tracker.check_read_write_error(Some(&media_error()));
tracker.mark_io_quarantined();
tracker.reset_io_error_state();
assert_eq!(tracker.get_io_error_state(), (None, 0, false));
assert!(!tracker.should_quarantine());
}
#[test]
fn test_helper_arms_a_sustained_error() {
let tracker = IoErrorTracker::default();
tracker.set_last_io_error_for_test(Some("input/output error"));
assert!(tracker.should_quarantine());
assert_eq!(
tracker.get_io_error_state(),
(
Some("input/output error".to_string()),
IO_ERROR_TOLERANCE,
false
)
);
tracker.set_last_io_error_for_test(None);
assert_eq!(tracker.get_io_error_state(), (None, 0, false));
}
}