mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-13 17:48:29 +02:00
* rust: apply clippy --fix to both crates The mechanical part of a clippy sweep: `cargo clippy --all-targets --fix` on seaweed-volume and the seaweed-worker workspace, hand-reviewed. Both manifests declare their MSRV (1.91.1 and 1.94.1), so every suggestion clippy applied is within it: the collapsible_if sites become let chains (1.88, edition 2024), `% n == 0` becomes is_multiple_of (1.87), chunks_exact with a constant becomes as_chunks (1.88), repeat().take() becomes repeat_n (1.82), and io::Error::new(Other, ..) becomes io::Error::other (1.74). The rest is redundant clones, borrows, casts, closures and field names. Nothing here changes behaviour. The three let_and_return sites in needle_map.rs and store_ec.rs deserve a note: the `let result = ..; result` shape was a deliberate edition-2021 workaround to drop a redb guard before the table it borrows. Edition 2024 drops tail-expression temporaries before locals, which is why clippy now flags it, and the two comments that described the workaround say so instead. Manual edits on top of the tool output: the blocks clippy rewrote are re-indented the way rustfmt lays them out (only those blocks — the crate is not rustfmt-clean and a whole-crate fmt would bury this diff), the blank lines let_and_return left behind are removed, and the CRC legacy_value test compares against a literal worked out from the original shift formula rather than restating rotate_right. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CjZY429aVU74SLDmo1wiuU * rust: clear the clippy warnings --fix cannot apply, and say why the rest stay Hand fixes for the lints clippy only reports. Behaviour is unchanged throughout; each rewrite is the one clippy names. - needless_range_loop (7): index loops over shard vectors become iterator loops. Where the old code indexed `v[..n]` the new loop iterates `v[..n]` so an undersized vector still panics the same way. - field_reassign_with_default (6): struct literals with `..Default`. - redundant_pattern_matching (3): `if let Err(_) = guard.check()` becomes `.is_err()`, which also releases the read guard at the end of the condition instead of at the end of the block. - manual_strip (2), manual_checked_ops, format_in_format_args, redundant_locals, wrong_self_convention (to_vif takes self by value, so it is into_vif; CompactEntry is Copy, so to_needle_value takes self). - type_complexity (2): `OrphanShardLoad` and `RawNeedleEntry` name two tuples that were spelled out inline. - new_without_default: CompactNeedleMap gets a Default that calls new(). - suspicious_open_options: a test helper spells out `.truncate(false)`, which is what `.create(true).write(true)` already did. What stays, and the attribute that says so: - too_many_arguments (10): `#[expect]` on each function. Folding 8–15 parameters into a struct is a design change, not a lint fix. - await_holding_lock / readonly_write_lock: one test holds the store write guard across a sleep on purpose, as a barrier that parks the copy task at the mount block. `#[expect(.., reason = ..)]` records it. - module_inception: needle/needle.rs mirrors the Go package layout. Two lints become crate-wide policy in `[lints.clippy]`, with the reason next to each: result_large_err, because every RPC path returns tonic::Status (176 bytes) and boxing it would change every handler signature; and needless_update, because `..Default::default()` on a protobuf message literal is what lets a proto gain a field without touching every constructor (all 11 sites are pb messages). The worker workspace gets the same table and its members opt in with `lints.workspace = true`; its generated plugin.rs also allows large_enum_variant on prost's oneof enums. Both crates are now clean under `cargo clippy --all-targets -- -D warnings`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CjZY429aVU74SLDmo1wiuU * rust volume: use the std APIs the 1.91 MSRV already pays for The crate declares rust-version 1.91.1, so a few things the code still worked around are plain std now. All of them come from the 1.85–1.91 release notes; nothing here needs a newer toolchain than the manifest already requires. - std::sync::LazyLock (1.80) replaces the lazy_static! block in metrics.rs, and the lazy_static dependency goes. Every use site reads the same through Deref, so no caller changes. - Duration::from_mins / from_hours (1.91) replace `from_secs(v * 60)` and `from_secs(v * 3600)` in the option parser and the shard-location refresh TTLs. One difference for the parser: an absurd count that overflows u64 seconds now panics in release builds too, where the multiplication used to wrap. - Result::flatten (1.89) replaces `.and_then(|r| r)` on the replication join handle. - OsStr::display (1.87) replaces `to_string_lossy()` where the name was only being formatted; the output is byte-identical. - `#[allow]` becomes `#[expect]` (1.81) on the suppressions that are meant to be permanent, so a suppression that stops being needed becomes a warning rather than lingering. Doing that found four that already had: dead_code on ChunkManifest, base_name and last_io_error, and too_many_arguments on read_from_data_shards, which is down to seven parameters. Those attributes are deleted. The three allows that depend on cfg (a unix-only mutation, a linux-only field set, a profiling-only parameter) stay as allow, because expect would be unfulfilled on the other platforms. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CjZY429aVU74SLDmo1wiuU * ci: add a commented-out clippy step to both Rust workflows Both crates are warning-free under `cargo clippy --all-targets -D warnings` now. Whether that becomes a gate is a policy call, so the step is present but commented out; uncommenting it is the whole change. The comment points at the `[lints.clippy]` table where crate-wide exceptions are recorded, so the gate does not become a reason to sprinkle allows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CjZY429aVU74SLDmo1wiuU * rust volume: guard parse_duration against overflow panics Duration::from_mins/from_hours panic when the count overflows u64 seconds. Use checked_mul so an oversized CLI value falls back to the parser default instead of crashing volume startup. --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Chris Lu <chris.lu@gmail.com>
4426 lines
178 KiB
Rust
4426 lines
178 KiB
Rust
//! EcVolume: an erasure-coded volume with up to MAX_SHARD_COUNT shards.
|
|
//!
|
|
//! Each EcVolume has a sorted index (.ecx) and a deletion journal (.ecj).
|
|
//! Shards (.ec00-.ec13) may be distributed across multiple servers.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::fs::{self, File, OpenOptions};
|
|
use std::io::{self, Write};
|
|
use std::sync::RwLock;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use crate::pb::master_pb;
|
|
use crate::storage::erasure_coding::ec_locate;
|
|
use crate::storage::erasure_coding::ec_shard::*;
|
|
use crate::storage::needle::needle::{get_actual_size, Needle, NeedleError};
|
|
use crate::storage::types::*;
|
|
use crate::storage::volume_open::open_volume_file;
|
|
|
|
/// An erasure-coded volume managing its local shards and index.
|
|
pub const IO_ERROR_TOLERANCE: i32 = 3;
|
|
|
|
pub struct EcVolume {
|
|
pub volume_id: VolumeId,
|
|
pub collection: String,
|
|
pub dir: String,
|
|
pub dir_idx: String,
|
|
pub version: Version,
|
|
pub shards: Vec<Option<EcVolumeShard>>, // indexed by ShardId (0..MAX_SHARD_COUNT)
|
|
pub dat_file_size: i64,
|
|
pub data_shards: u32,
|
|
pub parity_shards: u32,
|
|
/// Uniform block layout: each shard is one contiguous block of this many
|
|
/// bytes. 0 = legacy 1GiB/1MiB two-tier layout. Loaded from .vif.
|
|
pub block_size: i64,
|
|
ecx_file: Option<File>,
|
|
ecx_file_size: i64,
|
|
ecj_file: Option<File>,
|
|
/// On-disk size of the .ecj deletion journal. Used only by IO helpers
|
|
/// (seek / set_len on partial writes) — the authoritative runtime
|
|
/// delete count comes from `deleted_needles.len()`.
|
|
ecj_file_size: i64,
|
|
/// In-memory set of needle ids that have been deleted since the volume
|
|
/// was encoded. .ecx is immutable at runtime — it only stores the
|
|
/// sorted (id, offset, size) index written at encode time — and runtime
|
|
/// deletes are journaled to .ecj + tracked here. Reads consult this
|
|
/// set to mask out deleted needles on top of the sealed .ecx lookup.
|
|
/// Seeded from .ecj in `new()` and updated by `journal_delete`.
|
|
deleted_needles: RwLock<HashSet<NeedleId>>,
|
|
pub disk_type: DiskType,
|
|
/// Directory where .ecx/.ecj were actually found (may differ from dir_idx after fallback).
|
|
ecx_actual_dir: String,
|
|
/// Maps shard ID -> list of server addresses where that shard exists.
|
|
/// Used for distributed EC reads across the cluster. Wrapped in
|
|
/// `RwLock` so the read path can refresh the map (under master
|
|
/// lookup) without holding the Store write lock — mirrors Go's
|
|
/// `ShardLocationsLock sync.RWMutex` in `weed/storage/erasure_coding/ec_volume.go`.
|
|
pub shard_locations: std::sync::RwLock<HashMap<ShardId, Vec<String>>>,
|
|
/// Wall-clock timestamp of the most recent successful
|
|
/// `LookupEcVolume` refresh of `shard_locations`. `None` until the
|
|
/// first refresh. Drives the staleness heuristic in
|
|
/// `cached_lookup_ec_shard_locations` (mirrors Go's
|
|
/// `ShardLocationsRefreshTime`).
|
|
pub shard_locations_refresh_time: std::sync::Mutex<Option<std::time::Instant>>,
|
|
/// Marks the map for a prompt re-check: a read that failed against a cached
|
|
/// location has disproved what the map claims, and the normal freshness
|
|
/// window is far too long to serve from a map known to be wrong. Mirrors the
|
|
/// invalidation Go's `forgetShardId` performs. A mutex rather than an atomic
|
|
/// so the refresh can judge the map and consume the mark in one critical
|
|
/// section, and a mark raised meanwhile survives for the next refresh.
|
|
pub shard_locations_stale: std::sync::Mutex<bool>,
|
|
/// EC volume expiration time (unix epoch seconds), set during EC encode from TTL.
|
|
pub expire_at_sec: u64,
|
|
/// Encode-run identity (unix nanos) loaded from the .vif EcShardConfig. A read
|
|
/// served from a shard of a different encode run is rejected (server- and
|
|
/// client-side); 0 for a pre-feature volume, which is treated leniently.
|
|
pub encode_ts_ns: i64,
|
|
/// Active-generation EC bitrot checksum sidecar (`<base>.ecsum`), loaded and
|
|
/// validated at mount. `None` unless `bitrot_status == On`.
|
|
pub(crate) bitrot: Option<crate::pb::volume_server_pb::EcBitrotProtection>,
|
|
/// Resolved protection state of the loaded sidecar. Cached alongside `bitrot`
|
|
/// so `bitrot_protection()` can return the `Off`/`Invalid` distinction without
|
|
/// re-reading, mirroring Go's `EcVolume.bitrotStatus`.
|
|
pub(crate) bitrot_status: crate::storage::erasure_coding::ec_bitrot::BitrotStatus,
|
|
/// Directory the active sidecar was actually resolved from. The search in
|
|
/// `load_bitrot_for_generation` spans sibling disks, and a `.ecsum` records
|
|
/// no encode identity, so provenance is the only signal that a loaded
|
|
/// manifest may describe a different encode run. Same purpose as
|
|
/// `ecx_actual_dir`. Empty when no sidecar was found. Always committed in
|
|
/// the same step as `bitrot`/`bitrot_status`, so a reload whose `Err` gets
|
|
/// swallowed by `reload_bitrot_sidecar` cannot leave this describing a
|
|
/// sidecar other than the one those two fields actually hold.
|
|
pub(crate) bitrot_source_dir: String,
|
|
|
|
io_error_count: std::sync::atomic::AtomicI32,
|
|
io_error_quarantined: std::sync::atomic::AtomicBool,
|
|
last_io_error: std::sync::Mutex<Option<String>>,
|
|
}
|
|
|
|
/// Locate the `.vif` for a (collection, vid) by preferring the data dir
|
|
/// and falling back to the idx dir when it lives there instead. The
|
|
/// fallback covers the cross-disk reconcile path: when a volume's
|
|
/// shards live on one disk but its `.ecx` / `.ecj` / `.vif` live on a
|
|
/// sibling disk (seaweedfs/seaweedfs#9212 / #9244), we want to read the
|
|
/// real `.vif` from the sibling rather than write a stub on the shard
|
|
/// disk and lose the EC config + dat file size.
|
|
fn locate_vif_path(dir: &str, dir_idx: &str, collection: &str, volume_id: VolumeId) -> String {
|
|
let data_vif = format!(
|
|
"{}.vif",
|
|
crate::storage::volume::volume_file_name(dir, collection, volume_id),
|
|
);
|
|
if dir_idx != dir && !std::path::Path::new(&data_vif).exists() {
|
|
let idx_vif = format!(
|
|
"{}.vif",
|
|
crate::storage::volume::volume_file_name(dir_idx, collection, volume_id),
|
|
);
|
|
if std::path::Path::new(&idx_vif).exists() {
|
|
return idx_vif;
|
|
}
|
|
}
|
|
data_vif
|
|
}
|
|
|
|
/// Load the volume's `.vif` (data dir first, then idx dir, then the active
|
|
/// generation's versioned sidecar — see [`locate_vif_path`]). Absent
|
|
/// everywhere is legal — legacy volumes predate the sidecar — and returns
|
|
/// `None`; so does a zero-byte stub (an ec.decode copy from a source
|
|
/// without one), mirroring Go's MaybeLoadVolumeInfo. A
|
|
/// present-but-unreadable or malformed `.vif` is an ERROR: silently
|
|
/// defaulting would mount a uniform-layout volume with legacy offset math
|
|
/// and serve wrong bytes with a straight face.
|
|
pub fn load_vif_info(
|
|
dir: &str,
|
|
dir_idx: &str,
|
|
collection: &str,
|
|
volume_id: VolumeId,
|
|
) -> io::Result<Option<crate::storage::volume::VifVolumeInfo>> {
|
|
let vif_path = locate_vif_path(dir, dir_idx, collection, volume_id);
|
|
match std::fs::read_to_string(&vif_path) {
|
|
Ok(content) if content.trim().is_empty() => Ok(None),
|
|
Ok(content) => serde_json::from_str(&content).map(Some).map_err(|e| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!("parse {}: {}", vif_path, e),
|
|
)
|
|
}),
|
|
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
|
|
Err(e) => Err(io::Error::new(
|
|
e.kind(),
|
|
format!("read {}: {}", vif_path, e),
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Read the EC data/parity shard counts and shard block size from `.vif`,
|
|
/// defaulting to the build's standard ratio and the legacy block layout when
|
|
/// no `.vif` is present. A present-but-unreadable or malformed `.vif`
|
|
/// fails instead of defaulting (see [`load_vif_info`]).
|
|
/// Looks at the data dir first, then the idx dir — see [`locate_vif_path`].
|
|
pub fn read_ec_shard_config(
|
|
dir: &str,
|
|
dir_idx: &str,
|
|
collection: &str,
|
|
volume_id: VolumeId,
|
|
) -> io::Result<(u32, u32, i64)> {
|
|
let vif = load_vif_info(dir, dir_idx, collection, volume_id)?;
|
|
ec_shard_config_from(vif.as_ref(), &[dir, dir_idx], collection, volume_id)
|
|
}
|
|
|
|
/// Load the volume's `.vif` from the selected location or, failing that, from
|
|
/// any sibling disk — returning the directory it came from so the caller can
|
|
/// resolve the rest of the volume's metadata against the same place. A rebuild
|
|
/// picks one disk to write into, but a multi-disk server may hold that
|
|
/// volume's metadata on another.
|
|
pub fn load_vif_info_across_dirs(
|
|
dir: &str,
|
|
dir_idx: &str,
|
|
other_dirs: &[String],
|
|
collection: &str,
|
|
volume_id: VolumeId,
|
|
) -> io::Result<Option<(crate::storage::volume::VifVolumeInfo, String)>> {
|
|
if let Some(vif) = load_vif_info(dir, dir_idx, collection, volume_id)? {
|
|
// Report where it actually came from. load_vif_info probes the data
|
|
// directory then the index directory, so naming `dir` unconditionally
|
|
// pointed callers at the wrong disk whenever the vif lived with the
|
|
// index — and a caller resolving the rest of the volume's metadata
|
|
// against that answer would look in a directory holding none of it.
|
|
let data_vif = format!(
|
|
"{}.vif",
|
|
crate::storage::volume::volume_file_name(dir, collection, volume_id)
|
|
);
|
|
let found_in = if std::path::Path::new(&data_vif).exists() {
|
|
dir.to_string()
|
|
} else {
|
|
dir_idx.to_string()
|
|
};
|
|
return Ok(Some((vif, found_in)));
|
|
}
|
|
for other in other_dirs {
|
|
if let Some(vif) = load_vif_info(other, other, collection, volume_id)? {
|
|
return Ok(Some((vif, other.clone())));
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
/// [`read_ec_shard_config`] for a caller that may find the volume's metadata on
|
|
/// any of the server's disks. Without this a rebuild that lands on a disk
|
|
/// holding only shards reads the default 10+4 and the legacy block layout,
|
|
/// then reconstructs a custom-ratio or uniform volume through the wrong matrix
|
|
/// and de-striping geometry.
|
|
pub fn read_ec_shard_config_across_dirs(
|
|
dir: &str,
|
|
dir_idx: &str,
|
|
other_dirs: &[String],
|
|
collection: &str,
|
|
volume_id: VolumeId,
|
|
) -> io::Result<(u32, u32, i64)> {
|
|
// Every directory this volume's metadata could be in. A split -dir/-dir.idx
|
|
// location keeps .vif and .ecsum with the INDEX, and callers exclude their
|
|
// own index directory from other_dirs on the understanding that it is
|
|
// passed here separately — so it is chained explicitly, exactly as the .vif
|
|
// lookup and Go's findBitrotSidecar both do.
|
|
let mut candidates: Vec<&str> = Vec::with_capacity(2 + other_dirs.len());
|
|
candidates.push(dir);
|
|
if !dir_idx.is_empty() && dir_idx != dir {
|
|
candidates.push(dir_idx);
|
|
}
|
|
candidates.extend(other_dirs.iter().map(|s| s.as_str()));
|
|
|
|
// A vif that carries no ecShardConfig answers nothing about the layout, so
|
|
// it must NOT short-circuit the sidecar search: a legacy config-free vif
|
|
// and the generation-0 sidecar can sit in different directories, and
|
|
// stopping at the vif resolved a 12+4 uniform volume as 10+4 legacy. Pass
|
|
// the whole candidate list so the fallback covers the same ground the vif
|
|
// lookup did.
|
|
let vif = load_vif_info_across_dirs(dir, dir_idx, other_dirs, collection, volume_id)?;
|
|
ec_shard_config_from(
|
|
vif.as_ref().map(|(v, _)| v),
|
|
&candidates,
|
|
collection,
|
|
volume_id,
|
|
)
|
|
}
|
|
|
|
/// Resolve (data_shards, parity_shards, block_size) from an already-loaded
|
|
/// `.vif`. With no vif — or one carrying no EC config — the bitrot sidecar
|
|
/// records the same config at encode time and answers the layout question the
|
|
/// vif cannot: defaulting a uniform-layout volume to the legacy block sizes
|
|
/// maps every read to the wrong shard offset. `weed fix -ecx` reads the
|
|
/// sidecar for the same reason. Absent both, the build's standard ratio and
|
|
/// the legacy layout.
|
|
pub fn ec_shard_config_from(
|
|
vif: Option<&crate::storage::volume::VifVolumeInfo>,
|
|
dirs: &[&str],
|
|
collection: &str,
|
|
volume_id: VolumeId,
|
|
) -> io::Result<(u32, u32, i64)> {
|
|
let default_ds = crate::storage::erasure_coding::ec_shard::DATA_SHARDS_COUNT as u32;
|
|
let default_ps = crate::storage::erasure_coding::ec_shard::PARITY_SHARDS_COUNT as u32;
|
|
// Sum as u64: counts near the u32 ceiling would wrap and pass the bound.
|
|
let usable =
|
|
|ds: u32, ps: u32| ds > 0 && ps > 0 && (ds as u64 + ps as u64) <= MAX_SHARD_COUNT as u64;
|
|
|
|
if let Some(ec) = vif.and_then(|v| v.ec_shard_config.as_ref()) {
|
|
// A config that is PRESENT but records an impossible ratio is not a
|
|
// volume to fall back on: dropping through to the sidecar or the
|
|
// defaults would read uniform shards with the legacy offset math and
|
|
// answer with the wrong bytes. Only an ENTIRELY absent config means
|
|
// "this predates the record".
|
|
if !usable(ec.data_shards, ec.parity_shards) {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!(
|
|
"vif for volume {} records invalid shard counts {}+{}",
|
|
volume_id.0, ec.data_shards, ec.parity_shards
|
|
),
|
|
));
|
|
}
|
|
// A recorded block size that no encoder could have produced maps
|
|
// every read to the wrong shard offset. Refuse the mount rather
|
|
// than serve those bytes or silently pick a layout.
|
|
validate_block_size(ec.block_size)?;
|
|
return Ok((ec.data_shards, ec.parity_shards, ec.block_size));
|
|
}
|
|
// With no usable vif config the sidecar is the ONLY record of this volume's
|
|
// layout, so the three answers stay distinct: absent EVERYWHERE means the
|
|
// volume may genuinely predate the sidecar and legacy is the right guess;
|
|
// present-but-unusable means the record is corrupt, and answering reads
|
|
// from a guessed layout returns wrong bytes rather than none.
|
|
//
|
|
// Every candidate directory is searched, not just the first: a split
|
|
// -dir/-dir.idx location keeps the sidecar with the INDEX, and a multi-disk
|
|
// server may keep it on a sibling. Stopping at the data directory resolved
|
|
// a 12+4 uniform volume as 10+4 legacy.
|
|
let mut path = String::new();
|
|
for candidate in dirs.iter().filter(|d| !d.is_empty()) {
|
|
let base = crate::storage::volume::volume_file_name(candidate, collection, volume_id);
|
|
let probe = crate::storage::erasure_coding::ec_bitrot::bitrot_sidecar_path(&base, 0);
|
|
match std::fs::metadata(&probe) {
|
|
Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
|
|
Err(e) => {
|
|
return Err(io::Error::new(e.kind(), format!("stat {}: {}", probe, e)));
|
|
}
|
|
Ok(_) => {
|
|
path = probe;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if path.is_empty() {
|
|
return Ok((default_ds, default_ps, 0));
|
|
}
|
|
let prot = crate::storage::erasure_coding::ec_bitrot::load_bitrot_sidecar(&path)
|
|
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("read {}: {}", path, e)))?;
|
|
// The un-suffixed sidecar describes generation 0 and nothing else.
|
|
if prot.generation != 0 {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!(
|
|
"{} records generation {}, not generation 0",
|
|
path, prot.generation
|
|
),
|
|
));
|
|
}
|
|
let ec = prot.ec_shard_config.as_ref().ok_or_else(|| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!("{} records no EC config", path),
|
|
)
|
|
})?;
|
|
if !usable(ec.data_shards, ec.parity_shards) {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!(
|
|
"{} records invalid shard counts {}+{}",
|
|
path, ec.data_shards, ec.parity_shards
|
|
),
|
|
));
|
|
}
|
|
validate_block_size(ec.block_size)
|
|
.map_err(|e| io::Error::new(e.kind(), format!("{}: {}", path, e)))?;
|
|
Ok((ec.data_shards, ec.parity_shards, ec.block_size))
|
|
}
|
|
|
|
/// Reports whether a `.vif`-recorded shard block size is one an encoder could
|
|
/// have produced. 0 means the legacy two-tier layout, which is always valid;
|
|
/// anything positive must be a whole number of small blocks, because that is
|
|
/// what `uniform_block_size` rounds to. Mirrors Go's `ValidateBlockSize`.
|
|
pub fn validate_block_size(block_size: i64) -> io::Result<()> {
|
|
if block_size == 0 {
|
|
return Ok(());
|
|
}
|
|
if block_size < 0
|
|
|| block_size
|
|
% crate::storage::erasure_coding::ec_shard::ERASURE_CODING_SMALL_BLOCK_SIZE as i64
|
|
!= 0
|
|
{
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!(
|
|
"invalid shard block size {}: expected 0 (legacy) or a multiple of {}",
|
|
block_size,
|
|
crate::storage::erasure_coding::ec_shard::ERASURE_CODING_SMALL_BLOCK_SIZE
|
|
),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
impl EcVolume {
|
|
/// Create a new EcVolume. Loads .ecx index and .ecj journal if present.
|
|
pub fn new(
|
|
dir: &str,
|
|
dir_idx: &str,
|
|
collection: &str,
|
|
volume_id: VolumeId,
|
|
) -> io::Result<Self> {
|
|
// One load of the volume's `.vif`, used for both the shard config and
|
|
// the version / dat-size fields below.
|
|
let vif = load_vif_info(dir, dir_idx, collection, volume_id)?;
|
|
// Both directories: a split -dir/-dir.idx layout keeps the .ecsum with
|
|
// the INDEX, and it is the only layout record a volume whose vif is
|
|
// absent or config-free still has.
|
|
let (data_shards, parity_shards, block_size) =
|
|
ec_shard_config_from(vif.as_ref(), &[dir, dir_idx], collection, volume_id)?;
|
|
|
|
let total_shards = (data_shards + parity_shards) as usize;
|
|
let mut shards = Vec::with_capacity(total_shards);
|
|
for _ in 0..total_shards {
|
|
shards.push(None);
|
|
}
|
|
|
|
// Read expire_at_sec, version, and dat_file_size from .vif if
|
|
// present (matches Go's MaybeLoadVolumeInfo). Prefer the data
|
|
// dir; fall back to the idx dir for the cross-disk reconcile
|
|
// case (#9212 / #9244). `dat_file_size` is the source .dat
|
|
// size at encode time, used both by `locate_ec_shard_needle`
|
|
// for shard-size math and by the Store-level prune in
|
|
// `store_ec_reconcile.rs` to verify a sibling-disk .dat is
|
|
// plausibly the encoding source (#9478).
|
|
let (expire_at_sec, vif_version, vif_dat_file_size, encode_ts_ns) =
|
|
// Absent everywhere = legacy defaults; a present-but-unreadable or
|
|
// malformed vif already failed the mount in load_vif_info above.
|
|
match vif.as_ref() {
|
|
Some(vif_info) => {
|
|
let ver = if vif_info.version > 0 {
|
|
Version(vif_info.version as u8)
|
|
} else {
|
|
Version::current()
|
|
};
|
|
let cfg_encode_ts_ns = vif_info
|
|
.ec_shard_config
|
|
.as_ref()
|
|
.map_or(0, |c| c.encode_ts_ns);
|
|
(
|
|
vif_info.expire_at_sec,
|
|
ver,
|
|
vif_info.dat_file_size,
|
|
cfg_encode_ts_ns,
|
|
)
|
|
}
|
|
None => (0, Version::current(), 0, 0),
|
|
};
|
|
|
|
let mut vol = EcVolume {
|
|
volume_id,
|
|
collection: collection.to_string(),
|
|
dir: dir.to_string(),
|
|
dir_idx: dir_idx.to_string(),
|
|
version: vif_version,
|
|
shards,
|
|
dat_file_size: vif_dat_file_size,
|
|
data_shards,
|
|
parity_shards,
|
|
block_size,
|
|
ecx_file: None,
|
|
ecx_file_size: 0,
|
|
ecj_file: None,
|
|
ecj_file_size: 0,
|
|
deleted_needles: RwLock::new(HashSet::new()),
|
|
disk_type: DiskType::default(),
|
|
ecx_actual_dir: dir_idx.to_string(),
|
|
shard_locations: std::sync::RwLock::new(HashMap::new()),
|
|
shard_locations_refresh_time: std::sync::Mutex::new(None),
|
|
shard_locations_stale: std::sync::Mutex::new(false),
|
|
expire_at_sec,
|
|
encode_ts_ns,
|
|
bitrot: None,
|
|
bitrot_status: crate::storage::erasure_coding::ec_bitrot::BitrotStatus::Off,
|
|
bitrot_source_dir: String::new(),
|
|
io_error_count: std::sync::atomic::AtomicI32::new(0),
|
|
io_error_quarantined: std::sync::atomic::AtomicBool::new(false),
|
|
last_io_error: std::sync::Mutex::new(None),
|
|
};
|
|
|
|
// Open .ecx file (sorted index) in read/write mode for in-place deletion marking.
|
|
// Matches Go which opens ecx for writing via MarkNeedleDeleted.
|
|
let ecx_path = vol.ecx_file_name();
|
|
if std::path::Path::new(&ecx_path).exists() {
|
|
let file = open_volume_file(OpenOptions::new().read(true).write(true), &ecx_path)?;
|
|
vol.ecx_file_size = file.metadata()?.len() as i64;
|
|
vol.ecx_file = Some(file);
|
|
} else if dir_idx != dir {
|
|
// Fall back to data directory if .ecx was created before -dir.idx was configured
|
|
let data_base = crate::storage::volume::volume_file_name(dir, collection, volume_id);
|
|
let fallback_ecx = format!("{}.ecx", data_base);
|
|
if std::path::Path::new(&fallback_ecx).exists() {
|
|
tracing::info!(
|
|
volume_id = volume_id.0,
|
|
"ecx file not found in idx dir, falling back to data dir"
|
|
);
|
|
let file =
|
|
open_volume_file(OpenOptions::new().read(true).write(true), &fallback_ecx)?;
|
|
vol.ecx_file_size = file.metadata()?.len() as i64;
|
|
vol.ecx_file = Some(file);
|
|
vol.ecx_actual_dir = dir.to_string();
|
|
}
|
|
}
|
|
|
|
// Open .ecj file (deletion journal) — use ecx_actual_dir for consistency.
|
|
// Note: Go does NOT replay .ecj into .ecx at volume load (RebuildEcxFile
|
|
// is only invoked from specific decode/rebuild gRPC handlers), so we
|
|
// don't either. Tombstones from prior sessions were already written
|
|
// in-place in .ecx, and the journal grows monotonically until a
|
|
// decode/rebuild operation folds it in.
|
|
let ecj_base =
|
|
crate::storage::volume::volume_file_name(&vol.ecx_actual_dir, collection, volume_id);
|
|
let ecj_path = format!("{}.ecj", ecj_base);
|
|
let ecj_file = open_volume_file(
|
|
OpenOptions::new()
|
|
.read(true)
|
|
.write(true)
|
|
.create(true)
|
|
.append(true),
|
|
&ecj_path,
|
|
)?;
|
|
vol.ecj_file_size = ecj_file.metadata()?.len() as i64;
|
|
vol.ecj_file = Some(ecj_file);
|
|
|
|
// Seed the in-memory deleted set from the journal.
|
|
vol.load_deleted_needles_from_ecj()?;
|
|
|
|
// Load the generation-0 EC bitrot checksum sidecar. Optional, except
|
|
// when it contradicts the volume's own geometry — see
|
|
// load_bitrot_for_generation.
|
|
vol.load_active_bitrot_sidecar(&[])?;
|
|
|
|
Ok(vol)
|
|
}
|
|
|
|
/// Re-resolve the checksum sidecar for a volume that is already mounted. A
|
|
/// shard delivery can bring the manifest with it, and the receive path only
|
|
/// writes the file, so without this the in-memory volume keeps the
|
|
/// protection state it resolved at mount (off) until a remount.
|
|
pub fn reload_bitrot_sidecar(&mut self, additional_dirs: &[String]) {
|
|
if let Err(e) = self.load_active_bitrot_sidecar(additional_dirs) {
|
|
tracing::warn!(
|
|
volume_id = self.volume_id.0,
|
|
error = %e,
|
|
"reload bitrot sidecar",
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Load the generation-0 checksum sidecar into `self.bitrot`/`self.bitrot_status`.
|
|
/// OSS only produces generation-0 (fresh-encode) sidecars, mirroring Go's
|
|
/// `loadActiveBitrotSidecar`.
|
|
fn load_active_bitrot_sidecar(&mut self, additional_dirs: &[String]) -> io::Result<()> {
|
|
self.load_bitrot_for_generation(0, additional_dirs)
|
|
}
|
|
|
|
/// Load and validate the sidecar describing `generation`, setting
|
|
/// `self.bitrot`/`self.bitrot_status`. Absent or generation/config-mismatched
|
|
/// => `Off` (protection off, not corruption); self-integrity or manifest
|
|
/// failure => `Invalid` with a warning (protection off pending repair); usable
|
|
/// => `On`. Mirrors Go's `loadBitrotForGeneration`.
|
|
fn load_bitrot_for_generation(
|
|
&mut self,
|
|
generation: u32,
|
|
additional_dirs: &[String],
|
|
) -> io::Result<()> {
|
|
use crate::storage::erasure_coding::ec_bitrot;
|
|
// Data base then index base, matching Go's findBitrotSidecar. A split
|
|
// -dir/-dir.idx location keeps the sidecar with the INDEX, and on a
|
|
// multi-disk server sharing one index directory that is the only copy
|
|
// the per-disk runtimes other than the first can see — searching the
|
|
// data base alone left them reporting no protection at all.
|
|
let data_path = ec_bitrot::bitrot_sidecar_path(&self.base_name(), generation);
|
|
let idx_path = ec_bitrot::bitrot_sidecar_path(&self.idx_base_name(), generation);
|
|
// Sibling disks last: startup mirroring gives each shard-bearing disk
|
|
// its own .ecx/.ecj/.vif but not the sidecar, and a delivery lands
|
|
// exactly one copy, so a runtime restricted to its own two directories
|
|
// would report no protection however often it reloaded.
|
|
let path = std::iter::once(data_path.clone())
|
|
.chain(std::iter::once(idx_path))
|
|
.chain(additional_dirs.iter().filter(|d| !d.is_empty()).map(|d| {
|
|
ec_bitrot::bitrot_sidecar_path(
|
|
&crate::storage::volume::volume_file_name(d, &self.collection, self.volume_id),
|
|
generation,
|
|
)
|
|
}))
|
|
.find(|p| std::path::Path::new(p).exists())
|
|
.unwrap_or(data_path);
|
|
let loaded = ec_bitrot::load_bitrot_sidecar(&path);
|
|
// A sidecar written for THIS generation that contradicts the volume's
|
|
// geometry is not "no protection" — it says the layout the volume is
|
|
// about to serve reads with is wrong. Fail the mount.
|
|
if let Ok(prot) = &loaded
|
|
&& prot.generation == generation
|
|
&& !ec_bitrot::geometry_matches(
|
|
prot,
|
|
self.data_shards as usize,
|
|
self.parity_shards as usize,
|
|
self.block_size,
|
|
)
|
|
{
|
|
let cfg = prot.ec_shard_config.as_ref();
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!(
|
|
"ec volume {} generation {}: {} records layout {}+{} block {} but the volume is mounted as {}+{} block {}; refusing to serve one of the two layouts",
|
|
self.volume_id.0,
|
|
generation,
|
|
path,
|
|
cfg.map(|c| c.data_shards).unwrap_or(0),
|
|
cfg.map(|c| c.parity_shards).unwrap_or(0),
|
|
cfg.map(|c| c.block_size).unwrap_or(0),
|
|
self.data_shards,
|
|
self.parity_shards,
|
|
self.block_size,
|
|
),
|
|
));
|
|
}
|
|
let status = ec_bitrot::resolve_status(
|
|
&loaded,
|
|
generation,
|
|
self.data_shards as usize,
|
|
self.parity_shards as usize,
|
|
);
|
|
// Provenance is committed together with the sidecar it describes, so a
|
|
// swallowed reload (`reload_bitrot_sidecar` logs and discards the `Err`
|
|
// above) cannot leave them disagreeing: the geometry-mismatch return
|
|
// happens before this point, so `bitrot`/`bitrot_status` below and
|
|
// `bitrot_source_dir` here always describe the same load attempt.
|
|
// Only a path that actually exists is a real source: `path` falls back
|
|
// to the data path when nothing was found, and recording that would
|
|
// claim a provenance the volume does not have.
|
|
self.bitrot_source_dir = if std::path::Path::new(&path).exists() {
|
|
std::path::Path::new(&path)
|
|
.parent()
|
|
.map(|p| p.to_string_lossy().into_owned())
|
|
.unwrap_or_default()
|
|
} else {
|
|
String::new()
|
|
};
|
|
self.bitrot = None;
|
|
self.bitrot_status = status;
|
|
match status {
|
|
ec_bitrot::BitrotStatus::On => self.bitrot = loaded.ok(),
|
|
ec_bitrot::BitrotStatus::Off => {}
|
|
ec_bitrot::BitrotStatus::Invalid => {
|
|
tracing::warn!(
|
|
volume_id = self.volume_id.0,
|
|
path = %path,
|
|
generation,
|
|
"ec volume: bitrot sidecar present but invalid; protection off pending repair",
|
|
);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// The active-generation bitrot protection AND its status (cached at mount),
|
|
/// mirroring Go's `EcVolume.BitrotProtection()`. Preserves the distinction
|
|
/// `checksum_scrub` needs: an absent/generation-mismatched sidecar is `Off`
|
|
/// (a clean no-op), a present-but-malformed one is `Invalid` (a real integrity
|
|
/// error). Returns `Some(prot)` only for `On`.
|
|
pub(crate) fn bitrot_protection(
|
|
&self,
|
|
) -> (
|
|
Option<crate::pb::volume_server_pb::EcBitrotProtection>,
|
|
crate::storage::erasure_coding::ec_bitrot::BitrotStatus,
|
|
) {
|
|
(self.bitrot.clone(), self.bitrot_status)
|
|
}
|
|
|
|
/// Read-only EC bitrot checksum scrub of the LOCAL shards of this volume's
|
|
/// active generation.
|
|
///
|
|
/// Loads the active-generation `.ecsum` sidecar and, for each locally-held
|
|
/// shard, reads the on-disk shard file in `block_size` chunks and compares
|
|
/// each block's CRC32C against the sidecar. Returns
|
|
/// `(blocks_scanned, mismatched_shards, errors)`.
|
|
///
|
|
/// If more than `parity_shards` shards mismatch wholesale (i.e. every block
|
|
/// of those shards is wrong — the signature of a stale/wrong sidecar rather
|
|
/// than localized disk rot), the result is classified as a suspect sidecar:
|
|
/// `mismatched_shards` is cleared and an integrity note is added to `errors`
|
|
/// instead. A genuine multi-shard disk failure of that magnitude is
|
|
/// already unrecoverable, so treating it as a sidecar-integrity issue avoids
|
|
/// raising false shard-corruption alarms.
|
|
///
|
|
/// A third outcome sits ahead of both of the above: if the identity fence
|
|
/// excluded a runtime AND the sidecar that supplied the protection was
|
|
/// resolved from that excluded runtime's directory, the checksums cannot be
|
|
/// trusted against
|
|
/// the merged shards at all. That case returns `(0, [], [errors])` with an
|
|
/// "unverifiable" note and never touches a shard handle — no scan, no
|
|
/// wholesale-mismatch classification, no blamed shard.
|
|
///
|
|
/// This method NEVER deletes or mutates anything — it is purely diagnostic.
|
|
/// Duplicates the mounted shard handles so `run()` can scan with the store
|
|
/// guard released; see `EcChecksumScrubPlan` for why that matters.
|
|
pub fn checksum_scrub_plan(&self) -> EcChecksumScrubPlan {
|
|
EcChecksumScrubPlan::for_volumes(&[self]).expect("a single runtime is never an empty slice")
|
|
}
|
|
|
|
/// Convenience wrapper preserving the original call shape. Callers that
|
|
/// hold the store lock MUST use `checksum_scrub_plan()` + `run()` instead.
|
|
pub fn checksum_scrub(&self) -> (u64, Vec<u32>, Vec<String>) {
|
|
self.checksum_scrub_plan().run()
|
|
}
|
|
|
|
/// Walk the .ecj journal and populate `deleted_needles`. Called once
|
|
/// from `new()` under exclusive ownership of the just-constructed
|
|
/// EcVolume, so locking is not strictly required — but we take the
|
|
/// write lock anyway for symmetry with later mutations.
|
|
fn load_deleted_needles_from_ecj(&mut self) -> io::Result<()> {
|
|
let ecj_file = match self.ecj_file.as_ref() {
|
|
Some(f) => f,
|
|
None => return Ok(()),
|
|
};
|
|
if self.ecj_file_size < NEEDLE_ID_SIZE as i64 {
|
|
return Ok(());
|
|
}
|
|
let mut buf = [0u8; NEEDLE_ID_SIZE];
|
|
let mut set = self
|
|
.deleted_needles
|
|
.write()
|
|
.map_err(|_| io::Error::other("deleted_needles lock poisoned"))?;
|
|
let mut off: i64 = 0;
|
|
while off + NEEDLE_ID_SIZE as i64 <= self.ecj_file_size {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::FileExt;
|
|
ecj_file.read_exact_at(&mut buf, off as u64)?;
|
|
}
|
|
set.insert(NeedleId::from_bytes(&buf));
|
|
off += NEEDLE_ID_SIZE as i64;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Returns (file_count, delete_count) for this EC volume. Mirrors Go's
|
|
/// `EcVolume.FileAndDeleteCount`:
|
|
///
|
|
/// file_count = ecx_file_size / NEEDLE_MAP_ENTRY_SIZE — total
|
|
/// entries in the sealed sorted .ecx index.
|
|
/// delete_count = deleted_needles.len() — unique
|
|
/// runtime deletes tracked in memory (seeded from
|
|
/// .ecj on load and updated by `journal_delete`).
|
|
///
|
|
/// Because each needle delete is applied on exactly one shard holder,
|
|
/// the admin aggregation sums delete_count across nodes while taking
|
|
/// file_count from a single holder (they are identical per volume).
|
|
pub fn file_and_delete_count(&self) -> (u64, u64) {
|
|
let file_count = (self.ecx_file_size as u64) / (NEEDLE_MAP_ENTRY_SIZE as u64);
|
|
let delete_count = self
|
|
.deleted_needles
|
|
.read()
|
|
.map(|s| s.len() as u64)
|
|
.unwrap_or(0);
|
|
(file_count, delete_count)
|
|
}
|
|
|
|
/// Reports whether the given needle id is in the in-memory deleted set.
|
|
pub fn is_needle_deleted(&self, needle_id: NeedleId) -> bool {
|
|
self.deleted_needles
|
|
.read()
|
|
.map(|s| s.contains(&needle_id))
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
// ---- File names ----
|
|
|
|
fn base_name(&self) -> String {
|
|
crate::storage::volume::volume_file_name(&self.dir, &self.collection, self.volume_id)
|
|
}
|
|
|
|
/// Base path for the .ecx / .ecj index pair. Resolved from
|
|
/// `ecx_actual_dir` (initialized to `dir_idx` and only updated after a
|
|
/// successful idx-dir → data-dir fallback in `new()`), so every call site
|
|
/// agrees on the same file regardless of whether the fallback fired.
|
|
fn idx_base_name(&self) -> String {
|
|
crate::storage::volume::volume_file_name(
|
|
&self.ecx_actual_dir,
|
|
&self.collection,
|
|
self.volume_id,
|
|
)
|
|
}
|
|
|
|
pub fn ecx_file_name(&self) -> String {
|
|
format!("{}.ecx", self.idx_base_name())
|
|
}
|
|
|
|
pub fn ecj_file_name(&self) -> String {
|
|
format!("{}.ecj", self.idx_base_name())
|
|
}
|
|
|
|
/// Sync the EC volume's journal and index files to disk (matching Go's ecv.Sync()).
|
|
/// Go flushes both .ecj and .ecx to ensure in-place deletion marks are persisted.
|
|
pub fn sync_to_disk(&self) -> io::Result<()> {
|
|
if let Some(ref ecj_file) = self.ecj_file {
|
|
ecj_file.sync_all()?;
|
|
}
|
|
if let Some(ref ecx_file) = self.ecx_file {
|
|
ecx_file.sync_all()?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ---- Shard management ----
|
|
|
|
/// Add a shard to this volume.
|
|
pub fn add_shard(&mut self, mut shard: EcVolumeShard) -> io::Result<()> {
|
|
let id = shard.shard_id as usize;
|
|
let total_shards = (self.data_shards + self.parity_shards) as usize;
|
|
if id >= total_shards {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
format!("invalid shard id: {} (max {})", id, total_shards - 1),
|
|
));
|
|
}
|
|
shard.open()?;
|
|
// A 0-byte shard file beside an index with entries is residue of a
|
|
// failed copy or a truncation, not a mountable shard: registering
|
|
// it would advertise a size-0 claim that serves nothing and, since
|
|
// placement pins re-copies to the owning disk, would keep
|
|
// attracting repairs to a file that was never valid. A 0-byte shard
|
|
// beside a 0-byte index is different — that is the legitimate
|
|
// layout of a volume encoded with no live needles, and it must keep
|
|
// mounting. The startup scan already skips 0-byte shard files; this
|
|
// covers the mount path. Mirrors AddEcVolumeShard in
|
|
// weed/storage/erasure_coding/ec_volume.go.
|
|
if shard.file_size() == 0 && self.ecx_file_size > 0 {
|
|
let path = shard.file_name();
|
|
shard.close();
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!(
|
|
"ec volume shard {} is empty (0 bytes) but the index has entries: residue of a failed copy, not a mountable shard",
|
|
path
|
|
),
|
|
));
|
|
}
|
|
self.shards[id] = Some(shard);
|
|
Ok(())
|
|
}
|
|
|
|
/// Override the disk type the EC volume (and its already-mounted
|
|
/// shards) reports under. Used by the `VolumeEcShardsMount` handler
|
|
/// so the source volume's disk type is preserved across encoding
|
|
/// (#9423). Not persisted across restarts — disk-scan reload paths
|
|
/// default to the physical location's disk type.
|
|
pub fn set_disk_type(&mut self, d: DiskType) {
|
|
self.disk_type = d.clone();
|
|
for shard in self.shards.iter_mut().flatten() {
|
|
shard.disk_type = d.clone();
|
|
}
|
|
}
|
|
|
|
/// Remove and close a shard.
|
|
pub fn remove_shard(&mut self, shard_id: ShardId) {
|
|
if let Some(ref mut shard) = self.shards[shard_id as usize] {
|
|
shard.close();
|
|
}
|
|
self.shards[shard_id as usize] = None;
|
|
}
|
|
|
|
/// Get a ShardBits bitmap of locally available shards.
|
|
pub fn shard_bits(&self) -> ShardBits {
|
|
let mut bits = ShardBits::default();
|
|
for (i, shard) in self.shards.iter().enumerate() {
|
|
if shard.is_some() {
|
|
bits.add_shard_id(i as ShardId);
|
|
}
|
|
}
|
|
bits
|
|
}
|
|
|
|
/// Count of locally available shards.
|
|
pub fn shard_count(&self) -> usize {
|
|
self.shards.iter().filter(|s| s.is_some()).count()
|
|
}
|
|
|
|
/// Reports whether `shard_id` is currently registered to this
|
|
/// EcVolume (used by the cross-disk reconcile to skip already-
|
|
/// loaded shards).
|
|
pub fn has_shard(&self, shard_id: u8) -> bool {
|
|
self.shards
|
|
.get(shard_id as usize)
|
|
.map(|s| s.is_some())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Directory where this EcVolume's `.ecx` was actually opened
|
|
/// (may differ from `dir_idx` when the legacy "written before
|
|
/// -dir.idx was set" fallback or the cross-disk reconcile path
|
|
/// pointed it elsewhere).
|
|
pub fn ecx_actual_dir(&self) -> &str {
|
|
&self.ecx_actual_dir
|
|
}
|
|
|
|
pub fn is_time_to_destroy(&self) -> bool {
|
|
self.expire_at_sec > 0
|
|
&& SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs()
|
|
> self.expire_at_sec
|
|
}
|
|
|
|
pub fn to_volume_ec_shard_information_messages(
|
|
&self,
|
|
disk_id: u32,
|
|
) -> Vec<master_pb::VolumeEcShardInformationMessage> {
|
|
let mut ec_index_bits: u32 = 0;
|
|
let mut shard_sizes = Vec::new();
|
|
for shard in self.shards.iter().flatten() {
|
|
ec_index_bits |= 1u32 << shard.shard_id;
|
|
shard_sizes.push(shard.file_size());
|
|
}
|
|
|
|
if ec_index_bits == 0 {
|
|
return Vec::new();
|
|
}
|
|
|
|
let (file_count, delete_count) = self.file_and_delete_count();
|
|
|
|
vec![master_pb::VolumeEcShardInformationMessage {
|
|
id: self.volume_id.0,
|
|
collection: self.collection.clone(),
|
|
ec_index_bits,
|
|
shard_sizes,
|
|
disk_type: self.disk_type.to_string(),
|
|
expire_at_sec: self.expire_at_sec,
|
|
disk_id,
|
|
file_count,
|
|
delete_count,
|
|
encode_ts_ns: self.encode_ts_ns,
|
|
..Default::default()
|
|
}]
|
|
}
|
|
|
|
// ---- Shard locations (distributed tracking) ----
|
|
|
|
/// Set the list of server addresses for a single shard ID. Does
|
|
/// NOT touch `shard_locations_refresh_time` — a per-shard write
|
|
/// from inside a multi-shard population (e.g. iterating the
|
|
/// `LookupEcVolume` response shard-by-shard) would otherwise
|
|
/// flip the staleness flag while the map is still incomplete,
|
|
/// letting a concurrent reader observe `needs_refresh == false`
|
|
/// against a half-populated cache and return NotFound for the
|
|
/// not-yet-inserted shards.
|
|
///
|
|
/// Callers writing back a whole `LookupEcVolume` reply should use
|
|
/// [`Self::merge_shard_locations`] instead — it upserts the reply's
|
|
/// shards under the write lock and advances the refresh timestamp in
|
|
/// one step, retaining cached shards the reply omits.
|
|
pub fn set_shard_locations(&self, shard_id: ShardId, locations: Vec<String>) {
|
|
self.shard_locations
|
|
.write()
|
|
.unwrap()
|
|
.insert(shard_id, locations);
|
|
}
|
|
|
|
/// Atomically replace the entire shard-locations map and stamp
|
|
/// the refresh time. Used by the distributed-read path's
|
|
/// post-`LookupEcVolume` write-back so the cache transitions
|
|
/// from old → fresh in a single observable step — concurrent
|
|
/// readers either see the full prior map or the full new map,
|
|
/// never an intermediate state with the freshness flag flipped.
|
|
pub fn replace_shard_locations(&self, locations: HashMap<ShardId, Vec<String>>) {
|
|
*self.shard_locations.write().unwrap() = locations;
|
|
*self.shard_locations_refresh_time.lock().unwrap() = Some(std::time::Instant::now());
|
|
}
|
|
|
|
/// Merge a fresh `LookupEcVolume` reply into the shard-locations cache and
|
|
/// stamp the refresh time, returning a clone of the resulting map.
|
|
///
|
|
/// Per-shard upsert (mirrors Go's `cachedLookupEcShardLocations`): each shard
|
|
/// id present in the reply overwrites its cached entry, while shard ids absent
|
|
/// from the reply keep their previously-cached locations — so a reply that
|
|
/// passes the data-shard completeness guard but omits a shard already in cache
|
|
/// does not drop that shard's known location (unlike a full replace).
|
|
pub fn merge_shard_locations(
|
|
&self,
|
|
locations: HashMap<ShardId, Vec<String>>,
|
|
) -> HashMap<ShardId, Vec<String>> {
|
|
let merged = {
|
|
let mut guard = self.shard_locations.write().unwrap();
|
|
for (shard_id, addrs) in locations {
|
|
guard.insert(shard_id, addrs);
|
|
}
|
|
guard.clone()
|
|
};
|
|
*self.shard_locations_refresh_time.lock().unwrap() = Some(std::time::Instant::now());
|
|
merged
|
|
}
|
|
|
|
/// Get a cloned list of server addresses for a given shard ID.
|
|
pub fn get_shard_locations(&self, shard_id: ShardId) -> Vec<String> {
|
|
self.shard_locations
|
|
.read()
|
|
.unwrap()
|
|
.get(&shard_id)
|
|
.cloned()
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
// ---- I/O error tracking (mirrors Go's EcVolume IoErrorTracker) ----
|
|
|
|
pub fn check_read_write_error(&self, err: Option<&io::Error>) {
|
|
use std::sync::atomic::Ordering;
|
|
if let Some(e) = err
|
|
&& crate::storage::volume::is_storage_io_error(e)
|
|
{
|
|
self.io_error_count.fetch_add(1, Ordering::Relaxed);
|
|
if let Ok(mut guard) = self.last_io_error.lock() {
|
|
*guard = Some(e.to_string());
|
|
}
|
|
crate::metrics::STORAGE_IO_ERROR_COUNTER.inc();
|
|
return;
|
|
}
|
|
self.io_error_count.store(0, Ordering::Relaxed);
|
|
if let Ok(mut guard) = self.last_io_error.lock()
|
|
&& guard.is_some()
|
|
{
|
|
*guard = None;
|
|
}
|
|
}
|
|
|
|
pub fn get_io_error_state(&self) -> (Option<String>, i32, bool) {
|
|
use std::sync::atomic::Ordering;
|
|
let err = self.last_io_error.lock().ok().and_then(|g| g.clone());
|
|
let count = self.io_error_count.load(Ordering::Relaxed);
|
|
let quarantined = self.io_error_quarantined.load(Ordering::Relaxed);
|
|
(err, count, quarantined)
|
|
}
|
|
|
|
pub fn mark_io_quarantined(&self) {
|
|
self.io_error_quarantined
|
|
.store(true, std::sync::atomic::Ordering::Relaxed);
|
|
}
|
|
|
|
pub fn reset_io_error_state(&self) {
|
|
use std::sync::atomic::Ordering;
|
|
self.io_error_count.store(0, Ordering::Relaxed);
|
|
self.io_error_quarantined.store(false, Ordering::Relaxed);
|
|
if let Ok(mut guard) = self.last_io_error.lock() {
|
|
*guard = None;
|
|
}
|
|
}
|
|
|
|
// ---- Index operations ----
|
|
|
|
/// Find a needle's offset and size in the sorted .ecx index via binary search.
|
|
pub fn find_needle_from_ecx(&self, needle_id: NeedleId) -> io::Result<Option<(Offset, Size)>> {
|
|
let ecx_file = self
|
|
.ecx_file
|
|
.as_ref()
|
|
.ok_or_else(|| io::Error::other("ecx file not open"))?;
|
|
|
|
let entry_count = self.ecx_file_size as usize / NEEDLE_MAP_ENTRY_SIZE;
|
|
if entry_count == 0 {
|
|
return Ok(None);
|
|
}
|
|
|
|
// Binary search
|
|
let mut lo: usize = 0;
|
|
let mut hi: usize = entry_count;
|
|
let mut entry_buf = [0u8; NEEDLE_MAP_ENTRY_SIZE];
|
|
|
|
while lo < hi {
|
|
let mid = lo + (hi - lo) / 2;
|
|
let file_offset = (mid * NEEDLE_MAP_ENTRY_SIZE) as u64;
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::FileExt;
|
|
if let Err(e) = ecx_file.read_exact_at(&mut entry_buf, file_offset) {
|
|
self.check_read_write_error(Some(&e));
|
|
return Err(e);
|
|
}
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
// Positional read so concurrent find_needle_from_ecx calls on
|
|
// the shared .ecx handle don't interleave seek/read and corrupt
|
|
// each other's binary search. Mirrors the read_exact_at helper
|
|
// in storage::volume.
|
|
if let Err(e) = read_exact_at(ecx_file, &mut entry_buf, file_offset) {
|
|
self.check_read_write_error(Some(&e));
|
|
return Err(e);
|
|
}
|
|
}
|
|
#[cfg(not(any(unix, windows)))]
|
|
{
|
|
compile_error!("Platform not supported: only unix and windows are supported");
|
|
}
|
|
|
|
let (key, offset, size) = idx_entry_from_bytes(&entry_buf);
|
|
if key == needle_id {
|
|
// Apply runtime deletion state on top of the sealed .ecx
|
|
// lookup: a needle in the in-memory deleted set is
|
|
// reported with TOMBSTONE_FILE_SIZE even though the .ecx
|
|
// record itself is untouched.
|
|
if self.is_needle_deleted(needle_id) {
|
|
self.check_read_write_error(None);
|
|
return Ok(Some((offset, TOMBSTONE_FILE_SIZE)));
|
|
}
|
|
self.check_read_write_error(None);
|
|
return Ok(Some((offset, size)));
|
|
} else if key < needle_id {
|
|
lo = mid + 1;
|
|
} else {
|
|
hi = mid;
|
|
}
|
|
}
|
|
|
|
self.check_read_write_error(None);
|
|
Ok(None)
|
|
}
|
|
|
|
/// Large-block length of this volume's shard layout (the uniform block
|
|
/// size when set, the legacy 1GiB otherwise). Mirrors Go's
|
|
/// ECContext.LargeBlockSize.
|
|
pub fn large_block_size(&self) -> i64 {
|
|
if self.block_size > 0 {
|
|
self.block_size
|
|
} else {
|
|
ERASURE_CODING_LARGE_BLOCK_SIZE as i64
|
|
}
|
|
}
|
|
|
|
/// Small-block length of this volume's shard layout.
|
|
pub fn small_block_size(&self) -> i64 {
|
|
if self.block_size > 0 {
|
|
self.block_size
|
|
} else {
|
|
ERASURE_CODING_SMALL_BLOCK_SIZE as i64
|
|
}
|
|
}
|
|
|
|
/// Locate the EC shard intervals needed to read a needle.
|
|
/// Locate the EC shard intervals covering a needle at `actual_offset` whose
|
|
/// index size is `size`. Mirrors Go's EcVolume.LocateEcShardNeedleInterval.
|
|
pub fn locate_ec_shard_needle_interval(
|
|
&self,
|
|
actual_offset: i64,
|
|
size: Size,
|
|
) -> Vec<ec_locate::Interval> {
|
|
// shardSize = datFileSize / DataShards when known, else ecdFileSize - 1
|
|
// (shards are padded to the small block size; the -1 avoids an
|
|
// off-by-one in the large-block row count).
|
|
let shard_size = if self.dat_file_size > 0 {
|
|
self.dat_file_size / self.data_shards as i64
|
|
} else {
|
|
self.shard_file_size() - 1
|
|
};
|
|
// locate_data wants the on-disk size (header+body+checksum+timestamp+padding).
|
|
let actual = get_actual_size(size, self.version);
|
|
ec_locate::locate_data(
|
|
actual_offset,
|
|
Size(actual as i32),
|
|
shard_size,
|
|
self.data_shards,
|
|
self.large_block_size(),
|
|
self.small_block_size(),
|
|
)
|
|
}
|
|
|
|
/// Resolve an interval against this volume's shard block layout. Mirrors
|
|
/// Go's EcVolume.IntervalToShardIdAndOffset.
|
|
pub fn interval_to_shard_id_and_offset(
|
|
&self,
|
|
interval: &ec_locate::Interval,
|
|
) -> (ShardId, i64) {
|
|
interval.to_shard_id_and_offset(
|
|
self.data_shards,
|
|
self.large_block_size(),
|
|
self.small_block_size(),
|
|
)
|
|
}
|
|
|
|
pub fn locate_needle(
|
|
&self,
|
|
needle_id: NeedleId,
|
|
) -> io::Result<Option<(Offset, Size, Vec<ec_locate::Interval>)>> {
|
|
let (offset, size) = match self.find_needle_from_ecx(needle_id)? {
|
|
Some((o, s)) => (o, s),
|
|
None => return Ok(None),
|
|
};
|
|
|
|
if size.is_deleted() || offset.is_zero() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let intervals = self.locate_ec_shard_needle_interval(offset.to_actual_offset(), size);
|
|
Ok(Some((offset, size, intervals)))
|
|
}
|
|
|
|
/// Read a full needle from locally available EC shards.
|
|
///
|
|
/// Locates the needle in the .ecx index, determines which shard intervals
|
|
/// contain its data, reads from local shards, and parses the result into
|
|
/// a fully populated Needle (including last_modified, checksum, ttl).
|
|
///
|
|
/// Returns `Ok(None)` if the needle is not found or is deleted.
|
|
/// Returns an error if a required shard is not available locally.
|
|
pub fn read_ec_shard_needle(&self, needle_id: NeedleId) -> io::Result<Option<Needle>> {
|
|
let (offset, size, intervals) = match self.locate_needle(needle_id)? {
|
|
Some(v) => v,
|
|
None => return Ok(None),
|
|
};
|
|
|
|
if intervals.is_empty() {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
"no intervals for needle",
|
|
));
|
|
}
|
|
|
|
// Compute the total bytes we need to read (full needle on disk)
|
|
let actual_size = get_actual_size(size, self.version) as usize;
|
|
let mut bytes = Vec::with_capacity(actual_size);
|
|
|
|
for interval in &intervals {
|
|
let (shard_id, shard_offset) = self.interval_to_shard_id_and_offset(interval);
|
|
let shard = self
|
|
.shards
|
|
.get(shard_id as usize)
|
|
.and_then(|s| s.as_ref())
|
|
.ok_or_else(|| {
|
|
io::Error::new(
|
|
io::ErrorKind::NotFound,
|
|
format!("ec shard {} not available locally", shard_id),
|
|
)
|
|
})?;
|
|
|
|
let mut buf = vec![0u8; interval.size as usize];
|
|
let n = shard.read_at(&mut buf, shard_offset as u64)?;
|
|
if n != buf.len() {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::UnexpectedEof,
|
|
format!(
|
|
"short read on ec shard {}: read {} of {} bytes for needle {}",
|
|
shard_id,
|
|
n,
|
|
buf.len(),
|
|
needle_id
|
|
),
|
|
));
|
|
}
|
|
bytes.extend_from_slice(&buf);
|
|
}
|
|
|
|
// Truncate to exact actual_size (intervals may span more than needed)
|
|
bytes.truncate(actual_size);
|
|
|
|
if bytes.len() < actual_size {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::UnexpectedEof,
|
|
format!(
|
|
"read {} bytes but need {} for needle {}",
|
|
bytes.len(),
|
|
actual_size,
|
|
needle_id
|
|
),
|
|
));
|
|
}
|
|
|
|
let mut n = Needle::default();
|
|
n.read_bytes(&bytes, offset.to_actual_offset(), size, self.version)
|
|
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("{}", e)))?;
|
|
|
|
Ok(Some(n))
|
|
}
|
|
|
|
/// Get the size of a single shard (all shards are the same size).
|
|
fn shard_file_size(&self) -> i64 {
|
|
if let Some(s) = self.shards.iter().flatten().next() {
|
|
return s.file_size();
|
|
}
|
|
0
|
|
}
|
|
|
|
/// Walk the .ecx index and return (file_count, file_deleted_count, total_size).
|
|
/// total_size sums size.Raw() for all entries (including deleted), matching Go's WalkIndex.
|
|
pub fn walk_ecx_stats(&self) -> io::Result<(u64, u64, u64)> {
|
|
let ecx_file = match self.ecx_file.as_ref() {
|
|
Some(f) => f,
|
|
None => return Ok((0, 0, 0)),
|
|
};
|
|
|
|
let entry_count = self.ecx_file_size as usize / NEEDLE_MAP_ENTRY_SIZE;
|
|
let mut files: u64 = 0;
|
|
let mut files_deleted: u64 = 0;
|
|
let mut total_size: u64 = 0;
|
|
let mut entry_buf = [0u8; NEEDLE_MAP_ENTRY_SIZE];
|
|
|
|
for i in 0..entry_count {
|
|
let file_offset = (i * NEEDLE_MAP_ENTRY_SIZE) as u64;
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::FileExt;
|
|
ecx_file.read_exact_at(&mut entry_buf, file_offset)?;
|
|
}
|
|
let (_key, _offset, size) = idx_entry_from_bytes(&entry_buf);
|
|
// Match Go's Size.Raw(): tombstone (-1) returns 0, other negatives return abs
|
|
if !size.is_tombstone() {
|
|
total_size += size.0.unsigned_abs() as u64;
|
|
}
|
|
if size.is_deleted() {
|
|
files_deleted += 1;
|
|
} else {
|
|
files += 1;
|
|
}
|
|
}
|
|
|
|
Ok((files, files_deleted, total_size))
|
|
}
|
|
|
|
/// ScrubIndex verifies index integrity of an EC volume.
|
|
/// Matches Go's `(ev *EcVolume) ScrubIndex()` → `idx.CheckIndexFile()`.
|
|
/// Returns (entry_count, errors).
|
|
/// Snapshot for `scrub_index`, so the index walk can run with the store
|
|
/// lock released. Same rationale as `checksum_scrub_plan`.
|
|
pub fn scrub_index_plan(&self) -> EcIndexScrubPlan {
|
|
let ecx_path = self.ecx_file_name();
|
|
// Opened under the guard, for the same reason the checksum plan
|
|
// duplicates its shard handles. A fresh open rather than a clone of the
|
|
// cached handle: `check_index_file` seeks, and `dup` would share the
|
|
// cursor the shared handle is read from elsewhere.
|
|
let ecx_handle = File::open(&ecx_path);
|
|
EcIndexScrubPlan {
|
|
volume_id: self.volume_id,
|
|
has_ecx_file: self.ecx_file.is_some(),
|
|
ecx_path,
|
|
ecx_file_size: self.ecx_file_size,
|
|
version: self.version,
|
|
ecx_handle,
|
|
}
|
|
}
|
|
|
|
/// Convenience wrapper preserving the original call shape. Callers holding
|
|
/// the store lock MUST use `scrub_index_plan()` + `run()` instead.
|
|
pub fn scrub_index(&self) -> (u64, Vec<String>) {
|
|
self.scrub_index_plan().run()
|
|
}
|
|
|
|
/// Snapshot for `scrub_local`, so the needle walk can run with the store
|
|
/// lock released. Same rationale as `checksum_scrub_plan`: this reads every
|
|
/// local needle's bytes, which is GB-scale on a real volume.
|
|
///
|
|
/// The shard vector keeps its SLOT structure — index is the shard id, gaps
|
|
/// are the shards this node does not hold. `scrub_local` reads that
|
|
/// distinction to decide whether a needle can be reassembled locally at
|
|
/// all, so compacting it would silently change which needles get verified.
|
|
pub fn scrub_local_plan(&self) -> EcLocalScrubPlan {
|
|
EcLocalScrubPlan::for_volumes(&[self]).expect("a single runtime is never an empty slice")
|
|
}
|
|
|
|
/// ScrubLocal verifies each needle against the LOCAL shards only; it cannot
|
|
/// CRC-check a needle whose intervals span shards held on other servers.
|
|
/// Mirrors Go's EcVolume.ScrubLocal. Returns (rows walked, broken shards, errors).
|
|
///
|
|
/// Convenience wrapper preserving the original call shape. Callers that
|
|
/// hold the store lock MUST use `scrub_local_plan()` + `run()` instead.
|
|
pub fn scrub_local(
|
|
&self,
|
|
) -> (
|
|
u64,
|
|
Vec<crate::pb::volume_server_pb::EcShardInfo>,
|
|
Vec<String>,
|
|
) {
|
|
self.scrub_local_plan().run()
|
|
}
|
|
|
|
// ---- Deletion ----
|
|
|
|
/// Write `TOMBSTONE_FILE_SIZE` over the Size field of an existing .ecx
|
|
/// entry, matching Go's `MarkNeedleDeleted`. Only used by the offline
|
|
/// `rebuild_ecx_from_journal` path — the runtime delete path does not
|
|
/// touch .ecx because the index is treated as an immutable sorted
|
|
/// (id, offset, size) table. Returns `false` if the needle is not in
|
|
/// the index (ignored by callers) and an error on IO failure.
|
|
fn tombstone_ecx_entry(&self, needle_id: NeedleId) -> io::Result<bool> {
|
|
let ecx_file = self.ecx_file.as_ref().ok_or_else(|| {
|
|
io::Error::other(format!(
|
|
"ec volume {} has no open .ecx file (closed or corrupt)",
|
|
self.volume_id.0
|
|
))
|
|
})?;
|
|
|
|
let entry_count = self.ecx_file_size as usize / NEEDLE_MAP_ENTRY_SIZE;
|
|
if entry_count == 0 {
|
|
return Ok(false);
|
|
}
|
|
|
|
let mut lo: usize = 0;
|
|
let mut hi: usize = entry_count;
|
|
let mut entry_buf = [0u8; NEEDLE_MAP_ENTRY_SIZE];
|
|
while lo < hi {
|
|
let mid = lo + (hi - lo) / 2;
|
|
let file_offset = (mid * NEEDLE_MAP_ENTRY_SIZE) as u64;
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::FileExt;
|
|
ecx_file.read_exact_at(&mut entry_buf, file_offset)?;
|
|
}
|
|
let (key, _offset, _old_size) = idx_entry_from_bytes(&entry_buf);
|
|
if key == needle_id {
|
|
let size_offset = file_offset + NEEDLE_ID_SIZE as u64 + OFFSET_SIZE as u64;
|
|
let mut size_buf = [0u8; SIZE_SIZE];
|
|
TOMBSTONE_FILE_SIZE.to_bytes(&mut size_buf);
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::FileExt;
|
|
ecx_file.write_all_at(&size_buf, size_offset)?;
|
|
}
|
|
return Ok(true);
|
|
} else if key < needle_id {
|
|
lo = mid + 1;
|
|
} else {
|
|
hi = mid;
|
|
}
|
|
}
|
|
Ok(false)
|
|
}
|
|
|
|
/// Replay .ecj journal entries into .ecx: for each needle id in .ecj,
|
|
/// overwrite its .ecx size field with a tombstone, then remove the
|
|
/// journal file. Mirrors Go's `RebuildEcxFile`, which is invoked from
|
|
/// specific decode / rebuild gRPC handlers — it is intentionally
|
|
/// **not** called on volume load (runtime reads consult
|
|
/// `deleted_needles` instead). The rebuild is atomic with respect to
|
|
/// the journal: if any individual write fails the .ecj file is left
|
|
/// in place and the error is propagated so tombstones are not lost.
|
|
#[expect(dead_code, reason = "no caller yet; see the doc comment")]
|
|
fn rebuild_ecx_from_journal(&mut self) -> io::Result<()> {
|
|
let ecj_path = self.ecj_file_name();
|
|
if !std::path::Path::new(&ecj_path).exists() {
|
|
return Ok(());
|
|
}
|
|
|
|
let data = fs::read(&ecj_path)?;
|
|
if data.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let count = data.len() / NEEDLE_ID_SIZE;
|
|
for i in 0..count {
|
|
let start = i * NEEDLE_ID_SIZE;
|
|
if start + NEEDLE_ID_SIZE > data.len() {
|
|
break;
|
|
}
|
|
let needle_id = NeedleId::from_bytes(&data[start..start + NEEDLE_ID_SIZE]);
|
|
// A needle that never made it into .ecx is fine (e.g. the
|
|
// delete raced against encode). Any other IO error aborts the
|
|
// rebuild so the journal survives to be retried later.
|
|
self.tombstone_ecx_entry(needle_id)?;
|
|
}
|
|
|
|
// Durably flush the newly-written .ecx tombstones before dropping
|
|
// the journal: the writes went through write_all_at and may still
|
|
// be in page cache.
|
|
if let Some(ref ecx_file) = self.ecx_file {
|
|
ecx_file.sync_all()?;
|
|
}
|
|
|
|
// Fold successful — drop and recreate the journal, clear the
|
|
// in-memory deleted set (all of its contents are now materialized
|
|
// in .ecx), and reset the cached size.
|
|
fs::remove_file(&ecj_path)?;
|
|
let ecj_file = open_volume_file(
|
|
OpenOptions::new()
|
|
.read(true)
|
|
.write(true)
|
|
.create(true)
|
|
.append(true),
|
|
&ecj_path,
|
|
)?;
|
|
self.ecj_file = Some(ecj_file);
|
|
self.ecj_file_size = 0;
|
|
if let Ok(mut set) = self.deleted_needles.write() {
|
|
set.clear();
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ---- Deletion journal ----
|
|
|
|
/// Record a needle delete: append the id to the .ecj deletion journal
|
|
/// and insert it into the in-memory deleted set. `.ecx` is not touched
|
|
/// at runtime — it is a sealed sorted (id, offset, size) index and
|
|
/// runtime deletion state lives exclusively in .ecj + `deleted_needles`.
|
|
/// A lookup via `find_needle_from_ecx` masks the id out by returning
|
|
/// `TOMBSTONE_FILE_SIZE` on a subsequent read.
|
|
///
|
|
/// The .ecj append is the durable commit point. On any failure the
|
|
/// file is truncated back to the pre-append length so the on-disk
|
|
/// journal and in-memory state cannot drift. Only after the sync
|
|
/// succeeds is the id published into the set, so a failure leaves
|
|
/// the delete invisible to readers.
|
|
pub fn journal_delete(&mut self, needle_id: NeedleId) -> io::Result<()> {
|
|
// Look the needle up read-only. Missing is a silent no-op; a
|
|
// pre-existing .ecx tombstone (from a prior decode/rebuild) is
|
|
// mirrored into the in-memory set so delete_count stays accurate
|
|
// without needing to walk .ecx on every heartbeat.
|
|
match self.find_needle_from_ecx_raw(needle_id)? {
|
|
None => return Ok(()),
|
|
Some((_, size)) if size.is_deleted() => {
|
|
if let Ok(mut set) = self.deleted_needles.write() {
|
|
set.insert(needle_id);
|
|
}
|
|
return Ok(());
|
|
}
|
|
Some(_) => {}
|
|
}
|
|
|
|
// Idempotent fast path for repeat deletes — avoids the journal
|
|
// append entirely so the derived delete_count stays stable.
|
|
if self.is_needle_deleted(needle_id) {
|
|
return Ok(());
|
|
}
|
|
|
|
let prev_ecj_size = self.ecj_file_size;
|
|
let append_result: io::Result<()> = {
|
|
let ecj_file = self
|
|
.ecj_file
|
|
.as_mut()
|
|
.ok_or_else(|| io::Error::other("ecj file not open"))?;
|
|
let mut buf = [0u8; NEEDLE_ID_SIZE];
|
|
needle_id.to_bytes(&mut buf);
|
|
ecj_file.write_all(&buf).and_then(|_| ecj_file.sync_all())
|
|
};
|
|
|
|
match append_result {
|
|
Ok(()) => {
|
|
self.ecj_file_size += NEEDLE_ID_SIZE as i64;
|
|
if let Ok(mut set) = self.deleted_needles.write() {
|
|
set.insert(needle_id);
|
|
}
|
|
Ok(())
|
|
}
|
|
Err(e) => {
|
|
// write_all may have extended the file on disk before
|
|
// sync_all failed; truncate back to the known-good size so
|
|
// the on-disk journal never drifts past `deleted_needles`.
|
|
if let Some(ecj) = self.ecj_file.as_mut()
|
|
&& let Err(trunc_err) = ecj.set_len(prev_ecj_size as u64)
|
|
{
|
|
tracing::error!(
|
|
volume_id = self.volume_id.0,
|
|
needle_id = needle_id.0,
|
|
truncate_error = %trunc_err,
|
|
"failed to truncate ecj after append failure"
|
|
);
|
|
}
|
|
Err(e)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Internal: binary search .ecx without masking by `deleted_needles`.
|
|
/// Used by `journal_delete` so a repeat delete can still see the raw
|
|
/// pre-existing .ecx tombstone from a prior rebuild.
|
|
fn find_needle_from_ecx_raw(&self, needle_id: NeedleId) -> io::Result<Option<(Offset, Size)>> {
|
|
let ecx_file = self
|
|
.ecx_file
|
|
.as_ref()
|
|
.ok_or_else(|| io::Error::other("ecx file not open"))?;
|
|
let entry_count = self.ecx_file_size as usize / NEEDLE_MAP_ENTRY_SIZE;
|
|
if entry_count == 0 {
|
|
return Ok(None);
|
|
}
|
|
let mut lo: usize = 0;
|
|
let mut hi: usize = entry_count;
|
|
let mut entry_buf = [0u8; NEEDLE_MAP_ENTRY_SIZE];
|
|
while lo < hi {
|
|
let mid = lo + (hi - lo) / 2;
|
|
let file_offset = (mid * NEEDLE_MAP_ENTRY_SIZE) as u64;
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::FileExt;
|
|
ecx_file.read_exact_at(&mut entry_buf, file_offset)?;
|
|
}
|
|
let (key, offset, size) = idx_entry_from_bytes(&entry_buf);
|
|
if key == needle_id {
|
|
return Ok(Some((offset, size)));
|
|
} else if key < needle_id {
|
|
lo = mid + 1;
|
|
} else {
|
|
hi = mid;
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
/// Append a deleted needle ID to the .ecj journal, validating the cookie first.
|
|
/// Matches Go's DeleteEcShardNeedle which validates cookie before journaling.
|
|
/// A cookie of 0 means skip cookie check (e.g., orphan cleanup).
|
|
pub fn journal_delete_with_cookie(
|
|
&mut self,
|
|
needle_id: NeedleId,
|
|
cookie: crate::storage::types::Cookie,
|
|
) -> io::Result<()> {
|
|
// cookie == 0 indicates SkipCookieCheck was requested
|
|
if cookie.0 != 0 {
|
|
// Try to read the needle's cookie from the EC shards to validate
|
|
// Look up the needle in ecx index to find its offset, then read header from shard
|
|
if let Ok(Some((offset, size))) = self.find_needle_from_ecx(needle_id)
|
|
&& !size.is_deleted()
|
|
&& !offset.is_zero()
|
|
{
|
|
let actual_offset = offset.to_actual_offset() as u64;
|
|
// Determine which shard contains this offset and read the cookie
|
|
let shard_size = self
|
|
.shards
|
|
.iter()
|
|
.filter_map(|s| s.as_ref())
|
|
.map(|s| s.file_size())
|
|
.next()
|
|
.unwrap_or(0) as u64;
|
|
if let Some(shard_id) = actual_offset.checked_div(shard_size) {
|
|
let shard_id = shard_id as usize;
|
|
let shard_offset = actual_offset % shard_size;
|
|
if let Some(Some(shard)) = self.shards.get(shard_id) {
|
|
let mut header_buf = [0u8; 4]; // cookie is first 4 bytes of needle
|
|
if shard.read_at(&mut header_buf, shard_offset).is_ok() {
|
|
let needle_cookie =
|
|
crate::storage::types::Cookie(u32::from_be_bytes(header_buf));
|
|
if needle_cookie != cookie {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!("unexpected cookie {:x}", cookie.0),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
self.journal_delete(needle_id)
|
|
}
|
|
|
|
/// Read all deleted needle IDs from the .ecj journal.
|
|
pub fn read_deleted_needles(&self) -> io::Result<Vec<NeedleId>> {
|
|
let ecj_path = self.ecj_file_name();
|
|
if !std::path::Path::new(&ecj_path).exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let data = fs::read(&ecj_path)?;
|
|
let count = data.len() / NEEDLE_ID_SIZE;
|
|
let mut needles = Vec::with_capacity(count);
|
|
for i in 0..count {
|
|
let start = i * NEEDLE_ID_SIZE;
|
|
let id = NeedleId::from_bytes(&data[start..start + NEEDLE_ID_SIZE]);
|
|
needles.push(id);
|
|
}
|
|
Ok(needles)
|
|
}
|
|
|
|
// ---- Lifecycle ----
|
|
|
|
pub fn close(&mut self) {
|
|
for shard in &mut self.shards {
|
|
if let Some(s) = shard {
|
|
s.close();
|
|
}
|
|
*shard = None;
|
|
}
|
|
// Sync .ecx before closing to flush in-place deletion marks (matches Go's ev.ecxFile.Sync())
|
|
if let Some(ref ecx_file) = self.ecx_file {
|
|
let _ = ecx_file.sync_all();
|
|
}
|
|
self.ecx_file = None;
|
|
self.ecj_file = None;
|
|
}
|
|
|
|
pub fn destroy(&mut self) {
|
|
for shard in &mut self.shards {
|
|
if let Some(s) = shard {
|
|
s.destroy();
|
|
}
|
|
*shard = None;
|
|
}
|
|
// Remove .ecx/.ecj/.vif from ecx_actual_dir (where they were found)
|
|
// Go's Destroy() removes .ecx, .ecj, and .vif files.
|
|
let actual_base = crate::storage::volume::volume_file_name(
|
|
&self.ecx_actual_dir,
|
|
&self.collection,
|
|
self.volume_id,
|
|
);
|
|
let _ = fs::remove_file(format!("{}.ecx", actual_base));
|
|
let _ = fs::remove_file(format!("{}.ecj", actual_base));
|
|
let _ = fs::remove_file(format!("{}.vif", actual_base));
|
|
// Also sweep the originally-configured idx dir in case stale files
|
|
// exist there (ecx_file_name() / ecj_file_name() now resolve from
|
|
// ecx_actual_dir, so we have to build the idx-dir paths explicitly).
|
|
if self.ecx_actual_dir != self.dir_idx {
|
|
let idx_base = crate::storage::volume::volume_file_name(
|
|
&self.dir_idx,
|
|
&self.collection,
|
|
self.volume_id,
|
|
);
|
|
let _ = fs::remove_file(format!("{}.ecx", idx_base));
|
|
let _ = fs::remove_file(format!("{}.ecj", idx_base));
|
|
let _ = fs::remove_file(format!("{}.vif", idx_base));
|
|
}
|
|
if self.ecx_actual_dir != self.dir && self.dir_idx != self.dir {
|
|
let data_base = crate::storage::volume::volume_file_name(
|
|
&self.dir,
|
|
&self.collection,
|
|
self.volume_id,
|
|
);
|
|
let _ = fs::remove_file(format!("{}.ecx", data_base));
|
|
let _ = fs::remove_file(format!("{}.ecj", data_base));
|
|
let _ = fs::remove_file(format!("{}.vif", data_base));
|
|
}
|
|
// Go's Destroy() also removes bitrot checksum sidecars so a later
|
|
// volume-id reuse cannot load stale protection, and so
|
|
// collection.delete does not leave orphaned <base>.ecsum files.
|
|
// ecx_actual_dir is always one of these two dirs.
|
|
let _ =
|
|
crate::storage::erasure_coding::ec_bitrot::remove_bitrot_sidecars(&self.base_name());
|
|
if self.dir_idx != self.dir {
|
|
let idx_base = crate::storage::volume::volume_file_name(
|
|
&self.dir_idx,
|
|
&self.collection,
|
|
self.volume_id,
|
|
);
|
|
let _ = crate::storage::erasure_coding::ec_bitrot::remove_bitrot_sidecars(&idx_base);
|
|
}
|
|
self.ecx_file = None;
|
|
self.ecj_file = None;
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::TempDir;
|
|
|
|
/// `destroy()` must remove co-located `.ecsum` sidecars (Go Destroy parity).
|
|
/// Without this, `collection.delete` leaves orphaned bitrot files that
|
|
/// inflate EC-health scanners after the shards are gone.
|
|
#[test]
|
|
fn test_destroy_removes_bitrot_sidecar() {
|
|
use crate::storage::needle_map::NeedleMapKind;
|
|
use crate::storage::volume::Volume;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
let mut v = Volume::new(
|
|
dir,
|
|
dir,
|
|
"ec1c",
|
|
VolumeId(2074),
|
|
NeedleMapKind::InMemory,
|
|
None,
|
|
None,
|
|
0,
|
|
Version::current(),
|
|
)
|
|
.unwrap();
|
|
for i in 1..=3 {
|
|
let data = format!("needle {}", i);
|
|
let mut n = Needle {
|
|
id: NeedleId(i),
|
|
cookie: Cookie(i as u32),
|
|
data: data.as_bytes().to_vec(),
|
|
data_size: data.len() as u32,
|
|
..Needle::default()
|
|
};
|
|
v.write_needle(&mut n, true, false).unwrap();
|
|
}
|
|
v.sync_to_disk().unwrap();
|
|
v.close();
|
|
crate::storage::erasure_coding::ec_encoder::write_ec_files(
|
|
dir,
|
|
dir,
|
|
"ec1c",
|
|
VolumeId(2074),
|
|
10,
|
|
4,
|
|
)
|
|
.unwrap();
|
|
|
|
let base = crate::storage::volume::volume_file_name(dir, "ec1c", VolumeId(2074));
|
|
let ecsum = format!("{}.ecsum", base);
|
|
assert!(
|
|
std::path::Path::new(&ecsum).exists(),
|
|
"precondition: encode must write generation-0 .ecsum"
|
|
);
|
|
assert!(
|
|
std::path::Path::new(&format!("{}.ec00", base)).exists(),
|
|
"precondition: encode must write shards"
|
|
);
|
|
|
|
let mut vol = EcVolume::new(dir, dir, "ec1c", VolumeId(2074)).unwrap();
|
|
// Mount at least one local shard so destroy's shard loop has work.
|
|
vol.add_shard(EcVolumeShard::new(dir, "ec1c", VolumeId(2074), 0))
|
|
.unwrap();
|
|
vol.destroy();
|
|
|
|
assert!(
|
|
!std::path::Path::new(&format!("{}.ec00", base)).exists(),
|
|
"destroy should remove shards"
|
|
);
|
|
assert!(
|
|
!std::path::Path::new(&ecsum).exists(),
|
|
"destroy should remove co-located .ecsum (Go Destroy parity)"
|
|
);
|
|
assert!(
|
|
!std::path::Path::new(&format!("{}.ecx", base)).exists(),
|
|
"destroy should remove .ecx"
|
|
);
|
|
}
|
|
|
|
/// Mounting an EC volume loads and validates its generation-0 `.ecsum`
|
|
/// sidecar, so `bitrot_protection()` reports `On` with the parsed manifest.
|
|
#[test]
|
|
fn test_mount_loads_bitrot_sidecar() {
|
|
use crate::storage::erasure_coding::ec_bitrot::BitrotStatus;
|
|
use crate::storage::needle_map::NeedleMapKind;
|
|
use crate::storage::volume::Volume;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
let mut v = Volume::new(
|
|
dir,
|
|
dir,
|
|
"",
|
|
VolumeId(1),
|
|
NeedleMapKind::InMemory,
|
|
None,
|
|
None,
|
|
0,
|
|
Version::current(),
|
|
)
|
|
.unwrap();
|
|
for i in 1..=5 {
|
|
let data = format!("test data for needle {}", i);
|
|
let mut n = Needle {
|
|
id: NeedleId(i),
|
|
cookie: Cookie(i as u32),
|
|
data: data.as_bytes().to_vec(),
|
|
data_size: data.len() as u32,
|
|
..Needle::default()
|
|
};
|
|
v.write_needle(&mut n, true, false).unwrap();
|
|
}
|
|
v.sync_to_disk().unwrap();
|
|
v.close();
|
|
crate::storage::erasure_coding::ec_encoder::write_ec_files(
|
|
dir,
|
|
dir,
|
|
"",
|
|
VolumeId(1),
|
|
10,
|
|
4,
|
|
)
|
|
.unwrap();
|
|
|
|
let vol = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap();
|
|
assert!(
|
|
vol.bitrot.is_some(),
|
|
"mount should load the generation-0 .ecsum sidecar"
|
|
);
|
|
let (prot, status) = vol.bitrot_protection();
|
|
assert_eq!(status, BitrotStatus::On);
|
|
assert_eq!(prot.unwrap().shards.len(), 14);
|
|
}
|
|
|
|
/// REGRESSION: the scrub plans must be SELF-CONTAINED, so the handler can
|
|
/// drop the store lock before the scan runs — see `EcChecksumScrubPlan`.
|
|
///
|
|
/// The volume is DROPPED before the plans run, and the plans are moved to
|
|
/// another thread. A plan that borrowed from `EcVolume` could do neither, so
|
|
/// this stops COMPILING if the snapshot ever regresses to a borrow.
|
|
#[test]
|
|
fn test_scrub_plans_are_self_contained_and_match_direct_call() {
|
|
use crate::storage::needle_map::NeedleMapKind;
|
|
use crate::storage::volume::Volume;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
let mut v = Volume::new(
|
|
dir,
|
|
dir,
|
|
"",
|
|
VolumeId(1),
|
|
NeedleMapKind::InMemory,
|
|
None,
|
|
None,
|
|
0,
|
|
Version::current(),
|
|
)
|
|
.unwrap();
|
|
for i in 1..=8 {
|
|
let data = format!("test data for needle {} with a bit more length", i);
|
|
let mut n = Needle {
|
|
id: NeedleId(i),
|
|
cookie: Cookie(i as u32),
|
|
data: data.as_bytes().to_vec(),
|
|
data_size: data.len() as u32,
|
|
..Needle::default()
|
|
};
|
|
v.write_needle(&mut n, true, false).unwrap();
|
|
}
|
|
v.sync_to_disk().unwrap();
|
|
v.close();
|
|
crate::storage::erasure_coding::ec_encoder::write_ec_files(
|
|
dir,
|
|
dir,
|
|
"",
|
|
VolumeId(1),
|
|
10,
|
|
4,
|
|
)
|
|
.unwrap();
|
|
|
|
let mut vol = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap();
|
|
for id in 0..14u8 {
|
|
vol.add_shard(EcVolumeShard::new(dir, "", VolumeId(1), id))
|
|
.unwrap();
|
|
}
|
|
|
|
// Baseline via the original call shape, with the volume still alive.
|
|
let direct_checksum = vol.checksum_scrub();
|
|
let direct_index = vol.scrub_index();
|
|
let direct_local = vol.scrub_local();
|
|
assert!(
|
|
direct_checksum.0 > 0 && direct_local.0 > 0,
|
|
"fixture scanned nothing, so the equalities below would be vacuous"
|
|
);
|
|
|
|
let checksum_plan = vol.checksum_scrub_plan();
|
|
let index_plan = vol.scrub_index_plan();
|
|
let local_plan = vol.scrub_local_plan();
|
|
|
|
// The lock (and here the whole volume) is gone before the scan runs.
|
|
drop(vol);
|
|
|
|
let (from_plan_checksum, from_plan_index, from_plan_local) =
|
|
std::thread::spawn(move || (checksum_plan.run(), index_plan.run(), local_plan.run()))
|
|
.join()
|
|
.expect("scrub plans must be runnable off the owning thread");
|
|
|
|
assert_eq!(
|
|
from_plan_checksum, direct_checksum,
|
|
"checksum scrub result changed when run from a released-lock plan"
|
|
);
|
|
assert_eq!(
|
|
from_plan_index, direct_index,
|
|
"index scrub result changed when run from a released-lock plan"
|
|
);
|
|
assert_eq!(
|
|
from_plan_local, direct_local,
|
|
"local scrub result changed when run from a released-lock plan"
|
|
);
|
|
}
|
|
|
|
/// REGRESSION: a malformed `.ecx` row must not abort the scrub TASK.
|
|
///
|
|
/// `EcLocalScrubPlan::run()` skips only the -1 tombstone, matching Go's
|
|
/// `ScrubLocal`, so any OTHER negative size reaches the reassembly buffer
|
|
/// with a negative `get_actual_size()`. Go pays nothing for that (it
|
|
/// appends to a nil slice); Rust sizes a per-needle `Vec` from it, and
|
|
/// `Vec::with_capacity(negative as usize)` aborts the process. Now that the
|
|
/// plan runs under `spawn_blocking`, that abort comes back as a JoinError
|
|
/// and would take the whole node-wide scrub RPC down with every result
|
|
/// already collected. The row must be REPORTED, as Go reports it.
|
|
#[test]
|
|
fn test_local_scrub_plan_reports_negative_size_ecx_row() {
|
|
use crate::storage::needle_map::NeedleMapKind;
|
|
use crate::storage::volume::Volume;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
let mut v = Volume::new(
|
|
dir,
|
|
dir,
|
|
"",
|
|
VolumeId(1),
|
|
NeedleMapKind::InMemory,
|
|
None,
|
|
None,
|
|
0,
|
|
Version::current(),
|
|
)
|
|
.unwrap();
|
|
for i in 1..=8 {
|
|
let data = format!("test data for needle {} with a bit more length", i);
|
|
let mut n = Needle {
|
|
id: NeedleId(i),
|
|
cookie: Cookie(i as u32),
|
|
data: data.as_bytes().to_vec(),
|
|
data_size: data.len() as u32,
|
|
..Needle::default()
|
|
};
|
|
v.write_needle(&mut n, true, false).unwrap();
|
|
}
|
|
v.sync_to_disk().unwrap();
|
|
v.close();
|
|
crate::storage::erasure_coding::ec_encoder::write_ec_files(
|
|
dir,
|
|
dir,
|
|
"",
|
|
VolumeId(1),
|
|
10,
|
|
4,
|
|
)
|
|
.unwrap();
|
|
|
|
// Rewrite the first .ecx row's size as -1000: a negative that is NOT
|
|
// the -1 tombstone the walk skips. A scrub is what you point at an
|
|
// index you already suspect, so an arbitrary i32 in the size field is
|
|
// in-scope input, whatever wrote it.
|
|
let ecx = format!(
|
|
"{}.ecx",
|
|
crate::storage::volume::volume_file_name(dir, "", VolumeId(1))
|
|
);
|
|
let mut raw = std::fs::read(&ecx).unwrap();
|
|
assert!(
|
|
raw.len() >= NEEDLE_MAP_ENTRY_SIZE,
|
|
"fixture must write at least one .ecx row"
|
|
);
|
|
let (key, offset, _) = idx_entry_from_bytes(&raw[..NEEDLE_MAP_ENTRY_SIZE]);
|
|
idx_entry_to_bytes(&mut raw[..NEEDLE_MAP_ENTRY_SIZE], key, offset, Size(-1000));
|
|
std::fs::write(&ecx, &raw).unwrap();
|
|
assert!(
|
|
get_actual_size(Size(-1000), Version::current()) < 0,
|
|
"precondition: the row must drive get_actual_size negative"
|
|
);
|
|
|
|
let mut vol = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap();
|
|
for id in 0..14u8 {
|
|
vol.add_shard(EcVolumeShard::new(dir, "", VolumeId(1), id))
|
|
.unwrap();
|
|
}
|
|
let plan = vol.scrub_local_plan();
|
|
drop(vol);
|
|
|
|
// The join IS the assertion: a panic here is the JoinError that used to
|
|
// fail the whole ScrubEcVolume RPC.
|
|
let (_count, _broken, errs) = std::thread::spawn(move || plan.run())
|
|
.join()
|
|
.expect("a malformed .ecx row must not abort the scrub task");
|
|
|
|
assert!(
|
|
errs.iter()
|
|
.any(|e| e.contains(&format!("bytes for needle {}", key.0))),
|
|
"the malformed row must be reported, got {:?}",
|
|
errs
|
|
);
|
|
}
|
|
|
|
/// REGRESSION: a scrub running with the store lock RELEASED must not turn a
|
|
/// concurrent, intentional removal into a corruption report — see
|
|
/// `EcChecksumScrubPlan`.
|
|
///
|
|
/// Deleting every file after the plans are built is the whole test: reads
|
|
/// that resolve a path diverge from the direct call, reads through the
|
|
/// captured descriptors are identical.
|
|
#[test]
|
|
fn test_scrub_plans_survive_files_removed_after_snapshot() {
|
|
use crate::storage::needle_map::NeedleMapKind;
|
|
use crate::storage::volume::Volume;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
let mut v = Volume::new(
|
|
dir,
|
|
dir,
|
|
"",
|
|
VolumeId(1),
|
|
NeedleMapKind::InMemory,
|
|
None,
|
|
None,
|
|
0,
|
|
Version::current(),
|
|
)
|
|
.unwrap();
|
|
for i in 1..=8 {
|
|
let data = format!("test data for needle {} with a bit more length", i);
|
|
let mut n = Needle {
|
|
id: NeedleId(i),
|
|
cookie: Cookie(i as u32),
|
|
data: data.as_bytes().to_vec(),
|
|
data_size: data.len() as u32,
|
|
..Needle::default()
|
|
};
|
|
v.write_needle(&mut n, true, false).unwrap();
|
|
}
|
|
v.sync_to_disk().unwrap();
|
|
v.close();
|
|
crate::storage::erasure_coding::ec_encoder::write_ec_files(
|
|
dir,
|
|
dir,
|
|
"",
|
|
VolumeId(1),
|
|
10,
|
|
4,
|
|
)
|
|
.unwrap();
|
|
|
|
let mut vol = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap();
|
|
for id in 0..14u8 {
|
|
vol.add_shard(EcVolumeShard::new(dir, "", VolumeId(1), id))
|
|
.unwrap();
|
|
}
|
|
|
|
// Baseline with every file present and the volume still mounted.
|
|
let direct_checksum = vol.checksum_scrub();
|
|
let direct_index = vol.scrub_index();
|
|
let direct_local = vol.scrub_local();
|
|
assert!(
|
|
direct_checksum.0 > 0 && direct_local.0 > 0,
|
|
"fixture scanned nothing, so the equalities below would be vacuous"
|
|
);
|
|
assert!(
|
|
direct_checksum.2.is_empty() && direct_index.1.is_empty() && direct_local.2.is_empty(),
|
|
"fixture is not clean, so a false error could not be told apart: {:?} {:?} {:?}",
|
|
direct_checksum.2,
|
|
direct_index.1,
|
|
direct_local.2
|
|
);
|
|
|
|
let checksum_plan = vol.checksum_scrub_plan();
|
|
let index_plan = vol.scrub_index_plan();
|
|
let local_plan = vol.scrub_local_plan();
|
|
|
|
// The teardown a store writer would perform, after the plans exist.
|
|
drop(vol);
|
|
let base = crate::storage::volume::volume_file_name(dir, "", VolumeId(1));
|
|
for id in 0..14u8 {
|
|
std::fs::remove_file(format!("{}.ec{:02}", base, id)).unwrap();
|
|
}
|
|
std::fs::remove_file(format!("{}.ecx", base)).unwrap();
|
|
assert!(
|
|
!std::path::Path::new(&format!("{}.ec00", base)).exists(),
|
|
"the removal under test did not happen"
|
|
);
|
|
|
|
assert_eq!(
|
|
checksum_plan.run(),
|
|
direct_checksum,
|
|
"shard files unlinked after the snapshot were reported as corruption"
|
|
);
|
|
assert_eq!(
|
|
index_plan.run(),
|
|
direct_index,
|
|
"the .ecx unlinked after the snapshot was reported as an index error"
|
|
);
|
|
assert_eq!(
|
|
local_plan.run(),
|
|
direct_local,
|
|
"files unlinked after the snapshot were reported as local-scrub errors"
|
|
);
|
|
}
|
|
|
|
/// CHECKSUM scrub verifies clean shards against the sidecar and flags a shard
|
|
/// whose bytes are corrupted after encode.
|
|
#[test]
|
|
fn test_checksum_scrub_clean_and_detects_corruption() {
|
|
use crate::storage::needle_map::NeedleMapKind;
|
|
use crate::storage::volume::Volume;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
let mut v = Volume::new(
|
|
dir,
|
|
dir,
|
|
"",
|
|
VolumeId(1),
|
|
NeedleMapKind::InMemory,
|
|
None,
|
|
None,
|
|
0,
|
|
Version::current(),
|
|
)
|
|
.unwrap();
|
|
for i in 1..=8 {
|
|
let data = format!("test data for needle {} with a bit more length", i);
|
|
let mut n = Needle {
|
|
id: NeedleId(i),
|
|
cookie: Cookie(i as u32),
|
|
data: data.as_bytes().to_vec(),
|
|
data_size: data.len() as u32,
|
|
..Needle::default()
|
|
};
|
|
v.write_needle(&mut n, true, false).unwrap();
|
|
}
|
|
v.sync_to_disk().unwrap();
|
|
v.close();
|
|
crate::storage::erasure_coding::ec_encoder::write_ec_files(
|
|
dir,
|
|
dir,
|
|
"",
|
|
VolumeId(1),
|
|
10,
|
|
4,
|
|
)
|
|
.unwrap();
|
|
|
|
let mut vol = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap();
|
|
for id in 0..14u8 {
|
|
vol.add_shard(EcVolumeShard::new(dir, "", VolumeId(1), id))
|
|
.unwrap();
|
|
}
|
|
|
|
// Clean scrub: no mismatches, no errors, blocks scanned > 0.
|
|
let (scanned, broken, errs) = vol.checksum_scrub();
|
|
assert!(errs.is_empty(), "unexpected scrub errors: {:?}", errs);
|
|
assert!(broken.is_empty(), "unexpected mismatches: {:?}", broken);
|
|
assert!(scanned > 0, "scrub should scan at least one block");
|
|
|
|
// Corrupt one byte of shard 3 on disk; re-scrub must report shard 3.
|
|
let shard3 = format!("{}/1.ec03", dir);
|
|
let mut bytes = std::fs::read(&shard3).unwrap();
|
|
assert!(!bytes.is_empty());
|
|
bytes[0] ^= 0xFF;
|
|
std::fs::write(&shard3, &bytes).unwrap();
|
|
|
|
let (_, broken2, _) = vol.checksum_scrub();
|
|
assert!(
|
|
broken2.contains(&3),
|
|
"corrupted shard 3 should be flagged, got {:?}",
|
|
broken2
|
|
);
|
|
}
|
|
|
|
fn write_ecx_file(
|
|
dir: &str,
|
|
collection: &str,
|
|
vid: VolumeId,
|
|
entries: &[(NeedleId, Offset, Size)],
|
|
) {
|
|
let base = crate::storage::volume::volume_file_name(dir, collection, vid);
|
|
let ecx_path = format!("{}.ecx", base);
|
|
let mut file = File::create(&ecx_path).unwrap();
|
|
|
|
// Write sorted entries
|
|
for &(key, offset, size) in entries {
|
|
let mut buf = [0u8; NEEDLE_MAP_ENTRY_SIZE];
|
|
idx_entry_to_bytes(&mut buf, key, offset, size);
|
|
file.write_all(&buf).unwrap();
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_ec_volume_find_needle() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
|
|
// Write sorted ecx entries
|
|
let entries = vec![
|
|
(NeedleId(1), Offset::from_actual_offset(8), Size(100)),
|
|
(NeedleId(5), Offset::from_actual_offset(200), Size(200)),
|
|
(NeedleId(10), Offset::from_actual_offset(500), Size(300)),
|
|
];
|
|
write_ecx_file(dir, "", VolumeId(1), &entries);
|
|
|
|
let vol = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap();
|
|
|
|
// Found
|
|
let result = vol.find_needle_from_ecx(NeedleId(5)).unwrap();
|
|
assert!(result.is_some());
|
|
let (offset, size) = result.unwrap();
|
|
assert_eq!(offset.to_actual_offset(), 200);
|
|
assert_eq!(size, Size(200));
|
|
|
|
// Not found
|
|
let result = vol.find_needle_from_ecx(NeedleId(7)).unwrap();
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_ec_volume_journal() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
|
|
// .ecj append is gated on a live->tombstone transition in .ecx, so
|
|
// the fixture must contain the needles we are about to delete.
|
|
let entries = vec![
|
|
(NeedleId(10), Offset::from_actual_offset(8), Size(100)),
|
|
(NeedleId(20), Offset::from_actual_offset(200), Size(200)),
|
|
];
|
|
write_ecx_file(dir, "", VolumeId(1), &entries);
|
|
|
|
let mut vol = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap();
|
|
let (fc0, dc0) = vol.file_and_delete_count();
|
|
assert_eq!((fc0, dc0), (2, 0));
|
|
|
|
vol.journal_delete(NeedleId(10)).unwrap();
|
|
vol.journal_delete(NeedleId(20)).unwrap();
|
|
|
|
let deleted = vol.read_deleted_needles().unwrap();
|
|
assert_eq!(deleted, vec![NeedleId(10), NeedleId(20)]);
|
|
|
|
let (fc, dc) = vol.file_and_delete_count();
|
|
assert_eq!((fc, dc), (2, 2));
|
|
|
|
// Idempotent re-delete must not bump delete_count.
|
|
vol.journal_delete(NeedleId(10)).unwrap();
|
|
// Deleting a missing needle must not bump delete_count either.
|
|
vol.journal_delete(NeedleId(999)).unwrap();
|
|
let (fc, dc) = vol.file_and_delete_count();
|
|
assert_eq!((fc, dc), (2, 2));
|
|
}
|
|
|
|
#[test]
|
|
fn test_ec_volume_shard_bits() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
write_ecx_file(dir, "", VolumeId(1), &[]);
|
|
|
|
let mut vol = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap();
|
|
assert_eq!(vol.shard_count(), 0);
|
|
|
|
// Create a shard file so we can add it
|
|
let mut shard = EcVolumeShard::new(dir, "", VolumeId(1), 3);
|
|
shard.create().unwrap();
|
|
shard.write_all(&[0u8; 100]).unwrap();
|
|
shard.close();
|
|
|
|
vol.add_shard(EcVolumeShard::new(dir, "", VolumeId(1), 3))
|
|
.unwrap();
|
|
assert_eq!(vol.shard_count(), 1);
|
|
assert!(vol.shard_bits().has_shard_id(3));
|
|
}
|
|
|
|
/// A 0-byte shard file beside an index WITH entries is residue of a
|
|
/// failed copy: `add_shard` must refuse it and leave the slot
|
|
/// unregistered, so no size-0 claim is advertised (and no re-copy is
|
|
/// attracted to a file that was never valid). Beside a 0-byte index it
|
|
/// is the legitimate empty-volume layout and must keep mounting. The
|
|
/// startup scan already skips such files; this covers the mount path.
|
|
/// Mirrors TestLoadEcShardRefusesEmptyShardFile in Go.
|
|
#[test]
|
|
fn test_add_shard_refuses_empty_shard_file() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
write_ecx_file(
|
|
dir,
|
|
"",
|
|
VolumeId(1),
|
|
&[(NeedleId(1), Offset::from_actual_offset(8), Size(100))],
|
|
);
|
|
|
|
let mut vol = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap();
|
|
|
|
let mut shard = EcVolumeShard::new(dir, "", VolumeId(1), 4);
|
|
shard.create().unwrap();
|
|
shard.close();
|
|
|
|
let err = vol
|
|
.add_shard(EcVolumeShard::new(dir, "", VolumeId(1), 4))
|
|
.expect_err("adding a 0-byte shard file must fail when the index has entries");
|
|
assert!(
|
|
err.to_string().contains("empty (0 bytes)"),
|
|
"a 0-byte shard should be refused as empty, got: {}",
|
|
err
|
|
);
|
|
assert_eq!(vol.shard_count(), 0, "a refused shard must not register");
|
|
assert!(!vol.shard_bits().has_shard_id(4));
|
|
}
|
|
|
|
/// The legitimate empty-volume layout: a volume encoded with no live
|
|
/// needles has a 0-byte .ecx AND 0-byte shards, and mounting it must
|
|
/// keep working.
|
|
#[test]
|
|
fn test_add_shard_accepts_empty_shard_of_empty_volume() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
write_ecx_file(dir, "", VolumeId(1), &[]);
|
|
|
|
let mut vol = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap();
|
|
|
|
let mut shard = EcVolumeShard::new(dir, "", VolumeId(1), 4);
|
|
shard.create().unwrap();
|
|
shard.close();
|
|
|
|
vol.add_shard(EcVolumeShard::new(dir, "", VolumeId(1), 4))
|
|
.expect("a 0-byte shard of an empty volume (0-byte index) must mount");
|
|
assert!(vol.shard_bits().has_shard_id(4));
|
|
}
|
|
|
|
#[test]
|
|
fn test_ec_volume_uses_collection_prefixed_vif_config() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
write_ecx_file(dir, "pics", VolumeId(1), &[]);
|
|
|
|
let vif = crate::storage::volume::VifVolumeInfo {
|
|
ec_shard_config: Some(crate::storage::volume::VifEcShardConfig {
|
|
data_shards: 6,
|
|
parity_shards: 3,
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
let base = crate::storage::volume::volume_file_name(dir, "pics", VolumeId(1));
|
|
std::fs::write(
|
|
format!("{}.vif", base),
|
|
serde_json::to_string_pretty(&vif).unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
let vol = EcVolume::new(dir, dir, "pics", VolumeId(1)).unwrap();
|
|
assert_eq!(vol.data_shards, 6);
|
|
assert_eq!(vol.parity_shards, 3);
|
|
}
|
|
|
|
/// A vif that RECORDS a ratio no volume could have must fail the mount.
|
|
/// Substituting the default 10+4 (and with it the legacy block layout)
|
|
/// would read a uniform volume's shards at the wrong offsets and answer
|
|
/// with the wrong bytes; only an entirely absent config means "this
|
|
/// predates the record", which
|
|
/// `test_ec_volume_absent_vif_config_uses_defaults` covers.
|
|
#[test]
|
|
fn test_ec_volume_invalid_vif_config_fails_the_mount() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
write_ecx_file(dir, "pics", VolumeId(1), &[]);
|
|
|
|
// data + parity exceeds MAX_SHARD_COUNT, so the config is rejected.
|
|
let vif = crate::storage::volume::VifVolumeInfo {
|
|
ec_shard_config: Some(crate::storage::volume::VifEcShardConfig {
|
|
data_shards: 20,
|
|
parity_shards: 20,
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
let base = crate::storage::volume::volume_file_name(dir, "pics", VolumeId(1));
|
|
std::fs::write(
|
|
format!("{}.vif", base),
|
|
serde_json::to_string_pretty(&vif).unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
// EcVolume has no Debug impl, so match rather than expect_err.
|
|
match EcVolume::new(dir, dir, "pics", VolumeId(1)) {
|
|
Ok(_) => panic!("an impossible ratio must fail the mount"),
|
|
Err(e) => assert_eq!(e.kind(), io::ErrorKind::InvalidData, "got {e}"),
|
|
}
|
|
}
|
|
|
|
/// The compatibility case the check above must not swallow: a vif with no
|
|
/// EC config at all predates the record, and mounts on the defaults.
|
|
#[test]
|
|
fn test_ec_volume_absent_vif_config_uses_defaults() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
write_ecx_file(dir, "pics", VolumeId(1), &[]);
|
|
let base = crate::storage::volume::volume_file_name(dir, "pics", VolumeId(1));
|
|
std::fs::write(format!("{}.vif", base), r#"{"version":3}"#).unwrap();
|
|
|
|
let vol = EcVolume::new(dir, dir, "pics", VolumeId(1)).unwrap();
|
|
assert_eq!(vol.data_shards, DATA_SHARDS_COUNT as u32);
|
|
assert_eq!(vol.parity_shards, PARITY_SHARDS_COUNT as u32);
|
|
assert_eq!(vol.block_size, 0, "legacy layout for a pre-record volume");
|
|
}
|
|
|
|
#[test]
|
|
fn test_ec_volume_wide_ratio_vif_config() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
write_ecx_file(dir, "pics", VolumeId(1), &[]);
|
|
|
|
// A wider-than-default ratio within MAX_SHARD_COUNT must load as-is.
|
|
let vif = crate::storage::volume::VifVolumeInfo {
|
|
ec_shard_config: Some(crate::storage::volume::VifEcShardConfig {
|
|
data_shards: 16,
|
|
parity_shards: 4,
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
let base = crate::storage::volume::volume_file_name(dir, "pics", VolumeId(1));
|
|
std::fs::write(
|
|
format!("{}.vif", base),
|
|
serde_json::to_string_pretty(&vif).unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
let vol = EcVolume::new(dir, dir, "pics", VolumeId(1)).unwrap();
|
|
assert_eq!(vol.data_shards, 16);
|
|
assert_eq!(vol.parity_shards, 4);
|
|
}
|
|
|
|
#[test]
|
|
fn test_scrub_local_skips_tombstones() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
let entries = vec![(NeedleId(1), Offset::from_actual_offset(0), Size(-1))];
|
|
write_ecx_file(dir, "", VolumeId(1), &entries);
|
|
|
|
let vol = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap();
|
|
let (count, broken, errs) = vol.scrub_local();
|
|
assert_eq!(count, 1);
|
|
assert!(broken.is_empty(), "{:?}", broken);
|
|
assert!(errs.is_empty(), "{:?}", errs);
|
|
}
|
|
|
|
#[test]
|
|
fn test_scrub_local_clean_when_no_local_shards() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
let entries = vec![(NeedleId(1), Offset::from_actual_offset(0), Size(100))];
|
|
write_ecx_file(dir, "", VolumeId(1), &entries);
|
|
|
|
// dat_file_size makes the shard-size math well-defined.
|
|
let vif = crate::storage::volume::VifVolumeInfo {
|
|
dat_file_size: 14000,
|
|
..Default::default()
|
|
};
|
|
let base = crate::storage::volume::volume_file_name(dir, "", VolumeId(1));
|
|
std::fs::write(
|
|
format!("{}.vif", base),
|
|
serde_json::to_string_pretty(&vif).unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
// No shard files present: nothing to verify locally, so no errors.
|
|
let vol = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap();
|
|
let (count, broken, errs) = vol.scrub_local();
|
|
assert_eq!(count, 1);
|
|
assert!(broken.is_empty(), "{:?}", broken);
|
|
assert!(errs.is_empty(), "{:?}", errs);
|
|
}
|
|
|
|
#[test]
|
|
fn test_scrub_local_suppresses_delete_state_disagreement() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
// Live index entry (size > 0) whose reassembled on-disk header reports size 0
|
|
// (deleted-on-shards but live-in-index) — a delete-state disagreement, not corruption.
|
|
let entries = vec![(NeedleId(1), Offset::from_actual_offset(0), Size(100))];
|
|
write_ecx_file(dir, "", VolumeId(1), &entries);
|
|
|
|
let vif = crate::storage::volume::VifVolumeInfo {
|
|
dat_file_size: 14000,
|
|
..Default::default()
|
|
};
|
|
let base = crate::storage::volume::volume_file_name(dir, "", VolumeId(1));
|
|
std::fs::write(
|
|
format!("{}.vif", base),
|
|
serde_json::to_string_pretty(&vif).unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
// Shard 0 holds the needle's bytes; all-zero so the parsed header size is 0.
|
|
let mut shard0 = EcVolumeShard::new(dir, "", VolumeId(1), 0);
|
|
shard0.create().unwrap();
|
|
shard0.write_all(&[0u8; 256]).unwrap();
|
|
shard0.close();
|
|
|
|
let mut vol = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap();
|
|
vol.add_shard(EcVolumeShard::new(dir, "", VolumeId(1), 0))
|
|
.unwrap();
|
|
|
|
let (count, broken, errs) = vol.scrub_local();
|
|
assert_eq!(count, 1);
|
|
assert!(broken.is_empty(), "{:?}", broken);
|
|
assert!(
|
|
errs.is_empty(),
|
|
"delete-state disagreement must be suppressed, got {:?}",
|
|
errs
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_scrub_local_reports_genuine_size_corruption() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
let entries = vec![(NeedleId(1), Offset::from_actual_offset(0), Size(100))];
|
|
write_ecx_file(dir, "", VolumeId(1), &entries);
|
|
|
|
let vif = crate::storage::volume::VifVolumeInfo {
|
|
dat_file_size: 14000,
|
|
..Default::default()
|
|
};
|
|
let base = crate::storage::volume::volume_file_name(dir, "", VolumeId(1));
|
|
std::fs::write(
|
|
format!("{}.vif", base),
|
|
serde_json::to_string_pretty(&vif).unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
// On-disk header reports a non-zero size (50) that disagrees with the live
|
|
// index size (100): genuine corruption, not a delete-state race — must report.
|
|
let mut bytes = vec![0u8; 256];
|
|
bytes[15] = 50; // header size field (big-endian u32) = 50
|
|
let mut shard0 = EcVolumeShard::new(dir, "", VolumeId(1), 0);
|
|
shard0.create().unwrap();
|
|
shard0.write_all(&bytes).unwrap();
|
|
shard0.close();
|
|
|
|
let mut vol = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap();
|
|
vol.add_shard(EcVolumeShard::new(dir, "", VolumeId(1), 0))
|
|
.unwrap();
|
|
|
|
let (_count, _broken, errs) = vol.scrub_local();
|
|
assert!(
|
|
!errs.is_empty(),
|
|
"a non-zero size mismatch is genuine corruption and must be reported"
|
|
);
|
|
}
|
|
|
|
/// Build one real encoded EC volume in `dir`, then hand back N runtimes over
|
|
/// it, each holding the shard ids it was given. Models the reconciled
|
|
/// split-disk mount without needing N directories.
|
|
fn split_runtimes(dir: &str, vid: VolumeId, subsets: &[&[u8]]) -> Vec<EcVolume> {
|
|
use crate::storage::needle_map::NeedleMapKind;
|
|
use crate::storage::volume::Volume;
|
|
|
|
let mut v = Volume::new(
|
|
dir,
|
|
dir,
|
|
"",
|
|
vid,
|
|
NeedleMapKind::InMemory,
|
|
None,
|
|
None,
|
|
0,
|
|
Version::current(),
|
|
)
|
|
.unwrap();
|
|
for i in 1..=8 {
|
|
let data = format!("test data for needle {} with a bit more length", i);
|
|
let mut n = Needle {
|
|
id: NeedleId(i),
|
|
cookie: Cookie(i as u32),
|
|
data: data.as_bytes().to_vec(),
|
|
data_size: data.len() as u32,
|
|
..Needle::default()
|
|
};
|
|
v.write_needle(&mut n, true, false).unwrap();
|
|
}
|
|
v.sync_to_disk().unwrap();
|
|
v.close();
|
|
crate::storage::erasure_coding::ec_encoder::write_ec_files(dir, dir, "", vid, 10, 4)
|
|
.unwrap();
|
|
|
|
subsets
|
|
.iter()
|
|
.map(|ids| {
|
|
let mut ev = EcVolume::new(dir, dir, "", vid).unwrap();
|
|
for &id in ids.iter() {
|
|
ev.add_shard(EcVolumeShard::new(dir, "", vid, id)).unwrap();
|
|
}
|
|
ev
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// The union must reach every disk's shards, and first-disk-wins must
|
|
/// resolve a shard mounted on two disks — the same rule
|
|
/// `collect_ec_shard_dirs` and Go's `CollectEcShards` already use.
|
|
#[test]
|
|
fn test_merge_ec_runtimes_unions_slots_first_disk_wins() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
// Disk 0: 0..=6 plus 9 (9 is also on disk 1 — a duplicate mount).
|
|
// Disk 1: 7..=13.
|
|
let runtimes = split_runtimes(
|
|
dir,
|
|
VolumeId(1),
|
|
&[&[0, 1, 2, 3, 4, 5, 6, 9], &[7, 8, 9, 10, 11, 12, 13]],
|
|
);
|
|
let refs: Vec<&EcVolume> = runtimes.iter().collect();
|
|
|
|
let merged = merge_ec_runtimes(&refs).expect("non-empty input merges");
|
|
assert_eq!(merged.merged.len(), 2, "both runtimes share generation 0");
|
|
assert!(
|
|
merged.skipped.is_empty(),
|
|
"nothing to skip: {:?}",
|
|
merged.skipped
|
|
);
|
|
|
|
// Every shard 0..=13 is reachable from the merged slots.
|
|
for id in 0..14usize {
|
|
assert!(
|
|
merged.slots[id].is_some(),
|
|
"shard {} missing from the union",
|
|
id
|
|
);
|
|
}
|
|
// Shard 9 is held by both; the first disk wins.
|
|
let (owner, _) = merged.slots[9].unwrap();
|
|
assert!(
|
|
std::ptr::eq(owner, refs[0]),
|
|
"duplicate shard must resolve to the first disk"
|
|
);
|
|
// Slots are shard-id indexed across the volume's whole shard space,
|
|
// never compacted: index N is shard N or nothing.
|
|
assert_eq!(
|
|
merged.slots.len(),
|
|
14,
|
|
"slots must span the full 10+4 shard space"
|
|
);
|
|
}
|
|
|
|
/// Leniency is keyed on the ANCHOR, never the holder: a known identity must
|
|
/// not accept an unstamped holder (store_ec.rs:667-673,
|
|
/// grpc_server.rs:3743-3748). Merging leftover legacy shards beside a
|
|
/// current encode is what produces false corruption reports.
|
|
#[test]
|
|
fn test_merge_ec_runtimes_excludes_incompatible_identities() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
let mut runtimes = split_runtimes(
|
|
dir,
|
|
VolumeId(1),
|
|
&[&[0, 1, 2, 3, 4, 5, 6], &[7, 8, 9], &[10, 11, 12, 13]],
|
|
);
|
|
runtimes[0].encode_ts_ns = 500; // stale, but stamped
|
|
runtimes[1].encode_ts_ns = 0; // legacy, unstamped
|
|
runtimes[2].encode_ts_ns = 900; // the live encode run
|
|
let refs: Vec<&EcVolume> = runtimes.iter().collect();
|
|
|
|
let merged = merge_ec_runtimes(&refs).unwrap();
|
|
|
|
// Anchor is the newest known identity; only exact matches merge.
|
|
assert_eq!(merged.anchor.encode_ts_ns, 900);
|
|
assert_eq!(merged.merged.len(), 1);
|
|
assert!(
|
|
merged.slots[10].is_some(),
|
|
"the anchor's own shards must merge"
|
|
);
|
|
assert!(
|
|
merged.slots[0].is_none(),
|
|
"an older known generation must not merge"
|
|
);
|
|
assert!(
|
|
merged.slots[7].is_none(),
|
|
"an unstamped holder must not merge"
|
|
);
|
|
|
|
// Exclusions are REPORTED, never silent — that is what keeps this from
|
|
// regressing to the silent-clean bug this whole change fixes.
|
|
assert_eq!(merged.skipped.len(), 2, "got {:?}", merged.skipped);
|
|
assert!(merged.skipped.iter().all(|s| s.contains("not verified")));
|
|
}
|
|
|
|
/// When no runtime carries an identity there is nothing to fence on, so
|
|
/// behavior stays exactly as it is today: everything merges.
|
|
#[test]
|
|
fn test_merge_ec_runtimes_is_lenient_when_no_identity_is_known() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
let runtimes = split_runtimes(dir, VolumeId(1), &[&[0, 1, 2], &[3, 4, 5]]);
|
|
assert!(runtimes.iter().all(|r| r.encode_ts_ns == 0));
|
|
let refs: Vec<&EcVolume> = runtimes.iter().collect();
|
|
|
|
let merged = merge_ec_runtimes(&refs).unwrap();
|
|
assert_eq!(merged.merged.len(), 2);
|
|
assert!(merged.skipped.is_empty());
|
|
assert!(merged.slots[3].is_some());
|
|
}
|
|
|
|
/// The anchor supplies the volume-level metadata, so prefer a shard-bearing
|
|
/// runtime at the anchor generation over an empty one.
|
|
#[test]
|
|
fn test_merge_ec_runtimes_anchor_prefers_a_shard_bearing_runtime() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
let runtimes = split_runtimes(dir, VolumeId(1), &[&[], &[0, 1, 2]]);
|
|
let refs: Vec<&EcVolume> = runtimes.iter().collect();
|
|
|
|
let merged = merge_ec_runtimes(&refs).unwrap();
|
|
assert!(
|
|
std::ptr::eq(merged.anchor, refs[1]),
|
|
"anchor must hold shards"
|
|
);
|
|
assert!(
|
|
merged.skipped.is_empty(),
|
|
"same generation: nothing is excluded"
|
|
);
|
|
}
|
|
|
|
/// Empty input is the vanished-volume case and the ONLY None.
|
|
#[test]
|
|
fn test_merge_ec_runtimes_none_only_when_empty() {
|
|
assert!(merge_ec_runtimes(&[]).is_none());
|
|
}
|
|
|
|
/// The vanished-volume case: callers' `let ... else` guards depend on this
|
|
/// being the ONLY None.
|
|
#[test]
|
|
fn test_for_volumes_is_none_only_for_an_empty_slice() {
|
|
assert!(EcChecksumScrubPlan::for_volumes(&[]).is_none());
|
|
assert!(EcLocalScrubPlan::for_volumes(&[]).is_none());
|
|
}
|
|
|
|
/// The whole point: corruption on a shard that only a sibling runtime holds
|
|
/// must be reported. Before this change the scrub saw disk 0 alone and
|
|
/// returned clean.
|
|
#[test]
|
|
fn test_checksum_scrub_for_volumes_catches_sibling_disk_corruption() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
// Shard 11 is held ONLY by the second runtime.
|
|
let runtimes = split_runtimes(
|
|
dir,
|
|
VolumeId(1),
|
|
&[&[0, 1, 2, 3, 4, 5, 6], &[7, 8, 9, 10, 11, 12, 13]],
|
|
);
|
|
let refs: Vec<&EcVolume> = runtimes.iter().collect();
|
|
|
|
// Clean to start.
|
|
let (scanned, broken, errs) = EcChecksumScrubPlan::for_volumes(&refs).unwrap().run();
|
|
assert!(errs.is_empty(), "unexpected errors: {:?}", errs);
|
|
assert!(broken.is_empty(), "unexpected mismatches: {:?}", broken);
|
|
assert!(scanned > 0, "merged plan scanned nothing");
|
|
|
|
// Corrupt shard 11 — reachable only through the second runtime.
|
|
let shard11 = format!("{}/1.ec11", dir);
|
|
let mut bytes = std::fs::read(&shard11).unwrap();
|
|
bytes[0] ^= 0xFF;
|
|
std::fs::write(&shard11, &bytes).unwrap();
|
|
|
|
let (_, broken2, _) = EcChecksumScrubPlan::for_volumes(&refs).unwrap().run();
|
|
assert!(
|
|
broken2.contains(&11),
|
|
"sibling-disk corruption must be flagged, got {:?}",
|
|
broken2
|
|
);
|
|
|
|
// The single-runtime path is exactly the old blind spot: disk 0 alone
|
|
// still reports clean, which is why aggregation was needed.
|
|
let (_, broken_disk0, _) = refs[0].checksum_scrub();
|
|
assert!(!broken_disk0.contains(&11));
|
|
}
|
|
|
|
/// An excluded runtime is reported through the plan's errors, but ONLY on
|
|
/// the On path — the Off arm's clean-empty contract is Go parity
|
|
/// (`case BitrotOff: return 0, nil, nil`) and must not grow errors.
|
|
#[test]
|
|
fn test_checksum_scrub_skips_are_reported_but_never_break_the_off_arm() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
let mut runtimes = split_runtimes(dir, VolumeId(1), &[&[0, 1, 2], &[3, 4, 5]]);
|
|
runtimes[0].encode_ts_ns = 100; // excluded: older known identity
|
|
runtimes[1].encode_ts_ns = 200; // anchor
|
|
let refs: Vec<&EcVolume> = runtimes.iter().collect();
|
|
|
|
// With protection ON, the skip surfaces as an error line, and the
|
|
// surviving (anchor) runtime is still actually scanned.
|
|
let (scanned, _, errs) = EcChecksumScrubPlan::for_volumes(&refs).unwrap().run();
|
|
assert!(
|
|
scanned > 0,
|
|
"excluding one runtime must not stop the rest from being scanned"
|
|
);
|
|
assert!(
|
|
errs.iter().any(|e| e.contains("not verified")),
|
|
"excluded runtime must be reported, got {:?}",
|
|
errs
|
|
);
|
|
|
|
// With protection OFF, the arm stays byte-for-byte clean.
|
|
let mut off = runtimes;
|
|
for r in off.iter_mut() {
|
|
r.bitrot_status = crate::storage::erasure_coding::ec_bitrot::BitrotStatus::Off;
|
|
r.bitrot = None;
|
|
}
|
|
let off_refs: Vec<&EcVolume> = off.iter().collect();
|
|
assert_eq!(
|
|
EcChecksumScrubPlan::for_volumes(&off_refs).unwrap().run(),
|
|
(0, Vec::new(), Vec::new()),
|
|
"BitrotOff must stay clean-empty for Go parity"
|
|
);
|
|
}
|
|
|
|
/// The Invalid arm returns a non-empty error vector of its own, so the
|
|
/// Go-parity contract that silences the Off arm does not reach it. A volume
|
|
/// with BOTH a malformed sidecar and a fenced-out disk must report BOTH:
|
|
/// reporting only the sidecar hides an unscanned disk behind an unrelated
|
|
/// integrity error, which is the failure mode this whole change exists to
|
|
/// close.
|
|
#[test]
|
|
fn test_checksum_scrub_reports_skips_on_a_malformed_sidecar() {
|
|
use crate::storage::erasure_coding::ec_bitrot::BitrotStatus;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
let mut runtimes = split_runtimes(dir, VolumeId(1), &[&[0, 1, 2], &[3, 4, 5]]);
|
|
runtimes[0].encode_ts_ns = 100; // excluded: older known identity
|
|
runtimes[1].encode_ts_ns = 200; // anchor
|
|
for r in runtimes.iter_mut() {
|
|
r.bitrot_status = BitrotStatus::Invalid;
|
|
}
|
|
let refs: Vec<&EcVolume> = runtimes.iter().collect();
|
|
|
|
let (scanned, broken, errs) = EcChecksumScrubPlan::for_volumes(&refs).unwrap().run();
|
|
assert_eq!(
|
|
scanned, 0,
|
|
"a malformed sidecar must not be scanned against"
|
|
);
|
|
assert!(broken.is_empty(), "must never blame shards: {:?}", broken);
|
|
assert_eq!(
|
|
errs.len(),
|
|
2,
|
|
"expected the sidecar error AND the skip: {:?}",
|
|
errs
|
|
);
|
|
assert!(
|
|
errs[0].contains("malformed/unverifiable"),
|
|
"the sidecar error stays first: {:?}",
|
|
errs
|
|
);
|
|
assert!(
|
|
errs[1].contains("belong to encode run 100") && errs[1].contains("not verified"),
|
|
"the excluded disk must still be named: {:?}",
|
|
errs
|
|
);
|
|
}
|
|
|
|
/// When the anchor's sidecar came from a directory belonging to a runtime
|
|
/// the fence excluded, the checksums cannot be trusted against the anchor's
|
|
/// shards. Report that, never "shard N is corrupt".
|
|
#[test]
|
|
fn test_checksum_scrub_reports_unverifiable_sidecar_from_excluded_runtime() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
let mut runtimes = split_runtimes(dir, VolumeId(1), &[&[0, 1, 2], &[3, 4, 5]]);
|
|
|
|
// Mount actually resolved the real sidecar from `dir` (both subsets
|
|
// share one physical directory in this fixture), confirming
|
|
// `load_bitrot_for_generation` populates the field before the test
|
|
// overwrites it below to simulate a cross-disk borrow.
|
|
assert_eq!(
|
|
runtimes[1].bitrot_source_dir, dir,
|
|
"mount must record the dir the sidecar was actually resolved from"
|
|
);
|
|
|
|
// Runtime 0 is an older encode run, and the anchor resolved its sidecar
|
|
// from runtime 0's directory.
|
|
runtimes[0].encode_ts_ns = 100;
|
|
runtimes[0].dir = "/disk-old".to_string();
|
|
runtimes[0].dir_idx = "/disk-old".to_string();
|
|
runtimes[1].encode_ts_ns = 200;
|
|
runtimes[1].dir = "/disk-new".to_string();
|
|
runtimes[1].dir_idx = "/disk-new".to_string();
|
|
runtimes[1].bitrot_source_dir = "/disk-old".to_string();
|
|
let refs: Vec<&EcVolume> = runtimes.iter().collect();
|
|
|
|
let (scanned, broken, errs) = EcChecksumScrubPlan::for_volumes(&refs).unwrap().run();
|
|
assert_eq!(
|
|
scanned, 0,
|
|
"an unverifiable sidecar must not be scanned against"
|
|
);
|
|
assert!(broken.is_empty(), "must never blame shards: {:?}", broken);
|
|
assert!(
|
|
errs.iter().any(|e| e.contains("unverifiable")),
|
|
"expected an unverifiable-protection note, got {:?}",
|
|
errs
|
|
);
|
|
assert!(
|
|
errs.iter().any(|e| e.contains("were not verified")),
|
|
"the fenced-out runtime must still be reported alongside the unverifiable note: {:?}",
|
|
errs
|
|
);
|
|
}
|
|
|
|
/// The provenance rule fires only when a runtime was actually excluded. A
|
|
/// healthy single-encode volume whose sidecar lives on a sibling disk is the
|
|
/// normal mirrored case and must scrub normally.
|
|
#[test]
|
|
fn test_checksum_scrub_provenance_rule_does_not_fire_without_exclusions() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
let mut runtimes = split_runtimes(
|
|
dir,
|
|
VolumeId(1),
|
|
&[&[0, 1, 2, 3, 4, 5, 6], &[7, 8, 9, 10, 11, 12, 13]],
|
|
);
|
|
// Same generation: nothing is excluded, so provenance is irrelevant even
|
|
// though the anchor's sidecar appears to have come from somewhere
|
|
// neither runtime owns. (The anchor is runtimes[0]: `merge_ec_runtimes`
|
|
// picks the first shard-bearing runtime at the anchor generation.)
|
|
runtimes[0].bitrot_source_dir = "/somewhere-else".to_string();
|
|
let refs: Vec<&EcVolume> = runtimes.iter().collect();
|
|
|
|
let (scanned, broken, errs) = EcChecksumScrubPlan::for_volumes(&refs).unwrap().run();
|
|
assert!(scanned > 0, "a normal volume must still be scanned");
|
|
assert!(broken.is_empty(), "{:?}", broken);
|
|
assert!(
|
|
!errs.iter().any(|e| e.contains("unverifiable")),
|
|
"{:?}",
|
|
errs
|
|
);
|
|
}
|
|
|
|
/// The identity fence keys on `encode_ts_ns` ALONE, never on geometry, so
|
|
/// two same-generation runtimes whose `.vif`s disagree about the layout do
|
|
/// merge -- and `slots` is then sized to the WIDER of them while the
|
|
/// volume's actual shard-id range is the ANCHOR's `data + parity`.
|
|
///
|
|
/// The two consumers disagreed about exactly that range: the mode 2|5 arm
|
|
/// builds `dirs` over `0..anchor.data_shards + anchor.parity_shards` and
|
|
/// silently drops the surplus slots, while `EcChecksumScrubPlan` iterated
|
|
/// the full width and reported each one "present but missing from sidecar
|
|
/// manifest". Nothing in this volume describes those ids -- the sidecar
|
|
/// manifest and the Reed-Solomon matrix are both the anchor's -- so that
|
|
/// message is the width disagreement talking, not a finding.
|
|
#[test]
|
|
fn test_checksum_scrub_truncates_slots_to_the_anchors_geometry() {
|
|
use crate::pb::volume_server_pb::{
|
|
ChecksumAlgorithm, EcBitrotProtection, EcShardChecksums,
|
|
};
|
|
use crate::storage::erasure_coding::ec_bitrot;
|
|
use crate::storage::volume::{VifEcShardConfig, VifVolumeInfo};
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let vid = VolumeId(1);
|
|
|
|
// One dir per geometry: `EcVolume::new` reads `data_shards`/
|
|
// `parity_shards` from the `.vif` beside the volume, so two runtimes can
|
|
// only disagree if they mount from different directories.
|
|
let seed = |name: &str, ds: u32, ps: u32, shard_id: u8| -> String {
|
|
let dir = tmp.path().join(name);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let d = dir.to_str().unwrap().to_string();
|
|
let base = crate::storage::volume::volume_file_name(&d, "", vid);
|
|
std::fs::write(format!("{}.ecx", base), b"").unwrap();
|
|
std::fs::write(format!("{}.ecj", base), b"").unwrap();
|
|
std::fs::write(
|
|
format!("{}.vif", base),
|
|
serde_json::to_string(&VifVolumeInfo {
|
|
version: 3,
|
|
ec_shard_config: Some(VifEcShardConfig {
|
|
data_shards: ds,
|
|
parity_shards: ps,
|
|
encode_ts_ns: 500,
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
})
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
std::fs::write(
|
|
format!("{}.ec{:02}", base, shard_id),
|
|
b"shard data nonempty",
|
|
)
|
|
.unwrap();
|
|
d
|
|
};
|
|
|
|
// 10+4, and the only disk with a sidecar -- so it supplies `prot`, and
|
|
// its manifest covers shard ids 0..=13 and nothing else.
|
|
let narrow = seed("narrow", 10, 4, 0);
|
|
let prot = EcBitrotProtection {
|
|
algorithm: ChecksumAlgorithm::ChecksumCrc32c as i32,
|
|
block_size: ec_bitrot::DEFAULT_BITROT_BLOCK_SIZE as u32,
|
|
generation: 0,
|
|
ec_shard_config: Some(ec_bitrot::ec_shard_config(10, 4, 0)),
|
|
shards: (0..14u32)
|
|
.map(|shard_id| EcShardChecksums {
|
|
shard_id,
|
|
covered_size: 4,
|
|
block_crc32c: vec![0u8; 4],
|
|
})
|
|
.collect(),
|
|
encode_uuid: vec![0u8; 16],
|
|
};
|
|
ec_bitrot::save_bitrot_sidecar(
|
|
&ec_bitrot::bitrot_sidecar_path(
|
|
&crate::storage::volume::volume_file_name(&narrow, "", vid),
|
|
0,
|
|
),
|
|
&prot,
|
|
)
|
|
.unwrap();
|
|
|
|
// 12+4: 16 slots, and it holds shard 14 -- an id the anchor's layout
|
|
// does not contain at all. Same encode_ts_ns, but the geometry fence
|
|
// now excludes it rather than merging incompatible shards.
|
|
let wide = seed("wide", 12, 4, 14);
|
|
|
|
let mut narrow_v = EcVolume::new(&narrow, &narrow, "", vid).unwrap();
|
|
narrow_v
|
|
.add_shard(EcVolumeShard::new(&narrow, "", vid, 0))
|
|
.unwrap();
|
|
let mut wide_v = EcVolume::new(&wide, &wide, "", vid).unwrap();
|
|
wide_v
|
|
.add_shard(EcVolumeShard::new(&wide, "", vid, 14))
|
|
.unwrap();
|
|
|
|
assert_eq!(narrow_v.shards.len(), 14);
|
|
assert_eq!(wide_v.shards.len(), 16);
|
|
assert_eq!(
|
|
narrow_v.encode_ts_ns, wide_v.encode_ts_ns,
|
|
"the two runtimes must share a generation"
|
|
);
|
|
assert_eq!(
|
|
narrow_v.bitrot_status,
|
|
crate::storage::erasure_coding::ec_bitrot::BitrotStatus::On,
|
|
"the narrow disk must carry usable protection"
|
|
);
|
|
|
|
let refs: Vec<&EcVolume> = vec![&narrow_v, &wide_v];
|
|
let merged = merge_ec_runtimes(&refs).unwrap();
|
|
assert!(
|
|
std::ptr::eq(merged.anchor, refs[0]),
|
|
"the NARROW runtime must anchor"
|
|
);
|
|
assert!(
|
|
!merged.skipped.is_empty(),
|
|
"the wide runtime must be excluded by the geometry fence: {:?}",
|
|
merged.skipped
|
|
);
|
|
assert_eq!(
|
|
merged.slots.len(),
|
|
14,
|
|
"slots are sized to the anchor's geometry, not the excluded wide runtime"
|
|
);
|
|
assert!(
|
|
merged.slots.get(14).is_none() || merged.slots.get(14).unwrap().is_none(),
|
|
"shard 14 from the excluded runtime must not appear in slots"
|
|
);
|
|
|
|
let (scanned, broken, errs) = EcChecksumScrubPlan::for_volumes(&refs).unwrap().run();
|
|
assert!(
|
|
!errs.iter().any(|e| e.contains("shard 14")),
|
|
"shard 14 belongs to an excluded runtime and must not be scanned: {:?}",
|
|
errs
|
|
);
|
|
assert!(
|
|
scanned > 0,
|
|
"the anchor's shard 0 must still be scanned: {:?}",
|
|
errs
|
|
);
|
|
assert_eq!(
|
|
broken,
|
|
vec![0],
|
|
"shard 0 is inside the anchor's geometry and its bytes do not match \
|
|
the manifest, so it must still be reported: errs={:?}",
|
|
errs
|
|
);
|
|
}
|
|
|
|
/// `for_volumes` must reach every runtime's shards, not just the first's.
|
|
/// A needle walk alone can't prove that with this fixture: ~500 bytes of
|
|
/// data under a legacy 1GiB large block puts every needle interval on
|
|
/// shard 0, so the slot vector is asserted directly. The walk-level checks
|
|
/// below additionally show the merged plan verifies cleanly and then
|
|
/// detects corruption reachable through the first runtime, while a
|
|
/// single-runtime plan stays blind to it.
|
|
#[test]
|
|
fn test_scrub_local_for_volumes_merges_slots_across_runtimes() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
let runtimes = split_runtimes(dir, VolumeId(1), &[&[0, 1, 2], &[7, 8, 9]]);
|
|
let refs: Vec<&EcVolume> = runtimes.iter().collect();
|
|
|
|
let (count, broken, errs) = EcLocalScrubPlan::for_volumes(&refs).unwrap().run();
|
|
assert!(count > 0, "the merged plan walked no needles");
|
|
assert!(
|
|
broken.is_empty(),
|
|
"clean volume reported broken shards: {:?}",
|
|
broken
|
|
);
|
|
assert!(errs.is_empty(), "clean volume reported errors: {:?}", errs);
|
|
|
|
// The property this task exists to deliver, asserted directly on the
|
|
// plan rather than through a needle walk. With ~500 bytes of fixture
|
|
// data and a legacy 1GiB large block every needle interval lands in
|
|
// shard 0, so no needle walk can ever reach a sibling runtime's
|
|
// shards -- but the slot vector can. Neither runtime holds every
|
|
// shard here, so the gap at shard 3 makes compaction load-bearing:
|
|
// a compacting implementation could still satisfy a "populated slots
|
|
// are reachable" check while silently reindexing the vector.
|
|
let merged_plan = EcLocalScrubPlan::for_volumes(&refs).unwrap();
|
|
assert_eq!(
|
|
merged_plan.shards.len(),
|
|
14,
|
|
"slots must span the full 10+4 shard space"
|
|
);
|
|
assert!(
|
|
merged_plan.shards[0].is_some(),
|
|
"disk 0's shard 0 must be reachable"
|
|
);
|
|
assert!(
|
|
merged_plan.shards[3].is_none(),
|
|
"a shard no runtime holds must stay an empty slot -- compaction would fill it"
|
|
);
|
|
assert!(
|
|
merged_plan.shards[7].is_some(),
|
|
"disk 1's shard 7 must be reachable -- the bug being fixed"
|
|
);
|
|
|
|
// A single-runtime plan still sees only its own disk, which is exactly
|
|
// what made aggregation necessary.
|
|
let solo = EcLocalScrubPlan::for_volumes(&[refs[0]]).unwrap();
|
|
assert!(solo.shards[0].is_some());
|
|
assert!(
|
|
solo.shards[7].is_none(),
|
|
"refs[0] alone must not see the sibling's shard 7"
|
|
);
|
|
|
|
// This fixture's few hundred bytes of needle data all land inside
|
|
// shard 0's legacy 1GiB large block (the tiny volume never fills even
|
|
// one), so shard 0 alone — held only by the first runtime — carries
|
|
// every needle. Corrupting a needle body byte there is the LOCAL
|
|
// analogue of Task 3's sibling-disk corruption: the whole point is
|
|
// that a partial-shard `scrub_local` cannot see it at all (it just
|
|
// silently skips a needle it can't reassemble locally — that is not
|
|
// an error), while the merged plan reaches it through refs[0].
|
|
let shard0 = format!("{}/1.ec00", dir);
|
|
let mut bytes = std::fs::read(&shard0).unwrap();
|
|
assert!(
|
|
bytes.len() > 268,
|
|
"fixture shrank below the corruption offset; split_runtimes's \
|
|
needle payloads changed, so this offset needs picking again"
|
|
);
|
|
bytes[268] ^= 0xFF; // inside needle 4's data payload
|
|
std::fs::write(&shard0, &bytes).unwrap();
|
|
|
|
let (_, _, merged_errs) = EcLocalScrubPlan::for_volumes(&refs).unwrap().run();
|
|
assert!(
|
|
!merged_errs.is_empty(),
|
|
"merged plan must detect corruption on shard 0, reachable through refs[0]"
|
|
);
|
|
|
|
// refs[1] never mounted shard 0, so it cannot see this corruption at
|
|
// all — that silent blind spot is exactly the bug aggregation fixes.
|
|
let (_, _, refs1_errs) = refs[1].scrub_local();
|
|
assert!(
|
|
refs1_errs.is_empty(),
|
|
"runtime without shard 0 has no way to detect this corruption: {:?}",
|
|
refs1_errs
|
|
);
|
|
}
|
|
|
|
/// LOCAL's legacy `shard_size` fallback (`dat_file_size == 0`) is now a
|
|
/// NODE-WIDE input: it feeds `locate_data`'s offset math for every merged
|
|
/// sibling's shards, not just the anchor's. `anchor.shard_file_size()`
|
|
/// returns the anchor's FIRST held shard rather than a maximum, so one
|
|
/// truncated shard on the anchor disk would mis-offset every needle read
|
|
/// across every disk and manufacture corruption reports wholesale. Take the
|
|
/// max over the merged slots, the way `verify_ec_shards` already answers the
|
|
/// same question (`if size > shard_size { shard_size = size }`).
|
|
#[test]
|
|
fn test_scrub_local_shard_size_fallback_takes_the_max_across_disks() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
|
|
// Mount two shardless runtimes first so the shard files can be doctored
|
|
// before `add_shard` caches their sizes.
|
|
let mut runtimes = split_runtimes(dir, VolumeId(1), &[&[], &[]]);
|
|
|
|
// Disk 0's shard 0 is truncated -- a partially-copied shard, which is
|
|
// exactly the containment this fallback used to have and now must not
|
|
// lose.
|
|
let shard0_path = format!("{}/1.ec00", dir);
|
|
let full = std::fs::metadata(&shard0_path).unwrap().len();
|
|
assert!(full > 1, "fixture shard is too small to truncate");
|
|
std::fs::OpenOptions::new()
|
|
.write(true)
|
|
.open(&shard0_path)
|
|
.unwrap()
|
|
.set_len(full / 2)
|
|
.unwrap();
|
|
|
|
runtimes[0]
|
|
.add_shard(EcVolumeShard::new(dir, "", VolumeId(1), 0))
|
|
.unwrap();
|
|
runtimes[1]
|
|
.add_shard(EcVolumeShard::new(dir, "", VolumeId(1), 1))
|
|
.unwrap();
|
|
let refs: Vec<&EcVolume> = runtimes.iter().collect();
|
|
|
|
let intact = std::fs::metadata(format!("{}/1.ec01", dir)).unwrap().len() as i64;
|
|
let truncated = (full / 2) as i64;
|
|
assert!(
|
|
truncated < intact,
|
|
"fixture must leave disk 0's shard SHORTER than disk 1's"
|
|
);
|
|
|
|
let merged = merge_ec_runtimes(&refs).unwrap();
|
|
assert!(
|
|
std::ptr::eq(merged.anchor, refs[0]),
|
|
"the truncated disk must anchor, or the old sizing was already right"
|
|
);
|
|
assert_eq!(
|
|
refs[0].dat_file_size, 0,
|
|
"this fixture writes no .vif, so the legacy fallback is the path \
|
|
under test; with a dat_file_size the branch is never reached"
|
|
);
|
|
assert_eq!(
|
|
refs[0].shard_file_size(),
|
|
truncated,
|
|
"the old source -- the anchor's first held shard -- is the \
|
|
truncated one, which is what makes this observable"
|
|
);
|
|
|
|
let plan = EcLocalScrubPlan::for_volumes(&refs).unwrap();
|
|
assert_eq!(
|
|
plan.shard_size,
|
|
intact - 1,
|
|
"one disk's truncated shard must not size every merged sibling's \
|
|
shards; locate_data would then mis-offset every needle on every \
|
|
disk and report corruption that is not there"
|
|
);
|
|
}
|
|
|
|
/// LOCAL has no bitrot status gate, so an excluded runtime is always
|
|
/// reported.
|
|
#[test]
|
|
fn test_scrub_local_reports_skipped_runtimes() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
let mut runtimes = split_runtimes(dir, VolumeId(1), &[&[0, 1, 2], &[3, 4, 5]]);
|
|
runtimes[0].encode_ts_ns = 100;
|
|
runtimes[1].encode_ts_ns = 200;
|
|
let refs: Vec<&EcVolume> = runtimes.iter().collect();
|
|
|
|
let (_, _, errs) = EcLocalScrubPlan::for_volumes(&refs).unwrap().run();
|
|
assert!(
|
|
errs.iter().any(|e| e.contains("not verified")),
|
|
"excluded runtime must be reported, got {:?}",
|
|
errs
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod uniform_layout_tests {
|
|
use super::*;
|
|
use crate::storage::needle_map::NeedleMapKind;
|
|
use crate::storage::volume::{VifEcShardConfig, VifVolumeInfo, Volume};
|
|
use tempfile::TempDir;
|
|
|
|
// Write ~26MB of needles so the uniform block size (3MB) diverges from the
|
|
// legacy layout, encode, and verify EcVolume reads every needle back
|
|
// through the .vif-recorded geometry. A legacy-encoded fixture (no .vif
|
|
// block size) must keep reading through the legacy interpretation.
|
|
#[test]
|
|
fn test_read_needles_uniform_and_legacy_layouts() {
|
|
for legacy in [false, true] {
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
let vid = VolumeId(8);
|
|
|
|
let mut v = Volume::new(
|
|
dir,
|
|
dir,
|
|
"",
|
|
vid,
|
|
NeedleMapKind::InMemory,
|
|
None,
|
|
None,
|
|
0,
|
|
Version::current(),
|
|
)
|
|
.unwrap();
|
|
let mut expected: Vec<(NeedleId, Vec<u8>)> = Vec::new();
|
|
for i in 1u64..=11 {
|
|
let size = if i <= 5 { 4 << 20 } else { 1 << 20 };
|
|
let data: Vec<u8> = (0..size)
|
|
.map(|b| ((b as u64).wrapping_mul(2654435761).wrapping_add(i) >> 8) as u8)
|
|
.collect();
|
|
let mut n = Needle {
|
|
id: NeedleId(i),
|
|
cookie: Cookie(i as u32),
|
|
data: data.clone(),
|
|
data_size: data.len() as u32,
|
|
..Needle::default()
|
|
};
|
|
v.write_needle(&mut n, true, false).unwrap();
|
|
expected.push((NeedleId(i), data));
|
|
}
|
|
v.sync_to_disk().unwrap();
|
|
let dat_size = v.dat_file_size().unwrap() as i64;
|
|
v.close();
|
|
|
|
let block_size = if legacy {
|
|
// Legacy fixture: two-tier encode plus a .vif without a block
|
|
// size, the state every pre-upgrade EC volume is in.
|
|
use crate::storage::erasure_coding::ec_bitrot::{
|
|
ShardChecksumBuilder, DEFAULT_BITROT_BLOCK_SIZE,
|
|
};
|
|
use reed_solomon_erasure::galois_8::ReedSolomon;
|
|
let base = crate::storage::volume::volume_file_name(dir, "", vid);
|
|
crate::storage::erasure_coding::ec_encoder::write_sorted_ecx_from_idx(
|
|
&format!("{}.idx", base),
|
|
&format!("{}.ecx", base),
|
|
)
|
|
.unwrap();
|
|
let dat_file = std::fs::File::open(format!("{}.dat", base)).unwrap();
|
|
let rs = ReedSolomon::new(10, 4).unwrap();
|
|
let mut shards: Vec<EcVolumeShard> = (0..14u8)
|
|
.map(|i| EcVolumeShard::new(dir, "", vid, i))
|
|
.collect();
|
|
for shard in &mut shards {
|
|
shard.create().unwrap();
|
|
}
|
|
let mut builders: Vec<ShardChecksumBuilder> = (0..14)
|
|
.map(|_| ShardChecksumBuilder::new(DEFAULT_BITROT_BLOCK_SIZE as i64))
|
|
.collect();
|
|
crate::storage::erasure_coding::ec_encoder::encode_dat_file(
|
|
&dat_file,
|
|
dat_size,
|
|
&rs,
|
|
&mut shards,
|
|
&mut builders,
|
|
10,
|
|
4,
|
|
256 * 1024,
|
|
ERASURE_CODING_LARGE_BLOCK_SIZE,
|
|
ERASURE_CODING_SMALL_BLOCK_SIZE,
|
|
)
|
|
.unwrap();
|
|
for shard in &mut shards {
|
|
shard.close();
|
|
}
|
|
0
|
|
} else {
|
|
let bs = crate::storage::erasure_coding::ec_encoder::write_ec_files(
|
|
dir, dir, "", vid, 10, 4,
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
bs > ERASURE_CODING_SMALL_BLOCK_SIZE as i64,
|
|
"block size {} does not diverge from the legacy layout",
|
|
bs
|
|
);
|
|
bs
|
|
};
|
|
|
|
let base = crate::storage::volume::volume_file_name(dir, "", vid);
|
|
let vif = VifVolumeInfo {
|
|
version: Version::current().0 as u32,
|
|
dat_file_size: dat_size,
|
|
ec_shard_config: Some(VifEcShardConfig {
|
|
data_shards: 10,
|
|
parity_shards: 4,
|
|
block_size,
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
std::fs::write(
|
|
format!("{}.vif", base),
|
|
serde_json::to_string_pretty(&vif).unwrap(),
|
|
)
|
|
.unwrap();
|
|
std::fs::remove_file(format!("{}.dat", base)).unwrap();
|
|
std::fs::remove_file(format!("{}.idx", base)).unwrap();
|
|
|
|
let mut vol = EcVolume::new(dir, dir, "", vid).unwrap();
|
|
assert_eq!(
|
|
vol.block_size, block_size,
|
|
"block size not loaded from .vif"
|
|
);
|
|
for i in 0..10u8 {
|
|
vol.add_shard(EcVolumeShard::new(dir, "", vid, i)).unwrap();
|
|
}
|
|
|
|
for (id, data) in &expected {
|
|
let n = vol
|
|
.read_ec_shard_needle(*id)
|
|
.unwrap()
|
|
.unwrap_or_else(|| panic!("needle {} not found (legacy={})", id.0, legacy));
|
|
assert_eq!(
|
|
n.data, *data,
|
|
"needle {} data mismatch (legacy={})",
|
|
id.0, legacy
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// A present-but-malformed .vif must FAIL the mount: every new encode
|
|
// records a positive uniform block size there, and silently defaulting
|
|
// to the legacy layout would serve those shards with the wrong offset
|
|
// math. Absence stays legal — legacy volumes predate the sidecar.
|
|
#[test]
|
|
fn new_fails_on_malformed_vif() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
let base = crate::storage::volume::volume_file_name(dir, "", VolumeId(1));
|
|
std::fs::write(format!("{}.ecx", base), b"").unwrap();
|
|
std::fs::write(format!("{}.vif", base), b"not json").unwrap();
|
|
let res = EcVolume::new(dir, dir, "", VolumeId(1));
|
|
assert!(res.is_err(), "mount over a malformed .vif must fail");
|
|
}
|
|
|
|
#[test]
|
|
fn new_absent_vif_mounts_with_defaults() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let dir = tmp.path().to_str().unwrap();
|
|
let base = crate::storage::volume::volume_file_name(dir, "", VolumeId(1));
|
|
std::fs::write(format!("{}.ecx", base), b"").unwrap();
|
|
let ev = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap();
|
|
assert_eq!(ev.block_size, 0, "legacy mount must use the legacy layout");
|
|
}
|
|
|
|
/// A rebuild lands on one disk, but the volume's metadata may live on
|
|
/// another. Reading only the selected directory returns the default 10+4
|
|
/// and the legacy block layout, which reconstructs a custom-ratio or
|
|
/// uniform volume through the wrong matrix.
|
|
fn seed_uniform_sidecar(base: &str, ds: u32, ps: u32, block: i64) {
|
|
use crate::pb::volume_server_pb::{
|
|
ChecksumAlgorithm, EcBitrotProtection, EcShardChecksums,
|
|
};
|
|
use crate::storage::erasure_coding::ec_bitrot;
|
|
let prot = EcBitrotProtection {
|
|
algorithm: ChecksumAlgorithm::ChecksumCrc32c as i32,
|
|
block_size: ec_bitrot::DEFAULT_BITROT_BLOCK_SIZE as u32,
|
|
generation: 0,
|
|
ec_shard_config: Some(ec_bitrot::ec_shard_config(ds, ps, block)),
|
|
shards: (0..(ds + ps))
|
|
.map(|shard_id| EcShardChecksums {
|
|
shard_id,
|
|
covered_size: 4,
|
|
block_crc32c: vec![0u8; 4],
|
|
})
|
|
.collect(),
|
|
encode_uuid: vec![0u8; 16],
|
|
};
|
|
ec_bitrot::save_bitrot_sidecar(&ec_bitrot::bitrot_sidecar_path(base, 0), &prot).unwrap();
|
|
}
|
|
|
|
fn seed_config_free_vif(base: &str) {
|
|
std::fs::write(format!("{}.vif", base), r#"{"version":3,"datFileSize":0}"#).unwrap();
|
|
}
|
|
|
|
// A .vif that omits ecShardConfig answers nothing about the layout, so it
|
|
// must not short-circuit the sidecar search. Returning as soon as any
|
|
// parseable vif turned up resolved a 12+4 uniform volume as 10+4 legacy.
|
|
#[test]
|
|
fn read_ec_shard_config_falls_back_when_the_vif_has_no_config() {
|
|
let d = tempfile::TempDir::new().unwrap();
|
|
let dir = d.path().to_str().unwrap();
|
|
let base = crate::storage::volume::volume_file_name(dir, "", VolumeId(11));
|
|
seed_config_free_vif(&base);
|
|
seed_uniform_sidecar(&base, 12, 4, 3 * 1024 * 1024);
|
|
|
|
let got = read_ec_shard_config(dir, dir, "", VolumeId(11)).unwrap();
|
|
assert_eq!(got, (12, 4, 3 * 1024 * 1024));
|
|
}
|
|
|
|
// Split -dir/-dir.idx: the vif and the sidecar both live with the INDEX,
|
|
// and the resolver was told to report the DATA directory, so the fallback
|
|
// looked in a directory holding neither.
|
|
#[test]
|
|
fn read_ec_shard_config_finds_a_config_free_vifs_sidecar_in_the_index_dir() {
|
|
let data = tempfile::TempDir::new().unwrap();
|
|
let idx = tempfile::TempDir::new().unwrap();
|
|
let (dir, dir_idx) = (data.path().to_str().unwrap(), idx.path().to_str().unwrap());
|
|
let idx_base = crate::storage::volume::volume_file_name(dir_idx, "", VolumeId(12));
|
|
seed_config_free_vif(&idx_base);
|
|
seed_uniform_sidecar(&idx_base, 12, 4, 3 * 1024 * 1024);
|
|
|
|
let got = read_ec_shard_config(dir, dir_idx, "", VolumeId(12)).unwrap();
|
|
assert_eq!(got, (12, 4, 3 * 1024 * 1024));
|
|
}
|
|
|
|
// Same gap in the cross-disk resolver the rebuild uses.
|
|
#[test]
|
|
fn read_ec_shard_config_across_dirs_falls_back_when_the_vif_has_no_config() {
|
|
let data = tempfile::TempDir::new().unwrap();
|
|
let sib = tempfile::TempDir::new().unwrap();
|
|
let (dir, sibling) = (data.path().to_str().unwrap(), sib.path().to_str().unwrap());
|
|
seed_config_free_vif(&crate::storage::volume::volume_file_name(
|
|
dir,
|
|
"",
|
|
VolumeId(13),
|
|
));
|
|
seed_uniform_sidecar(
|
|
&crate::storage::volume::volume_file_name(sibling, "", VolumeId(13)),
|
|
12,
|
|
4,
|
|
3 * 1024 * 1024,
|
|
);
|
|
|
|
let got =
|
|
read_ec_shard_config_across_dirs(dir, dir, &[sibling.to_string()], "", VolumeId(13))
|
|
.unwrap();
|
|
assert_eq!(got, (12, 4, 3 * 1024 * 1024));
|
|
}
|
|
|
|
// Absence stays legal: a volume predating both records is genuinely legacy.
|
|
#[test]
|
|
fn read_ec_shard_config_config_free_vif_without_a_sidecar_is_legacy() {
|
|
let d = tempfile::TempDir::new().unwrap();
|
|
let dir = d.path().to_str().unwrap();
|
|
seed_config_free_vif(&crate::storage::volume::volume_file_name(
|
|
dir,
|
|
"",
|
|
VolumeId(14),
|
|
));
|
|
let got = read_ec_shard_config(dir, dir, "", VolumeId(14)).unwrap();
|
|
assert_eq!(got, (10, 4, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn read_ec_shard_config_finds_a_sibling_disks_vif() {
|
|
let a = tempfile::TempDir::new().unwrap();
|
|
let b = tempfile::TempDir::new().unwrap();
|
|
let (rebuild, sibling) = (a.path().to_str().unwrap(), b.path().to_str().unwrap());
|
|
let base = crate::storage::volume::volume_file_name(sibling, "", VolumeId(3));
|
|
std::fs::write(
|
|
format!("{}.vif", base),
|
|
r#"{"version":3,"ecShardConfig":{"dataShards":12,"parityShards":4,"blockSize":3145728}}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let (ds, ps, bs) = read_ec_shard_config_across_dirs(
|
|
rebuild,
|
|
rebuild,
|
|
&[sibling.to_string()],
|
|
"",
|
|
VolumeId(3),
|
|
)
|
|
.unwrap();
|
|
assert_eq!((ds, ps, bs), (12, 4, 3 * 1024 * 1024));
|
|
}
|
|
|
|
/// Same, for the sidecar: with no .vif anywhere it is the surviving record
|
|
/// of the geometry, wherever it sits.
|
|
// A split -dir/-dir.idx location keeps its metadata with the INDEX, and
|
|
// callers leave their own index directory out of other_dirs because it is
|
|
// passed separately. With no .vif anywhere the generation-0 .ecsum is the
|
|
// only record of the layout, so missing that directory resolves a 12+4
|
|
// uniform volume to 10+4 legacy and reconstructs through the wrong matrix.
|
|
#[test]
|
|
fn read_ec_shard_config_finds_the_sidecar_in_its_own_index_dir() {
|
|
use crate::pb::volume_server_pb::{
|
|
ChecksumAlgorithm, EcBitrotProtection, EcShardChecksums,
|
|
};
|
|
use crate::storage::erasure_coding::ec_bitrot;
|
|
|
|
let data = tempfile::TempDir::new().unwrap();
|
|
let idx = tempfile::TempDir::new().unwrap();
|
|
let (dir, dir_idx) = (data.path().to_str().unwrap(), idx.path().to_str().unwrap());
|
|
let base = crate::storage::volume::volume_file_name(dir_idx, "", VolumeId(7));
|
|
let prot = EcBitrotProtection {
|
|
algorithm: ChecksumAlgorithm::ChecksumCrc32c as i32,
|
|
block_size: ec_bitrot::DEFAULT_BITROT_BLOCK_SIZE as u32,
|
|
generation: 0,
|
|
ec_shard_config: Some(ec_bitrot::ec_shard_config(12, 4, 3 * 1024 * 1024)),
|
|
shards: (0..16u32)
|
|
.map(|shard_id| EcShardChecksums {
|
|
shard_id,
|
|
covered_size: 4,
|
|
block_crc32c: vec![0u8; 4],
|
|
})
|
|
.collect(),
|
|
encode_uuid: vec![0u8; 16],
|
|
};
|
|
ec_bitrot::save_bitrot_sidecar(&ec_bitrot::bitrot_sidecar_path(&base, 0), &prot).unwrap();
|
|
|
|
let (ds, ps, bs) =
|
|
read_ec_shard_config_across_dirs(dir, dir_idx, &[], "", VolumeId(7)).unwrap();
|
|
assert_eq!((ds, ps, bs), (12, 4, 3 * 1024 * 1024));
|
|
}
|
|
|
|
#[test]
|
|
fn read_ec_shard_config_finds_a_sibling_disks_sidecar() {
|
|
use crate::pb::volume_server_pb::{
|
|
ChecksumAlgorithm, EcBitrotProtection, EcShardChecksums,
|
|
};
|
|
use crate::storage::erasure_coding::ec_bitrot;
|
|
|
|
let a = tempfile::TempDir::new().unwrap();
|
|
let b = tempfile::TempDir::new().unwrap();
|
|
let (rebuild, sibling) = (a.path().to_str().unwrap(), b.path().to_str().unwrap());
|
|
let base = crate::storage::volume::volume_file_name(sibling, "", VolumeId(4));
|
|
let prot = EcBitrotProtection {
|
|
algorithm: ChecksumAlgorithm::ChecksumCrc32c as i32,
|
|
block_size: ec_bitrot::DEFAULT_BITROT_BLOCK_SIZE as u32,
|
|
generation: 0,
|
|
ec_shard_config: Some(ec_bitrot::ec_shard_config(12, 4, 3 * 1024 * 1024)),
|
|
shards: (0..16u32)
|
|
.map(|shard_id| EcShardChecksums {
|
|
shard_id,
|
|
covered_size: 4,
|
|
block_crc32c: vec![0u8; 4],
|
|
})
|
|
.collect(),
|
|
encode_uuid: vec![0u8; 16],
|
|
};
|
|
ec_bitrot::save_bitrot_sidecar(&ec_bitrot::bitrot_sidecar_path(&base, 0), &prot).unwrap();
|
|
|
|
let (ds, ps, bs) = read_ec_shard_config_across_dirs(
|
|
rebuild,
|
|
rebuild,
|
|
&[sibling.to_string()],
|
|
"",
|
|
VolumeId(4),
|
|
)
|
|
.unwrap();
|
|
assert_eq!((ds, ps, bs), (12, 4, 3 * 1024 * 1024));
|
|
}
|
|
}
|
|
|
|
/// One volume id's per-disk runtimes resolved into a single scrubbable view.
|
|
/// Every scrub mode builds from this, so there is exactly one answer to
|
|
/// "which disks count" per volume.
|
|
pub(crate) struct MergedEcRuntimes<'a> {
|
|
/// Volume-level metadata source: geometry, `.ecx` handles, version. NOT
|
|
/// bitrot protection — the `.ecsum` sidecar is per-DISK state, so
|
|
/// `EcChecksumScrubPlan::for_volumes` sources it from the first `merged`
|
|
/// runtime that has any.
|
|
pub anchor: &'a EcVolume,
|
|
/// Runtimes whose shards are safe to verify together.
|
|
pub merged: Vec<&'a EcVolume>,
|
|
/// Indexed BY SHARD ID. `None` is a shard no merged runtime holds. The
|
|
/// volume's shard-id range is the anchor's geometry
|
|
/// (`0..data_shards + parity_shards`); consumers truncate to it.
|
|
pub slots: Vec<Option<(&'a EcVolume, &'a EcVolumeShard)>>,
|
|
/// One line per runtime excluded by the identity or geometry fence.
|
|
/// Reported, never dropped.
|
|
pub skipped: Vec<String>,
|
|
}
|
|
|
|
/// Resolve `runtimes` (one vid's mounts, in location order) into a merged view.
|
|
/// `None` for an empty slice (the vanished-volume case).
|
|
///
|
|
/// Two fences admit a runtime into `merged`: the anchor's `encode_ts_ns` (the
|
|
/// maximum, so `0` implies every runtime is `0` and nothing is excluded —
|
|
/// legacy leniency), and the anchor's geometry (`data_shards`, `parity_shards`,
|
|
/// `block_size`). Equal timestamps do not guarantee equal layouts, so a
|
|
/// same-generation runtime whose `.vif` disagrees is excluded and reported
|
|
/// rather than merged into a plan that would apply the anchor's offsets and
|
|
/// checksums to incompatible shards.
|
|
pub(crate) fn merge_ec_runtimes<'a>(runtimes: &[&'a EcVolume]) -> Option<MergedEcRuntimes<'a>> {
|
|
let anchor_gen = runtimes.iter().map(|v| v.encode_ts_ns).max()?;
|
|
|
|
let gen_matches: Vec<&'a EcVolume> = runtimes
|
|
.iter()
|
|
.copied()
|
|
.filter(|v| v.encode_ts_ns == anchor_gen)
|
|
.collect();
|
|
|
|
let anchor = gen_matches
|
|
.iter()
|
|
.copied()
|
|
.find(|v| v.shards.iter().any(|s| s.is_some()))
|
|
.unwrap_or(gen_matches[0]);
|
|
|
|
let merged: Vec<&'a EcVolume> = gen_matches
|
|
.iter()
|
|
.copied()
|
|
.filter(|v| {
|
|
v.data_shards == anchor.data_shards
|
|
&& v.parity_shards == anchor.parity_shards
|
|
&& v.block_size == anchor.block_size
|
|
})
|
|
.collect();
|
|
|
|
let width = merged.iter().map(|v| v.shards.len()).max().unwrap_or(0);
|
|
let mut slots: Vec<Option<(&'a EcVolume, &'a EcVolumeShard)>> = vec![None; width];
|
|
for v in &merged {
|
|
for (id, slot) in v.shards.iter().enumerate() {
|
|
if let Some(shard) = slot.as_ref()
|
|
&& slots[id].is_none()
|
|
{
|
|
slots[id] = Some((*v, shard));
|
|
}
|
|
}
|
|
}
|
|
|
|
let skipped: Vec<String> = runtimes
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, v)| {
|
|
v.encode_ts_ns != anchor_gen
|
|
|| v.data_shards != anchor.data_shards
|
|
|| v.parity_shards != anchor.parity_shards
|
|
|| v.block_size != anchor.block_size
|
|
})
|
|
.map(|(pos, v)| {
|
|
if v.encode_ts_ns != anchor_gen {
|
|
format!(
|
|
"EC volume {} shards at {} (position {}) belong to encode run {} but the scrub anchors on {}; they were not verified",
|
|
v.volume_id.0, v.dir, pos, v.encode_ts_ns, anchor_gen
|
|
)
|
|
} else {
|
|
format!(
|
|
"EC volume {} shards at {} (position {}) share encode run {} but disagree on geometry ({}+{} bs {} vs {}+{} bs {}); they were not verified",
|
|
v.volume_id.0, v.dir, pos, v.encode_ts_ns,
|
|
v.data_shards, v.parity_shards, v.block_size,
|
|
anchor.data_shards, anchor.parity_shards, anchor.block_size
|
|
)
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
Some(MergedEcRuntimes {
|
|
anchor,
|
|
merged,
|
|
slots,
|
|
skipped,
|
|
})
|
|
}
|
|
|
|
/// Self-contained input for an EC checksum scrub: the sidecar plus a duplicate
|
|
/// of every mounted local shard handle, taken under the store read guard.
|
|
///
|
|
/// Two things follow from holding descriptors rather than paths. `run()` needs
|
|
/// no store access, so the guard is released before a scan that reads every
|
|
/// byte of every local shard — under the guard that scan parks the periodic
|
|
/// heartbeat's `store.write()`, and `std::sync::RwLock` is write-preferring, so
|
|
/// every later reader queues behind it and the node stops serving. And a
|
|
/// teardown that legitimately unlinks the shards mid-scan (the heartbeat's
|
|
/// `delete_expired_ec_volumes`, `volume_ec_shards_delete`) cannot masquerade as
|
|
/// bitrot, because the descriptor outlives the name. Go reads the same way:
|
|
/// `ChecksumScrub` goes through `shard.ReadAt`.
|
|
///
|
|
/// Not `Clone`: it owns those descriptors.
|
|
#[derive(Debug)]
|
|
pub struct EcChecksumScrubPlan {
|
|
pub volume_id: VolumeId,
|
|
pub prot: Option<crate::pb::volume_server_pb::EcBitrotProtection>,
|
|
pub status: crate::storage::erasure_coding::ec_bitrot::BitrotStatus,
|
|
pub parity_shards: u32,
|
|
/// One entry per LOCAL shard: its id and a duplicate of the mounted
|
|
/// handle (or the error to report). Private so a plan can only be built
|
|
/// (via `checksum_scrub_plan()` or `for_volumes()`) from `&EcVolume`
|
|
/// references, which cannot be obtained without the store guard — which
|
|
/// is what makes "captured under the guard" an invariant rather than a
|
|
/// convention.
|
|
///
|
|
/// `dup` shares the kernel file offset, so every read here is positional.
|
|
shards: Vec<(u32, std::io::Result<File>)>,
|
|
/// Runtimes the identity/geometry fence excluded, one line each. Surfaced
|
|
/// through `run()`'s errors on the `On`/`Invalid` paths.
|
|
skipped: Vec<String>,
|
|
/// `Some(source_dir)` when the sidecar that supplied `prot`/`status` was
|
|
/// resolved from a directory belonging to a runtime the fence excluded.
|
|
/// `run()` reports unverifiable protection instead of blaming shards.
|
|
unverifiable_sidecar: Option<String>,
|
|
/// One line per merged runtime whose sidecar resolved `Invalid`. Reported
|
|
/// even when protection is taken from a sibling that is `On`, so a
|
|
/// malformed sidecar is never silently discarded.
|
|
invalid_sidecar_errors: Vec<String>,
|
|
}
|
|
|
|
impl EcChecksumScrubPlan {
|
|
/// Build one plan over every per-disk runtime of a volume id. `None` only
|
|
/// when `runtimes` is empty (the vanished-volume case).
|
|
pub fn for_volumes(runtimes: &[&EcVolume]) -> Option<Self> {
|
|
use crate::storage::erasure_coding::ec_bitrot::BitrotStatus;
|
|
|
|
let merged = merge_ec_runtimes(runtimes)?;
|
|
let anchor = merged.anchor;
|
|
|
|
// Protection is per-DISK state: the `.ecsum` sidecar is not mirrored,
|
|
// and at mount each runtime resolves it from its own directories only,
|
|
// so after a restart the runtime without the sidecar mounts `Off`.
|
|
// Take the first merged runtime that has protection: `On` if any, else
|
|
// `Invalid`, else the anchor's `Off`. This only moves toward more
|
|
// verification (`Off->On`, `Off->Invalid`, `Invalid->On`), never toward
|
|
// silence — the anchor is itself in `merged`, so the fallback is `Off`
|
|
// only when every merged runtime is `Off`.
|
|
let protection_source = merged
|
|
.merged
|
|
.iter()
|
|
.copied()
|
|
.find(|v| matches!(v.bitrot_protection().1, BitrotStatus::On))
|
|
.or_else(|| {
|
|
merged
|
|
.merged
|
|
.iter()
|
|
.copied()
|
|
.find(|v| matches!(v.bitrot_protection().1, BitrotStatus::Invalid))
|
|
})
|
|
.unwrap_or(anchor);
|
|
let (prot, status) = protection_source.bitrot_protection();
|
|
|
|
// Collect every merged runtime whose sidecar resolved Invalid, excluding
|
|
// the protection_source (whose error the Invalid arm of run() already
|
|
// reports). These surface when protection is taken from a sibling that
|
|
// is On, so a malformed sidecar is never silently discarded.
|
|
let invalid_sidecar_errors: Vec<String> = merged
|
|
.merged
|
|
.iter()
|
|
.filter(|v| {
|
|
let v_ptr: *const EcVolume = **v;
|
|
let src_ptr: *const EcVolume = protection_source;
|
|
!std::ptr::eq(v_ptr, src_ptr)
|
|
&& matches!(v.bitrot_protection().1, BitrotStatus::Invalid)
|
|
})
|
|
.map(|v| {
|
|
format!(
|
|
"EC volume {} bitrot sidecar at {} is malformed/unverifiable (sidecar integrity)",
|
|
v.volume_id.0, v.dir
|
|
)
|
|
})
|
|
.collect();
|
|
|
|
let total = (anchor.data_shards + anchor.parity_shards) as usize;
|
|
|
|
let shards = match status {
|
|
crate::storage::erasure_coding::ec_bitrot::BitrotStatus::On => merged
|
|
.slots
|
|
.iter()
|
|
.take(total)
|
|
.enumerate()
|
|
.filter_map(|(id, slot)| slot.map(|(_, shard)| (id as u32, shard.try_clone_file())))
|
|
.collect(),
|
|
_ => Vec::new(),
|
|
};
|
|
|
|
// If the fence excluded a runtime AND the sidecar that supplied
|
|
// protection was resolved from that excluded runtime's directory, the
|
|
// checksums cannot be trusted against the merged shards.
|
|
let unverifiable_sidecar = if merged.skipped.is_empty() {
|
|
None
|
|
} else {
|
|
let own_dirs: Vec<String> = merged
|
|
.merged
|
|
.iter()
|
|
.flat_map(|v| [v.dir.as_str(), v.dir_idx.as_str()])
|
|
.map(|d| d.trim_end_matches('/').to_string())
|
|
.collect();
|
|
let src = protection_source.bitrot_source_dir.trim_end_matches('/');
|
|
if src.is_empty() || own_dirs.iter().any(|d| d == src) {
|
|
None
|
|
} else {
|
|
Some(protection_source.bitrot_source_dir.clone())
|
|
}
|
|
};
|
|
|
|
Some(EcChecksumScrubPlan {
|
|
volume_id: anchor.volume_id,
|
|
prot,
|
|
status,
|
|
parity_shards: anchor.parity_shards,
|
|
shards,
|
|
skipped: merged.skipped,
|
|
unverifiable_sidecar,
|
|
invalid_sidecar_errors,
|
|
})
|
|
}
|
|
|
|
/// The byte-verification pass. Touches only the filesystem — no store, no
|
|
/// lock — so it is safe to hand to `spawn_blocking`.
|
|
pub fn run(self) -> (u64, Vec<u32>, Vec<String>) {
|
|
use crate::storage::erasure_coding::ec_bitrot;
|
|
use crate::storage::erasure_coding::ec_bitrot::BitrotStatus;
|
|
|
|
let mut errors: Vec<String> = Vec::new();
|
|
|
|
// Mirrors Go's `ChecksumScrub` (`prot, status := ecv.BitrotProtection()`):
|
|
// - Off: sidecars are optional; return clean (Go: `case BitrotOff: return 0, nil, nil`).
|
|
// - Invalid: sidecar is present but malformed; report an integrity error.
|
|
// - On: scan local shards against it.
|
|
let prot = match (self.prot, self.status) {
|
|
(_, BitrotStatus::Off) => {
|
|
return (0, Vec::new(), Vec::new());
|
|
}
|
|
(_, BitrotStatus::Invalid) => {
|
|
let mut errs = vec![format!(
|
|
"EC volume {} bitrot sidecar is malformed/unverifiable (sidecar integrity)",
|
|
self.volume_id.0
|
|
)];
|
|
errs.extend(self.skipped);
|
|
errs.extend(self.invalid_sidecar_errors);
|
|
return (0, Vec::new(), errs);
|
|
}
|
|
(Some(p), BitrotStatus::On) => p,
|
|
(None, BitrotStatus::On) => {
|
|
return (0, Vec::new(), Vec::new());
|
|
}
|
|
};
|
|
|
|
errors.extend(self.skipped);
|
|
errors.extend(self.invalid_sidecar_errors);
|
|
|
|
if let Some(src) = self.unverifiable_sidecar {
|
|
errors.push(format!(
|
|
"EC volume {} bitrot sidecar was resolved from {}, which belongs to an excluded encode run; protection unverifiable",
|
|
self.volume_id.0, src
|
|
));
|
|
return (0, Vec::new(), errors);
|
|
}
|
|
|
|
let block_size = prot.block_size as i64;
|
|
|
|
let mut blocks_scanned: u64 = 0;
|
|
let mut mismatched_shards: Vec<u32> = Vec::new();
|
|
// Track shards whose blocks ALL mismatch (wholesale) to detect a
|
|
// stale/wrong sidecar.
|
|
let mut wholesale_mismatch = 0usize;
|
|
|
|
for (shard_id, handle) in self.shards {
|
|
let Some(entry) = ec_bitrot::shard_checksums(&prot, shard_id) else {
|
|
errors.push(format!(
|
|
"EC volume {} shard {} present but missing from sidecar manifest",
|
|
self.volume_id.0, shard_id
|
|
));
|
|
continue;
|
|
};
|
|
|
|
// The handle was opened under the store lock; reading through it
|
|
// means a concurrent unmount/unlink cannot masquerade as bitrot.
|
|
let file = match &handle {
|
|
Ok(f) => f,
|
|
Err(e) => {
|
|
errors.push(format!(
|
|
"EC volume {} shard {} scrub read error: {}",
|
|
self.volume_id.0, shard_id, e
|
|
));
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let expected_blocks = entry.block_crc32c.len() / 4;
|
|
match ec_bitrot::verify_shard_blocks(file, entry, block_size) {
|
|
Ok(mismatched) => {
|
|
blocks_scanned += expected_blocks as u64;
|
|
if !mismatched.is_empty() {
|
|
mismatched_shards.push(shard_id);
|
|
if expected_blocks > 0 && mismatched.len() == expected_blocks {
|
|
wholesale_mismatch += 1;
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
errors.push(format!(
|
|
"EC volume {} shard {} scrub read error: {}",
|
|
self.volume_id.0, shard_id, e
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
// If more shards mismatch wholesale than parity can mask, the sidecar
|
|
// itself is the likely culprit (stale generation / wrong volume), so
|
|
// suppress the shard-corruption verdict and flag a sidecar-integrity
|
|
// issue instead.
|
|
if wholesale_mismatch > self.parity_shards as usize {
|
|
errors.push(format!(
|
|
"EC volume {}: {} shards mismatch wholesale (> {} parity); suspect stale/wrong sidecar, not shard corruption",
|
|
self.volume_id.0, wholesale_mismatch, self.parity_shards
|
|
));
|
|
mismatched_shards.clear();
|
|
}
|
|
|
|
mismatched_shards.sort_unstable();
|
|
(blocks_scanned, mismatched_shards, errors)
|
|
}
|
|
}
|
|
|
|
/// Self-contained input for an EC index scrub.
|
|
///
|
|
/// Not `Clone`: it owns the .ecx handle opened under the store lock.
|
|
#[derive(Debug)]
|
|
pub struct EcIndexScrubPlan {
|
|
pub volume_id: VolumeId,
|
|
pub has_ecx_file: bool,
|
|
pub ecx_path: String,
|
|
pub ecx_file_size: i64,
|
|
pub version: Version,
|
|
/// The .ecx handle opened while the store lock was held. Private, so the
|
|
/// plan can only come from `EcVolume::scrub_index_plan`.
|
|
ecx_handle: std::io::Result<File>,
|
|
}
|
|
|
|
impl EcIndexScrubPlan {
|
|
/// Structural walk of the .ecx index. Filesystem only — no store, no lock.
|
|
pub fn run(self) -> (u64, Vec<String>) {
|
|
if !self.has_ecx_file {
|
|
return (
|
|
0,
|
|
vec![format!(
|
|
"no ECX file associated with EC volume {}",
|
|
self.volume_id.0
|
|
)],
|
|
);
|
|
}
|
|
if self.ecx_file_size == 0 {
|
|
return (
|
|
0,
|
|
vec![format!(
|
|
"zero-size ECX file for EC volume {}",
|
|
self.volume_id.0
|
|
)],
|
|
);
|
|
}
|
|
|
|
// A private fd, so the structural scan never moves the shared ecx_file
|
|
// cursor (the cached handle is read positionally elsewhere). Checked
|
|
// after the two guards above so the error ordering is unchanged.
|
|
let mut ecx_file = match self.ecx_handle {
|
|
Ok(f) => f,
|
|
Err(e) => return (0, vec![format!("open ECX file {}: {}", self.ecx_path, e)]),
|
|
};
|
|
crate::storage::idx::check_index_file(&mut ecx_file, self.ecx_file_size, self.version)
|
|
}
|
|
}
|
|
|
|
/// One local shard as `EcLocalScrubPlan` sees it: the mounted descriptor, the
|
|
/// size the scan compares against, and the identity a broken-shard report needs.
|
|
#[derive(Debug)]
|
|
pub struct EcLocalShard {
|
|
file: std::io::Result<File>,
|
|
/// The shard's cached size, as `scrub_local` has always compared against —
|
|
/// deliberately not a live `metadata()` call in `run()`.
|
|
file_size: i64,
|
|
info: crate::pb::volume_server_pb::EcShardInfo,
|
|
}
|
|
|
|
impl EcLocalShard {
|
|
/// Positional read through the duplicated handle. `dup` shares the kernel
|
|
/// offset with the mounted shard, so this must never seek.
|
|
fn read_at(&self, buf: &mut [u8], offset: u64) -> io::Result<usize> {
|
|
let file = self
|
|
.file
|
|
.as_ref()
|
|
.map_err(|e| io::Error::new(e.kind(), e.to_string()))?;
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::FileExt;
|
|
file.read_at(buf, offset)
|
|
}
|
|
#[cfg(not(unix))]
|
|
{
|
|
use std::io::{Read, Seek, SeekFrom};
|
|
let mut f = file.try_clone()?;
|
|
f.seek(SeekFrom::Start(offset))?;
|
|
f.read(buf)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Self-contained input for an EC LOCAL scrub: the index snapshot, a private
|
|
/// .ecx descriptor for the needle walk, and the mounted local shard handles.
|
|
///
|
|
/// Same reasoning as `EcChecksumScrubPlan` — `scrub_local` reads every local
|
|
/// needle's bytes, so it must not run under the store read guard.
|
|
///
|
|
/// Not `Clone`: it owns descriptors.
|
|
#[derive(Debug)]
|
|
pub struct EcLocalScrubPlan {
|
|
volume_id: VolumeId,
|
|
version: Version,
|
|
data_shards: u32,
|
|
shard_size: i64,
|
|
large_block_size: i64,
|
|
small_block_size: i64,
|
|
index: EcIndexScrubPlan,
|
|
ecx_path: String,
|
|
ecx_walk: std::io::Result<File>,
|
|
/// Indexed BY SHARD ID; `None` is a shard no merged runtime holds.
|
|
shards: Vec<Option<EcLocalShard>>,
|
|
/// Runtimes the identity fence excluded, one line each. LOCAL has no status
|
|
/// gate, so `run()` always reports these.
|
|
skipped: Vec<String>,
|
|
}
|
|
|
|
impl EcLocalScrubPlan {
|
|
/// Build one plan over every per-disk runtime of a volume id. `None` only
|
|
/// when `runtimes` is empty.
|
|
///
|
|
/// The shard vector keeps its SLOT structure — index is the shard id, gaps
|
|
/// are shards no merged runtime holds. `run()` reads that distinction to
|
|
/// decide whether a needle can be reassembled locally at all, so compacting
|
|
/// it would silently change which needles get verified.
|
|
pub fn for_volumes(runtimes: &[&EcVolume]) -> Option<Self> {
|
|
let merged = merge_ec_runtimes(runtimes)?;
|
|
let anchor = merged.anchor;
|
|
|
|
Some(EcLocalScrubPlan {
|
|
volume_id: anchor.volume_id,
|
|
version: anchor.version,
|
|
data_shards: anchor.data_shards,
|
|
// locate_data wants shardSize = datFileSize / DataShards when known,
|
|
// else ecdFileSize - 1 (shards are padded to the small block size;
|
|
// the -1 avoids an off-by-one in the large-block row count).
|
|
//
|
|
// The fallback takes the MAX over every merged slot, not
|
|
// `anchor.shard_file_size()` -- which returns the anchor's FIRST
|
|
// held shard, not a maximum. Before aggregation the plan only read
|
|
// the anchor's own shards, so a truncated shard there mis-sized only
|
|
// its own runtime; now one disk's truncated shard would set
|
|
// `shard_size` for every merged sibling's shards, mis-offset
|
|
// `locate_data` and manufacture needle corruption across the whole
|
|
// node. `verify_ec_shards` already answers this same question the
|
|
// same way (`if size > shard_size { shard_size = size }`), so this
|
|
// is the in-tree convention rather than a preference. Reached only
|
|
// on the legacy `dat_file_size == 0` path.
|
|
//
|
|
// `.take(..)` honors the `slots` contract: the volume's shard-id
|
|
// range is the ANCHOR's geometry, and a same-generation runtime with
|
|
// a disagreeing `.vif` can populate slots beyond it. Scanning those
|
|
// would let an OUT-OF-GEOMETRY shard -- one nothing in this volume's
|
|
// layout describes -- set the offset math for every shard that IS in
|
|
// it, which is a narrower path to exactly the node-wide mis-sizing
|
|
// this max exists to close.
|
|
shard_size: if anchor.dat_file_size > 0 {
|
|
anchor.dat_file_size / anchor.data_shards as i64
|
|
} else {
|
|
merged
|
|
.slots
|
|
.iter()
|
|
.take((anchor.data_shards + anchor.parity_shards) as usize)
|
|
.flatten()
|
|
.map(|(_, s)| s.file_size())
|
|
.max()
|
|
.unwrap_or(0)
|
|
- 1
|
|
},
|
|
large_block_size: anchor.large_block_size(),
|
|
small_block_size: anchor.small_block_size(),
|
|
index: anchor.scrub_index_plan(),
|
|
ecx_path: anchor.ecx_file_name(),
|
|
// A second descriptor: the index plan's is consumed by its own walk,
|
|
// and both seek.
|
|
ecx_walk: File::open(anchor.ecx_file_name()),
|
|
shards: merged
|
|
.slots
|
|
.iter()
|
|
.map(|slot| {
|
|
slot.map(|(_, s)| EcLocalShard {
|
|
file: s.try_clone_file(),
|
|
file_size: s.file_size(),
|
|
info: s.to_ec_shard_info(),
|
|
})
|
|
})
|
|
.collect(),
|
|
skipped: merged.skipped,
|
|
})
|
|
}
|
|
|
|
/// The needle walk. Filesystem only — no store, no lock.
|
|
pub fn run(
|
|
self,
|
|
) -> (
|
|
u64,
|
|
Vec<crate::pb::volume_server_pb::EcShardInfo>,
|
|
Vec<String>,
|
|
) {
|
|
let EcLocalScrubPlan {
|
|
volume_id,
|
|
version,
|
|
data_shards,
|
|
shard_size,
|
|
large_block_size,
|
|
small_block_size,
|
|
index,
|
|
ecx_path,
|
|
ecx_walk,
|
|
shards,
|
|
skipped,
|
|
} = self;
|
|
|
|
// Local scan also verifies the index.
|
|
let (_, mut errs) = index.run();
|
|
|
|
// LOCAL has no protection status to gate on, so an excluded runtime is
|
|
// always reported rather than silently unscanned.
|
|
errs.extend(skipped);
|
|
|
|
let mut broken_shards: HashSet<ShardId> = HashSet::new();
|
|
let mut count: u64 = 0;
|
|
|
|
let mut ecx_file = match ecx_walk {
|
|
Ok(f) => f,
|
|
Err(e) => {
|
|
errs.push(format!("open ECX file {}: {}", ecx_path, e));
|
|
return (count, Vec::new(), errs);
|
|
}
|
|
};
|
|
|
|
// Reused across every needle/chunk to avoid a per-chunk allocation.
|
|
let mut chunk_buf: Vec<u8> = Vec::new();
|
|
let walk = crate::storage::idx::walk_index_file(&mut ecx_file, 0, |id, offset, size| {
|
|
count += 1;
|
|
if size.is_tombstone() {
|
|
return Ok(());
|
|
}
|
|
|
|
// Go recomputes this at the size check below; hoisted because Rust
|
|
// also sizes the reassembly buffer from it. Any negative size other
|
|
// than the -1 tombstone skipped above drives it negative.
|
|
let want = get_actual_size(size, version);
|
|
|
|
let locations = ec_locate::locate_data(
|
|
offset.to_actual_offset(),
|
|
Size(want as i32),
|
|
shard_size,
|
|
data_shards,
|
|
large_block_size,
|
|
small_block_size,
|
|
);
|
|
// A needle is verifiable locally only if every shard it spans is local;
|
|
// when any is remote, skip the reassembly buffer entirely.
|
|
let has_remote_chunks = locations.iter().any(|iv| {
|
|
let (sid, _) =
|
|
iv.to_shard_id_and_offset(data_shards, large_block_size, small_block_size);
|
|
shards.get(sid as usize).and_then(|s| s.as_ref()).is_none()
|
|
});
|
|
let mut read: i64 = 0;
|
|
// `want <= 0` means the row is malformed. Go pays nothing for it:
|
|
// it appends to a nil slice and has no capacity hint here. Rust's
|
|
// per-needle buffer does, and `Vec::with_capacity(negative as usize)`
|
|
// aborts the process -- inside spawn_blocking that surfaces as a
|
|
// JoinError and takes the whole node-wide scrub RPC with it. Fall
|
|
// through with an empty buffer instead: locate_data already returns
|
|
// no intervals for a non-positive size, so read stays 0 and the
|
|
// `read != want` check below reports the row, exactly as Go does.
|
|
let mut data: Vec<u8> = if has_remote_chunks || want <= 0 {
|
|
Vec::new()
|
|
} else {
|
|
Vec::with_capacity(want as usize)
|
|
};
|
|
let mut local_shard_ids: Vec<ShardId> = Vec::new();
|
|
|
|
for (i, iv) in locations.iter().enumerate() {
|
|
let (sid, soffset) =
|
|
iv.to_shard_id_and_offset(data_shards, large_block_size, small_block_size);
|
|
let ssize = iv.size;
|
|
let shard = match shards.get(sid as usize).and_then(|s| s.as_ref()) {
|
|
Some(s) => s,
|
|
None => {
|
|
// Shard is not local; we can't verify it without decoding.
|
|
read += ssize;
|
|
continue;
|
|
}
|
|
};
|
|
local_shard_ids.push(sid);
|
|
|
|
if soffset + ssize > shard.file_size {
|
|
broken_shards.insert(sid);
|
|
errs.push(format!(
|
|
"local shard {} for needle {} is too short ({}), cannot read chunk {}/{}",
|
|
sid,
|
|
id.0,
|
|
shard.file_size,
|
|
i + 1,
|
|
locations.len()
|
|
));
|
|
continue;
|
|
}
|
|
|
|
chunk_buf.resize(ssize as usize, 0);
|
|
match shard.read_at(&mut chunk_buf, soffset as u64) {
|
|
Err(e) => {
|
|
broken_shards.insert(sid);
|
|
errs.push(format!(
|
|
"failed to read chunk {}/{} for needle {} from local shard {} at offset {}: {}",
|
|
i + 1,
|
|
locations.len(),
|
|
id.0,
|
|
sid,
|
|
soffset,
|
|
e
|
|
));
|
|
continue;
|
|
}
|
|
Ok(got) if got as i64 != ssize => {
|
|
broken_shards.insert(sid);
|
|
errs.push(format!(
|
|
"expected {} bytes for chunk {}/{} for needle {} from local shard {}, got {}",
|
|
ssize,
|
|
i + 1,
|
|
locations.len(),
|
|
id.0,
|
|
sid,
|
|
got
|
|
));
|
|
continue;
|
|
}
|
|
Ok(_) => {}
|
|
}
|
|
|
|
if !has_remote_chunks {
|
|
data.extend_from_slice(&chunk_buf);
|
|
}
|
|
read += ssize;
|
|
}
|
|
|
|
local_shard_ids.sort_unstable();
|
|
|
|
if read != want {
|
|
// Like Go, returning from the walk callback aborts the scan.
|
|
return Err(io::Error::other(format!(
|
|
"expected {} bytes for needle {} on volume {}, got {}",
|
|
want, id.0, volume_id.0, read
|
|
)));
|
|
}
|
|
|
|
// Only a fully-local needle can be reassembled and CRC-checked.
|
|
if !has_remote_chunks {
|
|
let mut n = Needle::default();
|
|
if let Err(e) = n.read_bytes(&data, 0, size, version) {
|
|
// A delete-state disagreement between the .ecx index and the reassembled
|
|
// on-disk header (live index vs zero header size) is not corruption.
|
|
let delete_state_disagrees = matches!(
|
|
&e,
|
|
NeedleError::SizeMismatch { found, .. } if size.is_deleted() != (found.0 == 0)
|
|
);
|
|
if !delete_state_disagrees {
|
|
errs.push(format!(
|
|
"needle {} on volume {}, shards {:?}: {}",
|
|
id.0, volume_id.0, local_shard_ids, e
|
|
));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
});
|
|
if let Err(e) = walk {
|
|
// Go appends the walk/callback error verbatim.
|
|
errs.push(e.to_string());
|
|
}
|
|
|
|
let mut broken: Vec<crate::pb::volume_server_pb::EcShardInfo> = broken_shards
|
|
.iter()
|
|
.filter_map(|sid| shards.get(*sid as usize).and_then(|s| s.as_ref()))
|
|
.map(|s| s.info.clone())
|
|
.collect();
|
|
broken.sort_by_key(|a| a.shard_id);
|
|
|
|
(count, broken, errs)
|
|
}
|
|
}
|
|
|
|
/// Windows helper: loop `seek_read` until the buffer is fully filled.
|
|
///
|
|
/// `seek_read` is positional (it passes the offset through `OVERLAPPED` and
|
|
/// never touches the shared file cursor), so concurrent callers reading the
|
|
/// same `&File` — as `find_needle_from_ecx` does on the cached `.ecx` handle —
|
|
/// can't interleave their reads. Mirrors the helper in `storage::volume`.
|
|
#[cfg(windows)]
|
|
fn read_exact_at(file: &File, buf: &mut [u8], mut offset: u64) -> io::Result<()> {
|
|
use std::os::windows::fs::FileExt;
|
|
let mut filled = 0;
|
|
while filled < buf.len() {
|
|
let n = file.seek_read(&mut buf[filled..], offset)?;
|
|
if n == 0 {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::UnexpectedEof,
|
|
"unexpected EOF in seek_read",
|
|
));
|
|
}
|
|
filled += n;
|
|
offset += n as u64;
|
|
}
|
|
Ok(())
|
|
}
|