Revert "io/writeback: medium-agnostic by construction, no DONTNEED, single detection point"
This reverts commit 41d4c6b464.
This commit is contained in:
+96
-127
@@ -22,48 +22,34 @@
|
|||||||
//! fast storage (NVMe) sees smaller chunks to keep cache pressure
|
//! fast storage (NVMe) sees smaller chunks to keep cache pressure
|
||||||
//! tight. Bounds: [4 MiB, 256 MiB].
|
//! tight. Bounds: [4 MiB, 256 MiB].
|
||||||
//!
|
//!
|
||||||
//! ## Medium-agnostic by construction
|
//! ## NFS escape hatch
|
||||||
//!
|
//!
|
||||||
//! `sync_file_range(WAIT_AFTER)` runs on every medium (local FS, NFS,
|
//! `sync_file_range(WAIT_AFTER)` on an NFS-mounted file can block
|
||||||
//! etc.). It bounds the dirty-page set at ~2 × `chunk_bytes` by
|
//! indefinitely waiting for the server's commit ack. If the server
|
||||||
//! waiting for each chunk's kernel writeback to complete before
|
//! never acks (network partition, server-side hang, slow commit), the
|
||||||
//! advancing. There is no `if is_nfs` branch in the hot path. The
|
//! syscall never returns and the consumer thread is stuck inside the
|
||||||
//! safety net described below ([`WAIT_AFTER_TIMEOUT`]) handles wedged
|
//! kernel — `/api/stop` can't reach it because halt is cooperative.
|
||||||
//! filesystems generically: if a particular FS proves unable to ack
|
|
||||||
//! `WAIT_AFTER` within the deadline, the pipeline flips to `degraded`
|
|
||||||
//! and skips for the rest of its life. That covers the original
|
|
||||||
//! NFS-hang concern (network partition, server stuck on commit)
|
|
||||||
//! without baking the failure mode into a per-FS branch.
|
|
||||||
//!
|
//!
|
||||||
//! **No `posix_fadvise(DONTNEED)` after the wait.** Earlier revisions
|
//! When `fstatfs` reports the file lives on an NFS mount
|
||||||
//! evicted the just-WAIT_AFTER'd range from the page cache on the
|
//! (`f_type == NFS_SUPER_MAGIC`), the pipeline skips the WAIT_AFTER +
|
||||||
//! theory that bounding *dirty* pages required also bounding *clean*
|
//! `posix_fadvise(DONTNEED)` dance entirely. NFS clients have their
|
||||||
//! pages. That was unnecessary and counterproductive: clean cached
|
//! own buffering and commit semantics that handle dirty-page bounds
|
||||||
//! pages cost nothing (the kernel reclaims them under LRU when memory
|
//! without us forcing the issue. The async `SYNC_FILE_RANGE_WRITE`
|
||||||
//! is needed) and DONTNEED forces a read-modify-write on any
|
//! kickoff still runs (non-blocking by spec) so writeback still gets
|
||||||
//! subsequent in-window write — exactly the pattern matroska
|
//! a nudge.
|
||||||
//! cluster-size backpatches produce, costing ~40-50% of measured NFS
|
|
||||||
//! write bandwidth in empirical tests 2026-05-15/16.
|
|
||||||
//!
|
|
||||||
//! Medium detection happens *exactly once*, at construction, and
|
|
||||||
//! produces *exactly one* value: the initial `chunk_bytes` seed for
|
|
||||||
//! the autotuner. The detector's job is to give the autotuner a
|
|
||||||
//! decent starting point on cold start; from there p95 of WAIT_AFTER
|
|
||||||
//! latency drives all subsequent decisions, identically on every
|
|
||||||
//! medium.
|
|
||||||
//!
|
//!
|
||||||
//! ## Defence in depth: WAIT_AFTER timeout
|
//! ## Defence in depth: WAIT_AFTER timeout
|
||||||
//!
|
//!
|
||||||
//! A degraded disk, wedged NFS server, or odd FS driver could in
|
//! Even on local storage, a degraded disk or odd filesystem driver
|
||||||
//! principle hang inside WAIT_AFTER. Each call runs on a worker
|
//! could in principle wedge inside WAIT_AFTER. Each WAIT_AFTER call
|
||||||
//! thread with a 30s recv_timeout on its result channel
|
//! runs on a worker thread with a 30s recv_timeout on its result
|
||||||
//! ([`crate::io::bounded::bounded_syscall`]). On timeout the pipeline
|
//! channel. On timeout we log a loud error, set a `degraded` flag,
|
||||||
//! flips to `degraded`, logs a loud error, and skips WAIT_AFTER for
|
//! and from then on skip WAIT_AFTER + DONTNEED for the rest of the
|
||||||
//! the rest of its life. The worker thread is intentionally leaked —
|
//! pipeline's life (same shape as the NFS path). The worker thread
|
||||||
//! it unwinds whenever the syscall eventually returns or the process
|
//! is intentionally leaked — it unwinds whenever the syscall
|
||||||
//! exits. The mux continues; the original dirty-burst pathology
|
//! eventually returns or the process exits. The mux continues; the
|
||||||
//! re-emerges only on the specific (medium, server, FS) combination
|
//! original dirty-burst pathology re-emerges but the rip can still
|
||||||
//! that actually wedged.
|
//! finish instead of freezing.
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
@@ -101,9 +87,11 @@ pub(crate) struct WritebackPipeline {
|
|||||||
/// Count of chunks emitted (used to space out periodic
|
/// Count of chunks emitted (used to space out periodic
|
||||||
/// `debug!` size snapshots).
|
/// `debug!` size snapshots).
|
||||||
chunk_count: u64,
|
chunk_count: u64,
|
||||||
/// What `detect_storage_class` saw at construction. Carried for
|
/// True when the underlying file is on an NFS mount. NFS makes
|
||||||
/// diagnostic logging only — *not* keyed off in the hot path.
|
/// WAIT_AFTER unsafe (can block forever on missing server ack), so
|
||||||
storage_class: StorageClass,
|
/// we skip it entirely and let the NFS client handle commit on
|
||||||
|
/// close.
|
||||||
|
is_nfs: bool,
|
||||||
/// Set the first time WAIT_AFTER exceeds [`WAIT_AFTER_TIMEOUT`].
|
/// Set the first time WAIT_AFTER exceeds [`WAIT_AFTER_TIMEOUT`].
|
||||||
/// Once set, behaviour matches the NFS path for the rest of the
|
/// Once set, behaviour matches the NFS path for the rest of the
|
||||||
/// pipeline's life. Wrapped in `Arc` only because both this
|
/// pipeline's life. Wrapped in `Arc` only because both this
|
||||||
@@ -120,25 +108,13 @@ impl WritebackPipeline {
|
|||||||
/// returned `WritebackPipeline` MUST be dropped before `file`
|
/// returned `WritebackPipeline` MUST be dropped before `file`
|
||||||
/// itself, or kept inside the same struct that owns `file` — the
|
/// itself, or kept inside the same struct that owns `file` — the
|
||||||
/// alias is unchecked.
|
/// alias is unchecked.
|
||||||
///
|
pub(crate) fn new(file: &File, start_pos: u64, chunk_bytes: u64) -> Self {
|
||||||
/// `chunk_bytes_hint` is the caller's preferred starting chunk
|
|
||||||
/// size; the constructor overrides it with a medium-specific
|
|
||||||
/// initial seed if `detect_storage_class` recognises the fd's
|
|
||||||
/// filesystem. The autotuner then drives the value from there
|
|
||||||
/// based on measured `WAIT_AFTER` latency, identically on every
|
|
||||||
/// medium.
|
|
||||||
pub(crate) fn new(file: &File, start_pos: u64, chunk_bytes_hint: u64) -> Self {
|
|
||||||
let fd = file.as_raw_fd();
|
let fd = file.as_raw_fd();
|
||||||
let (class, seed) = detect_storage_class(fd);
|
let is_nfs = detect_nfs(fd);
|
||||||
// Medium-aware initial seed: bias toward bigger chunks on
|
|
||||||
// slow-commit media so the autotuner doesn't waste a minute
|
|
||||||
// climbing from a too-small starting value. A returned seed
|
|
||||||
// of 0 means "the detector has no opinion; use the caller's
|
|
||||||
// hint". All subsequent tuning is medium-agnostic.
|
|
||||||
let chunk_bytes = if seed > 0 { seed } else { chunk_bytes_hint };
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
target: "mux",
|
target: "mux",
|
||||||
"WritebackPipeline fd={fd} storage_class={class:?} chunk_bytes={chunk_bytes}"
|
"WritebackPipeline fd={fd} is_nfs={is_nfs} chunk_bytes={chunk_bytes} strategy={}",
|
||||||
|
if is_nfs { "nfs-skip-wait" } else { "wait+dontneed" }
|
||||||
);
|
);
|
||||||
Self {
|
Self {
|
||||||
fd,
|
fd,
|
||||||
@@ -147,18 +123,17 @@ impl WritebackPipeline {
|
|||||||
pending: None,
|
pending: None,
|
||||||
wait_after_window: VecDeque::with_capacity(ADAPTIVE_WINDOW),
|
wait_after_window: VecDeque::with_capacity(ADAPTIVE_WINDOW),
|
||||||
chunk_count: 0,
|
chunk_count: 0,
|
||||||
storage_class: class,
|
is_nfs,
|
||||||
degraded: Arc::new(AtomicBool::new(false)),
|
degraded: Arc::new(AtomicBool::new(false)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True if we should bypass the WAIT_AFTER finalisation step. The
|
/// True if we should bypass the WAIT_AFTER + DONTNEED finalisation
|
||||||
/// pipeline only skips after `WAIT_AFTER` has *empirically* hung
|
/// step. NFS always bypasses; local storage bypasses once the
|
||||||
/// past [`WAIT_AFTER_TIMEOUT`] on this particular fd. No per-FS
|
/// pipeline has flipped to degraded after a WAIT_AFTER timeout.
|
||||||
/// branch — the failure is detected, not predicted.
|
|
||||||
#[inline]
|
#[inline]
|
||||||
fn skip_wait(&self) -> bool {
|
fn skip_wait(&self) -> bool {
|
||||||
self.degraded.load(Ordering::Relaxed)
|
self.is_nfs || self.degraded.load(Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Caller advanced the file position to `pos`. If a chunk boundary
|
/// Caller advanced the file position to `pos`. If a chunk boundary
|
||||||
@@ -181,29 +156,40 @@ impl WritebackPipeline {
|
|||||||
}
|
}
|
||||||
if let Some((prev_off, prev_len)) = self.pending.take() {
|
if let Some((prev_off, prev_len)) = self.pending.take() {
|
||||||
if self.skip_wait() {
|
if self.skip_wait() {
|
||||||
// Degraded path (set only after an empirical
|
// NFS branch (or degraded fallback after a prior
|
||||||
// WAIT_AFTER timeout on this fd). We still advance
|
// timeout): the WAIT_AFTER + DONTNEED dance is what
|
||||||
// `pending` so the next call has a stable cycle.
|
// hangs on NFS — skip it. We still advance `pending`
|
||||||
|
// so the next call has a stable cycle.
|
||||||
} else {
|
} else {
|
||||||
|
// Normal local-storage branch with belt-and-braces
|
||||||
|
// timeout. If WAIT_AFTER hangs > WAIT_AFTER_TIMEOUT
|
||||||
|
// we mark the pipeline degraded, log a loud error,
|
||||||
|
// and fall through to the skip path on subsequent
|
||||||
|
// calls.
|
||||||
match wait_after_with_timeout(self.fd, prev_off, prev_len) {
|
match wait_after_with_timeout(self.fd, prev_off, prev_len) {
|
||||||
Some(ms) => {
|
Some(ms) => {
|
||||||
wait_ms = ms;
|
wait_ms = ms;
|
||||||
// Deliberately NO `posix_fadvise(DONTNEED)`
|
let t_fadv = Instant::now();
|
||||||
// here. See module-level docs: evicting clean
|
unsafe {
|
||||||
// pages costs us read-modify-write on
|
libc::posix_fadvise(
|
||||||
// matroska cluster backpatches and gains
|
self.fd,
|
||||||
// nothing — the kernel will reclaim clean
|
prev_off as i64,
|
||||||
// pages under LRU when memory is needed.
|
prev_len as i64,
|
||||||
|
libc::POSIX_FADV_DONTNEED,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fadvise_ms = t_fadv.elapsed().as_millis() as u64;
|
||||||
self.record_wait(wait_ms);
|
self.record_wait(wait_ms);
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// Timeout branch: this fd's FS or server is
|
// Timeout branch: switch to NFS-style skip
|
||||||
// unable to ack WAIT_AFTER. Flip to skip mode
|
// for the rest of the pipeline's life. Do
|
||||||
// for the rest of the pipeline's life.
|
// NOT call DONTNEED — if WAIT_AFTER hasn't
|
||||||
|
// returned, the pages aren't safely flushed.
|
||||||
self.degraded.store(true, Ordering::Relaxed);
|
self.degraded.store(true, Ordering::Relaxed);
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
target: "mux",
|
target: "mux",
|
||||||
"WritebackPipeline WAIT_AFTER timed out after {}s on chunk off={} len={}, marking writeback degraded (subsequent chunks will skip WAIT_AFTER)",
|
"WritebackPipeline WAIT_AFTER timed out after {}s on chunk off={} len={}, marking writeback degraded (subsequent chunks will skip WAIT_AFTER + DONTNEED)",
|
||||||
WAIT_AFTER_TIMEOUT.as_secs(),
|
WAIT_AFTER_TIMEOUT.as_secs(),
|
||||||
prev_off,
|
prev_off,
|
||||||
prev_len
|
prev_len
|
||||||
@@ -215,10 +201,9 @@ impl WritebackPipeline {
|
|||||||
self.pending = Some((chunk_off as u64, chunk_len as u64));
|
self.pending = Some((chunk_off as u64, chunk_len as u64));
|
||||||
self.last_flush_pos = pos;
|
self.last_flush_pos = pos;
|
||||||
self.chunk_count += 1;
|
self.chunk_count += 1;
|
||||||
let _ = fadvise_ms;
|
|
||||||
tracing::trace!(
|
tracing::trace!(
|
||||||
target: "mux",
|
target: "mux",
|
||||||
"WritebackPipeline chunk off={} len={} sync_file_range_ms={wait_ms} chunk_bytes={} skip_wait={}",
|
"WritebackPipeline chunk off={} len={} sync_file_range_ms={wait_ms} fadvise_ms={fadvise_ms} chunk_bytes={} skip_wait={}",
|
||||||
chunk_off,
|
chunk_off,
|
||||||
chunk_len,
|
chunk_len,
|
||||||
self.chunk_bytes,
|
self.chunk_bytes,
|
||||||
@@ -227,10 +212,10 @@ impl WritebackPipeline {
|
|||||||
if self.chunk_count % SIZE_LOG_INTERVAL == 0 {
|
if self.chunk_count % SIZE_LOG_INTERVAL == 0 {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
target: "mux",
|
target: "mux",
|
||||||
"WritebackPipeline chunk_bytes={} after {} chunks storage_class={:?} degraded={}",
|
"WritebackPipeline chunk_bytes={} after {} chunks is_nfs={} degraded={}",
|
||||||
self.chunk_bytes,
|
self.chunk_bytes,
|
||||||
self.chunk_count,
|
self.chunk_count,
|
||||||
self.storage_class,
|
self.is_nfs,
|
||||||
self.degraded.load(Ordering::Relaxed),
|
self.degraded.load(Ordering::Relaxed),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -282,51 +267,46 @@ impl WritebackPipeline {
|
|||||||
if let Some((prev_off, prev_len)) = self.pending.take() {
|
if let Some((prev_off, prev_len)) = self.pending.take() {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
target: "mux",
|
target: "mux",
|
||||||
"WritebackPipeline finalize chunk off={prev_off} len={prev_len} skip_wait={} storage_class={:?} degraded={}",
|
"WritebackPipeline finalize chunk off={prev_off} len={prev_len} skip_wait={} is_nfs={} degraded={}",
|
||||||
self.skip_wait(),
|
self.skip_wait(),
|
||||||
self.storage_class,
|
self.is_nfs,
|
||||||
self.degraded.load(Ordering::Relaxed),
|
self.degraded.load(Ordering::Relaxed),
|
||||||
);
|
);
|
||||||
if self.skip_wait() {
|
if self.skip_wait() {
|
||||||
|
// NFS / degraded: skip WAIT_AFTER + DONTNEED. close()
|
||||||
|
// / sync_all() handle commit through their normal
|
||||||
|
// paths.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if wait_after_with_timeout(self.fd, prev_off, prev_len).is_none() {
|
match wait_after_with_timeout(self.fd, prev_off, prev_len) {
|
||||||
self.degraded.store(true, Ordering::Relaxed);
|
Some(_ms) => unsafe {
|
||||||
tracing::error!(
|
libc::posix_fadvise(
|
||||||
target: "mux",
|
self.fd,
|
||||||
"WritebackPipeline finalize WAIT_AFTER timed out after {}s on chunk off={prev_off} len={prev_len}, marking writeback degraded",
|
prev_off as i64,
|
||||||
WAIT_AFTER_TIMEOUT.as_secs(),
|
prev_len as i64,
|
||||||
);
|
libc::POSIX_FADV_DONTNEED,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
self.degraded.store(true, Ordering::Relaxed);
|
||||||
|
tracing::error!(
|
||||||
|
target: "mux",
|
||||||
|
"WritebackPipeline finalize WAIT_AFTER timed out after {}s on chunk off={prev_off} len={prev_len}, marking writeback degraded",
|
||||||
|
WAIT_AFTER_TIMEOUT.as_secs(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Coarse classification of the medium an open file is on. Used only
|
/// Probe whether `fd` lives on an NFS mount via `fstatfs`. Returns
|
||||||
/// at construction to seed the autotuner with a sensible initial
|
/// `false` on any error — we fail open, not closed: better to run the
|
||||||
/// `chunk_bytes`. Never branched on in the hot path.
|
/// normal local-storage path on a misdetected NFS mount (and surface
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
/// the freeze loudly via the timeout) than to needlessly disable
|
||||||
pub(crate) enum StorageClass {
|
/// writeback bounding on every local file because of a transient
|
||||||
/// `fstatfs.f_type == NFS_SUPER_MAGIC`. Slow-commit; seed a
|
/// stat error.
|
||||||
/// generous initial chunk so the autotuner doesn't waste cycles
|
fn detect_nfs(fd: RawFd) -> bool {
|
||||||
/// climbing from a too-small value.
|
|
||||||
Nfs,
|
|
||||||
/// Anything else we successfully stat'd. Local FS, tmpfs, ZFS,
|
|
||||||
/// network FS we don't have a constant for, etc. Use the caller's
|
|
||||||
/// hint as the seed.
|
|
||||||
Other,
|
|
||||||
/// `fstatfs` failed. Caller's hint wins.
|
|
||||||
Unknown,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Probe the FS class via `fstatfs` and return both the class label
|
|
||||||
/// and a recommended `chunk_bytes` seed. `seed == 0` means "no
|
|
||||||
/// medium-specific recommendation; use the caller's hint." Failure
|
|
||||||
/// modes default to `Unknown` + seed 0 so the caller's hint applies.
|
|
||||||
///
|
|
||||||
/// This is the single medium-detection point in the whole writeback
|
|
||||||
/// pipeline. Everything else is medium-agnostic.
|
|
||||||
fn detect_storage_class(fd: RawFd) -> (StorageClass, u64) {
|
|
||||||
// `libc::statfs` is repr(C) with a fixed layout; zeroing is the
|
// `libc::statfs` is repr(C) with a fixed layout; zeroing is the
|
||||||
// documented init pattern for the kernel uapi struct.
|
// documented init pattern for the kernel uapi struct.
|
||||||
let mut buf: libc::statfs = unsafe { std::mem::zeroed() };
|
let mut buf: libc::statfs = unsafe { std::mem::zeroed() };
|
||||||
@@ -335,9 +315,9 @@ fn detect_storage_class(fd: RawFd) -> (StorageClass, u64) {
|
|||||||
let errno = std::io::Error::last_os_error();
|
let errno = std::io::Error::last_os_error();
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "mux",
|
target: "mux",
|
||||||
"WritebackPipeline fstatfs(fd={fd}) failed: {errno} — defaulting to Unknown",
|
"WritebackPipeline fstatfs(fd={fd}) failed: {errno} — defaulting is_nfs=false",
|
||||||
);
|
);
|
||||||
return (StorageClass::Unknown, 0);
|
return false;
|
||||||
}
|
}
|
||||||
// `f_type` is signed (`__fsword_t`) on glibc and unsigned
|
// `f_type` is signed (`__fsword_t`) on glibc and unsigned
|
||||||
// (`c_ulong`) on musl. Cast both sides to i64 for a portable
|
// (`c_ulong`) on musl. Cast both sides to i64 for a portable
|
||||||
@@ -348,18 +328,7 @@ fn detect_storage_class(fd: RawFd) -> (StorageClass, u64) {
|
|||||||
let f_type = buf.f_type as i64;
|
let f_type = buf.f_type as i64;
|
||||||
#[allow(clippy::unnecessary_cast)]
|
#[allow(clippy::unnecessary_cast)]
|
||||||
let nfs_magic = libc::NFS_SUPER_MAGIC as i64;
|
let nfs_magic = libc::NFS_SUPER_MAGIC as i64;
|
||||||
if f_type == nfs_magic {
|
f_type == nfs_magic
|
||||||
// NFS commit ack typically ~10-30 ms RTT. The autotuner grows
|
|
||||||
// by doubling when p95 > 200 ms — so starting at 32 MiB it
|
|
||||||
// would never grow on a healthy NFS. Seed at 64 MiB so the
|
|
||||||
// commit cadence amortizes properly from the first chunk.
|
|
||||||
(StorageClass::Nfs, 64 * 1024 * 1024)
|
|
||||||
} else {
|
|
||||||
// Local FS / unknown remote FS. Caller's hint (typically 32
|
|
||||||
// MiB) is a fine starting point; autotuner adjusts from
|
|
||||||
// measured latency.
|
|
||||||
(StorageClass::Other, 0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run `sync_file_range(WAIT_AFTER)` on a worker thread and wait up
|
/// Run `sync_file_range(WAIT_AFTER)` on a worker thread and wait up
|
||||||
|
|||||||
Reference in New Issue
Block a user