mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-04 02:57:44 +01:00
* volume server: ec.decode reads the .ecx from the index dir it was copied to VolumeEcShardsCopy writes the .ecx/.ecj into the receiver's -dir.idx, so with a split data/index dir the decode target has no .ecx beside its shards. VolumeEcShardsToVolume sized the .dat from the right .ecx but built the .idx from the data dir, failing with NotFound after the .dat was already published. It now reads .ecx/.ecj from where the EC volume opened them and writes the .idx beside the .dat, where Go leaves it. The live-entry check and the .dat size also ignored deletions recorded only in the .ecj, which Go folds into the .ecx (RebuildEcxFile) first: a fully deleted volume was decoded instead of reported as having no live entries, and deleted tail needles were copied into the .dat. Both now treat journaled ids as deleted, without rewriting the sealed .ecx. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * volume server: ec.decode keeps the decoded volume writable and reads every .ecj The rebuilt .idx copied a journaled tail needle's .ecx row verbatim after the .dat was cut short before it, so the mount saw a row past EOF and marked the decoded volume read-only. Rows of deleted needles the .dat no longer holds are now dropped, and each journaled needle still in the .dat gets one tombstone instead of one per journal entry. VolumeEcShardsCopy appends journals collected from other holders into the idx dir, but the decode read only the .ecj beside the .ecx, which sits in the data dir when this server generated the shards. It now reads both, once, in bounded chunks via the loader EcVolume uses. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * volume server: test ec.decode drops a sealed .ecx tail tombstone Covers the other half of the rule added in the previous commit: a tail needle tombstoned in the .ecx itself (Go's RebuildEcxFile) is cut from the .dat, and its row must not reach the rebuilt .idx either. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * volume server: ec.decode runs its file I/O off the async runtime VolumeEcShardsToVolume released the store lock before decoding, but read the .ecx/.ecj, rebuilt the .dat and wrote the .idx inside the async handler, parking a runtime worker for the length of a volume-sized copy. The decode now runs in spawn_blocking on inputs snapshotted under the store lock. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * volume server: ec.decode checks the rebuilt .dat is complete Go stats the decoded .dat before writing the .idx (VerifyDecodedDatFile) and fails the decode when it is shorter than the extent the EC index references, since the caller deletes the shards once the call returns. The Rust handler returned success without that check. The rebuild already fails on a short shard read, so this guards the published file itself. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * volume server: ec.decode drops the decoded volume's bitrot sidecars Go removes <base>.ecsum and <base>.ecsum.v<N> beside the .dat and beside the .ecx once the .idx is written, so a stale checksum sidecar cannot pass for the protection of a later re-encode. The Rust handler left them in place. Removal is best effort, as in Go. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * volume server: ec.decode compacts the decoded volume Go ends VolumeEcShardsToVolume with an offline CompactVolumeFiles, so the decoded volume holds only live needles. The Rust decode left every needle deleted through the .ecj in the .dat, tombstoned in the .idx, until a later vacuum reclaimed it. Store::compact_volume_files loads the unmounted volume, checks free space the way the vacuum does (the estimate now lives in one helper), and runs the vacuum's compact-by-index and commit. As in Go a failed compaction is logged and the decode still succeeds, so the uncompacted .idx rules stay: the tests that pin them now make the compaction fail. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * volume server: ec.decode keeps deletes journaled while the .dat is written The decode read the .ecj journals once, before rebuilding the .dat, so a delete that reached the EC volume during the rebuild was left out of the new .idx and the needle came back live. Each journal's read length is now kept, and the bytes appended since are read just before the .idx is written, after waiting out any journal append in flight (appends hold the store write lock), so every delete acknowledged by then is in the .idx. A delete after that point is still lost, as in Go. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Guard overlapping ec decode requests; serialize journal catch-up volume_ec_shards_to_volume runs its decode in spawn_blocking, so a dropped request leaves the job running and a retry would race it on the temporary and final volume files. Claim the vid in a per-server in-flight set until the blocking job finishes, and return Unavailable to an overlapping request. The Go handler has the same exposure and gets the same guard. Journal appends hold the store write lock through their sync-or-truncate, so holding a read lock across the catch-up read guarantees every record it sees is committed: a rolled-back delete can no longer leave a tombstone in the decoded index. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * Reconcile the swap when offline compaction commit fails A CommitCompact that fails after the .cpc marker may have renamed .dat but not .idx. cleanup_compact refuses while the marker exists, so the mismatched pair survived until a restart reconciled it — and the decode caller treats the failure as non-fatal. Run reconcileCompactState on commit failure so a decided swap rolls forward and orphan temps are removed before the volume can mount. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * Release the decode claim on panic * volume: add ec_decodes_in_flight to the integration-test state literal * volume server: hold the decode tail's lock through compaction The catch_up read released before the rebuilt .idx was written and the volume compacted, so a delete synced to .ecj in that window was durably journaled yet absent from the published index — resurrecting the needle. Rust now holds the store read lock from catch_up through compact, and Go mirrors it by holding the volume's journal lock from the journal- consuming index write through CompactVolumeFiles. * volume server: serialize ec decode's tail per volume, not per store Review follow-ups on the decode path: - Rust: holding the store read lock from journal catch-up through the offline compaction stalled every writer on unrelated volumes for the whole rewrite. The new ec_decode_tail set marks the vid only while its .idx is published and .cpd/.cpx swapped; the two local .ecj append paths (VolumeEcBlobDelete, the distributed delete's local journal) wait on a Notify for that span — Go's per-volume ecjFileAccessLock semantics without the global stall. VolumeMount and the staged-adopt path are also held off while a decode claim is in flight so neither can race the swap. - Rust: the initial journal read ran unlocked, so bytes a rolled-back append later truncated could be folded in as phantom tombstones. The first pass stays unlocked (a slow journal must not stall the store) and a rescan under the quiescing read lock re-reads only committed content; catch_up now rebuilds the id set when a regular journal shrank. - Go: the decode resolved the compaction DiskLocation through FindEcVolume while holding the journal lock, inverting DestroyEcVolume's map->journal order into a deadlock. The lookup now happens first, and DestroyEcVolume/deleteEcVolumeById/DiskLocation.Close destroy outside the map lock. - Go: RebuildEcxFile unlinks .ecj while the volume's ecjFile handle stays open, so later deletes could commit to a detached inode. Both call sites now fold under the journal lock and ReopenDeletionJournal repoints the handle at the live path, working on the volume's resolved .ecx dir (EcIndexBaseFileName) rather than the configured index dir. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * volume server: fence EC remounts behind the destroy tombstone DestroyEcVolume, deleteEcVolumeById, and the collection-delete sweep now remove the EcVolume from ecVolumes before destroying it off-lock, so a concurrent remount could re-open shard files that the in-flight destroy then unlinks — registering a detached fd. Each destroy records a per-vid tombstone channel in a new ecVolumesDestroying map before dropping the map entry and closes it when Destroy returns. The tombstone intentionally survives as the vid's destroy generation: loadEcShardWithIdxDir compares it before and after opening the shard, so a destroy that both started and finished inside the open window is still detected. A mismatch drops the just-opened shard (releasing its fd and mount gauge) and retries after the destroy completes; a successful mount clears the stale tombstone. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * volume server: rescan the .ecj under the store lock only after a rollback The decode's second journal pass ran a full rescan under the store read lock on every decode, stalling unrelated writers for the length of the scan. Bump a process-wide epoch whenever a failed append truncates its uncommitted tail; an unchanged epoch between the unlocked read and the quiesced pass proves every id folded in was committed, so catch_up() suffices. catch_up() also treats a journal that was read but has since disappeared as shrunk to zero, so its earlier ids cannot linger. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * volume server: check the decode tail under the store write lock on delete A blob delete waited for the publishing tail before taking the store write lock, so a decode that claimed the tail while the delete was parked behind the decoder's read lock could still see the journal append land after the rebuilt .idx — an acknowledged delete the mount would miss. Test tail membership under the write lock instead, retrying after the wait; journal_delete_local reports WouldBlock for the same recheck on the distributed path. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * volume server: claim the vid for mount and staged adoption, per volume VolumeMount and the staged .copying adoption held the ec_decodes_in_flight set lock through slow file renames and mounts, stalling every unrelated volume's decode, mount, and adoption. Take the per-volume claim instead — the same exclusion against a racing decode for this vid, released when the call returns. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * volume server: fail the decode when a compaction commit marker survives CompactVolumeFiles' caller logged a compaction error and went on to delete the EC shards. When the commit marker (.cpc) is still on disk the .dat/.idx swap was decided but could not be reconciled, so the mounted pair may be mismatched — report the failure instead so the shards are kept and the caller can retry. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * volume server: gate the parked-delete test on the held write lock The releaser thread and the spawned delete raced for the store write lock; on a slow runner the delete could acquire it first and commit before the tail was ever claimed, failing !delete.is_finished() on the Windows unit-test job. Spawn the delete only after the thread reports the lock held. --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Chris Lu <chris.lu@gmail.com> Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com> Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1894 lines
73 KiB
Rust
1894 lines
73 KiB
Rust
//! 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, HashSet};
|
|
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, LookupEcVolumeRequest};
|
|
use crate::pb::volume_server_pb::{
|
|
CopyFileRequest, VolumeEcBlobDeleteRequest, VolumeEcShardReadRequest,
|
|
};
|
|
use crate::server::grpc_client::{
|
|
GrpcDialOptions, connect_channel, master_client, parse_grpc_address, volume_server_client,
|
|
};
|
|
use crate::server::volume_server::{VolumeServerState, to_http_address};
|
|
use crate::storage::erasure_coding::ec_shard::{ShardId, shard_id_try_from};
|
|
use crate::storage::needle::needle::{Needle, NeedleError, get_actual_size};
|
|
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<u8>),
|
|
/// 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<IntervalResult>,
|
|
cached_locations: HashMap<ShardId, Vec<String>>,
|
|
cache_refreshed_at: Option<Instant>,
|
|
/// 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,
|
|
}
|
|
|
|
/// Why a distributed EC read has no needle to return.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum EcMiss {
|
|
NotFound,
|
|
/// Tombstoned in the local `.ecx`/`.ecj`, or reported deleted by a peer.
|
|
Deleted,
|
|
VolumeNotFound,
|
|
}
|
|
|
|
/// Top-level entry point. Returns `Ok(None)` for any miss — absent,
|
|
/// deleted, or volume gone; errors propagate as `io::Error`.
|
|
pub async fn read_ec_shard_needle_distributed(
|
|
state: &Arc<VolumeServerState>,
|
|
vid: VolumeId,
|
|
needle_id: NeedleId,
|
|
) -> io::Result<Option<Needle>> {
|
|
Ok(read_ec_shard_needle_or_miss(state, vid, needle_id)
|
|
.await?
|
|
.ok())
|
|
}
|
|
|
|
/// Like `read_ec_shard_needle_distributed`, but says why there is no needle,
|
|
/// as Go's `ReadEcShardNeedle` tells `ErrorDeleted` from not-found.
|
|
pub async fn read_ec_shard_needle_or_miss(
|
|
state: &Arc<VolumeServerState>,
|
|
vid: VolumeId,
|
|
needle_id: NeedleId,
|
|
) -> io::Result<Result<Needle, EcMiss>> {
|
|
// 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)? {
|
|
Ok(s) => s,
|
|
Err(miss) => return Ok(Err(miss)),
|
|
};
|
|
|
|
// 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,
|
|
snapshot.encode_ts_ns,
|
|
) {
|
|
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<io::Result<(Vec<u8>, 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,
|
|
EcInterval {
|
|
vid,
|
|
needle_id,
|
|
shard_id,
|
|
shard_offset,
|
|
size,
|
|
expected_encode_ts_ns: encode_ts_ns,
|
|
},
|
|
EcShardMap {
|
|
locations: shard_locations,
|
|
data_shards,
|
|
parity_shards,
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
}
|
|
}
|
|
}))
|
|
.buffered(INTERVAL_READ_CONCURRENCY)
|
|
.collect()
|
|
.await;
|
|
|
|
// A peer reports the needle deleted (a cross-server window where the
|
|
// local index still shows it live): answer deleted rather than serving zeros.
|
|
let Some(assembled) = gather_intervals(fetched)? else {
|
|
return Ok(Err(EcMiss::Deleted));
|
|
};
|
|
|
|
// 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 {
|
|
id: needle_id,
|
|
..Needle::default()
|
|
};
|
|
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(Ok(n))
|
|
}
|
|
|
|
/// `None` when any holder reported the needle deleted. That outranks another
|
|
/// interval's error: deletes are never invented, so the needle is gone either way.
|
|
fn gather_intervals(fetched: Vec<io::Result<(Vec<u8>, bool)>>) -> io::Result<Option<Vec<Vec<u8>>>> {
|
|
if fetched.iter().any(|r| matches!(r, Ok((_, true)))) {
|
|
return Ok(None);
|
|
}
|
|
fetched
|
|
.into_iter()
|
|
.map(|r| r.map(|(buf, _)| buf))
|
|
.collect::<io::Result<_>>()
|
|
.map(Some)
|
|
}
|
|
|
|
/// What one EC delete RPC carries — `VolumeEcBlobDeleteRequest` minus tonic.
|
|
struct EcDeleteTarget<'a> {
|
|
vid: VolumeId,
|
|
collection: &'a str,
|
|
version: Version,
|
|
needle_id: NeedleId,
|
|
}
|
|
|
|
/// `Store.doDeleteNeedleFromAtLeastOneRemoteEcShards` in Go: journal the
|
|
/// tombstone on one holder of the needle's primary data shard, falling back
|
|
/// to any other shard holder when the primary has none. Exactly one node
|
|
/// journals — replicas of a shard hold identical .ecx copies, so journaling
|
|
/// on more than one would double the reported delete count.
|
|
///
|
|
/// `NotFound` means the volume or needle is gone; other errors mean every
|
|
/// reachable holder failed or no shard has a holder at all.
|
|
pub async fn delete_ec_shard_needle_distributed(
|
|
state: &Arc<VolumeServerState>,
|
|
vid: VolumeId,
|
|
needle_id: NeedleId,
|
|
) -> io::Result<()> {
|
|
let (
|
|
primary_shard_id,
|
|
collection,
|
|
version,
|
|
total_shards,
|
|
local_shards,
|
|
data_shards,
|
|
encode_ts_ns,
|
|
refreshed_at,
|
|
cached_locations,
|
|
) = {
|
|
let store = state.store.read().unwrap();
|
|
let ecv = store.find_ec_volume(vid).ok_or_else(|| {
|
|
io::Error::new(
|
|
io::ErrorKind::NotFound,
|
|
format!("ec volume {} not mounted", vid.0),
|
|
)
|
|
})?;
|
|
let (_, _, intervals) = ecv.locate_needle(needle_id)?.ok_or_else(|| {
|
|
io::Error::new(
|
|
io::ErrorKind::NotFound,
|
|
format!("needle {} not in ec volume {}", needle_id, vid.0),
|
|
)
|
|
})?;
|
|
let (shard_id, _) = intervals
|
|
.first()
|
|
.map(|i| ecv.interval_to_shard_id_and_offset(i))
|
|
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no intervals for needle"))?;
|
|
let (cached_locations, refreshed_at) = ecv.shard_locations_snapshot();
|
|
(
|
|
shard_id,
|
|
ecv.collection.clone(),
|
|
ecv.version,
|
|
ecv.data_shards + ecv.parity_shards,
|
|
local_shard_ids(ecv),
|
|
ecv.data_shards as usize,
|
|
ecv.encode_ts_ns,
|
|
refreshed_at,
|
|
cached_locations,
|
|
)
|
|
};
|
|
let target = EcDeleteTarget {
|
|
vid,
|
|
collection: &collection,
|
|
version,
|
|
needle_id,
|
|
};
|
|
|
|
// Holder addresses come from the same staleness-gated cache as the read
|
|
// path: a master LookupEcVolume only when due, merged on a complete reply.
|
|
let mut locations = cached_locations;
|
|
if claim_shard_locations_refresh(
|
|
state,
|
|
vid,
|
|
&locations,
|
|
refreshed_at,
|
|
data_shards,
|
|
total_shards as usize,
|
|
) {
|
|
match cached_lookup_ec_shard_locations(state, vid).await {
|
|
Ok(fresh) => {
|
|
match write_back_shard_locations(state, vid, fresh, data_shards, encode_ts_ns) {
|
|
Some(merged) => locations = merged,
|
|
None => mark_shard_locations_stale(state, vid),
|
|
}
|
|
}
|
|
Err(_) => mark_shard_locations_stale(state, vid),
|
|
}
|
|
}
|
|
|
|
match delete_on_ec_shard_holders(state, &locations, &local_shards, primary_shard_id, &target)
|
|
.await
|
|
{
|
|
Ok(true) => return Ok(()),
|
|
Err(e) => return Err(e),
|
|
Ok(false) => {}
|
|
}
|
|
|
|
for shard_id in 0..total_shards {
|
|
let Ok(shard_id) = shard_id_try_from(shard_id) else {
|
|
continue;
|
|
};
|
|
if shard_id == primary_shard_id {
|
|
continue;
|
|
}
|
|
if let Ok(true) =
|
|
delete_on_ec_shard_holders(state, &locations, &local_shards, shard_id, &target).await
|
|
{
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
Err(io::Error::other(format!(
|
|
"ec volume {}: no shard holder could journal the delete",
|
|
vid.0
|
|
)))
|
|
}
|
|
|
|
/// `doDeleteNeedleFromRemoteEcShardServers` in Go. `Ok(false)` is the
|
|
/// shard-missing signal — no live holder anywhere — that triggers the
|
|
/// caller's fallback walk over the remaining shards.
|
|
async fn delete_on_ec_shard_holders(
|
|
state: &Arc<VolumeServerState>,
|
|
locations: &HashMap<ShardId, Vec<String>>,
|
|
local_shards: &HashSet<ShardId>,
|
|
shard_id: ShardId,
|
|
target: &EcDeleteTarget<'_>,
|
|
) -> io::Result<bool> {
|
|
let addrs = locations.get(&shard_id);
|
|
if !local_shards.contains(&shard_id) && addrs.is_none_or(|a| a.is_empty()) {
|
|
return Ok(false);
|
|
}
|
|
|
|
let mut last_err = None;
|
|
if local_shards.contains(&shard_id) {
|
|
// A decode in its publishing tail must not miss this delete. The
|
|
// tail membership is verified again under the store write lock
|
|
// inside journal_delete_local — WouldBlock means the decode claimed
|
|
// it in the gap after this wait — so wait and retry.
|
|
loop {
|
|
crate::server::grpc_server::wait_ec_decode_tail(state, target.vid).await;
|
|
match journal_delete_local(state, target.vid, target.needle_id) {
|
|
Ok(()) => return Ok(true),
|
|
Err(e) if e.kind() == io::ErrorKind::WouldBlock => continue,
|
|
// Nothing was committed — the volume unmounted or remounted
|
|
// without the needle — so it is safe to fall back to other
|
|
// shard holders, unlike an RPC failure which may have landed.
|
|
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(false),
|
|
Err(e) => {
|
|
last_err = Some(e);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if let Some(addrs) = addrs {
|
|
let self_http = to_http_address(&state.self_url);
|
|
for addr in addrs {
|
|
// A stale self entry: the loopback RPC would journal on this same
|
|
// volume, which the local attempt above already covered.
|
|
if to_http_address(addr).as_ref() == self_http.as_ref() {
|
|
continue;
|
|
}
|
|
match delete_on_remote_ec_shard(state, addr, target).await {
|
|
Ok(()) => return Ok(true),
|
|
Err(e) => last_err = Some(e),
|
|
}
|
|
}
|
|
}
|
|
match last_err {
|
|
Some(e) => Err(e),
|
|
None => Ok(false),
|
|
}
|
|
}
|
|
|
|
/// `doDeleteNeedleFromRemoteEcShard` in Go — one `VolumeEcBlobDelete` RPC.
|
|
async fn delete_on_remote_ec_shard(
|
|
state: &Arc<VolumeServerState>,
|
|
addr: &str,
|
|
target: &EcDeleteTarget<'_>,
|
|
) -> io::Result<()> {
|
|
let grpc_addr =
|
|
parse_grpc_address(addr).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
|
|
let channel = connect_channel(
|
|
&grpc_addr,
|
|
state.outgoing_grpc_tls.as_ref(),
|
|
GrpcDialOptions::unary(),
|
|
)
|
|
.await
|
|
.map_err(|e| io::Error::other(format!("connect to {}: {}", addr, e)))?;
|
|
let mut client = volume_server_client(channel);
|
|
client
|
|
.volume_ec_blob_delete(Request::new(VolumeEcBlobDeleteRequest {
|
|
volume_id: target.vid.0,
|
|
collection: target.collection.to_string(),
|
|
file_key: target.needle_id.0,
|
|
version: target.version.0 as u32,
|
|
}))
|
|
.await
|
|
.map_err(|e| io::Error::other(format!("volume_ec_blob_delete on {}: {}", addr, e)))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Journals on the local volume — what the `VolumeEcBlobDelete` handler runs
|
|
/// when this server is the shard holder. An absent needle is an error, not a
|
|
/// no-op: `journal_delete` would accept it silently, but here it means the
|
|
/// volume remounted as a different generation mid-delete and the tombstone
|
|
/// should go to a replica that still has the needle.
|
|
fn journal_delete_local(
|
|
state: &Arc<VolumeServerState>,
|
|
vid: VolumeId,
|
|
needle_id: NeedleId,
|
|
) -> io::Result<()> {
|
|
let mut store = state.store.write().unwrap();
|
|
// Membership is read under the write lock: a decode can claim the
|
|
// publishing tail while this call waited for the decoder's read lock,
|
|
// so a check taken earlier would be stale by commit time.
|
|
if crate::server::grpc_server::ec_decode_tail_contains(state, vid) {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::WouldBlock,
|
|
format!("ec volume {} is in decode publishing tail", vid.0),
|
|
));
|
|
}
|
|
let ecv = store.find_ec_volume_mut(vid).ok_or_else(|| {
|
|
io::Error::new(
|
|
io::ErrorKind::NotFound,
|
|
format!("ec volume {} unmounted", vid.0),
|
|
)
|
|
})?;
|
|
match ecv.find_needle_from_ecx(needle_id)? {
|
|
None => {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::NotFound,
|
|
format!("needle {} not in local ecx", needle_id),
|
|
));
|
|
}
|
|
Some((_, size)) if size.is_deleted() => return Ok(()),
|
|
Some(_) => {}
|
|
}
|
|
ecv.journal_delete(needle_id)
|
|
}
|
|
|
|
fn local_shard_ids(ecv: &crate::storage::erasure_coding::EcVolume) -> HashSet<ShardId> {
|
|
ecv.shards
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(i, s)| s.as_ref().map(|_| i as ShardId))
|
|
.collect()
|
|
}
|
|
|
|
/// 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<VolumeServerState>,
|
|
vid: VolumeId,
|
|
expected_encode_ts_ns: i64,
|
|
force_deleted_needles_check: bool,
|
|
recover_unreadable: bool,
|
|
) -> (
|
|
i64,
|
|
Vec<crate::pb::volume_server_pb::EcShardInfo>,
|
|
Vec<String>,
|
|
) {
|
|
// Phase A — under the Store read lock, snapshot the index scrub and grab the
|
|
// paths/scalars + shard-location staleness; release the lock before any await.
|
|
let (
|
|
ecx_path,
|
|
collection,
|
|
index_plan,
|
|
ecx_walk,
|
|
encode_ts_ns,
|
|
cached_locations,
|
|
cache_refreshed_at,
|
|
data_shards,
|
|
total_shards,
|
|
) = {
|
|
let store = state.store.read().unwrap();
|
|
// Resolve the runtime matching the anchor's encode generation, not the
|
|
// first-match find_ec_volume — otherwise the needle walk can scan an
|
|
// older run while the parity half scans the newest.
|
|
let ecv = match find_ec_volume_for_scrub(&store, vid, expected_encode_ts_ns) {
|
|
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 index_plan = ecv.scrub_index_plan();
|
|
// A SECOND .ecx descriptor, opened under the guard for the needle walk
|
|
// below. The index plan's handle is consumed by its own structural walk,
|
|
// and both seek, so a `dup` would race the cursor. Reopening by PATH
|
|
// after the guard is dropped would let a teardown that legitimately
|
|
// unlinks or replaces the .ecx (the heartbeat's
|
|
// delete_expired_ec_volumes, volume_ec_shards_delete) surface an
|
|
// intentional removal as a scrub error or mix index generations within
|
|
// one scrub — the same race the vanished-volume policy exists to hide.
|
|
// The descriptor outlives the name, the same way the checksum plan's
|
|
// shard handles do.
|
|
let ecx_walk = fs::File::open(ecv.ecx_file_name());
|
|
// Encode-run identity of the volume this scrub started against. The
|
|
// per-needle `scrub_snapshot_under_lock` re-resolves the volume by id
|
|
// under a fresh guard, so a teardown-and-remount of the same vid between
|
|
// two rows would otherwise apply the captured .ecx's offsets to a
|
|
// replacement volume's shards. Bind the walk to this generation: if the
|
|
// mounted volume's encode_ts_ns no longer matches, abort like a
|
|
// mid-scan unmount rather than mixing generations.
|
|
let encode_ts_ns = ecv.encode_ts_ns;
|
|
// One read section, so the map and the refresh time it is aged against
|
|
// describe the same lookup.
|
|
let (cached_locations, cache_refreshed_at) = ecv.shard_locations_snapshot();
|
|
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(),
|
|
index_plan,
|
|
ecx_walk,
|
|
encode_ts_ns,
|
|
cached_locations,
|
|
cache_refreshed_at,
|
|
data_shards,
|
|
total_shards,
|
|
)
|
|
};
|
|
// Lock released: walk the index now, before anything else appends to errs,
|
|
// so the seeded errors keep their position in the reported details.
|
|
//
|
|
// `index_plan.run()` reads the whole .ecx synchronously, so run it in the
|
|
// blocking pool rather than on this async worker — a large index scan would
|
|
// otherwise block unrelated RPC work handled on the same executor. Same
|
|
// treatment as the CHECKSUM/LOCAL plans in the gRPC handler.
|
|
let (_, seed_errs) = match tokio::task::spawn_blocking(move || index_plan.run()).await {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
// A panic is evidence about the volume and counts as broken; a
|
|
// cancellation is not — spawn_blocking only reports it when the
|
|
// runtime is going down, the volume was never scanned, and the
|
|
// caller (FULL/READS) would put a false corruption into
|
|
// broken_volume_ids if it reached the errs path. Match the
|
|
// record_scrub_join_failure distinction used by the handler arms.
|
|
if e.is_panic() {
|
|
return (
|
|
0,
|
|
Vec::new(),
|
|
vec![format!(
|
|
"EC volume {} index scrub task panicked: {}",
|
|
vid.0, e
|
|
)],
|
|
);
|
|
}
|
|
// Cancellation: the runtime is shutting down, so this response is
|
|
// unlikely to reach anyone. Return clean rather than inventing a
|
|
// corruption for a volume that was never scanned.
|
|
return (0, Vec::new(), Vec::new());
|
|
}
|
|
};
|
|
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, expected_encode_ts_ns)
|
|
.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<ShardId, Vec<String>> = {
|
|
let store = state.store.read().unwrap();
|
|
let ecv = match find_ec_volume_for_scrub(&store, vid, expected_encode_ts_ns) {
|
|
Some(v) => v,
|
|
None => {
|
|
return (
|
|
0,
|
|
Vec::new(),
|
|
vec![format!("EC volume id {} not found", vid.0)],
|
|
);
|
|
}
|
|
};
|
|
ecv.shard_locations_snapshot().0
|
|
};
|
|
|
|
// Walk the .ecx (private fd captured under the lock, no lock held) for the
|
|
// row count + live (id, offset, size). Reading through the captured
|
|
// descriptor — not a pathname reopen — keeps a concurrent teardown from
|
|
// surfacing an intentional removal as a scrub error or mixing index
|
|
// generations, the same invariant the vanished-volume policy enforces.
|
|
//
|
|
// `walk_index_file` reads the full .ecx synchronously, so run it in the
|
|
// blocking pool rather than on this async worker — same reason as
|
|
// `index_plan.run()` above.
|
|
let (count, needles, walk_errs) = match tokio::task::spawn_blocking(
|
|
move || -> (i64, Vec<(NeedleId, Offset, Size)>, Vec<String>) {
|
|
let mut count: i64 = 0;
|
|
let mut needles: Vec<(NeedleId, Offset, Size)> = Vec::new();
|
|
let mut walk_errs: Vec<String> = Vec::new();
|
|
match ecx_walk {
|
|
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(())
|
|
})
|
|
{
|
|
walk_errs.push(format!("walk ECX file {}: {}", ecx_path, e));
|
|
}
|
|
}
|
|
Err(e) => walk_errs.push(format!("open ECX file {}: {}", ecx_path, e)),
|
|
}
|
|
(count, needles, walk_errs)
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
// A panic is evidence about the volume and counts as broken; a
|
|
// cancellation is not — see the index_plan join above for the
|
|
// same reasoning.
|
|
if e.is_panic() {
|
|
return (
|
|
0,
|
|
Vec::new(),
|
|
vec![format!("EC volume {} ecx walk task panicked: {}", vid.0, e)],
|
|
);
|
|
}
|
|
return (0, Vec::new(), Vec::new());
|
|
}
|
|
};
|
|
errs.extend(walk_errs);
|
|
|
|
// reads for EC chunks can hit the same shard repeatedly, so dedupe broken shards
|
|
let mut broken_shards: HashMap<ShardId, crate::pb::volume_server_pb::EcShardInfo> =
|
|
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, encode_ts_ns) {
|
|
Ok(s) => s,
|
|
// Volume unmounted (or remounted as a different encode run) 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<u8> = 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(&[]);
|
|
let iv = EcInterval {
|
|
vid,
|
|
needle_id: id,
|
|
shard_id: *shard_id,
|
|
shard_offset: *shard_offset,
|
|
size: *ssize,
|
|
expected_encode_ts_ns: snapshot.encode_ts_ns,
|
|
};
|
|
match read_remote_ec_shard_interval(state, sources, iv).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,
|
|
iv,
|
|
EcShardMap {
|
|
locations: &locations,
|
|
data_shards,
|
|
parity_shards: total_shards - data_shards,
|
|
},
|
|
)
|
|
.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<crate::pb::volume_server_pb::EcShardInfo> =
|
|
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<VolumeServerState>,
|
|
vid: VolumeId,
|
|
needle_id: NeedleId,
|
|
) -> io::Result<Result<Snapshot, EcMiss>> {
|
|
let store = state.store.read().unwrap();
|
|
let Some(ecv) = store.find_ec_volume(vid) else {
|
|
return Ok(Err(EcMiss::VolumeNotFound));
|
|
};
|
|
|
|
// 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 Some((offset, size, intervals)) = ecv.locate_needle(needle_id)? else {
|
|
// locate_needle folds a tombstone into not-found.
|
|
let deleted =
|
|
matches!(ecv.find_needle_from_ecx(needle_id)?, Some((_, s)) if s.is_deleted());
|
|
return Ok(Err(if deleted {
|
|
EcMiss::Deleted
|
|
} else {
|
|
EcMiss::NotFound
|
|
}));
|
|
};
|
|
build_snapshot(ecv, offset, size, &intervals).map(Ok)
|
|
}
|
|
|
|
/// 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<VolumeServerState>,
|
|
vid: VolumeId,
|
|
offset: Offset,
|
|
size: Size,
|
|
expected_encode_ts: i64,
|
|
) -> io::Result<ScrubSnapshot> {
|
|
let store = state.store.read().unwrap();
|
|
let ecv = match find_ec_volume_for_scrub(&store, vid, expected_encode_ts) {
|
|
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),
|
|
));
|
|
}
|
|
};
|
|
// The volume was torn down and remounted as a DIFFERENT encode run between
|
|
// two rows. The .ecx offsets captured at the start of the walk belong to
|
|
// the old generation; applying them to the replacement's shards would
|
|
// falsely report corruption. Abort like a mid-scan unmount instead of
|
|
// mixing generations within one scrub.
|
|
//
|
|
// `encode_ts_ns == 0` means the .vif carried no encode-run identity (a
|
|
// legacy or pre-feature volume). Two such volumes are NOT the same mount
|
|
// by this check alone — 0 == 0 would accept a teardown-and-remount and
|
|
// apply the old .ecx's offsets to the replacement's shards. Only treat a
|
|
// match as verified when the identity is non-zero; when it is zero, fall
|
|
// back to the pre-check behavior (no generation binding) rather than
|
|
// aborting a scrub that was already running without the guard.
|
|
if expected_encode_ts != 0 && ecv.encode_ts_ns != expected_encode_ts {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::NotFound,
|
|
format!(
|
|
"EC volume {} remounted as a different encode run during scrub (was {}, now {})",
|
|
vid.0, expected_encode_ts, ecv.encode_ts_ns
|
|
),
|
|
));
|
|
}
|
|
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<IntervalResult>,
|
|
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<IntervalResult> {
|
|
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<Snapshot> {
|
|
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, cache_refreshed_at) = ecv.shard_locations_snapshot();
|
|
|
|
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<ShardId, Vec<String>>,
|
|
refreshed_at: Option<Instant>,
|
|
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_mins(37)
|
|
} else {
|
|
Duration::from_mins(7)
|
|
};
|
|
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<VolumeServerState>, vid: VolumeId) {
|
|
let store = state.store.read().unwrap();
|
|
if let Some(ecv) = store.find_ec_volume(vid) {
|
|
ecv.mark_shard_locations_stale();
|
|
}
|
|
}
|
|
|
|
/// Decide whether the caller's snapshot is due a master lookup and, when it is,
|
|
/// consume the cache's 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<VolumeServerState>,
|
|
vid: VolumeId,
|
|
locations: &HashMap<ShardId, Vec<String>>,
|
|
refreshed_at: Option<Instant>,
|
|
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);
|
|
};
|
|
ecv.claim_shard_locations_refresh(|stale| {
|
|
needs_refresh(locations, refreshed_at, stale, data_shards, total_shards)
|
|
})
|
|
}
|
|
|
|
async fn cached_lookup_ec_shard_locations(
|
|
state: &Arc<VolumeServerState>,
|
|
vid: VolumeId,
|
|
) -> io::Result<HashMap<ShardId, Vec<String>>> {
|
|
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::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 channel = connect_channel(
|
|
&grpc_addr,
|
|
state.outgoing_grpc_tls.as_ref(),
|
|
GrpcDialOptions::unary(),
|
|
)
|
|
.await
|
|
.map_err(|e| io::Error::other(format!("master connect: {}", e)))?;
|
|
|
|
let mut client = master_client(channel);
|
|
|
|
let resp = client
|
|
.lookup_ec_volume(Request::new(LookupEcVolumeRequest { volume_id: vid.0 }))
|
|
.await
|
|
.map_err(|e| io::Error::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<String> = entry
|
|
.locations
|
|
.iter()
|
|
.map(format_location_as_server_address)
|
|
.collect();
|
|
// Defensive: skip out-of-range shard ids from the master instead of
|
|
// truncating (256 would alias 0). Valid replies are unaffected.
|
|
let Ok(sid) = shard_id_try_from(entry.shard_id) else {
|
|
continue;
|
|
};
|
|
out.insert(sid, 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<VolumeServerState>,
|
|
vid: VolumeId,
|
|
locations: HashMap<ShardId, Vec<String>>,
|
|
data_shards: usize,
|
|
expected_encode_ts_ns: i64,
|
|
) -> Option<HashMap<ShardId, Vec<String>>> {
|
|
if locations.len() < data_shards {
|
|
return None;
|
|
}
|
|
let store = state.store.read().unwrap();
|
|
let ecv = find_ec_volume_for_scrub(&store, vid, expected_encode_ts_ns)?;
|
|
Some(ecv.merge_shard_locations(locations))
|
|
}
|
|
|
|
/// Resolve the runtime matching the scrub's anchor encode generation, not the
|
|
/// first-match `find_ec_volume`. When `expected_encode_ts_ns` is 0 (legacy or
|
|
/// pre-feature), falls back to first-match so existing behavior is preserved.
|
|
fn find_ec_volume_for_scrub(
|
|
store: &crate::storage::store::Store,
|
|
vid: VolumeId,
|
|
expected_encode_ts_ns: i64,
|
|
) -> Option<&crate::storage::erasure_coding::EcVolume> {
|
|
if expected_encode_ts_ns != 0 {
|
|
store
|
|
.find_all_ec_volumes(vid)
|
|
.into_iter()
|
|
.find(|v| v.encode_ts_ns == expected_encode_ts_ns)
|
|
} else {
|
|
store.find_ec_volume(vid)
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
&& let Some((host, http_port)) = raw.rsplit_once(':')
|
|
{
|
|
return format!("{}:{}.{}", host, http_port, loc.grpc_port);
|
|
}
|
|
raw.to_string()
|
|
}
|
|
|
|
/// One shard-relative byte range of a needle on an EC volume, the unit the
|
|
/// peer-read and recovery paths work in. Mirrors the argument list of Go's
|
|
/// `readOneEcShardInterval`.
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct EcInterval {
|
|
vid: VolumeId,
|
|
needle_id: NeedleId,
|
|
/// The shard the bytes live on, or the one to rebuild when recovering.
|
|
shard_id: ShardId,
|
|
shard_offset: i64,
|
|
size: usize,
|
|
/// Encode run the caller expects the shard to belong to; 0 accepts any,
|
|
/// for peers that predate the identity check.
|
|
expected_encode_ts_ns: i64,
|
|
}
|
|
|
|
/// Where the shards of one EC volume can be fetched from, and the volume's
|
|
/// Reed-Solomon shape, as recovery needs both together.
|
|
#[derive(Clone, Copy)]
|
|
struct EcShardMap<'a> {
|
|
locations: &'a HashMap<ShardId, Vec<String>>,
|
|
data_shards: usize,
|
|
parity_shards: usize,
|
|
}
|
|
|
|
/// 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<VolumeServerState>,
|
|
iv: EcInterval,
|
|
map: EcShardMap<'_>,
|
|
) -> io::Result<(Vec<u8>, bool)> {
|
|
let EcInterval { vid, shard_id, .. } = iv;
|
|
// Direct peer read against the cached locations for this shard.
|
|
if let Some(sources) = map.locations.get(&shard_id)
|
|
&& !sources.is_empty()
|
|
{
|
|
match read_remote_ec_shard_interval(state, sources, iv).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, iv, map).await
|
|
}
|
|
|
|
async fn read_remote_ec_shard_interval(
|
|
state: &Arc<VolumeServerState>,
|
|
sources: &[String],
|
|
iv: EcInterval,
|
|
) -> io::Result<(Vec<u8>, bool)> {
|
|
let mut last_err: Option<io::Error> = None;
|
|
for src in sources {
|
|
match do_read_remote_ec_shard_interval(state, src, iv).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 {}.{}", iv.vid.0, iv.shard_id),
|
|
)
|
|
}))
|
|
}
|
|
|
|
async fn do_read_remote_ec_shard_interval(
|
|
state: &Arc<VolumeServerState>,
|
|
source: &str,
|
|
iv: EcInterval,
|
|
) -> io::Result<(Vec<u8>, bool)> {
|
|
let EcInterval {
|
|
vid,
|
|
needle_id,
|
|
shard_id,
|
|
shard_offset,
|
|
size,
|
|
expected_encode_ts_ns,
|
|
} = iv;
|
|
let grpc_addr =
|
|
parse_grpc_address(source).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
|
|
let channel = connect_channel(
|
|
&grpc_addr,
|
|
state.outgoing_grpc_tls.as_ref(),
|
|
GrpcDialOptions::long(),
|
|
)
|
|
.await
|
|
.map_err(|e| io::Error::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 = volume_server_client(channel);
|
|
|
|
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::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::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::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<VolumeServerState>,
|
|
iv: EcInterval,
|
|
map: EcShardMap<'_>,
|
|
) -> io::Result<(Vec<u8>, bool)> {
|
|
let EcInterval {
|
|
vid,
|
|
needle_id,
|
|
shard_id: shard_id_to_recover,
|
|
shard_offset,
|
|
size,
|
|
expected_encode_ts_ns,
|
|
} = iv;
|
|
let EcShardMap {
|
|
locations: shard_locations,
|
|
data_shards,
|
|
parity_shards,
|
|
} = map;
|
|
let total_shards = data_shards + parity_shards;
|
|
let rs = ReedSolomon::new(data_shards, parity_shards)
|
|
.map_err(|e| io::Error::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::other(format!(
|
|
"ec recover budget for shard {}.{}: {}",
|
|
vid.0, shard_id_to_recover, e
|
|
))
|
|
})?;
|
|
|
|
let mut bufs: Vec<Option<Vec<u8>>> = 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, slot) in bufs.iter_mut().enumerate() {
|
|
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 Ok(sid_shard) = ShardId::try_from(sid) else {
|
|
continue;
|
|
};
|
|
let owner = match store.find_ec_volume_with_shard(vid, sid_shard) {
|
|
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)
|
|
{
|
|
*slot = 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<String>)> = 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,
|
|
EcInterval {
|
|
shard_id: sid,
|
|
..iv
|
|
},
|
|
)
|
|
.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::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::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::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<VolumeServerState>,
|
|
filter_vid: u32,
|
|
) -> usize {
|
|
let missing: Vec<EcVolumeMissingIndex> = {
|
|
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<String> = 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<VolumeServerState>,
|
|
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<VolumeServerState>,
|
|
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 = connect_channel(
|
|
&grpc_addr,
|
|
state.outgoing_grpc_tls.as_ref(),
|
|
GrpcDialOptions::long(),
|
|
)
|
|
.await
|
|
.map_err(|e| io::Error::other(format!("connect {}: {}", peer, e)))?;
|
|
let mut client = volume_server_client(channel);
|
|
|
|
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::other(format!("copy .ecx: {}", e)))?
|
|
.into_inner();
|
|
drain_copy_stream(stream, ecx_path).await?;
|
|
|
|
let meta =
|
|
fs::metadata(ecx_path).map_err(|e| io::Error::other(format!("stat copied .ecx: {}", e)))?;
|
|
if meta.is_dir() || meta.len() == 0 {
|
|
let _ = fs::remove_file(ecx_path);
|
|
return Err(io::Error::other(format!(
|
|
"peer {} served an unusable .ecx (size {})",
|
|
peer,
|
|
meta.len()
|
|
)));
|
|
}
|
|
|
|
// .ecj is the source peer's deletion journal; .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. The journal is a *set*: merge the
|
|
// peer's ids into any local ones as a union instead of appending, so
|
|
// a volume bounced between servers cannot double its journal. The merge
|
|
// only appends whole records, so a failure leaves nothing to clean up.
|
|
match client.copy_file(copy_req(".ecj", true)).await {
|
|
Ok(resp) => {
|
|
let mut stream = resp.into_inner();
|
|
let merged = match crate::server::grpc_server::receive_ecj_ids(&mut stream).await {
|
|
Ok((ids, true)) => crate::server::grpc_server::merge_ecj_ids(
|
|
state,
|
|
m.vid,
|
|
m.data_dir.clone(),
|
|
ecj_path.to_string(),
|
|
ids,
|
|
)
|
|
.await
|
|
.map(|_| ()),
|
|
Ok((_, false)) => Ok(()),
|
|
Err(e) => Err(e),
|
|
};
|
|
if let Err(e) = merged {
|
|
tracing::warn!(volume_id = m.vid.0, peer = %peer, "copy .ecj: {}", e);
|
|
}
|
|
}
|
|
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).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, truncating it first.
|
|
async fn drain_copy_stream(
|
|
mut stream: tonic::Streaming<crate::pb::volume_server_pb::CopyFileResponse>,
|
|
dest_path: &str,
|
|
) -> io::Result<()> {
|
|
use std::io::Write;
|
|
let mut file = fs::File::create(dest_path)
|
|
.map_err(|e| io::Error::other(format!("create {}: {}", dest_path, e)))?;
|
|
while let Some(chunk) = stream
|
|
.message()
|
|
.await
|
|
.map_err(|e| io::Error::other(format!("recv {}: {}", dest_path, e)))?
|
|
{
|
|
file.write_all(&chunk.file_content)
|
|
.map_err(|e| io::Error::other(format!("write {}: {}", dest_path, e)))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn gather_intervals_puts_a_reported_deletion_ahead_of_errors() {
|
|
let failed = || Err(io::Error::other("shard unreachable"));
|
|
assert!(
|
|
gather_intervals(vec![failed(), Ok((Vec::new(), true))])
|
|
.unwrap()
|
|
.is_none()
|
|
);
|
|
assert!(gather_intervals(vec![failed(), Ok((vec![1], false))]).is_err());
|
|
assert_eq!(
|
|
gather_intervals(vec![Ok((vec![1], false)), Ok((vec![2], false))]).unwrap(),
|
|
Some(vec![vec![1], vec![2]])
|
|
);
|
|
}
|
|
|
|
fn locations(count: usize) -> HashMap<ShardId, Vec<String>> {
|
|
(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));
|
|
}
|
|
}
|