//! Distributed EC read path. Mirror of `weed/storage/store_ec.go`'s //! `readEcShardIntervals` → `readOneEcShardInterval` → //! `readRemoteEcShardInterval` → `recoverOneRemoteEcShardInterval` //! chain. //! //! The existing `EcVolume::read_ec_shard_needle` reads only locally- //! mounted shards and returns `NotFound` if any interval requires a //! shard held on a peer server. In a standard RS(10,4)-across-14 //! deployment each server holds one shard, so every read needs >=9 //! peer fetches. This module fills the gap by: //! //! 1. Locating the needle in `.ecx` (under the Store read lock) and //! computing the per-interval (shard_id, shard_offset, size). //! 2. Reading the local-resident intervals while still holding the //! lock — same path the local-only helper uses. //! 3. Dropping the lock and, for any remaining intervals, fetching //! from peer volume servers via `VolumeEcShardRead`. If the //! direct peer read fails, fan-out reads to other shards at the //! same (shard_offset, size) and rebuild the missing shard via //! Reed-Solomon — exactly Go's flow. //! 4. Refreshing the per-EcVolume `shard_locations` cache from the //! master's `LookupEcVolume` RPC when the cached map is stale. //! //! All gRPC IO is async; the file IO portion runs under the sync //! Store read lock, matching Go's `readLocalEcShardInterval`. The //! cache write-back briefly reacquires the EcVolume's internal //! `RwLock` so we do not contend with the Store-level lock at all. use std::collections::HashMap; use std::fs; use std::io; use std::sync::Arc; use std::time::{Duration, Instant}; use futures::future::join_all; use futures::stream::{self, StreamExt}; use reed_solomon_erasure::galois_8::ReedSolomon; use tokio::sync::Semaphore; use tonic::Request; use crate::pb::master_pb::{self, seaweed_client::SeaweedClient, LookupEcVolumeRequest}; use crate::pb::volume_server_pb::{ volume_server_client::VolumeServerClient, CopyFileRequest, VolumeEcShardReadRequest, }; use crate::server::grpc_client::{build_grpc_endpoint, parse_grpc_address, GRPC_MAX_MESSAGE_SIZE}; use crate::server::request_id::outgoing_request_id_interceptor; use crate::server::volume_server::{to_http_address, VolumeServerState}; use crate::storage::erasure_coding::ec_shard::ShardId; use crate::storage::needle::needle::{get_actual_size, Needle, NeedleError}; use crate::storage::store_ec_reconcile::EcVolumeMissingIndex; use crate::storage::types::*; use crate::storage::volume::volume_file_name; /// Bounds the fan-out of a single needle read. Mirrors Go's /// `ecIntervalReadConcurrency`. const INTERVAL_READ_CONCURRENCY: usize = 8; /// Bounds the bytes EC recovery holds in flight across every concurrent read. /// Recovery is the one read path that multiplies the served bytes — it keeps an /// interval-sized buffer per shard alive until Reed-Solomon runs — and a peer /// that is slow to fail holds each of them for the whole gRPC timeout, so a /// burst of reads during a network blip walked the server into an OOM. Mirrors /// Go's `ecRecoverBudget`. const EC_RECOVER_BUDGET: usize = 256 << 20; static EC_RECOVER_SEM: Semaphore = Semaphore::const_new(EC_RECOVER_BUDGET); /// One interval's data after Phase A. enum IntervalResult { /// Already read from a locally-mounted shard. Local(Vec), /// Shard not local (or local read failed). Must be fetched. NeedRemote { shard_id: ShardId, shard_offset: i64, size: usize, }, } /// Snapshot extracted under the Store read lock so Phases B/C can run /// without holding any sync lock across `.await`. struct Snapshot { data_shards: u32, parity_shards: u32, version: Version, actual_size: usize, offset: Offset, size_for_parse: Size, intervals: Vec, cached_locations: HashMap>, cache_refreshed_at: Option, /// This volume's encode identity, carried to peers on remote shard reads so a /// shard from a different encode run is rejected rather than served at a /// mismatched offset. 0 for a pre-feature volume (lenient). encode_ts_ns: i64, } /// Top-level entry point. Returns `Ok(None)` for "not found" (matches /// Go's `ReadEcShardNeedle`); errors propagate as `io::Error`. pub async fn read_ec_shard_needle_distributed( state: &Arc, vid: VolumeId, needle_id: NeedleId, ) -> io::Result> { // Phase A — under the Store read lock, locate the needle, compute // intervals, and read any locally-mounted shard intervals. We must // not `.await` while holding this guard (std::sync::RwLockReadGuard // is !Send). let mut snapshot = match snapshot_under_lock(state, vid, needle_id)? { Some(s) => s, None => return Ok(None), }; // Phase B — refresh the shard_locations cache from the master if // it is stale. Do this lazily: if every needed interval was read // locally we can skip the master RPC entirely. let any_remote = snapshot .intervals .iter() .any(|r| matches!(r, IntervalResult::NeedRemote { .. })); let total_shards = (snapshot.data_shards + snapshot.parity_shards) as usize; let mut shard_locations = snapshot.cached_locations.clone(); if any_remote && claim_shard_locations_refresh( state, vid, &shard_locations, snapshot.cache_refreshed_at, snapshot.data_shards as usize, total_shards, ) { match cached_lookup_ec_shard_locations(state, vid).await { Ok(fresh) => { // A complete reply merges into the cache; an incomplete one // (< data_shards) is left unwritten — keep the prior cache. match write_back_shard_locations(state, vid, fresh, snapshot.data_shards as usize) { Some(merged) => shard_locations = merged, // An incomplete reply leaves the cache unwritten and its refresh // time unadvanced, so the mark this refresh consumed goes back. None => mark_shard_locations_stale(state, vid), } } Err(e) => { // Lookup failed — proceed with cached values. If cache // is empty, the remote fetch below will fail and we // surface a NotFound (matching Go's behavior when no // locations are known). The mark goes back: nothing // answered for it, and the map stays disproved. mark_shard_locations_stale(state, vid); tracing::warn!( "ec lookup failed for volume {}: {} — using cached locations ({} entries)", vid.0, e, shard_locations.len(), ); } } } // Phase C — fetch missing intervals, reconstructing when the direct peer // read fails. Blocks that follow each other in the .dat live on different // shards, so a needle spanning several of them costs one round trip per // block when fetched in sequence; `buffered` keeps the order while letting // INTERVAL_READ_CONCURRENCY of them fly at once. let data_shards = snapshot.data_shards as usize; let parity_shards = snapshot.parity_shards as usize; let encode_ts_ns = snapshot.encode_ts_ns; let intervals = std::mem::take(&mut snapshot.intervals); let fetched: Vec, bool)>> = stream::iter(intervals.into_iter().map(|res| { let shard_locations = &shard_locations; async move { match res { IntervalResult::Local(buf) => Ok((buf, false)), IntervalResult::NeedRemote { shard_id, shard_offset, size, } => { fetch_one_interval( state, vid, needle_id, shard_id, shard_offset, size, shard_locations, data_shards, parity_shards, encode_ts_ns, ) .await } } } })) .buffered(INTERVAL_READ_CONCURRENCY) .collect() .await; let mut assembled: Vec> = Vec::with_capacity(fetched.len()); for res in fetched { let (buf, is_deleted) = res?; // A peer reports the needle deleted (a cross-server window where the // local index still shows it live): treat as not-found rather than // serving zeros, mirroring Go's ErrorDeleted. if is_deleted { return Ok(None); } assembled.push(buf); } // Phase D — assemble and parse the Needle. Mirrors the tail of // `EcVolume::read_ec_shard_needle`. let mut bytes = Vec::with_capacity(snapshot.actual_size); for chunk in assembled { bytes.extend_from_slice(&chunk); } bytes.truncate(snapshot.actual_size); if bytes.len() < snapshot.actual_size { return Err(io::Error::new( io::ErrorKind::UnexpectedEof, format!( "read {} bytes but need {} for needle {}", bytes.len(), snapshot.actual_size, needle_id ), )); } let mut n = Needle::default(); n.id = needle_id; n.read_bytes( &bytes, snapshot.offset.to_actual_offset(), snapshot.size_for_parse, snapshot.version, ) .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("{}", e)))?; Ok(Some(n)) } /// FULL EC scrub: verify every needle's bytes across local AND remote shards, /// without decoding (so genuine shard faults are reported rather than healed). /// Mirrors Go's `Store.ScrubEcVolume`. Returns (rows walked, broken shards, /// errors). `force_deleted_needles_check` disables the benign delete-state /// size-mismatch suppression. `recover_unreadable` (READS mode) rebuilds an /// unreadable interval from the surviving shards: the same shards are reported /// broken, but only needles parity can no longer recover become errors. /// /// Shard locations are refreshed once up front. Each needle is then processed via /// `scrub_snapshot_under_lock` + lock-drop + no-reconstruct `read_remote_ec_shard_interval`, /// so no `!Send` store guard is held across an `.await`. pub async fn scrub_ec_volume_distributed( state: &Arc, vid: VolumeId, force_deleted_needles_check: bool, recover_unreadable: bool, ) -> (i64, Vec, Vec) { // Phase A — under the Store read lock, run the index scrub and grab the // paths/scalars + shard-location staleness; release the lock before any await. let ( ecx_path, collection, seed_errs, cached_locations, cache_refreshed_at, data_shards, total_shards, ) = { let store = state.store.read().unwrap(); let ecv = match store.find_ec_volume(vid) { Some(v) => v, None => { return ( 0, Vec::new(), vec![format!("EC volume id {} not found", vid.0)], ) } }; // full scan means verifying the index as well let (_, errs) = ecv.scrub_index(); // Bind to locals so the inner RwLock/Mutex guards drop before the block ends. let cached_locations = ecv.shard_locations.read().unwrap().clone(); let cache_refreshed_at = *ecv.shard_locations_refresh_time.lock().unwrap(); let data_shards = ecv.data_shards as usize; let total_shards = (ecv.data_shards + ecv.parity_shards) as usize; ( ecv.ecx_file_name(), ecv.collection.clone(), errs, cached_locations, cache_refreshed_at, data_shards, total_shards, ) }; let mut errs = seed_errs; // Refresh the shard-location cache once up front (mirrors Go's // cachedLookupEcShardLocations). A partial reply (< data_shards locations, a // master mid-recovery) or a failed lookup is a hard, retryable error — never // overwrite a good cache with a partial map or storm a down master per needle. if claim_shard_locations_refresh( state, vid, &cached_locations, cache_refreshed_at, data_shards, total_shards, ) { match cached_lookup_ec_shard_locations(state, vid).await { Ok(fresh) => { if write_back_shard_locations(state, vid, fresh, data_shards).is_none() { mark_shard_locations_stale(state, vid); return ( 0, Vec::new(), vec![format!( "failed to locate shard via master grpc: fewer than {} data-shard locations returned", data_shards )], ); } } Err(e) => { mark_shard_locations_stale(state, vid); return ( 0, Vec::new(), vec![format!("failed to locate shard via master grpc: {}", e)], ); } } } // Hoist the post-refresh shard-location map once; it is stable for the whole // walk, so per-needle snapshots no longer clone it. let locations: HashMap> = { let store = state.store.read().unwrap(); let ecv = match store.find_ec_volume(vid) { Some(v) => v, None => { return ( 0, Vec::new(), vec![format!("EC volume id {} not found", vid.0)], ) } }; let map = ecv.shard_locations.read().unwrap().clone(); map }; // Walk the .ecx (private fd, no lock) for the row count + live (id, offset, size). let mut count: i64 = 0; let mut needles: Vec<(NeedleId, Offset, Size)> = Vec::new(); match fs::File::open(&ecx_path) { Ok(mut f) => { if let Err(e) = crate::storage::idx::walk_index_file(&mut f, 0, |id, offset, size| { count += 1; // Skip ALL deleted entries: -1 tombstones (runtime delete folded // into .ecx) and -originalSize entries (a needle deleted on the // regular volume before EC encode). get_actual_size uses the raw // signed size, so a negative would yield empty intervals // (false-positive) or an under-16-byte buffer (parse panic). if !size.is_deleted() { needles.push((id, offset, size)); } Ok(()) }) { errs.push(format!("walk ECX file {}: {}", ecx_path, e)); } } Err(e) => errs.push(format!("open ECX file {}: {}", ecx_path, e)), } // reads for EC chunks can hit the same shard repeatedly, so dedupe broken shards let mut broken_shards: HashMap = HashMap::new(); for (id, offset, size) in needles { // Per-needle snapshot under the lock from the RAW .ecx (offset, size) so // logically-deleted needles are still verified; lock dropped before await. let snapshot = match scrub_snapshot_under_lock(state, vid, offset, size) { Ok(s) => s, // Volume unmounted mid-scan: abort with an error rather than skipping // every remaining needle, which would report a false-CLEAN result. Err(e) if e.kind() == io::ErrorKind::NotFound => { errs.push(format!("EC volume {} unmounted during scrub: {}", vid.0, e)); break; } Err(e) => { errs.push(format!("needle {} on EC volume {}: {}", id.0, vid.0, e)); continue; } }; // Read each interval local-then-remote. Neither read decodes: the point is to // find shards that are themselves broken, not to heal around them. READS then // rebuilds what it could not read. Locations refreshed above. let n_intervals = snapshot.intervals.len(); let mut data: Vec = Vec::with_capacity(snapshot.actual_size); for (i, res) in snapshot.intervals.iter().enumerate() { match res { IntervalResult::Local(buf) => data.extend_from_slice(buf), IntervalResult::NeedRemote { shard_id, shard_offset, size: ssize, } => { let sources: &[String] = locations.get(shard_id).map(Vec::as_slice).unwrap_or(&[]); match read_remote_ec_shard_interval( state, sources, vid, id, *shard_id, *shard_offset, *ssize, snapshot.encode_ts_ns, ) .await { // A deleted shard yields no bytes; zero-fill the interval so // the assembled needle reaches read_bytes -> SizeMismatch{0} // -> the delete-state suppression (mirrors Go's pre-zeroed buffer). Ok((_, true)) => data.resize(data.len() + *ssize, 0), Ok((buf, false)) => data.extend_from_slice(&buf), Err(read_err) => { // The shard is broken whether or not the needle survives it, // so report it either way. broken_shards.insert( *shard_id, crate::pb::volume_server_pb::EcShardInfo { shard_id: *shard_id as u32, size: *ssize as i64, collection: collection.clone(), volume_id: vid.0, ..Default::default() }, ); if !recover_unreadable { errs.push(format!( "failed to read EC shard {} for needle {} on volume {} (interval {}/{}): {}", shard_id, id.0, vid.0, i + 1, n_intervals, read_err )); break; } match recover_one_remote_ec_shard_interval( state, vid, id, *shard_id, *shard_offset, *ssize, &locations, data_shards, total_shards - data_shards, snapshot.encode_ts_ns, ) .await { // Same as the direct read above: a holder reporting the // needle deleted is authoritative and answers with no // bytes, so zero-fill and let the delete-state // suppression have it. Ok((_, true)) => data.resize(data.len() + *ssize, 0), Ok((buf, false)) => data.extend_from_slice(&buf), Err(e) => { errs.push(format!( "failed to recover EC shard {} for needle {} on volume {} (interval {}/{}): {}", shard_id, id.0, vid.0, i + 1, n_intervals, e )); break; } } } } } } } // Also fires when a chunk read broke out above (data is short). if data.len() != snapshot.actual_size { errs.push(format!( "expected {} bytes for needle {}, got {}", snapshot.actual_size, id.0, data.len() )); continue; } let mut n = Needle::default(); if let Err(e) = n.read_bytes(&data, 0, snapshot.size_for_parse, snapshot.version) { // A delete-state disagreement between the index and the reassembled // header (live index vs zero header size) is not corruption. let delete_state_disagrees = matches!( &e, NeedleError::SizeMismatch { found, .. } if snapshot.size_for_parse.is_deleted() != (found.0 == 0) ); if !delete_state_disagrees || force_deleted_needles_check { errs.push(format!("needle {} on EC volume {}: {}", id.0, vid.0, e)); } } } // Mirror Go CmpEcShardInfo: sort by (volume_id, shard_id). let mut broken: Vec = broken_shards.into_values().collect(); broken.sort_by(|a, b| a.volume_id.cmp(&b.volume_id).then(a.shard_id.cmp(&b.shard_id))); (count, broken, errs) } fn snapshot_under_lock( state: &Arc, vid: VolumeId, needle_id: NeedleId, ) -> io::Result> { let store = state.store.read().unwrap(); let ecv = match store.find_ec_volume(vid) { Some(v) => v, None => return Ok(None), }; // Reuse EcVolume::locate_needle for offset/size resolution AND // the per-needle shard-interval math — it's the same routine the // local-only read path uses, so we stay byte-identical on the // shard-size + interval boundaries. locate_needle applies the runtime // delete mask, which is correct for serving reads. let (offset, size, intervals) = match ecv.locate_needle(needle_id)? { Some(v) => v, None => return Ok(None), }; build_snapshot(ecv, offset, size, &intervals).map(Some) } /// Like `snapshot_under_lock`, but locates intervals from the RAW .ecx /// (offset, size) the FULL-scrub walk supplies — NOT `locate_needle`, which /// masks runtime-deleted needles. EC deletes are logical (the shard bytes stay /// until re-encode), so the scrub must still byte-verify them, matching Go's /// `Store.ScrubEcVolume` which walks the unmasked index. fn scrub_snapshot_under_lock( state: &Arc, vid: VolumeId, offset: Offset, size: Size, ) -> io::Result { let store = state.store.read().unwrap(); let ecv = match store.find_ec_volume(vid) { Some(v) => v, // Volume unmounted mid-scan: a distinct NotFound so the caller aborts // with an error rather than silently skipping (which would false-CLEAN). None => { return Err(io::Error::new( io::ErrorKind::NotFound, format!("EC volume {} not found (unmounted mid-scan)", vid.0), )) } }; let intervals = ecv.locate_ec_shard_needle_interval(offset.to_actual_offset(), size); if intervals.is_empty() { return Err(io::Error::new( io::ErrorKind::InvalidData, "no intervals for needle", )); } Ok(ScrubSnapshot { version: ecv.version, actual_size: get_actual_size(size, ecv.version) as usize, size_for_parse: size, intervals: read_local_intervals(ecv, &intervals), encode_ts_ns: ecv.encode_ts_ns, }) } /// Scalars + locally-read intervals for the FULL scrub. Unlike `Snapshot` it /// omits the shard-location cache: the scrub hoists the refreshed map once up /// front instead of cloning it per needle. struct ScrubSnapshot { version: Version, actual_size: usize, size_for_parse: Size, intervals: Vec, encode_ts_ns: i64, } /// Read any locally-held shard intervals, marking the rest `NeedRemote`. Shared /// by the read-path `build_snapshot` and the scrub snapshot. fn read_local_intervals( ecv: &crate::storage::erasure_coding::ec_volume::EcVolume, intervals: &[crate::storage::erasure_coding::ec_locate::Interval], ) -> Vec { let mut interval_results = Vec::with_capacity(intervals.len()); for interval in intervals { let (shard_id, shard_offset) = ecv.interval_to_shard_id_and_offset(interval); let buf_size = interval.size as usize; let local = ecv.shards.get(shard_id as usize).and_then(|s| s.as_ref()); match local { Some(shard) => { let mut buf = vec![0u8; buf_size]; match shard.read_at(&mut buf, shard_offset as u64) { Ok(n) if n == buf_size => interval_results.push(IntervalResult::Local(buf)), _ => interval_results.push(IntervalResult::NeedRemote { shard_id, shard_offset, size: buf_size, }), } } None => interval_results.push(IntervalResult::NeedRemote { shard_id, shard_offset, size: buf_size, }), } } interval_results } /// Read local intervals, then snapshot the scalars + shard-location cache so the /// read path can drop the store lock before awaiting remote reads. fn build_snapshot( ecv: &crate::storage::erasure_coding::ec_volume::EcVolume, offset: Offset, size: Size, intervals: &[crate::storage::erasure_coding::ec_locate::Interval], ) -> io::Result { if intervals.is_empty() { return Err(io::Error::new( io::ErrorKind::InvalidData, "no intervals for needle", )); } let actual = get_actual_size(size, ecv.version); let interval_results = read_local_intervals(ecv, intervals); let cached_locations = ecv.shard_locations.read().unwrap().clone(); let cache_refreshed_at = *ecv.shard_locations_refresh_time.lock().unwrap(); Ok(Snapshot { data_shards: ecv.data_shards, parity_shards: ecv.parity_shards, version: ecv.version, actual_size: actual as usize, offset, size_for_parse: size, intervals: interval_results, cached_locations, cache_refreshed_at, encode_ts_ns: ecv.encode_ts_ns, }) } /// Master `LookupEcVolume` freshness rules — match Go's /// `cachedLookupEcShardLocations` thresholds in store_ec.go. fn needs_refresh( locations: &HashMap>, refreshed_at: Option, stale: bool, data_shards: usize, total_shards: usize, ) -> bool { let now = Instant::now(); let age = match refreshed_at { Some(t) => now.saturating_duration_since(t), None => return true, }; let shard_count = locations.len(); // A complete map is trusted longest. One short of data_shards, or one a // failed read has just disproved, is re-checked promptly: until it is, reads // keep aiming at a location the shard has left. let ttl = if stale || shard_count < data_shards { Duration::from_secs(11) } else if shard_count == total_shards { Duration::from_secs(37 * 60) } else { Duration::from_secs(7 * 60) }; age >= ttl } /// Mark the cached shard map for a prompt re-check after a read failed against /// one of its locations. Go drops the entry outright in `forgetShardId`, which /// costs it the direct read until the map is re-learned; here the entry stays /// (a dead peer just fails fast on the next attempt) and only the freshness /// window is cut, so a shard that has moved is picked up in seconds either way. fn mark_shard_locations_stale(state: &Arc, vid: VolumeId) { let store = state.store.read().unwrap(); if let Some(ecv) = store.find_ec_volume(vid) { *ecv.shard_locations_stale.lock().unwrap() = true; } } /// Decide whether the cached map is due a master lookup and, when it is, consume /// its stale mark in the same critical section. A mark raised from here on /// belongs to the next refresh: the read that raised it has disproved the map /// this lookup is about to install. fn claim_shard_locations_refresh( state: &Arc, vid: VolumeId, locations: &HashMap>, refreshed_at: Option, data_shards: usize, total_shards: usize, ) -> bool { let store = state.store.read().unwrap(); let Some(ecv) = store.find_ec_volume(vid) else { return needs_refresh(locations, refreshed_at, false, data_shards, total_shards); }; let mut stale = ecv.shard_locations_stale.lock().unwrap(); let refresh = needs_refresh(locations, refreshed_at, *stale, data_shards, total_shards); if refresh { *stale = false; } refresh } async fn cached_lookup_ec_shard_locations( state: &Arc, vid: VolumeId, ) -> io::Result>> { let master = { let live = state.current_master_url.read().await.clone(); if !live.is_empty() { live } else { state.master_url.clone() } }; if master.is_empty() { return Err(io::Error::new( io::ErrorKind::Other, "no master configured for ec shard lookup", )); } let grpc_addr = parse_grpc_address(&master) .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; let endpoint = build_grpc_endpoint(&grpc_addr, state.outgoing_grpc_tls.as_ref()) .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; let channel = endpoint .connect_timeout(Duration::from_secs(5)) .timeout(Duration::from_secs(10)) .connect() .await .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("master connect: {}", e)))?; let mut client = SeaweedClient::with_interceptor(channel, outgoing_request_id_interceptor) .max_decoding_message_size(GRPC_MAX_MESSAGE_SIZE) .max_encoding_message_size(GRPC_MAX_MESSAGE_SIZE); let resp = client .lookup_ec_volume(Request::new(LookupEcVolumeRequest { volume_id: vid.0 })) .await .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("lookup_ec_volume: {}", e)))?; let resp = resp.into_inner(); let mut out = HashMap::new(); for entry in resp.shard_id_locations { let addrs: Vec = entry .locations .iter() .map(format_location_as_server_address) .collect(); out.insert(entry.shard_id as ShardId, addrs); } Ok(out) } /// Merge a fresh `LookupEcVolume` reply into the per-EcVolume shard-location /// cache. Returns the merged map on success, or `None` when the reply is /// incomplete (and was therefore NOT written) or the volume is gone. /// /// Completeness guard (mirrors Go's `cachedLookupEcShardLocations`): a reply /// carrying fewer than `data_shards` shard locations is a master-mid-recovery /// partial, not ground truth — Go aborts the lookup with a retryable error and /// leaves the cache + refresh time untouched. Returning `None` without writing /// does the same, so a previously-complete cache is never clobbered with a /// partial map. On a complete reply the write is a per-shard MERGE (see /// `merge_shard_locations`), not a full replace. fn write_back_shard_locations( state: &Arc, vid: VolumeId, locations: HashMap>, data_shards: usize, ) -> Option>> { if locations.len() < data_shards { return None; } let store = state.store.read().unwrap(); let ecv = store.find_ec_volume(vid)?; Some(ecv.merge_shard_locations(locations)) } /// Build a SeaweedFS-style `host:httpPort.grpcPort` address from a /// master `Location` so the result is what `parse_grpc_address` (and /// the heartbeat path) already understand. fn format_location_as_server_address(loc: &master_pb::Location) -> String { let raw = loc .url .trim_start_matches("http://") .trim_start_matches("https://"); if loc.grpc_port > 0 { if let Some((host, http_port)) = raw.rsplit_once(':') { return format!("{}:{}.{}", host, http_port, loc.grpc_port); } } raw.to_string() } /// Try direct peer read; on failure, reconstruct via Reed-Solomon /// from the other shards. Mirrors `readOneEcShardInterval`'s tail. async fn fetch_one_interval( state: &Arc, vid: VolumeId, needle_id: NeedleId, shard_id: ShardId, shard_offset: i64, size: usize, shard_locations: &HashMap>, data_shards: usize, parity_shards: usize, expected_encode_ts_ns: i64, ) -> io::Result<(Vec, bool)> { // Direct peer read against the cached locations for this shard. if let Some(sources) = shard_locations.get(&shard_id) { if !sources.is_empty() { match read_remote_ec_shard_interval( state, sources, vid, needle_id, shard_id, shard_offset, size, expected_encode_ts_ns, ) .await { // A deleted needle short-circuits: don't reconstruct (every shard // would report deleted), let the caller return "deleted". Ok((buf, is_deleted)) => return Ok((buf, is_deleted)), Err(e) => { tracing::debug!( "direct read ec shard {}.{} from {:?} failed: {} — will reconstruct", vid.0, shard_id, sources, e ); // Reconstruction below skips this very shard, so nothing else // invalidates the location that just failed. mark_shard_locations_stale(state, vid); } } } } // Reconstruct: fan-out reads to every other shard at the same // (shard_offset, size). Mirrors `recoverOneRemoteEcShardInterval`. recover_one_remote_ec_shard_interval( state, vid, needle_id, shard_id, shard_offset, size, shard_locations, data_shards, parity_shards, expected_encode_ts_ns, ) .await } async fn read_remote_ec_shard_interval( state: &Arc, sources: &[String], vid: VolumeId, needle_id: NeedleId, shard_id: ShardId, shard_offset: i64, size: usize, expected_encode_ts_ns: i64, ) -> io::Result<(Vec, bool)> { let mut last_err: Option = None; for src in sources { match do_read_remote_ec_shard_interval( state, src, vid, needle_id, shard_id, shard_offset, size, expected_encode_ts_ns, ) .await { Ok(res) => return Ok(res), Err(e) => last_err = Some(e), } } Err(last_err.unwrap_or_else(|| { io::Error::new( io::ErrorKind::NotFound, format!("no source for ec shard {}.{}", vid.0, shard_id), ) })) } async fn do_read_remote_ec_shard_interval( state: &Arc, source: &str, vid: VolumeId, needle_id: NeedleId, shard_id: ShardId, shard_offset: i64, size: usize, expected_encode_ts_ns: i64, ) -> io::Result<(Vec, bool)> { let grpc_addr = parse_grpc_address(source).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; let endpoint = build_grpc_endpoint(&grpc_addr, state.outgoing_grpc_tls.as_ref()) .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; let channel = endpoint .connect_timeout(Duration::from_secs(5)) .timeout(Duration::from_secs(30)) .connect() .await .map_err(|e| { io::Error::new( io::ErrorKind::Other, format!("connect to {}: {}", source, e), ) })?; // TODO(grpc-jwt): clusters with `jwt.signing.key` configured will // reject peer-to-peer VolumeEcShardRead calls until the Rust // crate grows an outgoing-JWT interceptor. The gap is shared // with every other peer gRPC call from this binary // (`copy_file_from_source`, `batch_delete`, …) — handling it // here in isolation would split the credential plumbing across // call sites. Re-visit when outgoing JWT signing lands as a // server-wide helper. let mut client = VolumeServerClient::with_interceptor(channel, outgoing_request_id_interceptor) .max_decoding_message_size(GRPC_MAX_MESSAGE_SIZE) .max_encoding_message_size(GRPC_MAX_MESSAGE_SIZE); let req = VolumeEcShardReadRequest { volume_id: vid.0, shard_id: shard_id as u32, offset: shard_offset, size: size as i64, file_key: needle_id.0, encode_ts_ns: expected_encode_ts_ns, }; let resp = client .volume_ec_shard_read(Request::new(req)) .await .map_err(|e| { io::Error::new( io::ErrorKind::Other, format!("volume_ec_shard_read {}.{} from {}: {}", vid.0, shard_id, source, e), ) })?; let mut stream = resp.into_inner(); let mut out = Vec::with_capacity(size); let mut is_deleted = false; while let Some(msg) = stream .message() .await .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("recv: {}", e)))? { // Validate the served shard's identity client-side, so the guard holds even // against a pre-upgrade server that ignored the request field (returns 0). // A mismatch fails the read; the caller recovers from parity. if expected_encode_ts_ns != 0 && msg.encode_ts_ns != expected_encode_ts_ns { return Err(io::Error::new( io::ErrorKind::Other, format!( "ec shard {}.{} from {} belongs to a different encode run (want {} got {})", vid.0, shard_id, source, expected_encode_ts_ns, msg.encode_ts_ns ), )); } if msg.is_deleted { is_deleted = true; } if !msg.data.is_empty() { out.extend_from_slice(&msg.data); } } // A runtime EC delete keeps the .ecx size positive; the holder masks the delete // at read time and answers is_deleted with no payload. Signal the deletion to // the caller (Go's `(bytes, is_deleted)` contract) instead of synthesizing bytes // here: the scrub zero-fills the interval (so the assembled needle hits read_bytes // -> SizeMismatch{found:0} -> suppression), the serving direct read short-circuits // to "deleted", and reconstruction EXCLUDES the shard rather than feeding zeros // into Reed-Solomon. Exempt from the short-read guard below. if is_deleted { return Ok((Vec::new(), true)); } if out.len() < size { return Err(io::Error::new( io::ErrorKind::UnexpectedEof, format!( "short read from {} for ec shard {}.{}: got {} want {}", source, vid.0, shard_id, out.len(), size ), )); } out.truncate(size); Ok((out, false)) } async fn recover_one_remote_ec_shard_interval( state: &Arc, vid: VolumeId, needle_id: NeedleId, shard_id_to_recover: ShardId, shard_offset: i64, size: usize, shard_locations: &HashMap>, data_shards: usize, parity_shards: usize, expected_encode_ts_ns: i64, ) -> io::Result<(Vec, bool)> { let total_shards = data_shards + parity_shards; let rs = ReedSolomon::new(data_shards, parity_shards).map_err(|e| { io::Error::new( io::ErrorKind::Other, format!("reed-solomon init: {:?}", e), ) })?; // Charge the buffers this recovery is about to hold against the budget, so a // burst of them queues here rather than on the heap. An interval whose // fan-out outgrows the whole budget takes all of it and so runs alone, // rather than waiting on permits that can never be granted. let _permit = EC_RECOVER_SEM .acquire_many((size * data_shards).min(EC_RECOVER_BUDGET) as u32) .await .map_err(|e| { io::Error::new( io::ErrorKind::Other, format!( "ec recover budget for shard {}.{}: {}", vid.0, shard_id_to_recover, e ), ) })?; let mut bufs: Vec>> = vec![None; total_shards]; // Phase 0: seed bufs from LOCALLY mounted shards. If this node // already holds enough sibling shards, reconstruction completes // without any peer fan-out — and even with a cold/incomplete // shard_locations cache or a failed master lookup, local // survivors still contribute. let mut available = 0usize; { let store = state.store.read().unwrap(); for sid in 0..total_shards { if available >= data_shards { break; } if sid as ShardId == shard_id_to_recover { continue; } // Resolve the shard together with the EcVolume on the disk that owns // it: a reconciled volume has its shards split across data dirs. A // shard from a different encode run must not be fed to Reed-Solomon; // lenient only when the caller carries no identity (pre-upgrade). // Mirrors Go's `readLocalEcShardInterval`. let owner = match store.find_ec_volume_with_shard(vid, sid as u32) { Some(ecv) if expected_encode_ts_ns == 0 || ecv.encode_ts_ns == expected_encode_ts_ns => ecv, _ => continue, }; if let Some(Some(shard)) = owner.shards.get(sid) { let mut buf = vec![0u8; size]; if shard.read_at(&mut buf, shard_offset as u64).map(|n| n == size).unwrap_or(false) { bufs[sid] = Some(buf); available += 1; } } } } // Phase 1: remote fan-out over the shard locations we DON'T already have // locally and DON'T need to recover. Reconstruction consumes data_shards // shards, so reading every remaining one holds a third more buffers than // that and asks a third more of peers that may already be struggling: fetch // what is still missing, and widen only if some of those reads fail. let mut candidates: Vec<(ShardId, Vec)> = shard_locations .iter() .filter(|(sid, locs)| { **sid != shard_id_to_recover && (**sid as usize) < total_shards && !locs.is_empty() && bufs[**sid as usize].is_none() }) .map(|(sid, locs)| (*sid, locs.clone())) .collect(); let mut any_deleted = false; while available < data_shards && !candidates.is_empty() { let rest = candidates.split_off((data_shards - available).min(candidates.len())); let wave = std::mem::replace(&mut candidates, rest); let results = join_all(wave.into_iter().map(|(sid, locs)| { let state = state.clone(); async move { let res = read_remote_ec_shard_interval( &state, &locs, vid, needle_id, sid, shard_offset, size, expected_encode_ts_ns, ) .await; (sid, res) } })) .await; for (sid, res) in results { match res { // Exclude a deleted shard from reconstruction (Go gates on a full // read): feeding the empty/zero buffer into Reed-Solomon would // corrupt the recovered shard. Ok((buf, is_deleted)) => { if is_deleted { any_deleted = true; continue; } bufs[sid as usize] = Some(buf); available += 1; } Err(e) => { tracing::debug!( "recover: read {}.{} for needle {} failed: {}", vid.0, sid, needle_id, e ); mark_shard_locations_stale(state, vid); } } } if any_deleted { // every shard of a deleted needle answers deleted, so another wave cannot help break; } } if available < data_shards { // A holder reporting the needle deleted is authoritative -- deletes are // never invented and never undone -- so answer that rather than the // failure to gather shards of a needle that is gone. if any_deleted { return Ok((Vec::new(), true)); } return Err(io::Error::new( io::ErrorKind::Other, format!( "cannot recover ec shard {}.{}: only {} shards available, need at least {}", vid.0, shard_id_to_recover, available, data_shards ), )); } rs.reconstruct(&mut bufs).map_err(|e| { io::Error::new( io::ErrorKind::Other, format!( "reed-solomon reconstruct ec shard {}.{}: {:?}", vid.0, shard_id_to_recover, e ), ) })?; match bufs.into_iter().nth(shard_id_to_recover as usize).flatten() { Some(buf) => Ok((buf, any_deleted)), None => Err(io::Error::new( io::ErrorKind::Other, format!( "reconstructed buffer for shard {}.{} missing after RS reconstruct", vid.0, shard_id_to_recover ), )), } } // parse_grpc_address lives in `grpc_client.rs` and is re-exported // here via the use above so this module shares a single // HTTP↔gRPC port-translation routine with grpc_server.rs. // --------------------------------------------------------------------------- // Missing-index recovery (issue #10104). // // Mirrors weed/server/volume_grpc_erasure_coding_recover.go. EC shards whose // .ecx index lives only on a peer server cannot be mounted locally, so the // master never learns about them. recover_missing_ec_indexes fetches the index // from a peer and mounts the on-disk shards. Driven on demand by // VolumeEcShardsMount(recover_missing_index), so an operator triggers it through // ec.rebuild rather than a background loop. // --------------------------------------------------------------------------- /// Recover EC volumes whose shards sit on this server while the index lives only /// on a peer. `filter_vid` 0 recovers every orphan on this server (including /// volumes the master never registered); otherwise just that volume. Returns the /// number of volumes whose index was recovered. pub(crate) async fn recover_missing_ec_indexes( state: &Arc, filter_vid: u32, ) -> usize { let missing: Vec = { let store = state.store.read().unwrap(); store .collect_ec_volumes_missing_index() .into_iter() .filter(|m| filter_vid == 0 || m.vid.0 == filter_vid) .collect() }; if missing.is_empty() { return 0; } let self_http = to_http_address(&state.self_url).into_owned(); let mut recovered = 0usize; for m in &missing { let peers = match cached_lookup_ec_shard_locations(state, m.vid).await { Ok(map) => { let mut peers: Vec = Vec::new(); for addrs in map.values() { for a in addrs { if to_http_address(a).as_ref() == self_http.as_str() { continue; } if !peers.contains(a) { peers.push(a.clone()); } } } peers } Err(e) => { tracing::warn!( volume_id = m.vid.0, "cannot look up peers to recover missing .ecx: {}", e ); continue; } }; if peers.is_empty() { tracing::warn!( volume_id = m.vid.0, "shards present locally but .ecx missing and no peer holds it; leaving shards unloaded" ); continue; } if fetch_ec_index_from_peers(state, m, &peers).await { recovered += 1; } } if recovered > 0 { state.store.write().unwrap().mount_recovered_ec_shards(); tracing::info!( "recovered missing EC index for {} volume(s) from peers and mounted their shards", recovered ); } recovered } /// Try each peer in turn, copying the `.ecx` (required) and `.ecj` / `.vif` /// (best-effort) into m's local dirs. The `.ecx` is an immutable encode-time /// index, identical on every holder, so any peer's copy serves. The `.ecj` is a /// per-holder deletion journal that differs across holders; the recovered node /// adopts the source peer's deletion view, like a balanced or rebuilt shard. The /// first peer with a non-empty `.ecx` wins. async fn fetch_ec_index_from_peers( state: &Arc, m: &EcVolumeMissingIndex, peers: &[String], ) -> bool { let idx_base = volume_file_name(&m.idx_dir, &m.collection, m.vid); let data_base = volume_file_name(&m.data_dir, &m.collection, m.vid); let ecx_path = format!("{}.ecx", idx_base); let ecj_path = format!("{}.ecj", idx_base); let vif_path = format!("{}.vif", data_base); for peer in peers { match fetch_ec_index_from_one_peer(state, m, peer, &ecx_path, &ecj_path, &vif_path).await { Ok(()) => { tracing::info!( volume_id = m.vid.0, peer = %peer, "fetched missing .ecx into {}", m.idx_dir ); return true; } Err(e) => { // Remove any partial .ecx so a later attempt is not blocked by a stub. let _ = fs::remove_file(&ecx_path); tracing::debug!( volume_id = m.vid.0, peer = %peer, "fetch missing .ecx failed: {}", e ); } } } false } async fn fetch_ec_index_from_one_peer( state: &Arc, m: &EcVolumeMissingIndex, peer: &str, ecx_path: &str, ecj_path: &str, vif_path: &str, ) -> io::Result<()> { let grpc_addr = parse_grpc_address(peer).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; let channel = build_grpc_endpoint(&grpc_addr, state.outgoing_grpc_tls.as_ref()) .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))? .connect_timeout(Duration::from_secs(5)) .timeout(Duration::from_secs(30)) .connect() .await .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("connect {}: {}", peer, e)))?; let mut client = VolumeServerClient::with_interceptor(channel, outgoing_request_id_interceptor) .max_decoding_message_size(GRPC_MAX_MESSAGE_SIZE) .max_encoding_message_size(GRPC_MAX_MESSAGE_SIZE); let copy_req = |ext: &str, ignore_not_found: bool| CopyFileRequest { volume_id: m.vid.0, collection: m.collection.clone(), is_ec_volume: true, ext: ext.to_string(), compaction_revision: u32::MAX, stop_offset: i64::MAX as u64, ignore_source_file_not_found: ignore_not_found, ..Default::default() }; // .ecx is mandatory and written in place (create/truncate); a peer without it // errors and the caller moves on. let stream = client .copy_file(copy_req(".ecx", false)) .await .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("copy .ecx: {}", e)))? .into_inner(); drain_copy_stream(stream, ecx_path, false).await?; let meta = fs::metadata(ecx_path) .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("stat copied .ecx: {}", e)))?; if meta.is_dir() || meta.len() == 0 { let _ = fs::remove_file(ecx_path); return Err(io::Error::new( io::ErrorKind::Other, format!("peer {} served an unusable .ecx (size {})", peer, meta.len()), )); } // .ecj is the source peer's deletion journal (appended); .vif carries EC // params. Both are best-effort: a missing .ecj is recreated at mount and a // missing .vif falls back to default EC parameters. A failed .ecj append // leaves a partial file, so drop it. match client.copy_file(copy_req(".ecj", true)).await { Ok(resp) => { if let Err(e) = drain_copy_stream(resp.into_inner(), ecj_path, true).await { tracing::warn!(volume_id = m.vid.0, peer = %peer, "copy .ecj: {}", e); let _ = fs::remove_file(ecj_path); } } Err(e) => tracing::warn!(volume_id = m.vid.0, peer = %peer, "copy .ecj: {}", e), } match client.copy_file(copy_req(".vif", true)).await { Ok(resp) => { if let Err(e) = drain_copy_stream(resp.into_inner(), vif_path, false).await { tracing::warn!(volume_id = m.vid.0, peer = %peer, "copy .vif: {}", e); } } Err(e) => tracing::warn!(volume_id = m.vid.0, peer = %peer, "copy .vif: {}", e), } Ok(()) } /// Drain a CopyFile stream into a local file, appending or truncating. async fn drain_copy_stream( mut stream: tonic::Streaming, dest_path: &str, append: bool, ) -> io::Result<()> { use std::io::Write; let mut file = if append { fs::OpenOptions::new().create(true).append(true).open(dest_path) } else { fs::File::create(dest_path) } .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("create {}: {}", dest_path, e)))?; while let Some(chunk) = stream .message() .await .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("recv {}: {}", dest_path, e)))? { file.write_all(&chunk.file_content) .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("write {}: {}", dest_path, e)))?; } Ok(()) } #[cfg(test)] mod tests { use super::*; fn locations(count: usize) -> HashMap> { (0..count) .map(|sid| (sid as ShardId, vec!["127.0.0.1:8080".to_string()])) .collect() } #[test] fn needs_refresh_re_checks_a_map_a_failed_read_disproved() { let just_now = Some(Instant::now()); let aged = Some(Instant::now() - Duration::from_secs(12)); // A complete map is trusted for a long time, and one shard short still // outlasts a 12-second gap. assert!(!needs_refresh(&locations(14), aged, false, 10, 14)); assert!(!needs_refresh(&locations(13), aged, false, 10, 14)); // Disproved by a read, the same maps are re-checked within seconds. assert!(needs_refresh(&locations(14), aged, true, 10, 14)); assert!(needs_refresh(&locations(13), aged, true, 10, 14)); // But the mark buys one prompt re-check, not a lookup per read. assert!(!needs_refresh(&locations(14), just_now, true, 10, 14)); // A map short of the data shards is re-checked promptly regardless. assert!(needs_refresh(&locations(9), aged, false, 10, 14)); // An unrefreshed cache always looks up. assert!(needs_refresh(&locations(0), None, false, 10, 14)); } }