perf(ecstore): fold metadata read open+fstat+read into a single spawn_blocking (HP-12 item 1) (#4554)

Sub-change A only: the two local-disk metadata read paths
(`read_metadata_with_dmtime`, `read_all_data_with_dmtime` in
crates/ecstore/src/disk/local.rs) previously dispatched open, fstat and the
xl.meta read as three separate async fs hops (three spawn_blocking round-trips
under tokio::fs). Each is now folded into a single tokio::task::spawn_blocking
closure using std::fs, cutting per-metadata-read dispatch from 3 to 1.

This does NOT touch sub-change B (removing the entry-point access() call).

Correctness first: this is the hottest metadata read path, so the refactor
preserves byte-for-byte-equivalent Results. Every error mapping is kept
identical:
  - open failure     -> to_file_error
  - is_dir           -> Error::FileNotFound (not to_file_error(EISDIR))
  - metadata failure -> to_file_error
  - xl.meta parse    -> propagated verbatim from the parser
  - try_reserve      -> Error::other
  - read_to_end      -> to_file_error
For read_all_data_with_dmtime the async NotFound -> access(volume_dir) ->
VolumeNotFound fallback (and its warn! event) is preserved on the async side:
the closure returns the raw open error unmapped; only open() can yield ENOENT
once the fd is valid, so gating the fallback on the open error is equivalent to
the original open-arm-only fallback.

To run the parser inside a blocking closure, filemeta gains a synchronous twin
`read_xl_meta_no_data_sync` (+ `read_more_sync`) in
crates/filemeta/src/filemeta/version.rs that line-for-line mirrors the async
version, differing only in std vs tokio read_exact. A new equivalence test
(`read_xl_meta_sync_equivalence_tests`) feeds identical buffers to both the
async and sync readers and asserts equal Ok bytes / equal Err variants across:
v1.0; v1.1/v1.2/v1.3; large meta triggering read_more; header truncation ->
UnexpectedEof; CRC-trailer truncation -> FileCorrupt; unknown major/minor ->
InvalidData; and want boundaries (exact fit, inline-data drop, read_more EOF).

Co-authored-by: heihutu <heihutu@gmail.com>
This commit is contained in:
houseme
2026-07-09 02:28:57 +08:00
committed by GitHub
parent f7d2b25638
commit 608ab14d7d
2 changed files with 373 additions and 36 deletions
+86 -36
View File
@@ -39,7 +39,7 @@ use metrics::counter;
use parking_lot::{Mutex as ParkingLotMutex, RwLock as ParkingLotRwLock};
use rustfs_filemeta::{
Cache, FileInfo, FileInfoOpts, FileMeta, MetaCacheEntry, MetacacheWriter, ObjectPartInfo, Opts, RawFileInfo, UpdateFn,
get_file_info, read_xl_meta_no_data,
get_file_info, read_xl_meta_no_data_sync,
};
use rustfs_utils::HashAlgorithm;
use rustfs_utils::os::get_info;
@@ -62,7 +62,7 @@ use std::{
};
use time::OffsetDateTime;
use tokio::fs::{self, File};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWrite, AsyncWriteExt, ErrorKind, ReadBuf};
use tokio::io::{AsyncRead, AsyncSeekExt, AsyncWrite, AsyncWriteExt, ErrorKind, ReadBuf};
use tokio::sync::{Notify, RwLock};
use tokio::time::{Instant, Sleep, interval_at, timeout};
use tracing::{debug, error, info, warn};
@@ -3140,25 +3140,41 @@ impl LocalDisk {
async fn read_metadata_with_dmtime(&self, file_path: impl AsRef<Path>) -> Result<(Vec<u8>, Option<OffsetDateTime>)> {
check_path_length(file_path.as_ref().to_string_lossy().as_ref())?;
let mut f = super::fs::open_file(file_path.as_ref(), O_RDONLY)
.await
.map_err(to_file_error)?;
// HP-12 item 1 (sub-change A): fold the open + fstat + bounded xl.meta
// read into a single spawn_blocking dispatch instead of three separate
// async fs hops. The closure mirrors the previous async body one-to-one,
// including every error mapping, so the returned Result stays
// byte-for-byte equivalent (see read_xl_meta_no_data_sync equivalence
// tests in rustfs-filemeta):
// - open failure -> to_file_error (was fs::open_file(..).map_err(to_file_error))
// - is_dir -> Error::FileNotFound (NOT to_file_error(EISDIR))
// - metadata failure -> to_file_error
// - parse failure -> propagated verbatim from read_xl_meta_no_data_sync (`?`)
let path = file_path.as_ref().to_path_buf();
let (data, modtime) = tokio::task::spawn_blocking(move || -> Result<(Vec<u8>, Option<OffsetDateTime>)> {
// Read-only open, equivalent to O_RDONLY (get_readonly_options only sets read(true)).
let mut f = std::fs::File::open(&path).map_err(to_file_error)?;
let meta = f.metadata().await.map_err(to_file_error)?;
let meta = f.metadata().map_err(to_file_error)?;
if meta.is_dir() {
// fix use io::Error
return Err(Error::FileNotFound);
}
if meta.is_dir() {
// fix use io::Error
return Err(Error::FileNotFound);
}
let size = meta.len() as usize;
let size = meta.len() as usize;
let data = read_xl_meta_no_data(&mut f, size).await?;
let data = read_xl_meta_no_data_sync(&mut f, size)?;
let modtime = match meta.modified() {
Ok(md) => Some(OffsetDateTime::from(md)),
Err(_) => None,
};
let modtime = match meta.modified() {
Ok(md) => Some(OffsetDateTime::from(md)),
Err(_) => None,
};
Ok((data, modtime))
})
.await
.map_err(DiskError::from)??;
Ok((data, modtime))
}
@@ -3177,9 +3193,60 @@ impl LocalDisk {
volume_dir: impl AsRef<Path>,
file_path: impl AsRef<Path>,
) -> Result<(Vec<u8>, Option<OffsetDateTime>)> {
let mut f = match super::fs::open_file(file_path.as_ref(), O_RDONLY).await {
Ok(f) => f,
Err(e) => {
// HP-12 item 1 (sub-change A): fold open + fstat + is_dir + try_reserve +
// read_to_end into a single spawn_blocking dispatch. The closure mirrors
// the previous async body one-to-one so the returned Result stays
// byte-for-byte equivalent. Post-open errors are mapped to their final
// DiskError inside the closure (metadata/read -> to_file_error; is_dir ->
// FileNotFound; try_reserve -> Error::other) exactly as before. The raw
// open error is carried out unmapped so the async side can preserve the
// original NotFound -> access(volume_dir) -> VolumeNotFound fallback.
//
// Only the open() call can yield ErrorKind::NotFound here: once open
// succeeds the fd is valid, so fstat/read never return ENOENT. Hence
// gating the volume fallback on the open error alone is equivalent to
// the original code, where the fallback lived solely in the open match arm.
enum ReadAllError {
/// Raw, unmapped open() error; the async side decides the fallback.
Open(std::io::Error),
/// Already-mapped post-open error.
Disk(DiskError),
}
let path = file_path.as_ref().to_path_buf();
let res =
tokio::task::spawn_blocking(move || -> core::result::Result<(Vec<u8>, Option<OffsetDateTime>), ReadAllError> {
// Read-only open, equivalent to O_RDONLY (get_readonly_options only sets read(true)).
let mut f = std::fs::File::open(&path).map_err(ReadAllError::Open)?;
let meta = f.metadata().map_err(|e| ReadAllError::Disk(to_file_error(e).into()))?;
if meta.is_dir() {
return Err(ReadAllError::Disk(DiskError::FileNotFound));
}
let size = meta.len() as usize;
let mut bytes = Vec::new();
bytes
.try_reserve_exact(size)
.map_err(|e| ReadAllError::Disk(Error::other(e)))?;
std::io::Read::read_to_end(&mut f, &mut bytes).map_err(|e| ReadAllError::Disk(to_file_error(e).into()))?;
let modtime = match meta.modified() {
Ok(md) => Some(OffsetDateTime::from(md)),
Err(_) => None,
};
Ok((bytes, modtime))
})
.await
.map_err(DiskError::from)?;
let (bytes, modtime) = match res {
Ok(v) => v,
Err(ReadAllError::Disk(e)) => return Err(e),
Err(ReadAllError::Open(e)) => {
if e.kind() == ErrorKind::NotFound
&& !skip_access_checks(volume)
&& let Err(er) = access(volume_dir.as_ref()).await
@@ -3200,23 +3267,6 @@ impl LocalDisk {
}
};
let meta = f.metadata().await.map_err(to_file_error)?;
if meta.is_dir() {
return Err(DiskError::FileNotFound);
}
let size = meta.len() as usize;
let mut bytes = Vec::new();
bytes.try_reserve_exact(size).map_err(Error::other)?;
f.read_to_end(&mut bytes).await.map_err(to_file_error)?;
let modtime = match meta.modified() {
Ok(md) => Some(OffsetDateTime::from(md)),
Err(_) => None,
};
Ok((bytes, modtime))
}
+287
View File
@@ -3198,6 +3198,113 @@ pub async fn read_xl_meta_no_data<R: AsyncRead + Unpin>(reader: &mut R, size: us
}
}
/// Synchronous twin of [`read_more`].
///
/// Line-for-line mirror of the async version; the only difference is that
/// `reader.read_exact(&mut buf[has..]).await?` becomes the blocking
/// `std::io::Read::read_exact`. std's `read_exact` reports the same
/// `ErrorKind::UnexpectedEof` on a short read as tokio's, so the `?`
/// conversion into [`Error`] is identical. Kept in lockstep with `read_more`
/// so the two paths stay byte-for-byte equivalent (see equivalence tests).
fn read_more_sync<R: std::io::Read>(
reader: &mut R,
buf: &mut Vec<u8>,
total_size: usize,
read_size: usize,
has_full: bool,
) -> Result<()> {
let has = buf.len();
if has >= read_size {
return Ok(());
}
if has_full || read_size > total_size {
return Err(Error::other(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "Unexpected EOF")));
}
let extra = read_size - has;
if buf.capacity() >= read_size {
// Extend the buffer if we have enough space.
buf.resize(read_size, 0);
} else {
buf.extend(vec![0u8; extra]);
}
reader.read_exact(&mut buf[has..])?;
Ok(())
}
/// Synchronous twin of [`read_xl_meta_no_data`].
///
/// Byte-for-byte equivalent to the async version for any input: the parsing
/// logic (`check_xl2_v1` / `read_bytes_header` / major-minor branches / `want`
/// computation / 5-byte CRC trailer handling / `truncate` / `FileCorrupt` /
/// `InvalidData` / `UnexpectedEof`) is copied verbatim; only the reads switch
/// from async `read_exact(...).await` to blocking `std::io::Read::read_exact`.
/// This lets a caller fold open+fstat+read into a single `spawn_blocking`
/// closure without changing any observable result.
pub fn read_xl_meta_no_data_sync<R: std::io::Read>(reader: &mut R, size: usize) -> Result<Vec<u8>> {
let mut initial = size;
let mut has_full = true;
if initial > META_DATA_READ_DEFAULT {
initial = META_DATA_READ_DEFAULT;
has_full = false;
}
let mut buf = vec![0u8; initial];
reader.read_exact(&mut buf)?;
let (tmp_buf, major, minor) = FileMeta::check_xl2_v1(&buf)?;
match major {
1 => match minor {
0 => {
read_more_sync(reader, &mut buf, size, size, has_full)?;
Ok(buf)
}
1..=3 => {
let (sz, tmp_buf) = FileMeta::read_bytes_header(tmp_buf)?;
let mut want = sz as usize + (buf.len() - tmp_buf.len());
if minor < 2 {
read_more_sync(reader, &mut buf, size, want, has_full)?;
buf.truncate(want);
return Ok(buf);
}
let want_max = usize::min(want + MSGP_UINT32_SIZE, size);
read_more_sync(reader, &mut buf, size, want_max, has_full)?;
if buf.len() < want {
return Err(Error::FileCorrupt);
}
// The metadata block is followed by a 5-byte msgp uint32 CRC trailer;
// a file truncated inside the trailer is corrupt, not a shorter meta.
let crc_size = 5;
if buf.len() - want < crc_size {
return Err(Error::FileCorrupt);
}
want += crc_size;
buf.truncate(want);
Ok(buf)
}
_ => Err(Error::other(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Unknown minor metadata version",
))),
},
_ => Err(Error::other(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Unknown major metadata version",
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -4380,3 +4487,183 @@ mod tests {
assert!(decoded.mod_time.is_none(), "absent mod_time must not become Some(UNIX_EPOCH)");
}
}
/// Equivalence tests proving `read_xl_meta_no_data_sync` returns byte-for-byte
/// identical `Result`s to the async `read_xl_meta_no_data` for every code path.
///
/// This is the critical guard for HP-12 item 1: the ecstore metadata read path
/// folds open+fstat+read into one `spawn_blocking` closure that calls the sync
/// twin, and it MUST NOT drift from the async version in either the Ok bytes or
/// the Err variant.
#[cfg(test)]
mod read_xl_meta_sync_equivalence_tests {
use super::*;
use std::io::Cursor as StdCursor;
// tokio implements `AsyncRead` for `std::io::Cursor`, so the same type
// drives both the async and the sync readers.
use std::io::Cursor as TokioCursor;
/// 8-byte xl.meta header: "XL2 " magic + LE major + LE minor.
fn header(major: u16, minor: u16) -> Vec<u8> {
let mut v = Vec::with_capacity(8);
v.extend_from_slice(&XL_FILE_HEADER);
v.extend_from_slice(&major.to_le_bytes());
v.extend_from_slice(&minor.to_le_bytes());
v
}
/// 5-byte msgpack `bin32` length prefix (marker 0xc6 + 4-byte BE length),
/// matching what `read_bytes_header` decodes via `read_bin_len`.
fn bin32_prefix(len: u32) -> [u8; 5] {
let mut p = [0u8; 5];
p[0] = 0xc6;
p[1..].copy_from_slice(&len.to_be_bytes());
p
}
/// Build a v1.x xl.meta buffer: header + bin32(meta_len) + meta payload +
/// `crc_bytes` trailer bytes + `inline` trailing inline-data bytes. The meta
/// payload content is irrelevant: `read_xl_meta_no_data` never parses it.
fn build_v1x(minor: u16, meta_len: usize, crc_bytes: usize, inline: usize) -> Vec<u8> {
let mut v = header(1, minor);
v.extend_from_slice(&bin32_prefix(meta_len as u32));
v.extend(std::iter::repeat_n(0xABu8, meta_len));
v.extend(std::iter::repeat_n(0xC1u8, crc_bytes));
v.extend(std::iter::repeat_n(0xCDu8, inline));
v
}
async fn run_async(buf: &[u8], size: usize) -> Result<Vec<u8>> {
let mut r = TokioCursor::new(buf.to_vec());
read_xl_meta_no_data(&mut r, size).await
}
fn run_sync(buf: &[u8], size: usize) -> Result<Vec<u8>> {
let mut r = StdCursor::new(buf.to_vec());
read_xl_meta_no_data_sync(&mut r, size)
}
/// Drive both implementations with the same input and assert the results
/// are equivalent (Ok bytes equal, or Err variant equal per `Error`'s
/// `PartialEq`, which compares io kind + message for `Error::Io`).
async fn assert_equivalent(label: &str, buf: &[u8], size: usize) -> Result<Vec<u8>> {
let a = run_async(buf, size).await;
let s = run_sync(buf, size);
match (&a, &s) {
(Ok(ab), Ok(sb)) => assert_eq!(ab, sb, "[{label}] Ok bytes must match"),
(Err(ae), Err(se)) => assert_eq!(ae, se, "[{label}] Err variant must match"),
_ => panic!("[{label}] async/sync disagree on Ok vs Err: async={a:?} sync={s:?}"),
}
a
}
#[tokio::test]
async fn equivalence_across_all_paths() {
// (a) v1.0: whole-file read path.
{
let mut buf = header(1, 0);
buf.extend(std::iter::repeat_n(0x11u8, 24));
let len = buf.len();
let out = assert_equivalent("v1.0", &buf, len).await.unwrap();
assert_eq!(out, buf, "v1.0 returns the whole file");
}
// (b) v1.1 / v1.2 / v1.3, each a clean well-formed buffer.
// v1.1: minor < 2, no CRC trailer; returns header+prefix+meta.
{
let buf = build_v1x(1, 10, 0, 0);
let len = buf.len();
let out = assert_equivalent("v1.1", &buf, len).await.unwrap();
assert_eq!(out.len(), 8 + 5 + 10, "v1.1 -> header+prefix+meta");
}
// v1.2: minor >= 2, 5-byte CRC trailer retained.
{
let buf = build_v1x(2, 15, 5, 0);
let len = buf.len();
let out = assert_equivalent("v1.2", &buf, len).await.unwrap();
assert_eq!(out.len(), 8 + 5 + 15 + 5, "v1.2 -> header+prefix+meta+crc");
}
// v1.3: minor >= 2, 5-byte CRC trailer retained.
{
let buf = build_v1x(3, 20, 5, 0);
let len = buf.len();
let out = assert_equivalent("v1.3", &buf, len).await.unwrap();
assert_eq!(out.len(), 8 + 5 + 20 + 5, "v1.3 -> header+prefix+meta+crc");
}
// (c) size > META_DATA_READ_DEFAULT: initial 4KiB read then read_more.
{
let meta_len = 5000; // total = 8 + 5 + 5000 + 5 = 5018 > 4096
let buf = build_v1x(3, meta_len, 5, 0);
assert!(buf.len() > META_DATA_READ_DEFAULT);
let len = buf.len();
let out = assert_equivalent("v1.3-large", &buf, len).await.unwrap();
assert_eq!(out.len(), 8 + 5 + meta_len + 5);
}
// (d) truncated inside the header -> UnexpectedEof (initial read_exact
// fails because the reader holds fewer bytes than the claimed size).
{
let buf = XL_FILE_HEADER.to_vec(); // only 4 bytes present
assert_equivalent("truncated-header", &buf, 8).await.unwrap_err();
}
// (e) truncated inside the CRC trailer -> FileCorrupt (v1.3 with only
// 2 of the 5 trailer bytes present; size matches the buffer).
{
let buf = build_v1x(3, 10, 2, 0); // want=23, buf.len()=25, 25-23=2 < 5
let len = buf.len();
let e = assert_equivalent("truncated-crc", &buf, len).await.unwrap_err();
assert_eq!(e, Error::FileCorrupt);
}
// (f) unknown major / unknown minor -> InvalidData.
// Unknown major: major=0 passes check_xl2_v1 (0 <= MAJOR) but misses the
// `1 =>` arm.
{
let buf = header(0, 0);
let e = assert_equivalent("unknown-major", &buf, buf.len()).await.unwrap_err();
assert_eq!(
e,
Error::other(std::io::Error::new(std::io::ErrorKind::InvalidData, "Unknown major metadata version"))
);
}
// Unknown minor: major=1, minor=4 misses the `0` and `1..=3` arms.
{
let buf = header(1, 4);
let e = assert_equivalent("unknown-minor", &buf, buf.len()).await.unwrap_err();
assert_eq!(
e,
Error::other(std::io::Error::new(std::io::ErrorKind::InvalidData, "Unknown minor metadata version"))
);
}
// (g) want boundaries.
// Exact fit: size == want + crc, no inline data.
{
let buf = build_v1x(3, 20, 5, 0); // size == want+5 exactly
let len = buf.len();
let out = assert_equivalent("want-exact-fit", &buf, len).await.unwrap();
assert_eq!(out, buf);
}
// Inline data past the trailer: truncate drops it, want_max clamps below size.
{
let buf = build_v1x(3, 20, 5, 100); // size = want+5+100
let len = buf.len();
let out = assert_equivalent("want-with-inline", &buf, len).await.unwrap();
assert_eq!(out.len(), 8 + 5 + 20 + 5, "inline data is dropped by truncate");
}
// (h) read_more's own read_exact hits EOF (has_full=false, read_size
// within claimed size but the reader is physically shorter).
{
// prefix claims a 5000-byte meta so size=5018, but only 4100 bytes exist.
let mut buf = header(1, 3);
buf.extend_from_slice(&bin32_prefix(5000));
buf.extend(std::iter::repeat_n(0xABu8, 4100 - buf.len()));
assert_eq!(buf.len(), 4100);
let e = assert_equivalent("read_more-eof", &buf, 5018).await.unwrap_err();
assert_eq!(e, Error::Unexpected, "short read surfaces as Unexpected");
}
}
}